Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f415c7d090 | ||
|
|
e86c6367e1 | ||
|
|
bd140bd232 | ||
|
|
d161c12680 | ||
|
|
7537e8e6b2 | ||
|
|
5711fafa38 | ||
|
|
fa946a16b9 | ||
|
|
9a29eb4a7a | ||
|
|
1b52942bf8 | ||
|
|
6bec7c7cc6 | ||
|
|
8f4e71f8dc | ||
|
|
9ddf802665 | ||
|
|
b6ca692c13 | ||
|
|
f7eb844274 | ||
|
|
a75ed71428 | ||
|
|
b551f7dae8 | ||
|
|
e5046a2811 | ||
|
|
19d37c44b3 | ||
|
|
5be399a4f6 | ||
|
|
6c32890014 | ||
|
|
9c33bc9397 | ||
|
|
fcdb2a53de | ||
|
|
601f040ffe | ||
|
|
eac308412c | ||
|
|
65c4b4b17e | ||
|
|
329df4c1f4 | ||
|
|
5fc5da3256 | ||
|
|
a8922b454a | ||
|
|
790db5edbb | ||
|
|
82d39011ce | ||
|
|
8a4eac4c41 | ||
|
|
7e4fe80793 | ||
|
|
c4cf53c1fc | ||
|
|
d59a1a9606 | ||
|
|
7c411f7ef4 | ||
|
|
9e47f746ba | ||
|
|
13f8a1c5cd | ||
|
|
d5f2c63367 | ||
|
|
4e03dcc280 | ||
|
|
37813199b5 | ||
|
|
9cefa0a3ea | ||
|
|
83f6164027 | ||
|
|
dd097d1ef2 | ||
|
|
01946aa123 | ||
|
|
c0dcac7fa2 | ||
|
|
654c09d067 | ||
|
|
ab88a8fb40 | ||
|
|
66249710b9 | ||
|
|
3717466594 | ||
|
|
c7c9500e89 |
@@ -1,17 +1,23 @@
|
||||
# Deploy-only: bring up the two unom-1 pieces that live in THIS repo but whose normal
|
||||
# deploys are coupled to heavy build workflows — docs to docker.yml's 5-image matrix,
|
||||
# the flatpak server to flatpak.yml's full flatpak-builder run. This workflow does
|
||||
# NEITHER build: it just (re)places the compose files and pulls the already-published
|
||||
# images, so unom/infra's deploy-all can bring a fresh unom-1 fully up in a single
|
||||
# dispatch without triggering those rebuilds.
|
||||
# Deploy-only: bring up the unom-1 pieces that live in THIS repo but whose normal deploys
|
||||
# are coupled to heavy build workflows — docs to docker.yml's 5-image matrix, the flatpak
|
||||
# server to flatpak.yml's full flatpak-builder run, the nix cache to nix.yml's full Rust
|
||||
# build. This workflow does NONE of those builds: it just (re)places the compose files and
|
||||
# pulls the already-published images, so unom/infra's deploy-all can bring a fresh unom-1
|
||||
# fully up in a single dispatch without triggering those rebuilds.
|
||||
#
|
||||
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
|
||||
# brings it up on :3220.
|
||||
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
|
||||
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
|
||||
# on the next client build, or restored from the unom-1 backup
|
||||
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
|
||||
# an empty repo until then; that is expected.
|
||||
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
|
||||
# brings it up on :3220.
|
||||
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
|
||||
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
|
||||
# on the next client build, or restored from the unom-1 backup
|
||||
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
|
||||
# an empty repo until then; that is expected.
|
||||
# nix-cache -> brings up the caddy:2-alpine Nix binary cache on :3250. Same content/config
|
||||
# split: the cache CONTENT is republished by nix.yml on the next main push
|
||||
# that moves the flake. An empty cache is harmless — every path 404s and
|
||||
# users build from source, which is the pre-cache status quo.
|
||||
# winget -> brings up the winget REST source on :3240; catalogue shipped by
|
||||
# windows-host.yml on stable tags.
|
||||
#
|
||||
# Dispatched by unom/infra scripts/deploy-all.sh: `dispatch-and-wait.sh punktfunk
|
||||
# deploy-services.yml`. Uses the same secret set docker.yml/flatpak.yml already rely on:
|
||||
@@ -100,6 +106,46 @@ jobs:
|
||||
cd ~/unom-flatpak
|
||||
docker compose -f compose.production.yml up -d
|
||||
|
||||
nix-cache:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync nix cache compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Land all three flat in ~/unom-nix-cache/ (drop the packaging/nix/server/ prefix).
|
||||
source: "packaging/nix/server/compose.production.yml,packaging/nix/server/Caddyfile,packaging/nix/server/prune.sh"
|
||||
target: "~/unom-nix-cache"
|
||||
strip_components: 3
|
||||
overwrite: true
|
||||
|
||||
- name: Start nix binary cache server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# ./site (the cache CONTENT) is NOT shipped here — nix.yml rsyncs it on each main push
|
||||
# that moves the flake, same content/config split as the flatpak repo and the winget
|
||||
# catalogue. Ensure the bind-mount source exists so caddy starts; an empty cache is
|
||||
# harmless, it just 404s every path and users build from source as they do today.
|
||||
mkdir -p ~/unom-nix-cache/site/nar
|
||||
cd ~/unom-nix-cache
|
||||
docker compose -f compose.production.yml up -d
|
||||
# A cache that 404s a miss is healthy; one that cannot answer at all is not.
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:3250/nix-cache-info \
|
||||
|| echo "NOTE: no cache content yet - push to main with the flake touched to populate it"
|
||||
|
||||
winget:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
+174
-18
@@ -4,8 +4,9 @@
|
||||
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
|
||||
# in ci.yml for that story).
|
||||
#
|
||||
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push:
|
||||
# Three tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push — so the two cheap tiers gate every PR and the expensive one runs
|
||||
# only where its cost buys something (a published cache):
|
||||
#
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
|
||||
# without building them. Catches the failures that actually happen to this flake — a
|
||||
@@ -32,15 +33,28 @@
|
||||
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
|
||||
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
|
||||
#
|
||||
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
|
||||
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
||||
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
||||
# * cache — PUSH TO MAIN ONLY. Builds the Rust packages + gamescope for real and publishes every
|
||||
# punktfunk store path to the binary cache at https://nix.unom.io, so a NixOS user gets
|
||||
# prebuilt binaries instead of an hour of rustc and a gamescope compile. This is the
|
||||
# expensive tier and it is why the job timeout is 180 rather than 90.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
|
||||
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
|
||||
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
|
||||
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
|
||||
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
|
||||
# It needs NO extra trigger for releases: a release bumps the workspace version in
|
||||
# Cargo.toml, which is already in the path filter below, so the tag's content is
|
||||
# published by the version-bump commit on main.
|
||||
#
|
||||
# Only OUR paths are published — see the step for why that is both correct and the
|
||||
# difference between ~300 MB and several GB per publish.
|
||||
#
|
||||
# The Rust packages and punktfunk-gamescope are still not built on PRs: they are the expensive ones
|
||||
# and their inputs are already gated by the `rust` job in ci.yml. Build them on a PR by hand on a
|
||||
# Nix box, or with the `build-rust` / `build-gamescope` dispatch inputs below.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope matters more than it looks: `host.gamescopeHdr` DEFAULTS TRUE, so it is on
|
||||
# the critical path of every `services.punktfunk.host.enable = true` build. It patches whatever
|
||||
# gamescope the pinned nixpkgs carries, so a nixpkgs bump — not a change of ours — is what breaks
|
||||
# it, and the first person to find out would be an operator whose system rebuild fails. The `cache`
|
||||
# tier now compiles it on every main push, so a flake.lock bump that breaks it goes red HERE; the
|
||||
# dispatch input below is for checking it on a branch before merging.
|
||||
#
|
||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||
@@ -107,8 +121,15 @@ jobs:
|
||||
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
|
||||
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
|
||||
image: node:22-bookworm
|
||||
timeout-minutes: 90
|
||||
# 180, not 90: the `cache` tier on a main push compiles the whole Rust workspace AND gamescope
|
||||
# from source inside the nix sandbox, where the sccache every other Rust job leans on cannot
|
||||
# reach (no network in a derivation, and RUSTC_WRAPPER is not set inside one).
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
# Where the published cache lives on unom-1, and the URL users substitute from. Kept next to
|
||||
# the flatpak repo (3230) and winget source (3240) — see packaging/nix/server/.
|
||||
DEPLOY_DIR: unom-nix-cache
|
||||
CACHE_URL: https://nix.unom.io
|
||||
# The flake needs both experimental features. Also baked into the installer's --extra-conf
|
||||
# below; this covers any step that shells out before that config is read.
|
||||
NIX_CONFIG: "experimental-features = nix-command flakes"
|
||||
@@ -126,11 +147,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
|
||||
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
|
||||
# against a future slim-image swap, and costs one cached apt call.)
|
||||
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout;
|
||||
# rsync + ssh to ship the built cache to unom-1. (node:22-bookworm is the full image and
|
||||
# already has all but rsync — this is belt-and-braces against a future slim-image swap, and
|
||||
# costs one cached apt call.)
|
||||
- name: Installer prerequisites
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git rsync openssh-client
|
||||
|
||||
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
|
||||
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
|
||||
@@ -183,10 +205,144 @@ jobs:
|
||||
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
|
||||
echo "bun packages OK: $web $scripting"
|
||||
|
||||
# ── binary cache (push to main only) ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Decided against a bucket on storage.unom.io even though sccache already uses it and the
|
||||
# credentials already exist: it is local RustFS on the home uplink with no CDN in front, so
|
||||
# every NixOS user's download would come off the same pipe every CI runner uses — and S3
|
||||
# answers 403, not 404, for a missing key, which nix treats as a hard error rather than a
|
||||
# cache miss (see packaging/nix/server/Caddyfile). unom-1 already serves the flatpak repo
|
||||
# this way from a cloud IP; a Nix cache is the same static-files-behind-caddy shape.
|
||||
#
|
||||
# Gitea itself cannot host this at all: it has 23 package registry types and none is Nix, and
|
||||
# the binary cache protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
|
||||
# /<hash>.narinfo, /nar/<hash>.nar.xz) that /api/packages/{owner}/generic/… cannot express.
|
||||
- name: Cache publish preflight
|
||||
id: cachecfg
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
env:
|
||||
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
|
||||
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
|
||||
# repo deploy does, so this workflow stays green through setup.
|
||||
run: |
|
||||
set -eu
|
||||
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
|
||||
echo "go=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "go=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
|
||||
fi
|
||||
|
||||
- name: Build the publishable packages
|
||||
if: ${{ steps.cachecfg.outputs.go == 'true' }}
|
||||
# Everything a user can install. punktfunk-gamescope earns its place here more than any
|
||||
# other: host.gamescopeHdr DEFAULTS TRUE, so without it in the cache every
|
||||
# `services.punktfunk.host.enable = true` still compiles a compositor from source.
|
||||
run: |
|
||||
"$NIX" build --print-build-logs \
|
||||
.#punktfunk-host .#punktfunk-client .#punktfunk-tray \
|
||||
.#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope
|
||||
# This is now the heaviest job on the fleet — a full workspace build plus gamescope fills
|
||||
# the store with tens of GB, and this fleet ran a runner out of disk on 2026-08-06. Record
|
||||
# the headroom AFTER the build too, or a future "no space left on device" is a guess.
|
||||
df -h / /nix /tmp || true
|
||||
|
||||
- name: Sign + publish to nix.unom.io
|
||||
if: ${{ steps.cachecfg.outputs.go == 'true' }}
|
||||
env:
|
||||
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
|
||||
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
|
||||
# "Illegal option". The two places below where a pipeline's LEFT side must be able to fail
|
||||
# the step are written as redirects instead, so nothing depends on pipefail.
|
||||
set -eu
|
||||
PKGS=".#punktfunk-host .#punktfunk-client .#punktfunk-tray .#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope"
|
||||
|
||||
# 1) Pick what to publish. PUBLISH ONLY OUR OWN PATHS — this is the difference between
|
||||
# ~300 MB and several GB per run, and it is not a corner cut: a runtime closure here
|
||||
# is our binaries plus stock nixpkgs (ffmpeg, gtk4, glibc, …), and every stock path is
|
||||
# already on cache.nixos.org, served by a real CDN. Mirroring them onto unom-1 would
|
||||
# cost disk and home-to-cloud bandwidth to serve a WORSE copy of what users already
|
||||
# have. Nothing in nixpkgs is named punktfunk, so the name filter is exact.
|
||||
paths="$("$NIX" path-info -r $PKGS | grep -- '-punktfunk' || true)"
|
||||
[ -n "$paths" ] || { echo "::error::no punktfunk store paths in the closure — the name filter is broken"; exit 1; }
|
||||
echo "$paths"
|
||||
# The filter is a string match, so it would fail SILENTLY if a pname ever changed — and
|
||||
# the package most likely to drift is gamescope, the most expensive one to lose. Assert
|
||||
# every built output is actually covered rather than discovering it as a user rebuild.
|
||||
for out in $("$NIX" build --print-out-paths $PKGS); do
|
||||
printf '%s\n' "$paths" | grep -qxF "$out" \
|
||||
|| { echo "::error::$out is not matched by the '-punktfunk' filter — publish would silently omit it"; exit 1; }
|
||||
done
|
||||
|
||||
# 2) Sign into a local binary cache. The secret is the whole `name:base64` line from
|
||||
# `nix key generate-secret`; the matching public key is what users pin (README).
|
||||
KEYDIR="$(mktemp -d)"; chmod 700 "$KEYDIR"
|
||||
printf '%s' "$NIX_CACHE_SIGNING_KEY" > "$KEYDIR/key"; chmod 600 "$KEYDIR/key"
|
||||
printf '%s\n' "$paths" | xargs "$NIX" copy --to "file://$PWD/nix-cache?secret-key=$KEYDIR/key"
|
||||
# Publish the PUBLIC half beside the cache and echo it here. Users must pin this key, so
|
||||
# it needs to be fetchable from the cache itself rather than only from a doc that can
|
||||
# drift — and on the first run this log line is where the value for README.md comes from.
|
||||
# Redirect, not `| tee`: without pipefail a failing nix would be masked by tee's success
|
||||
# and publish an EMPTY public key, which every user would then pin.
|
||||
"$NIX" key convert-secret-to-public < "$KEYDIR/key" > nix-cache/punktfunk-cache.pub
|
||||
cat nix-cache/punktfunk-cache.pub
|
||||
rm -rf "$KEYDIR"
|
||||
echo "publishing $(find nix-cache -name '*.narinfo' | wc -l) paths, $(du -sh nix-cache | cut -f1)"
|
||||
|
||||
# 3) Ship it. Same deploy key and retry discipline as flatpak.yml — this runner's link to
|
||||
# unom-1 drops TCP dials under load.
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
|
||||
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
|
||||
# yet is a HARD download failure for whoever fetches it in that window; a NAR nothing
|
||||
# points at yet is simply invisible. rsync renames each file into place atomically, so a
|
||||
# cancelled run (this workflow has cancel-in-progress) can only ever under-publish.
|
||||
# No --delete: superseded paths are aged out by prune.sh below instead, so a client
|
||||
# mid-download is never pulled out from under.
|
||||
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" nix-cache/nar/ "$DEST:$DEPLOY_DIR/site/nar/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" nix-cache/nix-cache-info nix-cache/punktfunk-cache.pub nix-cache/*.narinfo "$DEST:$DEPLOY_DIR/site/"
|
||||
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" \
|
||||
packaging/nix/server/compose.production.yml packaging/nix/server/Caddyfile packaging/nix/server/prune.sh \
|
||||
"$DEST:$DEPLOY_DIR/"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
|
||||
|
||||
# 4) Bound it. The flatpak repo next door reached 3.84 GB publishing this same way with
|
||||
# no sweep, on a box that has run out of disk before; this one gets the sweep from the
|
||||
# first publish. Never allowed to fail the job — the cache is already live by now, and
|
||||
# a growing disk is a slower problem than a red main.
|
||||
bash scripts/ci/retry.sh 3 $SSH "$DEST" "sh ~/$DEPLOY_DIR/prune.sh ~/$DEPLOY_DIR/site 180" \
|
||||
|| echo "::warning::cache prune failed — published cache may be growing unbounded"
|
||||
|
||||
# 5) Prove the published cache actually answers, rather than assuming the rsync landed.
|
||||
# A substituter that 200s on nix-cache-info but 403s on a miss is the failure mode that
|
||||
# breaks users' builds, so check both.
|
||||
bash scripts/ci/retry.sh 5 curl -fsS "$CACHE_URL/nix-cache-info"
|
||||
miss="$(curl -sS -o /dev/null -w '%{http_code}' "$CACHE_URL/0000000000000000000000000000000000.narinfo")"
|
||||
[ "$miss" = 404 ] || { echo "::error::cache returns $miss for an absent path; nix needs 404 or every user build fails"; exit 1; }
|
||||
echo "published → $CACHE_URL"
|
||||
|
||||
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
|
||||
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
|
||||
# Accept BOTH shapes. A checkbox dispatched from the Gitea UI arrives as the STRING
|
||||
# "true", but an API dispatch (scripts, cross-repo automation) can deliver a real JSON
|
||||
# boolean, and `== 'true'` silently misses it — the step is skipped, the run goes green,
|
||||
# and the log looks identical to a run that genuinely had nothing to do. MEASURED
|
||||
# 2026-08-19: dispatched with build-gamescope while verifying a flake.lock bump, and this
|
||||
# step skipped while the job reported success — a green that proved nothing about the
|
||||
# very package being fixed. Still no `inputs.*`: that context is the thing Gitea's parser
|
||||
# is least reliable about, which is why this file used github.event.inputs to begin with.
|
||||
- name: Build the Rust packages (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
if: ${{ github.event.inputs.build-rust == 'true' || github.event.inputs.build-rust == true }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
|
||||
@@ -196,6 +352,6 @@ jobs:
|
||||
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
|
||||
# something actually builds it.
|
||||
- name: Build the patched gamescope (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' }}
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' || github.event.inputs.build-gamescope == true }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||
|
||||
@@ -35,6 +35,7 @@ on:
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- 'scripts/alsa-ucm2/**'
|
||||
- '.gitea/workflows/rpm.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
|
||||
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
|
||||
@@ -221,6 +222,15 @@ jobs:
|
||||
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
|
||||
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
|
||||
# fail takes the job down instead of waving the release through.
|
||||
- name: The DualSense UCM drop-in must still bite
|
||||
# scripts/alsa-ucm2/ hooks into alsa-ucm-conf's own dispatcher, so an upstream rename or
|
||||
# reorder can neuter it with no error anywhere — and what comes back is the Spider-Man
|
||||
# EXCEPTION_ACCESS_VIOLATION, not a quieter pad. This is the only leg that runs on a real
|
||||
# Fedora tree, hence the two packages. Skips itself on any box without them.
|
||||
run: |
|
||||
dnf -y install alsa-ucm alsa-ucm-utils
|
||||
sh scripts/ci/check-dualsense-ucm.sh
|
||||
|
||||
- name: Assert the capability matrix (rpm)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
|
||||
+431
@@ -12,6 +12,437 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.31.0
|
||||
|
||||
90 commits since v0.30.0 (65 non-merge).
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
Android app's Compose console is **deleted** — `pf-console-ui` over Skia/GL is now the console on all
|
||||
three ABIs, which removes the Compose screenshot scenes.
|
||||
|
||||
### Versions
|
||||
|
||||
| | 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 |
|
||||
| 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.30.0 tag |
|
||||
| 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 |
|
||||
| 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/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.
|
||||
|
||||
### ⚠ 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:
|
||||
|
||||
- **`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;
|
||||
the gate is simply "does the native host exist", and where it does not a controller drives the
|
||||
touch UI through focus. ~6.5 kLOC of `GamepadHome`, `GamepadSettingsScreen`,
|
||||
`GamepadAddHostScreen`, `GamepadDialogs`, `HomeTiles`, the console halves of `LibraryScreen`,
|
||||
`ConnectOverlay`/`ConnectTakeover`, the `gamepadUi` branches of `ConnectScreen`/`ConnectPrompts`/
|
||||
`AdaptiveDialogs`, `App.kt`'s `GamepadShell`/`GamepadScreen` and their tests are gone. The `!` is
|
||||
for the **store-screenshot surface**: the Compose console's marketing scenes cannot be rendered by
|
||||
Roborazzi any more (the shell draws over native GL); its shots come from the desktop screenshot dump
|
||||
or a device capture. Sysprop `debug.punktfunk.console_backend=compose` is meaningless; `=none`
|
||||
still forces the touch UI on glass.
|
||||
- **Linux desktop-audio capture topology flipped by default** — see the audio section. `=stream`
|
||||
restores 0.30 for **one release only**.
|
||||
- **Hyprland / sway: `topology: exclusive` now does what it says.** Both backends accepted it,
|
||||
echoed it as the session's effective topology, and dropped it with a warning; because `auto`
|
||||
resolves to Exclusive on any unpinned host, the *default* policy on every auto-detected Hyprland
|
||||
or sway box was an Exclusive that behaved as Extend. Operators who relied on that get their
|
||||
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.
|
||||
|
||||
### DualSense audio and haptics on Linux: five faults, and the files they needed
|
||||
|
||||
The whole in-game path — GE-Proton's haptic router → the pad's ALSA card → the voice coils — had
|
||||
never once worked against our virtual pad. In wire order:
|
||||
|
||||
- **`usbip`: the calibration feature report was 42 bytes; `hid-playstation` asks for 41.** On a USB
|
||||
backend an over-long reply is not truncated: the kernel treats it as hostile and tears down the
|
||||
connection, not the transfer — the pad vanished ~400 ms after enumerating, and the dmesg order made
|
||||
the teardown look like the cause. Three changes so the trap is not left set: the constant is 41 and
|
||||
all three feature-report sizes are pinned by test; `clamp_reply` clamps every reply to the requested
|
||||
length in the transport and drops any payload a handler returns on an OUT (the kernel never reads
|
||||
one; those bytes would misframe every following PDU); `DualSenseUsbip::open` waits for the kernel
|
||||
to actually bind a HID driver before reporting success (vhci attach succeeds immediately and
|
||||
enumerates asynchronously), so bring-up faults return `Err` and the uhid fallback catches them.
|
||||
New `PUNKTFUNK_USBIP_TRACE` (both socket directions to disk) and `scripts/usbip-trace-analyse.py`.
|
||||
- **`usbip`: every non-ISO OUT was answered with an empty buffer, i.e. `actual_length = 0`.** vhci
|
||||
copies that field verbatim into the URB's actual length; the driver returned 0 as the write's byte
|
||||
count; Wine's bus driver reads 0 as failure and prints the thread's *stale* errno — so the ENOENT /
|
||||
EINVAL / EAGAIN in the GE logs were never kernel verdicts. New
|
||||
`UsbIpResponse::usbip_ret_submit_out_success(header, accepted)`; the debug assertion now pins
|
||||
"OUT carries no buffer", not "OUT claims 0"; two wire-byte tests pin both directions. **The Steam
|
||||
Controller 2 shares this handler.** `usbip-trace-analyse.py` had flagged *any* nonzero OUT
|
||||
actual_length as a desync — the rule that would have hidden this bug — and now flags an OUT reply
|
||||
claiming more than it was sent, or 0 against a non-empty write.
|
||||
- **`usbip`: ISO completions were paced by relative sleeps**, so timer slop, socket I/O and lock waits
|
||||
accumulated per transfer: the pad's clock ran ~26 % slow (~35,700 frames/s against 48 kHz), its PCM
|
||||
backed up into dropouts, and because completion *is* the pad's audio clock, on the test box the pad
|
||||
sink became the graph driver and pulled desktop capture to 50 % delivery. Now a per-endpoint
|
||||
absolute deadline ledger (a stall > 20 ms re-anchors instead of fast-forwarding a burst); measured
|
||||
after: 48,005 frames/s. Two paused-clock tests pin the rate and the re-anchor.
|
||||
- **`usbip`: the capture forwarded the pad's hardware quad as the wire's speaker pair.** Hardware
|
||||
is HP-L, HP-R+mono-speaker, coil-L, coil-R; the wire puts the speaker pair first. Now: the speaker
|
||||
channel duplicated across the wire's speaker pair, coils passed through, HP-L dropped. The
|
||||
stream-sink (uhid) capture path already emitted the logical layout and is unchanged.
|
||||
- **`usbip`: `iSerialNumber` was the literal `"Serial"`.** A real DualSense reports none, ALSA bakes it
|
||||
into the card id (`…Wireless_Controller_Serial-00` vs `…Wireless_Controller-00`) and PipeWire
|
||||
carried it into every node name and `device.serial`. Cleared. Explicitly *not* a fix for anything
|
||||
observed broken — GE's winepulse leg matched the placeholder — and *not* a UCM-selection fix
|
||||
(alsa-ucm-conf keys on `${CardComponents}`, `USB054c:0ce6`).
|
||||
- **The pad's ALSA card was root-only.** It is created mid-session-bringup with no seat session
|
||||
active, so logind's ACL never materialises; WirePlumber's probe got EACCES and the card never
|
||||
appeared in PipeWire at all. `scripts/60-punktfunk.rules` gains two `SUBSYSTEM=="sound"` rules for
|
||||
`054c:0ce6` / `054c:0df2` (`GROUP="input" MODE="0660" TAG+="uaccess"`), matching physical pads too.
|
||||
Verified live on Bazzite f44.
|
||||
- **The DualSense's only playback route was a 1-channel `Default__Speaker__sink`**, from which
|
||||
GE-Proton mints its synthetic endpoint, and *Marvel's Spider-Man Remastered* overruns it ~74 s in
|
||||
(`EXCEPTION_ACCESS_VIOLATION`, write; the copy loop past the frame count, 5206/5207 vs 5034 — a
|
||||
game/GE bug on a code path that only exists when the mono sink does). Fix: delete the sink. New
|
||||
ALSA UCM drop-in `scripts/alsa-ucm2/USB-Audio/conf.d/{054c-0ce6,054c-0df2}.conf` +
|
||||
`scripts/alsa-ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic{,-HiFi}.conf` raises a `SpeakerHaptic`
|
||||
device at playback priority 200 against `Speaker`'s 100, so the card takes the 4-channel HiFi
|
||||
profile and the mono sink never exists. Shipped **without** replacing a file `alsa-ucm-conf` owns:
|
||||
`USB-Audio.conf` ends with an unconditional optional include of `conf.d/{vid}-{pid}.conf`
|
||||
(verified against alsa-lib source; hook and DualSense profile both since 1.2.15). New CI guard
|
||||
`scripts/ci/check-dualsense-ucm.sh` runs the chain on a real distro tree via UCM's card-less
|
||||
`conf.virt.d`, negative-tested both ways. **NixOS is not covered** (no `/usr/share/alsa/ucm2` to
|
||||
drop into).
|
||||
- **WirePlumber met every new pad card at `default-sink-volume` 0.4 — cubed, i.e. −23.88 dB — and
|
||||
both ends minted one**, so haptics reached the coils at 0.064² = −47.8 dB (field-measured −48).
|
||||
Client: `pin_sink_volume` from `correlate_pad_sink` at every pick (skipped for the `split_parent`
|
||||
pick). Host: new `audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open` succeeds
|
||||
(the host half matters because `pad_usb` captures at the ISO OUT endpoint, downstream of this
|
||||
sink), retrying 15 s because the USB device is live before its ALSA card is; only sinks of a
|
||||
DualSense **card** are touched (`device.id` keeps it off the host's own minted pad sink). Neither
|
||||
end restores on exit, deliberately. New `PUNKTFUNK_PAD_SINK_VOLUME=0` disables both ends for
|
||||
bisecting. Both pins unit-tested for one unity float per channel — PipeWire silently ignores a
|
||||
`channelVolumes` whose length mismatches the port count.
|
||||
- **`scripts/60-punktfunk-dualsense.conf`** — a new WirePlumber policy installed to
|
||||
`/usr/share/wireplumber/wireplumber.conf.d/` by rpm/deb/arch/nix: `node.always-process` + no
|
||||
suspend on the pad's `alsa_output` (GE opens the backing device raw when it is free, then hits
|
||||
"busy" against its own handle and spins a 100 Hz refresh loop — SteamOS never shows this because
|
||||
PipeWire always holds the device there), and `priority.driver = 0`. **Zero, not one**: the field is
|
||||
unsigned and a driver is skipped only when `<= 0`; at 1 the pad was merely *last*, and last is still
|
||||
elected whenever nothing above it qualifies — the ordinary in-session state on a host that has
|
||||
claimed its own sink as default and idled the real card. A second rule sets `priority.driver = 0`
|
||||
on the same cards' `alsa_input` (in the Pro Audio profile that node carries 2600 and clocked a
|
||||
reporter's whole desktop session with nothing linked to it). The rule's first landing duplicated
|
||||
its `%files` line into `%install`, which killed every RPM build on main for a few hours (fixed same
|
||||
day, no release affected).
|
||||
- **`0xD1` lane split:** speaker = Opus `Application::Audio` @ 96 kbps (~120 B / 10 ms frame),
|
||||
haptics = `Application::LowDelay` @ 64 kbps CBR, unchanged.
|
||||
- **`punktfunk-session --pad-audio-test`** now prints the effective `pad_speaker` / `pad_haptics`
|
||||
before the tone (the capability is never advertised when the toggle is off, so no later log line can
|
||||
catch it); the Android settings row states its default. Android is the one client defaulting pad
|
||||
speaker **off**; `pf_client_core`'s `default_pad_speaker` is `"pad"` and always was.
|
||||
|
||||
### The Linux desktop-audio capture drives its own graph group
|
||||
|
||||
The stream sink was a `pw_stream` wearing `media.class = Audio/Sink`. A stream is structurally a
|
||||
follower, so its group had no clock and PipeWire assigned it to the highest-priority *running*
|
||||
driver on the box. On a reporter's host that was a DualSense forwarded over VirtualHere in the Pro
|
||||
Audio profile — never suspended, nothing linked, its frame counter a kernel stub logging "not yet
|
||||
implemented" and returning 0 ~1900×/s. Not xruns: 11 errors in 15 min, wait never past 111 µs; the
|
||||
loss was *between* cycles — 3.9 delivery holes/s, worst 142 ms, **15.4 % synthesized silence** over
|
||||
a 15-minute session.
|
||||
|
||||
Now a `support.null-audio-sink` adapter created on our own connection, captured through its monitor
|
||||
(the same object `pactl load-module module-null-sink` creates). Three load-bearing properties:
|
||||
`node.passive` on the monitor tap (idle between sessions, so the null sink's timer parks — the
|
||||
objection that kept `node.always-process` off the old stream sink); `node.force-quantum`, not
|
||||
`node.latency` (a driver's quantum is the smallest follower latency rounded **down** to a power of
|
||||
two under the default `default.clock.power-of-two-quantum`, which is why the 240-frame ask has been
|
||||
served as **128** — 2.67 ms callbacks, not the 5 ms it is designed around — on every stock Linux host
|
||||
since the capture was written; force-quantum skips the rounding and forces nothing on anyone else,
|
||||
since this sink drives only its own group); and `node.dont-fallback` **with** `node.linger`, never
|
||||
one alone (WirePlumber 0.5 reads dont-fallback alone as licence to destroy the stream when its target
|
||||
is not visible). Routing claim, capture callback, stats line and everything downstream untouched.
|
||||
`PUNKTFUNK_STREAM_SINK`: unset = new topology, `stream` = 0.30's (one release), `0` = the legacy
|
||||
default-sink-monitor follower. Documented at last in `configuration.md`, with a new troubleshooting
|
||||
section on the `punktfunk-audio-…` recording stream and on another device clocking your capture.
|
||||
|
||||
Around it, from the same 2026-08-14/17 field logs:
|
||||
|
||||
- The host binds its own node and reads `node.driver-id` from its `info` event (a node-id→name map
|
||||
from the registry): on change, `audio capture graph driver` names the clocking node — WARN in the
|
||||
null-sink mode (exactly one right answer), INFO in the legacy topologies (they borrow a clock by
|
||||
design).
|
||||
- `CaptureStats::observe_gap` is now the one accounting behind both feeds (Linux callback cadence and
|
||||
the Windows discontinuity flag) and buckets holes at <20 / <50 / <100 / ≥100 ms — the client
|
||||
concealment edges. Both capture lines print `gap_hist=a/b/c/d missing_ms=`; the sum closes the
|
||||
arithmetic against `delivered_pct`. The Windows loopback **reader** thread now takes
|
||||
`boost_thread_priority(true)` like the paced sender it feeds.
|
||||
- **The pacer's schedule was wall clock; the source was not.** A missed 2.7 ms cycle is below the gap
|
||||
counter's floor and the infill threshold, so the schedule kept the debt and repaid the next ≥ 10 ms
|
||||
hole as a burst of (lag + 10)/5 silence frames (field: 33–72 % departures late, worst 99 ms,
|
||||
re-anchors 0). The infill decision now sees schedule lag; `after()` follows the real quantum
|
||||
(`InfillPolicy::note_quantum`) — one chunk plus one frame, never under two frames; a slot whose
|
||||
backlog exceeds one chunk plus one frame sends a second frame in the same slot (at most two), since
|
||||
a fast source clock could otherwise only grow the backlog — 5 ms of host latency per 50 s at
|
||||
100 ppm. Holes fade out over 1 ms (`pcm::raised_cosine_tail`) and the first real frame after fades
|
||||
in (`raised_cosine_head`).
|
||||
|
||||
### The client jitter ring can now grow without de-priming
|
||||
|
||||
`JitterStep::insert_front` mirrors `drop_front`: when the sync loop wants more than the adaptive
|
||||
target and the depth EWMA has sat > `INSERT_MARGIN_MS` below the request for `INSERT_SUSTAIN_MS` of
|
||||
consumed audio, duplicate one frame at the front, crossfaded (`crossfade_insert`, the RT-safe twin
|
||||
of `crossfade_drop`). Sync-only, primed-only, below-target-only. `hollow` is judged against the
|
||||
**adaptive** target, never the sync request — the bug was that a ≥ 10 ms sync request read as hollow
|
||||
on the next callback and the next late packet cost 15–60 ms of silence, since ~0.24/0.25. Margin is
|
||||
half the sync loop's ±10 ms deadband (a margin at or above it would leave every request it is allowed
|
||||
to make unanswered). Also fixes `crossfade_drop`'s seam: the fade-out source is now the continuation
|
||||
of the sample the device just played, not the tail of the discarded region — a hard-cap trim stepped
|
||||
2,688 samples where it now stays under 17. Wired into the PipeWire, WASAPI and AAudio rings
|
||||
(`PlaybackVitals.inserts`, `drift_inserts=` on the 10 s lines) and ported line for line to the Swift
|
||||
ring (`insertOneFrame()`, `AudioRingDriftTests` carrying the same vectors). No new `pub const`; the
|
||||
C header is unchanged.
|
||||
|
||||
Beside it: the Linux desktop client's playback stream now connects with `RT_PROCESS` (it ran on the
|
||||
main-loop thread at nice 0, and when late PipeWire rendered silence for our node and moved on — an
|
||||
underrun no counter saw); the ring is pre-reserved so `extend` never reallocates on the RT loop; new
|
||||
`audio_vitals::PlaybackVitals` printed from the decode thread on wall clock. New `audio_rt` module
|
||||
raises the decode, pad-audio, PipeWire-loop and Linux mic threads: `setpriority` where `RLIMIT_NICE`
|
||||
allows → inside a Flatpak the `org.freedesktop.portal.Realtime` portal → else rtkit
|
||||
`MakeThreadHighPriorityWithPID`. The split is `module-rt`'s and not optional: rtkit-daemon has no
|
||||
PID-namespace translation (verified on the Deck, rtkit 0.14), so a direct call from a sandbox is
|
||||
ENOENT; the portal maps pid/tid. Never setcap / `SCHED_RR`. Windows: MMCSS "Pro Audio" +
|
||||
`THREAD_PRIORITY_HIGHEST` on the render and mic loops. Acceptance on the Deck: `ps -eLo
|
||||
cls,rtprio,ni,comm` shows the decode thread at nice −10 after connect.
|
||||
|
||||
The client log ring drops DEBUG/TRACE from `cros_codecs` (its WARN+ still lands) and normalizes
|
||||
`log`-bridge events to their real target: a dozen DPB lines per frame at 120 fps last three seconds
|
||||
in a 4,096-line ring — a 2026-08-17 Deck bundle read "2,037,456 older lines evicted". `Cargo.lock`
|
||||
gains two direct deps already in the graph.
|
||||
|
||||
### Android: `pf-console-ui` is the console, presented through `ASurfaceControl`
|
||||
|
||||
- **`pf-client-core` un-gated for Android** (trust::Settings, known-hosts store, profiles model,
|
||||
deep links, the library *model*; the ureq fetches stay desktop), with `audio_format`,
|
||||
`decoder_pref`, `menu_nav` (`MenuEvent`/`MenuNav`/`PadInfo`) and `console` (`OverlayAction`,
|
||||
`PointerInput`, `SessionPhase`) split out and re-exported. `pf-console-ui`: Vulkan overlay + SDL
|
||||
event path behind the default `vulkan-overlay` feature (clients/session unchanged); a `Key` enum
|
||||
replaces SDL scancodes; a `SettingsStore` seam (desktop = the file, `SnapshotStore` across a
|
||||
language boundary); `Viewport{width,height,insets,scale}`; `Platform` filters the settings rows;
|
||||
`ConsoleOptions`; a portable `Console` driver. skia-safe features are target-specific: desktop
|
||||
`jpegd-jpege-pdf-textlayout-vulkan` (the flatpak pin), Android `gl-jpegd-jpege-pdf-textlayout`.
|
||||
Model types derive serde — the wire IS the model. `MenuNav` gains the stick hysteresis
|
||||
(`MENU_RELEASE = 0.3`) both the Apple and Android shells had grown on glass.
|
||||
- **`clients/android/native/src/console/`**: hand-declared EGL binding, Skia GL `DirectContext` over
|
||||
FBO 0, one render thread paced by `eglSwapBuffers`, ~28 `nativeConsole*` JNI seams; a run of GL
|
||||
setup failures ends the render thread through the normal release path, which raises the
|
||||
`SkiaConsole.healthy` handover to the touch UI. `SkiaConsoleShell` (SurfaceView + lifecycle,
|
||||
insets = systemBars ∪ displayCutout in surface px, system bars hidden transiently while the console
|
||||
is up, phone density floor **0.6 → 0.75**, pad probes into the shared `MenuNav`, remote D-pad,
|
||||
hardware keys, Back as B, touch as pointer). Pad-listener slot is a **stack** with removal by
|
||||
identity (a leaving Controllers/Licences page used to null the console's claim). Android-only
|
||||
settings rows ride `Settings::extra` `android.*` keys; `row_on()` keeps them off the desktop list.
|
||||
New `ConsoleCmd::PadAction { action, pad_key }` (`sc2_bluetooth`, `sc2_usb`, `ds_usb`, rumble,
|
||||
pad-audio self test); `PlatformScreen::Controllers` removed (the mechanism stays for Licences);
|
||||
`PadInfo` gains detail line / forwarded / rumble. Detail band 84 → 64 units; the grid's two-column
|
||||
minimum shrinks covers instead of clipping.
|
||||
- **Skia prebuilts** for all three ABIs come from `unom/skia-binaries` release **0.99.0** on
|
||||
git.unom.io (R2-backed), mirroring rust-skia's `{tag}/{key}` layout; the armv7 archive
|
||||
(`a25a0fdb7d90429aa2d1-armv7-linux-androideabi-gl-jpegd-jpege-pdf-textlayout`, sha256
|
||||
`4867856b…`) is built by us since rust-skia publishes none. GitHub is out of the Android build path;
|
||||
`-PskiaBinariesUrl` / `SKIA_BINARIES_URL` remain as overrides.
|
||||
- **Present path:** the codec renders into an `AImageReader`; frames are composited onto an
|
||||
`ASurfaceControl` layer via a transaction carrying a desired present time, and completion reports
|
||||
the real latch time and the previous buffer's release fence — so the panel period is learned from
|
||||
real latches (Android down-rates a game process's vsync callbacks; the old presenter could learn 60
|
||||
on a 120 Hz panel) and the frame budget is bounded by real completions. `ASurfaceControl` /
|
||||
`ASurfaceTransaction` are not in ndk-sys 0.6, so `surface_control.rs` hand-declares them and
|
||||
resolves via `dlsym` from `libandroid.so` (all API 29, above minSdk 28), same pattern as `adpf.rs` /
|
||||
`vsync.rs`. Memory safety does not rest on the fences (an `AImage` keeps its buffer alive through
|
||||
SurfaceFlinger's own reference; a mishandled fence is at worst a tear). **Default**; auto-fallback
|
||||
to the SurfaceView presenter, byte-for-byte unchanged, on API < 29 or any init failure; escape hatch
|
||||
`debug.punktfunk.present_backend=surfaceview`. The layer is sized to the view's on-screen pixels,
|
||||
not the window buffer (which is reported in a rotated/scaled space — 1260×567 for a 2800×1260
|
||||
stream, drawing into the top-left 45 %). The present-time grid uses the mode table's seed period
|
||||
for spacing and the last real latch only for phase (learning the period from latches was
|
||||
self-fulfilling and locked the panel at 60). On glass at 2800×1260@120: e2e p50 30 → ~18 ms,
|
||||
skipped 40–50/s → 0. Whether the panel *holds* 120 is the OEM's LTPO governor — measured: no
|
||||
app-side API (`preferredDisplayModeId`, `preferredRefreshRate`, the layer rate vote,
|
||||
`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.
|
||||
|
||||
### Hyprland / sway: `topology: exclusive` (closes #284)
|
||||
|
||||
`exclusive` disables the operator's outputs for the session and restores them when the display
|
||||
group's last member is torn down, through the same registry hand-off KWin uses (the compositor never
|
||||
sees zero enabled outputs; a sibling session's desk is never re-enabled under it). The disable filter
|
||||
is group-aware — enabled, not ours (`PF-<pid>-<n>` on Hyprland, the `HEADLESS-` prefix on sway), not
|
||||
managed. **The Hyprland restore is `hyprctl reload`, and that is measured, not chosen**: re-applying
|
||||
the head's own mode/position/scale does not undo a disable (probed 2026-08-18 against 0.56.2
|
||||
hyprlang and 0.55.4 Lua — every targeted form was accepted at exit 0 and changed nothing, including
|
||||
`,enable`, `preferred,auto,1`, `monitorv2 disabled=false`, `keyword unset monitor`, the Lua
|
||||
`disabled = false`, `dispatch dpms on`, `forcerendererreload`); a runtime rule is additive and the
|
||||
disable keeps winning. Disable is spelled per config era (`keyword monitor <n>,disable` under
|
||||
hyprlang; `hl.monitor{ output = "<n>", disabled = true }` under Lua) and confirmed by **read-back**,
|
||||
not exit status. `hyprctl_dispatch` now also matches "can't" (the Lua manager's "keyword can't work
|
||||
with non-legacy parsers"). `primary` stays extend and warns distinctly. ⚠ **The sway half is not
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### Windows host: two session-killers
|
||||
|
||||
- **`untune_process` logged from a TLS destructor.** By then `tracing`'s own thread-local state can be
|
||||
gone; the log call panicked, and a panic escaping a TLS destructor aborts. The panic hook then hid
|
||||
the evidence — it logged through the same framework and panicked the same way, and a panic inside
|
||||
the hook is a case where std deliberately does not format the message (the field log: a location, a
|
||||
blank line, "thread panicked while processing panic. aborting."). The service manager restarted the
|
||||
host ~6 s later, so it read as a reconnect. `untune_process` no longer logs (still atomic under the
|
||||
refcount lock); the panic hook writes straight to the `LogRing` (`OnceLock` + `Mutex`, TLS-free;
|
||||
`thread::current()` and `Backtrace::force_capture()` verified safe during TLS destruction).
|
||||
Reproduced standalone on 1.96.0, byte-identical to the field log.
|
||||
- **A Windows launch is a hand-off, and 0.30 read its exit as the game's.** `explorer.exe
|
||||
"playnite://…"`, `Steam.exe "steam://…"` and shell app-folder links spawn a forwarder that quits a
|
||||
second later (launcher already running) or *becomes* the launcher (it was not); the shim window that
|
||||
guards this was skipped for hint-less titles — the one shape that needs it — so the lease reported
|
||||
running, then the forwarder's exit closed the connection. The forwarder was also a termination
|
||||
target. `WinRecipe::owns_game` records which recipe lines start the game (only `gog`, `command` and
|
||||
a plugin's own recipe) and which forward; a forwarder's pid is dropped; the shim window applies to a
|
||||
bare child or pid whatever the spec holds; giving up on tracking lands on `GameState::Untracked`
|
||||
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.
|
||||
|
||||
### Everything else an integrator might notice
|
||||
|
||||
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
|
||||
the runner's log shipper and the tray dialing a dead port (task Running, plugins never registering,
|
||||
empty library, "no logs at all"). `sdk/src/config.ts::publishedMgmtUrl` reads
|
||||
`<config_dir>/mgmt-endpoint`; `resolveConfig` uses it after `PUNKTFUNK_MGMT_URL` and before the
|
||||
default; `runner-cli.ts` exports it into `PUNKTFUNK_MGMT_URL` before any plugin loads (older
|
||||
vendored SDK copies follow too). New `pf_paths::published_mgmt_port`; `punktfunk-tray` depends on
|
||||
`pf-paths` and its `mgmt_port` is `Option<u16>` — `None` re-reads the file every poll. SDK 83 tests
|
||||
(4 new). **Unpublished — `sdk-v0.1.5` owed.**
|
||||
- **`scripts/windows/scripting-run.cmd`** redirects the runner's stdout+stderr to
|
||||
`%ProgramData%\punktfunk\plugin-state\runner.log` (previous run rotated to `.1`; writability probed
|
||||
with `copy /y nul`; no `goto`, the file is LF). Verified by reading only.
|
||||
- **`@punktfunk/plugin-kit`: `SyncSettings.minInterval`** (optional; `LibraryPluginDef.minInterval`
|
||||
overrides), default `DEFAULT_FS_CHANGE_MIN_INTERVAL` = 30 s — a floor on top of the 3 s debounce,
|
||||
which cannot bound the *rate* under sustained churn (`plugin:steam sync (fs-change)` 102× in
|
||||
27 min). Changes inside the hold coalesce into one trailing sync. **Unpublished — `plugin-kit-v0.4.3`
|
||||
owed.** Narrowing the Steam plugin's watch set lives in the steam plugin repo.
|
||||
- **Nix binary cache at `https://nix.unom.io`** (`nix.yml` third tier: build Rust packages +
|
||||
gamescope, sign, publish on every main push; a release needs no new trigger since `Cargo.toml` is
|
||||
in the path filter). Only punktfunk's own store paths (~300 MB per publish); the step asserts every
|
||||
output matches the name filter; NARs before narinfos, rsync without `--delete`. New
|
||||
`packaging/nix/server/{Caddyfile,compose.production.yml,prune.sh}` (a `caddy:2-alpine` static tree
|
||||
on unom-1 beside the flatpak repo) and `scripts/setup-nix-cache.sh` (five stages; the secret key is
|
||||
shown once and never written to disk; four stages after #318, which also made it detect an
|
||||
installed key and refuse to casually regenerate one). The signing key is generated and installed as
|
||||
the `NIX_CACHE_SIGNING_KEY` Actions secret; its public half,
|
||||
`punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=`, is pinned in `install.md` and
|
||||
`packaging/nix/README.md` and served by the cache at `/punktfunk-cache.pub` (the wizard compares the
|
||||
two and warns on mismatch). DNS for `nix.unom.io` is provisioned through `unom/infra`'s OpenTofu
|
||||
(`terraform/cloudflare/records.tf`, applied by `dns-cutover.yml`) — not a dashboard click.
|
||||
`inputs.punktfunk.inputs.nixpkgs.follows` defeats the cache entirely. Rejected: Gitea's package
|
||||
registry (no Nix type), storage.unom.io (home uplink, and S3 answers 403 not 404 for a missing key,
|
||||
which nix treats as fatal).
|
||||
- **Apple console-UI parity** (Swift, PunktfunkKit/PunktfunkShared): `LibraryCollation` ports
|
||||
`pf-console-ui`'s `collate.rs` (the desktop's eight tests by name; both read
|
||||
`clients/shared/library-collate-vectors.json`, new — desktop is the source of truth and regenerates
|
||||
it); `GameEntry.platform` (sent in `GameMeta` all along, dropped by `Codable`); `LibraryPlaceStack`,
|
||||
`CollectionsHandover.decide`, `LibraryGridCursor` (port of `GridShape`/`grid_step`/`grid_col_hint`,
|
||||
nine grid tests by name), `GridGeometry` (the grid owns its scroll offset — no trackpad wheel on the
|
||||
grid, a named trade); `ConsoleContract.swift` pins `ConsoleMotion` to the shared vectors'
|
||||
`motion_spring` (response 0.42, damping 0.88, slide 36, scales 0.985/0.96, reveal 0.4,
|
||||
interruptible; the v1 `$deprecated` note now names Android as the last v1 reader — and Android
|
||||
moved to the shared shell in this same release). Device keys `librarySort` / `libraryView` /
|
||||
`libraryCollections` / `libraryGroupBy` — presentation only, never in a profile. `PosterImage`
|
||||
decodes at the drawn size (`CGImageSourceCreateThumbnailAtIndex`). `HostCardView`'s primary action
|
||||
reverted to connect (`22fdea66` reverted; `swift test` 375/0). New dev hooks
|
||||
`PUNKTFUNK_FAKE_LIBRARY=<file.json>`, `PUNKTFUNK_SHOT_EDITING=<field>`, `PUNKTFUNK_SHOT_INTERACTIVE=1`
|
||||
(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.
|
||||
`PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time.
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
|
||||
⚠ **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.
|
||||
|
||||
⚠ **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).
|
||||
|
||||
⚠ **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.
|
||||
|
||||
---
|
||||
|
||||
## v0.30.0
|
||||
|
||||
175 commits since v0.29.0 (131 non-merge).
|
||||
|
||||
Generated
+37
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3073,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3083,7 +3083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3109,7 +3109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3122,7 +3122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3136,11 +3136,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3169,14 +3169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3191,7 +3191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3199,7 +3199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3211,7 +3211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3220,7 +3220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3264,7 +3264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3275,7 +3275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3487,7 +3487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3497,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3521,7 +3521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pf-client-core",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3572,7 +3572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3604,7 +3604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3613,7 +3613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3683,7 +3683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3697,11 +3697,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
"libc",
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"serde",
|
||||
@@ -3720,7 +3721,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -49,6 +49,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
@@ -101,6 +104,26 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
// System bars have ONE owner: this effect. The stream and the console shell both want the
|
||||
// whole panel (bars hidden, a swipe shows them transiently); the touch shell wants them back.
|
||||
// It cannot live inside the screens themselves: `AnimatedContent` below keeps the outgoing
|
||||
// screen composed until its fade ends, so a per-screen `onDispose { show(...) }` fired AFTER
|
||||
// the incoming screen's hide — console → stream left the status and gesture bars parked over
|
||||
// the video. Keyed on the resolved intent, not the screens.
|
||||
val immersive = session != null || gamepadUi
|
||||
DisposableEffect(immersive) {
|
||||
val window = activity?.window ?: return@DisposableEffect onDispose {}
|
||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||
if (immersive) {
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
} else {
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -14,7 +13,6 @@ import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -49,11 +47,8 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
@@ -61,158 +56,34 @@ import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Connected-controllers debug view (Settings → Host → Connected controllers): everything the app
|
||||
* can see about attached input devices, plus a live input test. This exists for exactly the support
|
||||
* case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different
|
||||
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
|
||||
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
|
||||
* Connected-controllers debug view (Settings -> Controller -> Connected controllers): everything
|
||||
* the app can see about attached input devices, plus a live input test. This exists for exactly
|
||||
* the support case where a pad "doesn't work" - adapters and BT-to-USB dongles often enumerate
|
||||
* with a different identity than the physical pad, or not as a gamepad at all, and punktfunk only
|
||||
* forwards devices Android classifies as gamepad/joystick. This screen makes that visible on the
|
||||
* device itself.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
|
||||
* field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has
|
||||
* to be the same one whichever interface asked.
|
||||
* The TOUCH presentation, and since 2026-08 the only one: the console reaches the same answer
|
||||
* through its own Skia screen (`crates/pf-console-ui/src/screens/controllers.rs`), which keeps the
|
||||
* console's input on the page instead of suspending it behind a Compose takeover. What this screen
|
||||
* still owns alone is the live input test - the console receives only the aggregated navigation
|
||||
* sample, which is nowhere near a per-device axis/trigger readout. Everything the console DOES
|
||||
* need from here it asks for as a `ConsoleCmd::PadAction` (see [SkiaConsoleShell]), which is why
|
||||
* [padInfoOf] and [testRumble] are internal rather than private.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
observeInput = true,
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same screen on the console's field — the couch route to it, which a TV box has no other way to
|
||||
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
|
||||
*
|
||||
* Navigation, and how the pad is shared with the test:
|
||||
* * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and
|
||||
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
|
||||
* * A starts the input test, which is the one thing on this screen a controller can act on;
|
||||
* * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleControllersScreen(
|
||||
internal fun ControllersScreen(
|
||||
gamepadSetting: Int,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
// Events are OBSERVED (not consumed) while the test is off, which is what keeps the
|
||||
// "Last input" line live while browsing. Nothing else here wants the pad.
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
// Off while the test runs: both want the same single probe slot, and the test is the one
|
||||
// the user just asked for. The identity check in each teardown (here and in the body) is
|
||||
// what makes the handover safe in either direction.
|
||||
active = navActive && !testing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Nothing on this screen steps sideways; paging is the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { testing = true },
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
|
||||
// and the aurora is ambience. Only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// The body is written against the touch theme; on the console field it has to be inked
|
||||
// from the palette or it is grey-on-pastel over the six pale palettes.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
// proved it is seen — by moving the cursor here.
|
||||
observeInput = testing,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
// Clears the floating legend zone, like every other console list.
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Connected controllers", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (testing) {
|
||||
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
|
||||
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
|
||||
// test, after which B is the universal Back again. Tappable as the touch hatch.
|
||||
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
|
||||
} else {
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// Advertised only where they exist — a TV remote has no shoulders, and
|
||||
// claiming otherwise is both a lie and the reason a narrow legend overflows.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('A', "Test inputs") { testing = true },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
|
||||
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
|
||||
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
|
||||
*
|
||||
* [observeInput] decides whether this body installs the shared MainActivity probes at all — see the
|
||||
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun ControllersBody(
|
||||
gamepadSetting: Int,
|
||||
scroll: ScrollState,
|
||||
testing: Boolean,
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val onTestingChange: (Boolean) -> Unit = { testing = it }
|
||||
val contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp)
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
@@ -247,14 +118,14 @@ private fun ControllersBody(
|
||||
var bHeld by remember { mutableStateOf(false) }
|
||||
// The hold has lasted long enough; the test ends when B is let go (see the probe).
|
||||
var holdSatisfied by remember { mutableStateOf(false) }
|
||||
// The probes below are built ONCE per `observeInput` and then read these for the life of that
|
||||
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
|
||||
// would freeze the values they had when the probe was made — the test would consume nothing.
|
||||
// The probes below are built ONCE and then read these for the life of the screen, so
|
||||
// capturing `testing` plainly would freeze the value it had when the probe was made — the
|
||||
// test would consume nothing.
|
||||
val consuming by rememberUpdatedState(testing)
|
||||
// The console's refusal thud, on whatever actuator the driving pad or this device has.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
|
||||
DisposableEffect(observeInput) {
|
||||
DisposableEffect(Unit) {
|
||||
// One entry on the MainActivity probe stack, removed by identity on the way out — the rule
|
||||
// GamepadNavEffect2D follows. During the console shell's push/pop BOTH screens are briefly
|
||||
// composed, and only the identity removal keeps this screen's teardown from taking the
|
||||
@@ -317,11 +188,9 @@ private fun ControllersBody(
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
}
|
||||
val probes = if (observeInput) MainActivity.PadProbes(keyProbe, motionProbe) else null
|
||||
probes?.let { activity?.pushPadProbes(it) }
|
||||
onDispose {
|
||||
probes?.let { activity?.removePadProbes(it) }
|
||||
}
|
||||
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
|
||||
activity?.pushPadProbes(probes)
|
||||
onDispose { activity?.removePadProbes(probes) }
|
||||
}
|
||||
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
|
||||
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
|
||||
@@ -346,7 +215,7 @@ private fun ControllersBody(
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
heading()
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
@@ -937,7 +806,8 @@ private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
dev.vibrator.hasVibrator()
|
||||
}
|
||||
|
||||
private fun testRumble(dev: InputDevice) {
|
||||
/** A short pulse on the pad's own motor. Also the console's `PadAction::Rumble`. */
|
||||
internal fun testRumble(dev: InputDevice) {
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val vm = dev.vibratorManager
|
||||
|
||||
@@ -962,9 +962,14 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
// The one row here that is OFF by default (see Settings.padSpeaker for why), which
|
||||
// makes a silent pad speaker look exactly like broken hardware — the failure this
|
||||
// subtitle exists to pre-empt, after it cost a full evening of host-side measuring.
|
||||
// Say the default out loud rather than describing only what "on" does.
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker — " +
|
||||
"off by default, so the pad's speaker stays silent until you turn this on",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
|
||||
@@ -58,7 +58,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -420,10 +419,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(true)
|
||||
}
|
||||
controller?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// System bars: NOT hidden here — App.kt owns hide/show (one owner; the AnimatedContent
|
||||
// handoff broke per-screen ownership, see the `immersive` effect there).
|
||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||
@@ -817,7 +814,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||
}
|
||||
}
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(false)
|
||||
|
||||
@@ -11,6 +11,7 @@ import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.padInfoOf
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -229,16 +230,25 @@ internal object ConsoleJson {
|
||||
|
||||
/**
|
||||
* `{"label", "pref", "pads": [...]}` — the controller chip's text (the driving pad's name),
|
||||
* the glyph style's pref byte, and one entry per connected pad for the settings rows.
|
||||
* the glyph style's pref byte, and one entry per connected pad for the settings rows and the
|
||||
* console's Connected-controllers screen.
|
||||
*
|
||||
* `detail`/`forwarded`/`rumble` come straight from [padInfoOf], the same reader the touch
|
||||
* Controllers screen renders from: the support answer a user gets must not depend on which
|
||||
* interface asked, and two readers of `InputDevice` would be two answers waiting to drift.
|
||||
*/
|
||||
fun pads(pads: List<InputDevice>, driving: InputDevice?): String {
|
||||
val arr = JSONArray()
|
||||
for (d in pads) {
|
||||
val info = padInfoOf(d)
|
||||
val entry = JSONObject()
|
||||
.put("name", d.name)
|
||||
.put("key", "${d.vendorId}:${d.productId}:${d.name}")
|
||||
.put("pref", Gamepad.prefFor(d))
|
||||
.put("steam_virtual", false)
|
||||
.put("detail", info.detail)
|
||||
.put("forwarded", info.forwarded)
|
||||
.put("rumble", info.canRumble)
|
||||
val battery = if (android.os.Build.VERSION.SDK_INT >= 31) {
|
||||
val b = d.batteryState
|
||||
if (b.isPresent && b.capacity >= 0f) {
|
||||
|
||||
@@ -104,6 +104,7 @@ object SkiaConsole {
|
||||
private var onSettingsChange: ((Settings) -> Unit)? = null
|
||||
private var onQuit: (() -> Unit)? = null
|
||||
private var onPlatformScreen: ((String) -> Unit)? = null
|
||||
private var onPadAction: ((String, String) -> Unit)? = null
|
||||
private var onPulse: ((String) -> Unit)? = null
|
||||
|
||||
/** The connect in flight, if any — cancelable through `OverlayAction::CancelConnect`. */
|
||||
@@ -251,12 +252,14 @@ object SkiaConsole {
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
onPlatformScreen: (String) -> Unit,
|
||||
onPadAction: (String, String) -> Unit,
|
||||
onPulse: (String) -> Unit,
|
||||
) {
|
||||
this.onConnected = onConnected
|
||||
this.onSettingsChange = onSettingsChange
|
||||
this.onQuit = onQuit
|
||||
this.onPlatformScreen = onPlatformScreen
|
||||
this.onPadAction = onPadAction
|
||||
this.onPulse = onPulse
|
||||
discovery?.restart()
|
||||
// The touch UI may have paired/forgotten/edited hosts or profiles while we were away.
|
||||
@@ -270,6 +273,7 @@ object SkiaConsole {
|
||||
onSettingsChange = null
|
||||
onQuit = null
|
||||
onPlatformScreen = null
|
||||
onPadAction = null
|
||||
onPulse = null
|
||||
}
|
||||
|
||||
@@ -388,7 +392,7 @@ object SkiaConsole {
|
||||
NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all()))
|
||||
}
|
||||
|
||||
private fun notice(text: String) {
|
||||
internal fun notice(text: String) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleNotice(handle, text)
|
||||
}
|
||||
|
||||
@@ -539,6 +543,7 @@ object SkiaConsole {
|
||||
c.optJSONObject("Wake")?.let(::wake)
|
||||
c.optJSONObject("SetPin")?.let(::setPin)
|
||||
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
|
||||
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
|
||||
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
|
||||
?.let { onPlatformScreen?.invoke(it) }
|
||||
}
|
||||
|
||||
+116
-11
@@ -1,5 +1,9 @@
|
||||
package io.unom.punktfunk.console
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
@@ -26,15 +30,20 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import androidx.core.app.ActivityCompat
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
|
||||
import io.unom.punktfunk.MainActivity
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.SettingsStore
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.LibraryReturn
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
import io.unom.punktfunk.rememberConsoleHaptics
|
||||
import io.unom.punktfunk.testRumble
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -44,9 +53,9 @@ import kotlin.math.roundToInt
|
||||
*
|
||||
* What lives here is only what needs a composition: the surface lifecycle, the safe-area insets,
|
||||
* the pad probes (raw pad → the shared menu synthesizer, over JNI), the system Back, the
|
||||
* platform-native sub-screens the console can open (Controllers, Licences — Compose, drawn over the
|
||||
* surface), and the two intents the app hands over on the way in (a deep link, "come back to this
|
||||
* shelf").
|
||||
* platform-native sub-screen the console can open (Licences — Compose, drawn over the surface;
|
||||
* Connected controllers is the console's own Skia screen now), and the two intents the app hands
|
||||
* over on the way in (a deep link, "come back to this shelf").
|
||||
*/
|
||||
@Composable
|
||||
fun SkiaConsoleShell(
|
||||
@@ -74,6 +83,7 @@ fun SkiaConsoleShell(
|
||||
onSettingsChange = { currentOnSettingsChange(it) },
|
||||
onQuit = { activity?.moveTaskToBack(true) },
|
||||
onPlatformScreen = { platformScreen = it },
|
||||
onPadAction = { action, key -> padAction(activity, action, key) },
|
||||
onPulse = { pulse ->
|
||||
when (pulse) {
|
||||
"move" -> haptics.tick()
|
||||
@@ -102,8 +112,16 @@ fun SkiaConsoleShell(
|
||||
SkiaConsole.handleDeepLink(url)
|
||||
}
|
||||
|
||||
// The console owns the whole panel while it fronts the app, exactly like the stream: the
|
||||
// status bar and the gesture bar are hidden (a swipe shows them transiently). This is both the
|
||||
// space win AND the safe-area fix — hidden bars report zero insets, so the scroll clips that
|
||||
// used to end at the visible gesture-bar line now run to the panel edge. Only the display
|
||||
// cutout stays a real inset. The hide/show itself lives in App.kt (one owner; a per-screen
|
||||
// `onDispose { show }` fired after the stream's hide during the AnimatedContent cross-fade).
|
||||
|
||||
// The safe area, in surface pixels: system bars ∪ display cutout — the NP3's landscape punch
|
||||
// is a SIDE inset, and the console's chrome must stay clear of it (its backdrop need not).
|
||||
// With the bars hidden above, this is normally just the cutout.
|
||||
val density = LocalDensity.current
|
||||
val ld = LocalLayoutDirection.current
|
||||
val insets = WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
@@ -115,12 +133,14 @@ fun SkiaConsoleShell(
|
||||
// the same 800-unit field as a Deck); a phone or tablet in the hand gets a density FLOOR
|
||||
// under that formula, so type never shrinks below what the touch UI draws at the same
|
||||
// density (design D5 — a bare height/800 on a 460 dpi phone lands ~26 % smaller than a Deck).
|
||||
// The 0.6 is the on-glass tuning knob.
|
||||
// The 0.75 is the on-glass tuning knob — raised from 0.6 after a 460 dpi phone (Nothing
|
||||
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
|
||||
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
|
||||
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
|
||||
val scale = if (tv) 0f else {
|
||||
val dm = context.resources.displayMetrics
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
maxOf(couch, density.density * 0.6f).coerceIn(0.75f, 3f)
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
|
||||
@@ -293,16 +313,101 @@ fun SkiaConsoleShell(
|
||||
},
|
||||
)
|
||||
when (platformScreen) {
|
||||
"controllers" -> ConsoleControllersScreen(
|
||||
gamepadSetting = settings.gamepad,
|
||||
onBack = { platformScreen = null },
|
||||
navActive = true,
|
||||
)
|
||||
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `ConsoleCmd::PadAction` from the console's Connected-controllers screen — the handful of
|
||||
* things only the platform can do: a rumble pulse on the real [InputDevice], the USB/Bluetooth
|
||||
* grant dialogs, the DualSense pad-audio self test. The touch Controllers screen keeps its own
|
||||
* buttons for the same actions; both routes end in the same helpers ([testRumble], the grant
|
||||
* intents, `nativePadAudioSelfTest`), so the support answer cannot drift between interfaces.
|
||||
* Runs on the main thread (the command drain lives there); results ride [SkiaConsole.notice].
|
||||
*/
|
||||
private fun padAction(activity: MainActivity?, action: String, padKey: String) {
|
||||
if (activity == null) return
|
||||
val settings = SettingsStore(activity).load()
|
||||
val usb = activity.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
when (action) {
|
||||
"rumble" ->
|
||||
Gamepad.pads()
|
||||
.firstOrNull { "${it.vendorId}:${it.productId}:${it.name}" == padKey }
|
||||
?.let(::testRumble)
|
||||
"sc2_bluetooth" -> when {
|
||||
!settings.sc2Capture ->
|
||||
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
|
||||
Sc2BleLink.permissionGranted(activity) ->
|
||||
SkiaConsole.notice("Bluetooth access is already granted.")
|
||||
// The system dialog pauses the activity; onResume re-probes and engages the capture,
|
||||
// the same way the menu-time auto-ask completes.
|
||||
else -> Sc2BleLink.CONNECT_PERMISSION?.let {
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(it), 5)
|
||||
}
|
||||
}
|
||||
"sc2_usb" ->
|
||||
if (!settings.sc2Capture) {
|
||||
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
|
||||
} else {
|
||||
// Asks for the USB grant when one is missing and engages the capture on it.
|
||||
activity.startSc2MenuNav(forceAsk = true)
|
||||
}
|
||||
"ds_usb" -> {
|
||||
val dev = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
when {
|
||||
!settings.dsCapture ->
|
||||
SkiaConsole.notice(
|
||||
"Enable \"DualSense / DualShock passthrough (USB)\" in Settings first.",
|
||||
)
|
||||
dev == null -> SkiaConsole.notice("No wired DualSense or DualShock 4 detected.")
|
||||
usb.hasPermission(dev) -> SkiaConsole.notice("USB access is already granted.")
|
||||
else -> usb.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
activity, 3, // requestCode 3 — shared with the touch card's button
|
||||
Intent(DS_USB_PERMISSION_ACTION).setPackage(activity.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
"ds_haptics" -> {
|
||||
val dev = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
when {
|
||||
dev == null -> SkiaConsole.notice("No wired DualSense detected.")
|
||||
DsDevice.modelFor(dev.productId) == DsDevice.Model.DUALSHOCK4 ->
|
||||
SkiaConsole.notice("The DualShock 4 has no haptics audio device.")
|
||||
!usb.hasPermission(dev) -> SkiaConsole.notice("Grant USB access first.")
|
||||
else -> Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be shared with
|
||||
// another transfer engine, and that applies to this test as much as to the
|
||||
// real path (same rule as the touch card's test).
|
||||
val conn = runCatching { usb.openDevice(dev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) NativeBridge.nativePadAudioSelfTest(fd, 3, 60) else -1
|
||||
conn?.close()
|
||||
SkiaConsole.notice(
|
||||
when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 ->
|
||||
"Could not open the pad's audio interface. Some kernels " +
|
||||
"refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
},
|
||||
)
|
||||
}, "pf-pad-selftest-console").start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The raw pad as one `MenuSample`, pushed whenever any part of it changes. */
|
||||
private class PadState {
|
||||
var deviceId = -1
|
||||
|
||||
+3
-16
@@ -127,9 +127,9 @@ class ScreenshotTest {
|
||||
WakeTimedOutScene()
|
||||
}
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
// The licences view — the one screen the console still opens as a Compose takeover. Shot on a
|
||||
// dark AND a pale palette, because the console draws it through a ColorScheme derived from the
|
||||
// palette's ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
|
||||
|
||||
@@ -137,9 +137,6 @@ class ScreenshotTest {
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The touch presentation, pads connected — landscape, like every store frame: the app is
|
||||
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
|
||||
@@ -148,12 +145,6 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun controllers() = shootRoot("controllers") { ControllersScene() }
|
||||
|
||||
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleControllersLandscape() =
|
||||
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The same shelf as the TOUCH grid — the presentation a finger gets from a host card's
|
||||
* "Browse library…". Portrait (the default qualifiers), because that is the orientation a
|
||||
@@ -162,10 +153,6 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun libraryTouch() = shootRoot("library-touch") { TouchLibraryScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -62,7 +62,6 @@ import coil.test.FakeImageLoaderEngine
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.AddHostSheet
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleHeader
|
||||
import io.unom.punktfunk.ConsoleLegendInset
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
@@ -559,36 +558,24 @@ private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The two screens the console could not reach at all until WP8.3 — the open-source notices and the
|
||||
* connected-controllers view — in their console presentation.
|
||||
* The one Compose screen the console still opens over itself — the open-source notices — in its
|
||||
* console presentation. (Connected controllers used to be its sibling here; it is the console's
|
||||
* own Skia screen now, covered by pf-console-ui's tests.)
|
||||
*
|
||||
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
|
||||
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* Worth a shot, and worth a PALE one: it is an ordinary Material screen underneath, and the
|
||||
* console shows it through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Its touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the
|
||||
* deterministic connected-pads state the store listing needs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
// Robolectric enumerates no input devices, so the shot injects the two pads the store
|
||||
// listing talks about — the empty "no controller detected" state proves the palette but
|
||||
// sells nothing.
|
||||
ConsoleControllersScreen(
|
||||
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
|
||||
* [Surface]: the activity provides the dark ground in the app, and without one here the content
|
||||
* color falls back to black-on-white while the cards stay dark.
|
||||
* The controllers screen with [shotPads] injected — Robolectric enumerates no input devices, and
|
||||
* the connected-pad card is the point of the shot. Wrapped in a background [Surface]: the
|
||||
* activity provides the dark ground in the app, and without one here the content color falls
|
||||
* back to black-on-white while the cards stay dark.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScene() =
|
||||
|
||||
@@ -40,7 +40,4 @@ class TvScreenshotTest {
|
||||
@Test
|
||||
fun streamDetailed() =
|
||||
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
}
|
||||
|
||||
@@ -83,6 +83,13 @@ struct PadJson {
|
||||
steam_virtual: bool,
|
||||
#[serde(default)]
|
||||
battery: Option<BatteryJson>,
|
||||
/// `VID:PID · gamepad · dpad` — what the controllers screen prints under the name.
|
||||
#[serde(default)]
|
||||
detail: String,
|
||||
#[serde(default)]
|
||||
forwarded: bool,
|
||||
#[serde(default)]
|
||||
rumble: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -454,8 +461,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNavi
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetPads(handle, padsJson)` — the connected controllers for the
|
||||
/// chip + settings rows: `{"label": "DualSense", "pref": 1, "pads": [{name, key, pref,
|
||||
/// steam_virtual, battery: {percent, charging} | null}]}`.
|
||||
/// chip, the settings rows and the controllers screen: `{"label": "DualSense", "pref": 1,
|
||||
/// "pads": [{name, key, pref, steam_virtual, battery: {percent, charging} | null, detail,
|
||||
/// forwarded, rumble}]}`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetPads(
|
||||
mut env: EnvUnowned,
|
||||
@@ -479,6 +487,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetP
|
||||
percent: b.percent.min(100),
|
||||
charging: b.charging,
|
||||
}),
|
||||
detail: j.detail,
|
||||
forwarded: j.forwarded,
|
||||
rumble: j.rumble,
|
||||
})
|
||||
.collect();
|
||||
h.shared.send(Cmd::Pads {
|
||||
|
||||
@@ -115,7 +115,7 @@ pub(super) struct AscBackend {
|
||||
/// Fixed for the session; the mode table is authoritative for the panel's fastest refresh.
|
||||
panel_seed_ns: i64,
|
||||
last_latch_ns: i64,
|
||||
/// HDR `ADataSpace` for the transaction (`0` = SDR / leave default).
|
||||
/// `ADataSpace` for the transaction (BT709 for SDR — never untagged; see `color_dataspace`).
|
||||
dataspace: i32,
|
||||
/// Layer frame-rate vote (source Hz), applied once.
|
||||
frame_rate: f32,
|
||||
@@ -143,7 +143,7 @@ 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);
|
||||
/// `dataspace` the HDR `ADataSpace` (`0` = SDR); `source_hz` the negotiated stream rate.
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
@@ -571,9 +571,9 @@ impl AscBackend {
|
||||
}
|
||||
|
||||
impl AscBackend {
|
||||
/// Update the HDR `ADataSpace` applied to every subsequent transaction (from the codec's
|
||||
/// output format once it is known — the analogue of the SurfaceView path's
|
||||
/// `apply_hdr_dataspace`). `0` leaves the surface SDR.
|
||||
/// Update the `ADataSpace` applied to every subsequent transaction (a refinement from the
|
||||
/// codec's output format — the analogue of the SurfaceView path's `apply_hdr_dataspace`; the
|
||||
/// negotiated colour set the initial value at create).
|
||||
pub(super) fn set_dataspace(&mut self, dataspace: i32) {
|
||||
if self.dataspace != dataspace {
|
||||
self.dataspace = dataspace;
|
||||
|
||||
@@ -15,8 +15,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use super::asc_presenter::{asc_backend_selected, AscBackend};
|
||||
use super::display::{
|
||||
apply_hdr_dataspace, hdr_dataspace, install_render_callback, release_render_callback,
|
||||
DisplayTracker,
|
||||
apply_hdr_dataspace, color_dataspace, hdr_dataspace, install_render_callback,
|
||||
release_render_callback, DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
|
||||
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||
@@ -192,11 +192,9 @@ pub(super) fn run_async(
|
||||
// below is the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview`
|
||||
// sysprop. A non-null `asc` means the codec renders into the reader, not the SurfaceView window.
|
||||
let mut asc = if asc_backend_selected() {
|
||||
let initial_ds = if client.color.is_hdr() {
|
||||
i32::from(ndk::data_space::DataSpace::Bt2020ItuPq)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// The negotiated colour is authoritative (PQ vs HLG, range) — not a guess the codec's
|
||||
// output format later corrects; many decoders never echo `color-transfer` at all.
|
||||
let initial_ds = color_dataspace(&client.color);
|
||||
AscBackend::create(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
@@ -449,7 +447,12 @@ pub(super) fn run_async(
|
||||
if fmt_dirty {
|
||||
if let Some(a) = asc.as_mut() {
|
||||
// ASC carries the HDR signal on the transaction, not the SurfaceView window.
|
||||
a.set_dataspace(hdr_dataspace(&codec).map_or(0, i32::from));
|
||||
// Refine only when the codec actually reports an HDR transfer — a `None` echo
|
||||
// (decoders commonly omit `color-transfer`) must not clobber the negotiated
|
||||
// dataspace back to SDR before the first present.
|
||||
if let Some(ds) = hdr_dataspace(&codec) {
|
||||
a.set_dataspace(i32::from(ds));
|
||||
}
|
||||
} else {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
|
||||
@@ -274,3 +274,26 @@ pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the *negotiated* session colour ([`ColorInfo`], carried on Welcome) to the `ADataSpace`
|
||||
/// the presenter should tag buffers with. This is the authoritative source — the wire contract
|
||||
/// says clients configure the presenter from these code points, not from what the decoder happens
|
||||
/// to echo back (many decoders omit `color-transfer` from the output format).
|
||||
///
|
||||
/// SDR maps to `BT709` (limited-range video), never `0`/untagged: an untagged buffer on an
|
||||
/// ASurfaceControl transaction leaves SurfaceFlinger to guess, and a full-range guess shows
|
||||
/// limited-range black (16) as gray — the elevated-blacks bug.
|
||||
// ponytail: full-range SDR would need hand-composed dataspace bits (no named constant); the host
|
||||
// only encodes limited-range SDR today (ColorInfo::SDR_BT709), so BT709 covers every SDR session.
|
||||
pub(super) fn color_dataspace(color: &punktfunk_core::quic::ColorInfo) -> i32 {
|
||||
use punktfunk_core::quic::ColorInfo;
|
||||
let full = color.full_range != 0;
|
||||
let ds = match color.transfer {
|
||||
ColorInfo::TRC_PQ if full => DataSpace::Bt2020Pq,
|
||||
ColorInfo::TRC_PQ => DataSpace::Bt2020ItuPq,
|
||||
ColorInfo::TRC_HLG if full => DataSpace::Bt2020Hlg,
|
||||
ColorInfo::TRC_HLG => DataSpace::Bt2020ItuHlg,
|
||||
_ => DataSpace::Bt709, // SDR — limited-range BT.709 video
|
||||
};
|
||||
i32::from(ds)
|
||||
}
|
||||
|
||||
@@ -333,7 +333,8 @@ impl Layer {
|
||||
/// 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`,
|
||||
/// tagged with `seq`. `dataspace` is the HDR `ADataSpace` value (`0` = leave default/SDR).
|
||||
/// tagged with `seq`. `dataspace` is the `ADataSpace` value (`0` = leave the layer default —
|
||||
/// only the `setBufferDataSpace`-less API-29 fallback ever presents untagged).
|
||||
/// `frame_rate` votes the layer's rate once (`0.0` skips). Returns `false` if the transaction
|
||||
/// could not be created (the caller then frees the buffer itself).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -451,6 +451,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||
// The Kotlin side cancels by dropping the result (`Dial.cancelled`), not by aborting
|
||||
// the dial — its connect runs on a pool thread, so a parked one costs a thread, not a
|
||||
// stuck UI. Wire a flag through here if that ever stops being true.
|
||||
None,
|
||||
) {
|
||||
Ok(client) => {
|
||||
let handle = SessionHandle {
|
||||
|
||||
@@ -667,9 +667,12 @@ impl ServiceState {
|
||||
r.request();
|
||||
}
|
||||
}
|
||||
// A platform-native screen (Android's Controllers/Licences views) — the desktop
|
||||
// shell has no such rows, so this never arrives here.
|
||||
// A platform-native screen (Android's Licences view) — the desktop shell has no
|
||||
// such row, so this never arrives here.
|
||||
ConsoleCmd::OpenPlatformScreen { .. } => {}
|
||||
// Grants and rumble tests from the controllers screen. Android-only for the same
|
||||
// reason: the settings row that opens that screen is not on the desktop's list.
|
||||
ConsoleCmd::PadAction { .. } => {}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
profile_id,
|
||||
|
||||
@@ -840,6 +840,29 @@ mod session_main {
|
||||
// speaker pair.
|
||||
let speaker = arg_flag("--speaker");
|
||||
let coils = arg_flag("--coils") || !speaker;
|
||||
// Say up front whether a real session would render what this is about to prove
|
||||
// works. The devtest drives the pad DIRECTLY, so it is deliberately blind to the
|
||||
// settings — which makes "the tone plays here but the game is silent" a genuinely
|
||||
// confusing result, and one that has cost a whole debugging evening: the toggle is
|
||||
// on the client while every instinct sends you measuring the host. The capability
|
||||
// is never advertised when the toggle is off, so no later log line can catch this.
|
||||
{
|
||||
let s = trust::Settings::load();
|
||||
if speaker && !pf_client_core::pad_audio::speaker_active(&s.pad_speaker) {
|
||||
println!(
|
||||
"note: \"Controller speaker\" is OFF in your settings (pad_speaker = \
|
||||
{:?}), so a streaming session will NOT render the pad's speaker even if \
|
||||
the tone below is audible.",
|
||||
s.pad_speaker
|
||||
);
|
||||
}
|
||||
if coils && !s.pad_haptics {
|
||||
println!(
|
||||
"note: \"Controller haptics\" is OFF in your settings, so a streaming \
|
||||
session will NOT render the voice coils even if the tone below is felt."
|
||||
);
|
||||
}
|
||||
}
|
||||
return match pf_client_core::pad_audio::pad_audio_test(seconds, coils, speaker) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
|
||||
@@ -1075,6 +1075,14 @@ impl Worker {
|
||||
// Unknowable from an ID-based getter — SDL reports power only for an OPEN
|
||||
// device. `publish` fills it in for the one pad this service holds open.
|
||||
battery: None,
|
||||
// The three below feed the console's controllers screen, which is Android-only
|
||||
// (design android-skia-console-port.md D3) — nothing on the desktop reads them.
|
||||
// SDL enumerates only gamepad-classified devices, so the joystick-only case
|
||||
// `forwarded` exists to name cannot arise here; rumble, like battery, needs the
|
||||
// device OPEN and so is not knowable from this getter.
|
||||
detail: format!("{vid:04X}:{pid:04X}"),
|
||||
forwarded: true,
|
||||
rumble: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,20 @@ pub struct PadInfo {
|
||||
/// virtual gamepad reports nothing about the physical device behind it. Anything reading
|
||||
/// this must degrade to "no battery shown" rather than to "0 %".
|
||||
pub battery: Option<PadBattery>,
|
||||
/// The identity line the console's controllers screen shows under the name —
|
||||
/// `VID:PID · gamepad · dpad`. Support's first question when a pad "doesn't work" is
|
||||
/// whether the OS enumerated the pad or the adapter in front of it, and the name alone
|
||||
/// never answers that. Written by whoever enumerated the device; empty is "nothing more
|
||||
/// to say", never an error.
|
||||
pub detail: String,
|
||||
/// Actually forwarded to the host: a real, non-virtual controller the OS classifies as a
|
||||
/// GAMEPAD. A joystick-only node — an adapter that enumerates as a bare joystick, a
|
||||
/// DualSense's motion-sensor sibling — is listed and NOT forwarded, which is the single
|
||||
/// most common cause of "my pad is connected and nothing happens".
|
||||
pub forwarded: bool,
|
||||
/// The device reports a rumble motor. `false` is what turns the controllers screen's
|
||||
/// rumble test into the sentence explaining why host rumble will be silent on this pad.
|
||||
pub rumble: bool,
|
||||
}
|
||||
|
||||
/// A controller's power state, as SDL reports it.
|
||||
|
||||
@@ -231,6 +231,10 @@ pub(crate) fn props_say_ds5(
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct SinkNode {
|
||||
/// The registry's global id for this node — what [`pin_sink_volume`] binds to set the
|
||||
/// node's `Props`. Zero for a node no walk produced (test fixtures, and the split parent
|
||||
/// named by a sink we can see but never shown to us as an object of its own).
|
||||
pub(crate) id: u32,
|
||||
/// `node.name` — what a stream targets via `target.object`.
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
@@ -401,6 +405,8 @@ pub(crate) fn sink_from_props(props: &pipewire::spa::utils::dict::DictRef) -> Op
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(SinkNode {
|
||||
// Filled by the caller from the node's own `info` — the proplist does not carry it.
|
||||
id: 0,
|
||||
device_id: props.get("device.id").and_then(|v| v.parse().ok()),
|
||||
channels: props
|
||||
.get("audio.channels")
|
||||
@@ -484,7 +490,8 @@ fn walk_graph() -> anyhow::Result<(Vec<SinkNode>, Vec<CardDevice>)> {
|
||||
let sinks = sinks.clone();
|
||||
move |info| {
|
||||
let Some(p) = info.props() else { return };
|
||||
if let Some(s) = sink_from_props(p) {
|
||||
if let Some(mut s) = sink_from_props(p) {
|
||||
s.id = info.id();
|
||||
let mut v = sinks.borrow_mut();
|
||||
// `info` can fire more than once per node; keep one.
|
||||
if let Some(old) = v.iter_mut().find(|o| o.name == s.name) {
|
||||
@@ -873,6 +880,157 @@ fn profile_pod(index: u32) -> anyhow::Result<Vec<u8>> {
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// The `Props` object pod that puts every channel of a sink at unity gain.
|
||||
///
|
||||
/// Unity is 1.0 in `channelVolumes`, which is NOT the "100%" a mixer shows: pulse (and every UI
|
||||
/// built on it) displays a CUBED scale, so WirePlumber's 0.4 default reads as 40% on screen and
|
||||
/// is 0.4³ = 0.064 — a hair under −24 dB — in the linear units this pod speaks. 1.0 is unity in
|
||||
/// both, which is the whole reason this pins to unity rather than to some other number.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unity_volume_pod(channels: u32) -> anyhow::Result<Vec<u8>> {
|
||||
use anyhow::Context;
|
||||
use pipewire::spa;
|
||||
use spa::pod::{Object, Property, PropertyFlags, Value, ValueArray};
|
||||
let obj = Object {
|
||||
type_: spa::utils::SpaTypes::ObjectParamProps.as_raw(),
|
||||
id: spa::param::ParamType::Props.as_raw(),
|
||||
properties: vec![
|
||||
Property {
|
||||
key: spa::sys::SPA_PROP_volume,
|
||||
flags: PropertyFlags::empty(),
|
||||
value: Value::Float(1.0),
|
||||
},
|
||||
Property {
|
||||
key: spa::sys::SPA_PROP_channelVolumes,
|
||||
flags: PropertyFlags::empty(),
|
||||
value: Value::ValueArray(ValueArray::Float(vec![1.0; channels.max(1) as usize])),
|
||||
},
|
||||
],
|
||||
};
|
||||
Ok(spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&Value::Object(obj),
|
||||
)
|
||||
.context("serialize")?
|
||||
.0
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// Put the pad's sink at unity gain, because nobody chose the level it arrives at.
|
||||
///
|
||||
/// WirePlumber starts every new card's sink at `device.routes.default-sink-volume` — 0.4, which
|
||||
/// is −23.88 dB — and that setting is global: it cannot be scoped to one device in config, so
|
||||
/// there is no configuration fix to ship. It is a sane default for a laptop speaker somebody is
|
||||
/// about to turn up, and wrong for this sink twice over. The pad's is not a listening volume a
|
||||
/// user reaches for; and BOTH ends of a session mint one, so the two stack: −47.8 dB by the time
|
||||
/// a game's haptics reach a voice coil, which is felt as "the haptics are weak, maybe dead"
|
||||
/// rather than as a volume anyone would think to look at.
|
||||
///
|
||||
/// Deliberately NOT restored the way [`restore_profile`] restores a borrowed profile. A profile
|
||||
/// swap overrides a choice the user made; this overrides a default nobody made, and putting
|
||||
/// −24 dB back on the way out would be restoring the bug.
|
||||
///
|
||||
/// Best effort throughout: every failure here costs attenuation, never audio, so the caller logs
|
||||
/// and carries on. `PUNKTFUNK_PAD_SINK_VOLUME=0` leaves the sink exactly where it was found, for
|
||||
/// bisecting against a box where something else is doing the attenuating.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pin_sink_volume(node_id: u32, channels: u32) -> anyhow::Result<()> {
|
||||
use anyhow::{anyhow, Context};
|
||||
use pipewire as pw;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
static PW_INIT: std::sync::Once = std::sync::Once::new();
|
||||
PW_INIT.call_once(pw::init);
|
||||
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
|
||||
let core = context.connect_rc(None).context("pw connect")?;
|
||||
let registry = core.get_registry_rc().context("pw registry")?;
|
||||
|
||||
let node: Rc<RefCell<Option<pw::node::Node>>> = Rc::default();
|
||||
let _reg_listener = registry
|
||||
.add_listener_local()
|
||||
.global({
|
||||
let (registry, node) = (registry.clone(), node.clone());
|
||||
move |g| {
|
||||
if g.id != node_id || g.type_ != pw::types::ObjectType::Node {
|
||||
return;
|
||||
}
|
||||
if let Ok(n) = registry.bind::<pw::node::Node, _>(g) {
|
||||
*node.borrow_mut() = Some(n);
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
let awaited: Rc<Cell<Option<pw::spa::utils::result::AsyncSeq>>> = Rc::new(Cell::new(None));
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.done({
|
||||
let (mainloop, awaited) = (mainloop.clone(), awaited.clone());
|
||||
move |_, seq| {
|
||||
if awaited.get() == Some(seq) {
|
||||
mainloop.quit();
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
let round = |issue: &dyn Fn() -> anyhow::Result<()>| -> anyhow::Result<()> {
|
||||
issue()?;
|
||||
awaited.set(Some(core.sync(0).context("pw sync")?));
|
||||
mainloop.run();
|
||||
Ok(())
|
||||
};
|
||||
|
||||
round(&|| Ok(()))?; // 1: the registry replays its globals; our node gets bound
|
||||
let pod = unity_volume_pod(channels).context("serialize Props pod")?;
|
||||
round(&|| {
|
||||
let n = node.borrow();
|
||||
let n = n
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("sink node {node_id} is not in the PipeWire graph"))?;
|
||||
n.set_param(
|
||||
pw::spa::param::ParamType::Props,
|
||||
0,
|
||||
pw::spa::pod::Pod::from_bytes(&pod).ok_or_else(|| anyhow!("bad Props pod"))?,
|
||||
);
|
||||
Ok(())
|
||||
})?; // 2: flush the set_param before the loop and its proxies drop
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pass a picked node name through, pinning that node to unity gain on the way — see
|
||||
/// [`pin_sink_volume`] for why the level it arrives at is nobody's choice.
|
||||
///
|
||||
/// Runs on every (re)correlation rather than once, so a card that re-minted its nodes (a profile
|
||||
/// change, a replug) is pinned again without anything having to notice that it did.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pin_picked(name: String, sinks: &[SinkNode]) -> String {
|
||||
if matches!(
|
||||
std::env::var("PUNKTFUNK_PAD_SINK_VOLUME").as_deref(),
|
||||
Ok("0" | "false" | "off" | "no")
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
// Only a node the walk actually saw. The `split_parent` pick is a NAME lifted off another
|
||||
// node's proplist — there may be no object behind it we are allowed to bind, and pinning the
|
||||
// sink that named it would be pinning the wrong node.
|
||||
let Some(s) = sinks.iter().find(|s| s.name == name && s.id != 0) else {
|
||||
return name;
|
||||
};
|
||||
match pin_sink_volume(s.id, s.channels) {
|
||||
Ok(()) => tracing::debug!(node = %name, channels = s.channels, "pad sink pinned to 0 dB"),
|
||||
Err(e) => tracing::debug!(
|
||||
node = %name,
|
||||
error = %format!("{e:#}"),
|
||||
"could not pin the pad sink to 0 dB — haptics may be quiet if the session manager \
|
||||
left it at its default 40%"
|
||||
),
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Correlate: walk the graph, pick the pad's four-channel node, and move the card's profile if
|
||||
/// that is what stands between us and one. Returns the `node.name` to target.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -880,7 +1038,7 @@ pub fn correlate_pad_sink() -> anyhow::Result<String> {
|
||||
use anyhow::anyhow;
|
||||
let (sinks, cards) = walk_graph()?;
|
||||
match pick_pad_sink(&sinks, &cards) {
|
||||
Some(PadSinkPick::Node(name)) => Ok(name),
|
||||
Some(PadSinkPick::Node(name)) => Ok(pin_picked(name, &sinks)),
|
||||
Some(PadSinkPick::NeedsProfile(device_id)) => {
|
||||
if PROFILE_TRIED.lock().unwrap().contains(&device_id) {
|
||||
return Err(anyhow!(
|
||||
@@ -896,7 +1054,7 @@ pub fn correlate_pad_sink() -> anyhow::Result<String> {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let (sinks, cards) = walk_graph()?;
|
||||
if let Some(PadSinkPick::Node(name)) = pick_pad_sink(&sinks, &cards) {
|
||||
return Ok(name);
|
||||
return Ok(pin_picked(name, &sinks));
|
||||
}
|
||||
last = sinks;
|
||||
}
|
||||
@@ -1889,6 +2047,34 @@ fn pad_render_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The unity pod is what the 0 dB pin IS, so it has to be the shape PipeWire reads: one
|
||||
/// unity float per channel. PipeWire ignores a `channelVolumes` whose length does not match
|
||||
/// the port count, and an ignored pod looks exactly like the pin silently not working —
|
||||
/// which is the -23.88 dB this exists to undo, back again and just as invisible.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn unity_pod_is_one_float_per_channel() {
|
||||
use pipewire::spa::pod::{deserialize::PodDeserializer, Value, ValueArray};
|
||||
for channels in [1u32, 2, 4] {
|
||||
let bytes = unity_volume_pod(channels).expect("serialize");
|
||||
let (_, value) = PodDeserializer::deserialize_any_from(&bytes).expect("parse");
|
||||
let Value::Object(obj) = value else {
|
||||
panic!("not an object pod");
|
||||
};
|
||||
let vols = obj
|
||||
.properties
|
||||
.iter()
|
||||
.find(|p| p.key == pipewire::spa::sys::SPA_PROP_channelVolumes)
|
||||
.map(|p| p.value.clone())
|
||||
.expect("channelVolumes");
|
||||
let Value::ValueArray(ValueArray::Float(v)) = vols else {
|
||||
panic!("channelVolumes is not a float array");
|
||||
};
|
||||
assert_eq!(v.len(), channels as usize);
|
||||
assert!(v.iter().all(|&x| x == 1.0), "every channel must be unity");
|
||||
}
|
||||
}
|
||||
|
||||
/// The speaker mode gate: only `"pad"` renders today; `"mix"` is the declared TODO and
|
||||
/// reads as off; unknown values (a future store, a typo) fail safe to off.
|
||||
#[test]
|
||||
@@ -1985,6 +2171,9 @@ mod tests {
|
||||
|
||||
fn sink(name: &str, channels: u32, positions: &str, device_id: Option<u32>) -> SinkNode {
|
||||
SinkNode {
|
||||
// The picker never reads it (only the volume pin does), so these fixtures leave it
|
||||
// at the "no walk produced this" value.
|
||||
id: 0,
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
device_id,
|
||||
|
||||
@@ -883,6 +883,12 @@ fn pump(
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
params.connect_timeout,
|
||||
// THE session's stop flag, so the embedder's cancel reaches a dial that has not landed
|
||||
// yet. Without it this call parks the pump thread for the whole budget — 185 s on a
|
||||
// request-access connect the host holds pending approval — and the embedder's cancel
|
||||
// could not be answered until it returned: the console's takeover sat on "Canceling…"
|
||||
// with no session event to clear it.
|
||||
Some(stop.clone()),
|
||||
) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
|
||||
@@ -226,6 +226,17 @@ pub enum ConsoleCmd {
|
||||
/// while it is up, and the console never learns what it looked like. The desktop raises
|
||||
/// none — its settings list has no such rows.
|
||||
OpenPlatformScreen { id: String },
|
||||
/// Something only the PLATFORM can do to a controller, raised by the controllers screen:
|
||||
/// Android's USB / Bluetooth grant dialogs, a rumble pulse on the real `InputDevice`, the
|
||||
/// DualSense pad-audio self test. `action` is a
|
||||
/// [`crate::screens::controllers::PadAction::id`]; `pad_key` addresses one of
|
||||
/// [`crate::screens::Ctx::pads`] and is empty for the actions that are about a device the
|
||||
/// pad list cannot name (an SC2 in lizard mode is no input device at all).
|
||||
///
|
||||
/// ONE parameterised command rather than one per button: the host's answer to every one
|
||||
/// of them is the same shape — do the platform thing, report back as a notice — and a
|
||||
/// command per grant would make adding the next pad a change in three crates.
|
||||
PadAction { action: String, pad_key: String },
|
||||
}
|
||||
|
||||
/// The overlay→binary command queue. A plain deque under the same locking discipline as
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Which platform the shell fronts. One shell, two hosts (design
|
||||
//! android-skia-console-port.md D3/D7): the screens are the same everywhere, but not every
|
||||
//! settings row means something on every platform — a decoder picker is a desktop concept,
|
||||
//! low-latency decode an Android one — and only Android has native sub-screens (its
|
||||
//! Controllers and Licenses views) for the settings list to open. Everything platform-shaped
|
||||
//! is decided by asking this enum, so the row tables stay one union and no screen carries a
|
||||
//! `cfg`.
|
||||
//! low-latency decode an Android one — and only Android has a native sub-screen (its
|
||||
//! Licenses view) for the settings list to open. Everything platform-shaped is decided by
|
||||
//! asking this enum, so the row tables stay one union and no screen carries a `cfg`.
|
||||
|
||||
/// The host platform.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -20,9 +19,10 @@ pub enum Platform {
|
||||
/// its own input until the host says the screen closed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PlatformScreen {
|
||||
/// Android's connected-controllers view (USB grant, rumble/haptics tests, DS capture).
|
||||
Controllers,
|
||||
/// The open-source licences view.
|
||||
/// The open-source licences view. The last one: Connected controllers used to be here
|
||||
/// too, and is a shared Skia screen now ([`crate::screens::controllers`]) — the console
|
||||
/// keeps its own input on that page, and only the grant dialogs it cannot draw go back
|
||||
/// to the host, as a [`crate::model::ConsoleCmd::PadAction`].
|
||||
Licenses,
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ impl PlatformScreen {
|
||||
/// The stable id the host matches on (crosses JNI as a string).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PlatformScreen::Controllers => "controllers",
|
||||
PlatformScreen::Licenses => "licenses",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub(crate) mod add_host;
|
||||
pub(crate) mod collections;
|
||||
pub(crate) mod controllers;
|
||||
pub(crate) mod home;
|
||||
pub(crate) mod library;
|
||||
pub(crate) mod options;
|
||||
@@ -179,6 +180,10 @@ pub(crate) enum Screen {
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
PinHosts(pin_hosts::PinHostsScreen),
|
||||
/// "Connected controllers": the attached pads and their identity lines, plus the grants
|
||||
/// and tests only the platform can perform. Android-reachable only — the settings row
|
||||
/// that opens it is in `settings::row_on`'s Android-only list.
|
||||
Controllers(controllers::ControllersScreen),
|
||||
/// The context menu: a subject and the actions that apply to it — a host's Wake / Copy
|
||||
/// link / Edit / Forget, a title's Copy link — raised by [`Outbox::options`]. It still
|
||||
/// carries the host menu's name because [`host_options`] does; both are one rename.
|
||||
@@ -200,6 +205,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Controllers(s) => s.menu(ev, ctx, fx),
|
||||
Screen::HostOptions(s) => s.menu(ev, ctx, fx),
|
||||
}
|
||||
}
|
||||
@@ -218,6 +224,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Pair(s) => s.pointer(p, ctx, fx),
|
||||
Screen::PinHosts(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Controllers(s) => s.pointer(p, ctx, fx),
|
||||
Screen::HostOptions(s) => s.pointer(p, ctx, fx),
|
||||
}
|
||||
}
|
||||
@@ -267,6 +274,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.title(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
|
||||
Screen::Controllers(_) => "Connected controllers".into(),
|
||||
Screen::HostOptions(s) => s.title(),
|
||||
}
|
||||
}
|
||||
@@ -280,6 +288,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.hints(ctx),
|
||||
Screen::Pair(s) => s.hints(ctx),
|
||||
Screen::PinHosts(s) => s.hints(ctx),
|
||||
Screen::Controllers(s) => s.hints(ctx),
|
||||
Screen::HostOptions(s) => s.hints(ctx),
|
||||
}
|
||||
}
|
||||
@@ -304,6 +313,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Controllers(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::HostOptions(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
//! "Connected controllers" — everything the client can see about the attached pads, and the
|
||||
//! handful of actions only the platform can perform on them. Reached from the settings
|
||||
//! list's Controller tab.
|
||||
//!
|
||||
//! This exists for exactly one support case: a pad "doesn't work". Adapters and BT-to-USB
|
||||
//! dongles often enumerate with a different identity than the physical pad, or not as a
|
||||
//! gamepad at all, and only devices the OS classifies as a gamepad are forwarded — so the
|
||||
//! screen's real content is the identity line under each name, not the name.
|
||||
//!
|
||||
//! It was a Compose screen the Android host drew OVER the console (the D7 platform-screen
|
||||
//! mechanism) until 2026-08. Drawing it here instead is what lets the console keep its own
|
||||
//! input on the page; what genuinely cannot move — the USB and Bluetooth grant dialogs, a
|
||||
//! rumble pulse on a real `InputDevice` — stays with the host and is asked for by
|
||||
//! [`ConsoleCmd::PadAction`].
|
||||
//
|
||||
// ponytail: the Compose screen's live input test (button grid + axis bars, entered with A,
|
||||
// left by holding B) did NOT move here — the console only receives the aggregated
|
||||
// `MenuSample` (6 buttons, lx/ly, dpad), nowhere near a per-device axis/trigger readout,
|
||||
// and the hold-to-exit gesture has no home in the edge-triggered MenuEvent grammar. The
|
||||
// touch Controllers screen keeps the full test, so the feature exists on-device; add it
|
||||
// here by widening the pad-sample bridge with a per-device payload while the test is open.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::platform::Platform;
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse, PadInfo};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Work on a controller that only the HOST can do — every one of these needs a permission
|
||||
/// dialog or a real device handle, neither of which exists on this side of the bridge.
|
||||
/// Ordered as they are listed.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum PadAction {
|
||||
/// Pulse the focused pad's motor (the "is rumble even wired up" test).
|
||||
Rumble,
|
||||
/// `BLUETOOTH_CONNECT`, without which a BLE-paired Steam Controller 2 is invisible —
|
||||
/// not "detected and idle", absent, which is why the row is offered rather than hidden
|
||||
/// behind a detection that cannot run.
|
||||
Sc2Bluetooth,
|
||||
/// USB access for a wired or Puck-dongle Steam Controller 2.
|
||||
Sc2Usb,
|
||||
/// USB access for a wired Sony pad (DualSense, Edge, DualShock 4).
|
||||
DsUsb,
|
||||
/// The DualSense pad-audio self test: can this phone drive the pad's audio endpoint at
|
||||
/// all. Deliberately reachable with no stream running — it exists to rule the pad out
|
||||
/// when a session misbehaves, and gating it behind a session would make it depend on
|
||||
/// the very thing under suspicion.
|
||||
DsHaptics,
|
||||
}
|
||||
|
||||
impl PadAction {
|
||||
/// The stable id the host matches on (crosses JNI inside [`ConsoleCmd::PadAction`]).
|
||||
pub(crate) fn id(self) -> &'static str {
|
||||
match self {
|
||||
PadAction::Rumble => "rumble",
|
||||
PadAction::Sc2Bluetooth => "sc2_bluetooth",
|
||||
PadAction::Sc2Usb => "sc2_usb",
|
||||
PadAction::DsUsb => "ds_usb",
|
||||
PadAction::DsHaptics => "ds_haptics",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The passthrough rows, in list order. Platform-gated as one union exactly like the
|
||||
/// settings row table (`settings::row_on`): the desktop captures nothing over raw USB and
|
||||
/// asks for no grants, so it has no such rows — never a control that changes nothing.
|
||||
const PASSTHROUGH: [(PadAction, &str, &str); 4] = [
|
||||
(
|
||||
PadAction::Sc2Bluetooth,
|
||||
"Steam Controller 2 over Bluetooth",
|
||||
"Grant",
|
||||
),
|
||||
(PadAction::Sc2Usb, "Steam Controller 2 over USB", "Grant"),
|
||||
(PadAction::DsUsb, "DualSense / DualShock over USB", "Grant"),
|
||||
(PadAction::DsHaptics, "DualSense haptics self-test", "Test"),
|
||||
];
|
||||
|
||||
/// One line in the list. Pads first, then whatever the platform can be asked to do.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Row {
|
||||
/// An index into [`Ctx::pads`].
|
||||
Pad(usize),
|
||||
/// No pads at all — an inert row, so the list is never empty and the cursor always has
|
||||
/// something to sit on while the passthrough rows below it stay reachable.
|
||||
NoPads,
|
||||
/// An index into [`PASSTHROUGH`].
|
||||
Passthrough(usize),
|
||||
}
|
||||
|
||||
fn rows_for(ctx: &Ctx) -> Vec<Row> {
|
||||
let mut rows: Vec<Row> = if ctx.pads.is_empty() {
|
||||
vec![Row::NoPads]
|
||||
} else {
|
||||
(0..ctx.pads.len()).map(Row::Pad).collect()
|
||||
};
|
||||
if ctx.platform == Platform::Android {
|
||||
rows.extend((0..PASSTHROUGH.len()).map(Row::Passthrough));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub(crate) struct ControllersScreen {
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
impl ControllersScreen {
|
||||
pub(crate) fn new() -> ControllersScreen {
|
||||
ControllersScreen {
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let rows = rows_for(ctx);
|
||||
let (msg, pulse) = self.list.menu(ev, rows.len());
|
||||
self.activate(msg, pulse, &rows, ctx, fx)
|
||||
}
|
||||
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
let rows = rows_for(ctx);
|
||||
let (msg, pulse) = self.list.pointer(p, rows.len());
|
||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.activate(msg, pulse, &rows, ctx, fx);
|
||||
true
|
||||
}
|
||||
|
||||
/// One list message against the focused row — shared by the pad path and the pointer's,
|
||||
/// so a click and an A press can never drift apart.
|
||||
fn activate(
|
||||
&mut self,
|
||||
msg: ListMsg,
|
||||
pulse: Option<MenuPulse>,
|
||||
rows: &[Row],
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
let Some(&focused) = rows.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// Nothing here steps: every row is a button or a statement.
|
||||
if matches!(msg, ListMsg::Adjust(_)) {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
if !matches!(msg, ListMsg::Activate) {
|
||||
return pulse;
|
||||
}
|
||||
let (action, pad_key) = match focused {
|
||||
Row::NoPads => return Some(MenuPulse::Boundary),
|
||||
Row::Pad(i) => {
|
||||
// A pad with no motor has nothing to test; say so with the thud rather than
|
||||
// sending a command the host would silently drop.
|
||||
if !ctx.pads[i].rumble {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
(PadAction::Rumble, ctx.pads[i].key.clone())
|
||||
}
|
||||
// The grants are about a device the pad list cannot name (an SC2 in lizard mode
|
||||
// is no input device at all), so they carry no key.
|
||||
Row::Passthrough(i) => (PASSTHROUGH[i].0, String::new()),
|
||||
};
|
||||
fx.cmds.push(ConsoleCmd::PadAction {
|
||||
action: action.id().to_string(),
|
||||
pad_key,
|
||||
});
|
||||
pulse
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
let rows = rows_for(ctx);
|
||||
let confirm = match rows.get(self.list.cursor) {
|
||||
Some(Row::Pad(i)) if ctx.pads[*i].rumble => Some("Test rumble"),
|
||||
Some(Row::Passthrough(i)) => Some(match PASSTHROUGH[*i].0 {
|
||||
PadAction::DsHaptics => "Test haptics",
|
||||
_ => "Grant access",
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let mut hints = Vec::new();
|
||||
if let Some(label) = confirm {
|
||||
hints.push(Hint::new(HintKey::Confirm, label));
|
||||
}
|
||||
hints.push(Hint::new(HintKey::Back, "Done"));
|
||||
hints
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
// The focused row's explainer takes a reserved band under the list — the settings
|
||||
// screen's shape, and here it is the whole point: the identity of the device is the
|
||||
// support answer, and it is far too long to live on the row.
|
||||
let detail_h = 34.0 * k;
|
||||
let rows = rows_for(ctx);
|
||||
let specs: Vec<RowSpec> = rows.iter().map(|r| spec(*r, ctx)).collect();
|
||||
self.list.render(
|
||||
canvas,
|
||||
Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
),
|
||||
&specs,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
true,
|
||||
);
|
||||
let detail = rows
|
||||
.get(self.list.cursor)
|
||||
.map_or_else(String::new, |r| detail(*r, ctx));
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&detail,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(row: Row, ctx: &Ctx) -> RowSpec {
|
||||
match row {
|
||||
Row::NoPads => RowSpec {
|
||||
header: Some("Gamepads"),
|
||||
..RowSpec::action("No controller detected", false)
|
||||
},
|
||||
Row::Pad(i) => {
|
||||
let pad = &ctx.pads[i];
|
||||
RowSpec {
|
||||
header: (i == 0).then_some("Gamepads"),
|
||||
label: pad.name.clone(),
|
||||
value: Some(
|
||||
if pad.rumble {
|
||||
"Test rumble"
|
||||
} else {
|
||||
"No rumble"
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
value_dim: !pad.rumble,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: pad.rumble,
|
||||
}
|
||||
}
|
||||
Row::Passthrough(i) => {
|
||||
let (_, label, verb) = PASSTHROUGH[i];
|
||||
RowSpec {
|
||||
header: (i == 0).then_some("Passthrough"),
|
||||
label: label.into(),
|
||||
value: Some(verb.into()),
|
||||
value_dim: false,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The band under the list: what this row is, in one sentence.
|
||||
fn detail(row: Row, ctx: &Ctx) -> String {
|
||||
match row {
|
||||
Row::NoPads => "Punktfunk only forwards devices the system classifies as a gamepad or \
|
||||
joystick — a pad behind an adapter or hub may enumerate with the \
|
||||
adapter's identity, or not at all."
|
||||
.into(),
|
||||
Row::Pad(i) => pad_detail(&ctx.pads[i]),
|
||||
Row::Passthrough(i) => match PASSTHROUGH[i].0 {
|
||||
PadAction::Sc2Bluetooth => {
|
||||
"A Steam Controller 2 paired over Bluetooth cannot be detected at all without \
|
||||
Bluetooth access. Wired and Puck-dongle controllers need no permission."
|
||||
.into()
|
||||
}
|
||||
PadAction::Sc2Usb => {
|
||||
"A wired or Puck-dongle Steam Controller 2 needs USB access to be captured; \
|
||||
until then it stays in its built-in keyboard/mouse mode."
|
||||
.into()
|
||||
}
|
||||
PadAction::DsUsb => {
|
||||
"A wired DualSense or DualShock 4 needs USB access to be captured — with it, \
|
||||
streams drive rumble, adaptive triggers, lightbar and gyro directly."
|
||||
.into()
|
||||
}
|
||||
PadAction::DsHaptics => {
|
||||
"Play a short tone through a wired DualSense's audio endpoint, to tell a pad \
|
||||
that cannot do haptics from a stream that is not sending them."
|
||||
.into()
|
||||
}
|
||||
// Not offered as a passthrough row — the pads carry it.
|
||||
PadAction::Rumble => String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A pad's identity line: what the OS enumerated, whether it is forwarded, what the host
|
||||
/// will build for it, and its charge if it reports one.
|
||||
fn pad_detail(pad: &PadInfo) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !pad.detail.is_empty() {
|
||||
parts.push(pad.detail.clone());
|
||||
}
|
||||
if !pad.forwarded {
|
||||
parts.push("not forwarded — not classified as a gamepad".into());
|
||||
}
|
||||
let kind = pad.kind_label();
|
||||
parts.push(format!(
|
||||
"streams as {}",
|
||||
if kind.is_empty() { "Xbox 360" } else { kind }
|
||||
));
|
||||
if let Some(b) = pad.battery {
|
||||
parts.push(if b.charging {
|
||||
format!("battery {} %, charging", b.percent)
|
||||
} else {
|
||||
format!("battery {} %", b.percent)
|
||||
});
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pf_client_core::trust::Settings;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
fn pad(name: &str, rumble: bool) -> PadInfo {
|
||||
PadInfo {
|
||||
name: name.into(),
|
||||
key: format!("054c:0ce6:{name}"),
|
||||
pref: GamepadPref::DualSense,
|
||||
steam_virtual: false,
|
||||
battery: None,
|
||||
detail: "054C:0CE6 · gamepad".into(),
|
||||
forwarded: true,
|
||||
rumble,
|
||||
}
|
||||
}
|
||||
|
||||
fn drive(
|
||||
screen: &mut ControllersScreen,
|
||||
platform: Platform,
|
||||
pads: &[PadInfo],
|
||||
ev: MenuEvent,
|
||||
) -> (Outbox, Option<MenuPulse>) {
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = screen.menu(ev, &mut ctx, &mut fx);
|
||||
(fx, pulse)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_on_a_pad_asks_the_host_for_a_rumble_pulse() {
|
||||
let pads = [pad("DualSense", true)];
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, _) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::PadAction {
|
||||
action: "rumble".into(),
|
||||
pad_key: "054c:0ce6:DualSense".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pad_with_no_motor_thuds_instead_of_sending_a_pulse() {
|
||||
let pads = [pad("Adapter", false)];
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, pulse) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grant_rows_are_androids_alone_and_carry_no_pad_key() {
|
||||
// Desktop: pads and nothing else — it asks for no grants and captures nothing raw.
|
||||
let pads = [pad("DualSense", true)];
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
fn ctx<'a>(
|
||||
platform: Platform,
|
||||
settings: &'a mut Settings,
|
||||
library: &'a crate::library::LibraryShared,
|
||||
pads: &'a [PadInfo],
|
||||
) -> Ctx<'a> {
|
||||
Ctx {
|
||||
hosts: &[],
|
||||
library,
|
||||
settings,
|
||||
store: crate::store::file_store(),
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
rows_for(&ctx(Platform::Desktop, &mut settings, &library, &pads)).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
rows_for(&ctx(Platform::Android, &mut settings, &library, &pads)).len(),
|
||||
1 + PASSTHROUGH.len()
|
||||
);
|
||||
|
||||
// Down onto the first grant row, then A.
|
||||
let mut s = ControllersScreen::new();
|
||||
drive(
|
||||
&mut s,
|
||||
Platform::Android,
|
||||
&pads,
|
||||
MenuEvent::Move(pf_client_core::menu_nav::MenuDir::Down),
|
||||
);
|
||||
let (fx, _) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::PadAction {
|
||||
action: "sc2_bluetooth".into(),
|
||||
pad_key: String::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_pads_the_list_still_has_the_grants_under_an_inert_row() {
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, pulse) = drive(&mut s, Platform::Android, &[], MenuEvent::Confirm);
|
||||
assert!(fx.cmds.is_empty(), "the empty-state row does nothing");
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! on the shell's stack. B pops back to the host list; A launches the focused title in
|
||||
//! the same window. The shell owns the aurora, chrome, and the connecting overlay.
|
||||
|
||||
use crate::anim::{entrances, Entrance, EntranceAt, Spring};
|
||||
use crate::anim::{approach, entrances, Entrance, EntranceAt, Spring};
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::library::{
|
||||
card_matrix, grid_col_hint, grid_step, initials, step_cursor, store_label, GridDir, GridShape,
|
||||
@@ -28,8 +28,10 @@ const GRID_MARGIN: f64 = 48.0;
|
||||
const GRID_LABEL: f64 = 10.0;
|
||||
/// The band a grid group heading occupies.
|
||||
const GRID_HEADING: f64 = 30.0;
|
||||
/// The band the focused title's name and store occupy under either arrangement.
|
||||
const DETAIL_BAND: f64 = 84.0;
|
||||
/// The band the focused title's name occupies under either arrangement. Shrunk from 84 when
|
||||
/// the store/platform subtitle left: the cover badge already carries that answer, and on a
|
||||
/// phone the 20 units bought most of a grid row back.
|
||||
const DETAIL_BAND: f64 = 64.0;
|
||||
/// The corner on the view/sort bar's own glass, once it has focus.
|
||||
const BAR_CORNER: f64 = 14.0;
|
||||
/// Air between the bar and the field under it. The shelf centres its cards and would never
|
||||
@@ -394,6 +396,13 @@ pub(super) fn strip_caption(
|
||||
/// the persisted settings are the state and these only draw it and hit-test a click.
|
||||
struct LibraryBar {
|
||||
focus: bool,
|
||||
/// How present the bar is, 0–1. The bar only APPEARS while it holds the pad (▲ from the
|
||||
/// field, "Sort & view" in the legend) — the Apple client's behaviour, adopted after the
|
||||
/// always-on band proved too expensive on a phone: it taxed every library visit a strip
|
||||
/// of field height to answer a question ("what is the sort") that only matters in the
|
||||
/// moment of changing it. Chased toward `focus` each frame; the field takes the band's
|
||||
/// room back as this falls.
|
||||
reveal: f64,
|
||||
sort_tabs: TabStrip,
|
||||
view_tabs: TabStrip,
|
||||
}
|
||||
@@ -402,6 +411,7 @@ impl LibraryBar {
|
||||
fn new() -> LibraryBar {
|
||||
LibraryBar {
|
||||
focus: false,
|
||||
reveal: 0.0,
|
||||
sort_tabs: TabStrip::new(),
|
||||
view_tabs: TabStrip::new(),
|
||||
}
|
||||
@@ -1229,7 +1239,11 @@ impl LibraryScreen {
|
||||
// reaches the bar (the shell turns that hint into a `Move(Up)`), but focus is
|
||||
// not a choice — a mouse picks a sort by pressing the pill it wants, which is
|
||||
// why both strips hit-test themselves rather than leaning on the legend.
|
||||
let (sort_hit, view_hit) = if self.bar_shown() {
|
||||
// …and only while the bar is actually PRESENT: it appears on focus now, so
|
||||
// an unfocused library has no pills on screen and none to hit. The `TabStrip`s
|
||||
// keep the geometry they last drew, and a press must not land on furniture
|
||||
// that has faded out.
|
||||
let (sort_hit, view_hit) = if self.bar_shown() && self.bar.focus {
|
||||
(
|
||||
self.bar
|
||||
.sort_tabs
|
||||
@@ -1424,9 +1438,21 @@ impl LibraryScreen {
|
||||
if self.entrance.is_some_and(|e| e.done(ctx.t)) {
|
||||
self.entrance = None;
|
||||
}
|
||||
// The bar takes its band off the TOP of the field. The detail band keeps the
|
||||
// full rect — it is anchored to the bottom — and so does the loading path
|
||||
// above, which is centred in a field the bar is not part of.
|
||||
// The bar only takes its band off the TOP of the field while it is present
|
||||
// (see [`LibraryBar::reveal`]) — hidden, the field keeps the whole rect. The
|
||||
// detail band keeps the full rect either way — it is anchored to the bottom —
|
||||
// and so does the loading path above, which is centred in a field the bar is
|
||||
// not part of.
|
||||
let bar_target = if self.bar.focus { 1.0 } else { 0.0 };
|
||||
self.bar.reveal = if crate::theme::reduce_motion() {
|
||||
bar_target
|
||||
} else {
|
||||
approach(self.bar.reveal, bar_target, dt, 0.10)
|
||||
};
|
||||
if (self.bar.reveal - bar_target).abs() < 0.005 {
|
||||
self.bar.reveal = bar_target;
|
||||
}
|
||||
let reveal = self.bar.reveal;
|
||||
let bar = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
@@ -1435,7 +1461,7 @@ impl LibraryScreen {
|
||||
);
|
||||
let field = Rect::from_ltrb(
|
||||
rect.left,
|
||||
bar.bottom + (BAR_GAP * k) as f32,
|
||||
rect.top + ((TAB_STRIP_H + BAR_GAP) * k * reveal) as f32,
|
||||
rect.right,
|
||||
rect.bottom,
|
||||
);
|
||||
@@ -1445,7 +1471,21 @@ impl LibraryScreen {
|
||||
}
|
||||
// After the cards, like the detail band: it is the screen's readout, and a
|
||||
// short window must not let an arriving cover paint over the answer.
|
||||
self.draw_bar(canvas, bar, k, fonts, dt);
|
||||
// Faded as a unit while arriving/leaving, with a small rise — the crate's
|
||||
// transition grammar. Bounded layer: unbounded would allocate a surface-sized
|
||||
// offscreen for a strip of pills (see the twin warning in home.rs).
|
||||
if reveal > 0.01 {
|
||||
let bounds = Rect::from_ltrb(
|
||||
bar.left,
|
||||
bar.top - (12.0 * k) as f32,
|
||||
bar.right,
|
||||
bar.bottom + (12.0 * k) as f32,
|
||||
);
|
||||
canvas.save_layer_alpha_f(bounds, reveal as f32);
|
||||
canvas.translate((0.0f32, (-(1.0 - reveal) * 10.0 * k) as f32));
|
||||
self.draw_bar(canvas, bar, k, fonts, dt);
|
||||
canvas.restore();
|
||||
}
|
||||
self.draw_detail_band(canvas, rect, k, fonts);
|
||||
self.evict_art();
|
||||
}
|
||||
@@ -1514,13 +1554,13 @@ impl LibraryScreen {
|
||||
/// The bar over the field: what this library is sorted by, what it is arranged as, and
|
||||
/// the control for both.
|
||||
///
|
||||
/// Drawn whether or not it has focus, because the SORT is the thing the field cannot
|
||||
/// say. A coverflow under `Platform` and one under `A–Z` are the same screen with the
|
||||
/// cards in a different order, and until this band existed the only place that answer
|
||||
/// lived was the Collections screen — which a single-store library is never offered at
|
||||
/// all ([`crate::collate::worth_browsing`]). The arrangement IS visible in the field, and
|
||||
/// is named here anyway: one strip that answers both questions the same way is a control
|
||||
/// the user finds once.
|
||||
/// Drawn only while it holds the pad ([`LibraryBar::reveal`]): the field's legend keeps
|
||||
/// "▲ Sort & view" up permanently, so the ANSWER is one press away instead of one strip
|
||||
/// of always-spent field height — the Apple client's behaviour, adopted for the small
|
||||
/// screens where that strip priced out a full grid row. A coverflow under `Platform` and
|
||||
/// one under `A–Z` are still the same screen with the cards in a different order; this
|
||||
/// band is still the only place that names it (the Collections screen is never offered
|
||||
/// to a single-store library at all — [`crate::collate::worth_browsing`]).
|
||||
fn draw_bar(&mut self, canvas: &Canvas, bar: Rect, k: f64, fonts: &Fonts, dt: f64) {
|
||||
// Focused, the WHOLE band takes an accent WASH — the two groups are one control here
|
||||
// (◀ ▶ step the sort, the shoulders pick the arrangement), so a ring around one pill
|
||||
@@ -1645,8 +1685,17 @@ impl LibraryScreen {
|
||||
// the cursor — is what put the focus ring in a different column from the cover the
|
||||
// scroll had just brought up.
|
||||
let shape = GridShape::new(self.len(), cols, self.launcher_count());
|
||||
let (cw, ch) = (GRID_W * k, GRID_H * k);
|
||||
let pitch_x = cw + GRID_GAP * k;
|
||||
// `grid_cols` clamps at two columns, so on a narrow-enough viewport (a high-density
|
||||
// phone in portrait, where the density floor raises `k` past what the panel width
|
||||
// covers) two full-size covers plus margins can overflow the rect and clip at the
|
||||
// edges. The covers shrink to fit instead — only ever downward, and only the CELLS:
|
||||
// headings and labels keep the design scale, and geometry stays self-consistent
|
||||
// because everything below draws and records from these same metrics.
|
||||
let fit = ((f64::from(rect.width()) - 2.0 * GRID_MARGIN * k)
|
||||
/ ((cols as f64 * (GRID_W + GRID_GAP) - GRID_GAP) * k))
|
||||
.clamp(0.25, 1.0);
|
||||
let (cw, ch) = (GRID_W * k * fit, GRID_H * k * fit);
|
||||
let pitch_x = cw + GRID_GAP * k * fit;
|
||||
let pitch_y = ch + GRID_GAP * k + GRID_LABEL * k;
|
||||
// The launcher prefix keeps its own band, which is how design D4 reads in two
|
||||
// dimensions: the shelf says it with a heading that changes as the cursor crosses,
|
||||
@@ -1704,7 +1753,7 @@ impl LibraryScreen {
|
||||
(bump, self.scroll.pos)
|
||||
};
|
||||
|
||||
let grid_w = cols as f64 * pitch_x - GRID_GAP * k;
|
||||
let grid_w = cols as f64 * pitch_x - GRID_GAP * k * fit;
|
||||
let x0 = f64::from(rect.left) + (f64::from(rect.width()) - grid_w) / 2.0 + bump_x;
|
||||
let y0 = f64::from(rect.top);
|
||||
let viewport = Rect::from_xywh(rect.left, rect.top, rect.width(), (view_h.max(0.0)) as f32);
|
||||
@@ -2072,7 +2121,7 @@ impl LibraryScreen {
|
||||
canvas,
|
||||
note,
|
||||
f64::from(rect.left) + EDGE_INSET * k,
|
||||
f64::from(rect.bottom) - 30.0 * k,
|
||||
f64::from(rect.bottom) - 12.0 * k,
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
fg(0.55),
|
||||
@@ -2081,6 +2130,9 @@ impl LibraryScreen {
|
||||
let Some(g) = self.focused() else { return };
|
||||
let w = f64::from(rect.width());
|
||||
let cx = f64::from(rect.left) + w / 2.0;
|
||||
// The title alone. The store/platform subtitle that sat under it is gone: the cover
|
||||
// badge already names the store, so the line said everything twice and cost the band
|
||||
// 20 units of field height on every library visit.
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&g.title,
|
||||
@@ -2088,30 +2140,9 @@ impl LibraryScreen {
|
||||
27.0 * k,
|
||||
fg(1.0),
|
||||
cx,
|
||||
f64::from(rect.bottom) - 64.0 * k,
|
||||
f64::from(rect.bottom) - 34.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
// Store, and the PLATFORM when the host named one — the reason `platform` was
|
||||
// plumbed at all is that "Shadow of the Colossus" means something rather different
|
||||
// with "PS2" under it.
|
||||
let store = store_label(&g.store).to_uppercase();
|
||||
let sub = match (&g.platform, g.launcher) {
|
||||
(_, true) => format!("{store} · LAUNCHER"),
|
||||
(Some(p), _) if !p.trim().is_empty() => format!("{store} · {}", p.to_uppercase()),
|
||||
_ => store,
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&sub,
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
// The subtitle rung of the 0.55 / 0.7 / 0.85 ladder every other detail line
|
||||
// in the crate already sits on.
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.bottom) - 30.0 * k,
|
||||
w * 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -488,17 +488,27 @@ impl SettingsScreen {
|
||||
ListMsg::None => pulse,
|
||||
};
|
||||
}
|
||||
// The platform's own screens: A asks the host to open one; nothing here edits.
|
||||
RowId::Controllers | RowId::Licenses => {
|
||||
// Connected controllers is one of ours now — a shared Skia screen, so the console
|
||||
// keeps its own input on the page and only the grant dialogs go back to the host.
|
||||
RowId::Controllers => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
fx.push(Screen::Controllers(
|
||||
super::controllers::ControllersScreen::new(),
|
||||
));
|
||||
pulse
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
};
|
||||
}
|
||||
// The one screen still the platform's: A asks the host to open it; nothing here
|
||||
// edits.
|
||||
RowId::Licenses => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
let screen = if focused == RowId::Controllers {
|
||||
crate::platform::PlatformScreen::Controllers
|
||||
} else {
|
||||
crate::platform::PlatformScreen::Licenses
|
||||
};
|
||||
fx.cmds.push(crate::model::ConsoleCmd::OpenPlatformScreen {
|
||||
id: screen.id().to_string(),
|
||||
id: crate::platform::PlatformScreen::Licenses.id().to_string(),
|
||||
});
|
||||
pulse
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@ struct Toast {
|
||||
|
||||
struct Connecting {
|
||||
title: String,
|
||||
canceling: bool,
|
||||
appear: f64,
|
||||
/// A request-access wait (parked on the host until the operator approves) — the
|
||||
/// takeover reads "Waiting for approval" rather than "Connecting".
|
||||
@@ -436,7 +435,6 @@ impl Shell {
|
||||
self.last_connect_title = Some(title.clone());
|
||||
self.connecting = Some(Connecting {
|
||||
title,
|
||||
canceling: false,
|
||||
appear: 0.0,
|
||||
request_access: false,
|
||||
})
|
||||
@@ -504,7 +502,6 @@ impl Shell {
|
||||
.last_connect_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "the host".to_string()),
|
||||
canceling: false,
|
||||
appear: 1.0,
|
||||
request_access: false,
|
||||
});
|
||||
@@ -683,9 +680,19 @@ impl Shell {
|
||||
pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
|
||||
self.sync();
|
||||
// Modal precedence: the connect card, then the wake card, then the screens.
|
||||
if let Some(c) = &mut self.connecting {
|
||||
if ev == MenuEvent::Back && !c.canceling {
|
||||
c.canceling = true;
|
||||
if self.connecting.is_some() {
|
||||
if ev == MenuEvent::Back {
|
||||
// The takeover comes down HERE, not when the host answers. It used to wait for
|
||||
// the next `session_phase` and show "Canceling…" until one arrived — and one is
|
||||
// not guaranteed to: the dial is a blocking call on the host's side of this
|
||||
// interface, so the wait was the whole connect budget (185 s on a request-access
|
||||
// connect the host parks pending approval), and an embedder that simply drops a
|
||||
// canceled dial never sends a phase at all. Either way the console sat on
|
||||
// "Canceling…" with no input that could reach it — only killing the app cleared
|
||||
// it. Cancel is the USER's decision and needs no confirmation from the wire; the
|
||||
// action below still goes out, and every host already handles a dial that lands
|
||||
// after it (quit-close the connector, route the end back silently).
|
||||
self.connecting = None;
|
||||
self.actions.push_back(OverlayAction::CancelConnect);
|
||||
return Some(MenuPulse::Confirm);
|
||||
}
|
||||
|
||||
@@ -68,15 +68,7 @@ impl Shell {
|
||||
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
|
||||
if let Some(c) = &mut self.connecting {
|
||||
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
||||
if c.canceling {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
"Canceling…".to_string(),
|
||||
String::new(),
|
||||
vec![],
|
||||
))
|
||||
} else if c.request_access {
|
||||
if c.request_access {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
|
||||
@@ -178,15 +178,17 @@ fn connect_flow_raises_launch_and_cancel() {
|
||||
Some(OverlayAction::Launch { launch: None, .. })
|
||||
));
|
||||
assert!(s.connecting.is_some());
|
||||
// While connecting: B cancels exactly once.
|
||||
// While connecting: B cancels — and the takeover comes down on the spot. It must NOT wait
|
||||
// for a session phase to clear it: the dial is blocking on the host's side of this
|
||||
// interface, so that wait was the whole connect budget, and an embedder that just drops a
|
||||
// canceled dial sends no phase at all — the console stuck on "Canceling…" until the app died.
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
assert!(matches!(
|
||||
s.take_action(),
|
||||
Some(OverlayAction::CancelConnect)
|
||||
));
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
assert!(s.take_action().is_none(), "cancel is idempotent");
|
||||
// The canceled dial ends silently.
|
||||
assert!(s.connecting.is_none(), "cancel drops the takeover itself");
|
||||
// A dial that resolves afterwards (or never) changes nothing.
|
||||
s.session_ended(None);
|
||||
assert!(s.connecting.is_none());
|
||||
}
|
||||
|
||||
@@ -47,6 +47,30 @@ pub fn config_dir() -> PathBuf {
|
||||
base.join("punktfunk")
|
||||
}
|
||||
|
||||
/// The mgmt port the host actually bound, from `<config_dir>/mgmt-endpoint` — the one
|
||||
/// `PUNKTFUNK_MGMT_URL=https://127.0.0.1:<port>` line `punktfunk-host serve` publishes on every
|
||||
/// start (`mgmt::publish_endpoint`). This is how a `PUNKTFUNK_MGMT_BIND` move reaches a loopback
|
||||
/// consumer that inherits nothing from `host.env` — the tray, which on Windows cannot even read
|
||||
/// `host.env` (DACL-locked to SYSTEM/Administrators) while this file is deliberately Users-readable.
|
||||
/// `None` when the file is absent (an older host, or no host on this box) or unparsable; callers
|
||||
/// fall back to 47990, which is strictly what they did before.
|
||||
pub fn published_mgmt_port() -> Option<u16> {
|
||||
published_mgmt_port_in(&config_dir())
|
||||
}
|
||||
|
||||
/// The IO half of [`published_mgmt_port`], taking the directory so it is testable without touching
|
||||
/// `PUNKTFUNK_CONFIG_DIR` (this crate forbids the `unsafe` that `set_var` now needs).
|
||||
pub fn published_mgmt_port_in(dir: &std::path::Path) -> Option<u16> {
|
||||
let raw = std::fs::read_to_string(dir.join("mgmt-endpoint")).ok()?;
|
||||
let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
let value = line.split_once('=').map_or(line, |(_, v)| v).trim();
|
||||
// `https://127.0.0.1:47995` → the last `:`-separated field, tolerating a trailing `/`.
|
||||
value
|
||||
.trim_end_matches('/')
|
||||
.rsplit_once(':')
|
||||
.and_then(|(_, port)| port.parse().ok())
|
||||
}
|
||||
|
||||
/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable
|
||||
/// by other local users via a traversable config path). On Windows, applies a restrictive DACL
|
||||
/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in
|
||||
@@ -260,3 +284,41 @@ fn restrict_to_system_admins(path: &std::path::Path) {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn published_mgmt_port_follows_the_endpoint_file_and_is_absent_without_it() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-paths-endpoint-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
published_mgmt_port_in(&dir),
|
||||
None,
|
||||
"no file → fall back to the default"
|
||||
);
|
||||
|
||||
// exactly what `mgmt::endpoint_line` writes
|
||||
std::fs::write(
|
||||
dir.join("mgmt-endpoint"),
|
||||
"PUNKTFUNK_MGMT_URL=https://127.0.0.1:47995\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(published_mgmt_port_in(&dir), Some(47995));
|
||||
|
||||
std::fs::write(dir.join("mgmt-endpoint"), "\n").unwrap();
|
||||
assert_eq!(
|
||||
published_mgmt_port_in(&dir),
|
||||
None,
|
||||
"blank reads as unset, not port 0"
|
||||
);
|
||||
|
||||
std::fs::write(dir.join("mgmt-endpoint"), "PUNKTFUNK_MGMT_URL=\n").unwrap();
|
||||
assert_eq!(published_mgmt_port_in(&dir), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2094,20 +2094,37 @@ fn kill_unit(unit: &str) {
|
||||
/// 2026-07-07). `--runtime` keeps the mask in tmpfs so a reboot clears it even if the host dies
|
||||
/// without restoring (the same semantics as the persisted takeover file).
|
||||
///
|
||||
/// ⚠ The mask stops the UNIT from starting — it does NOT stop the relogin loop that keeps trying.
|
||||
/// On images whose SDDM session helper execs the session script directly (`/etc/sddm/wayland-session
|
||||
/// gamescope-session-plus steam`, f43 bazzite-deck — live-diagnosed on the .41 VM 2026-07-31) SDDM
|
||||
/// relogins ~3×/s regardless, each a full `bash --login` session start — 328 forks/s, load 6+, 1481
|
||||
/// logind sessions in 8 minutes, the journal flooded past its own rotation. The stream itself
|
||||
/// survives, but the storm starves the game and the encoder ("atrocious, unplayable 240fps"). The
|
||||
/// real defense against the storm is stopping the DM ([`dm_plan`]); the mask stays as belt-and-braces
|
||||
/// for the window before the stop lands, and as the degraded takeover when the stop is impossible.
|
||||
/// ⚠⚠⚠ **A mask laid while the display manager is still RUNNING is the relogin storm, not a defense
|
||||
/// against it** — the single most expensive misreading in this file's history, and the reason
|
||||
/// [`dm_plan`] no longer has a `mask` input. Measured end to end on `.41` (Bazzite `.41`, host
|
||||
/// `0.31.0`, 2026-08-18): `/usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop` runs
|
||||
/// `Exec=gamescope-session-plus ogui-steam`, and that script's last act is
|
||||
/// `systemctl --user --wait start gamescope-session-plus@ogui-steam.service`. So the mask sits
|
||||
/// **directly in SDDM's relogin path**: every autologin fails in milliseconds instead of taking the
|
||||
/// seconds a real gamescope + Steam start costs, and SDDM's `Relogin=true` has no backoff at all.
|
||||
/// That converts a slow, survivable relogin loop into a **4–5 logins/s fork storm**: 962 logind
|
||||
/// sessions in 3.7 min, `Watching system buttons` re-scanned 5,688 times, a box-wide udev `change`
|
||||
/// storm at ~20/s, `iio-sensor-proxy` crash-looping at ~16 starts/s, load 26 on 12 cores. What it
|
||||
/// breaks is not the display: `winebus` re-enumerates udev on every event instead of reading
|
||||
/// `hidraw`, so **the pad delivers input at ~1.4 Hz instead of 250 Hz** — "my DualSense is not
|
||||
/// detected in the game" (see `design/sddm-relogin-storm-starves-input-handoff.md`). The earlier
|
||||
/// reading of the same box (2026-07-31) recorded the storm but concluded the sddm helper "execs the
|
||||
/// session script directly, so the masked unit never enters the picture" — it does, one `systemctl`
|
||||
/// call further down, which is why masking looked inert and was left as the degraded takeover.
|
||||
///
|
||||
/// ⚠⚠ The mask DOES bite on that image, which is easy to miss and was the 2026-08-10 field bug: the
|
||||
/// session script's last act is `systemctl --user --wait start gamescope-session-plus@$1.service`, so
|
||||
/// a masked unit makes every entry into game mode — including the user's own deliberate "Return to
|
||||
/// Gaming Mode" — fail instantly, with Steam left sitting on its "Switch to Desktop…" modal forever.
|
||||
/// The mask is therefore only sound while our managed session actually holds the box: the moment the
|
||||
/// The rule that follows, enforced by [`stop_autologin_sessions`]: **the mask is laid only once the
|
||||
/// DM stop has landed, and is never a substitute for it.** With the DM down there is no relogin
|
||||
/// loop for the mask to accelerate, and it is pure belt-and-braces against a supervisor-side
|
||||
/// restart. It is also what keeps a mask-fragile flavor safe — Nobara's `plasmalogin` (KDE's SDDM
|
||||
/// successor) start-limit-kills ITSELF against a masked unit within ~1 s, leaving a permanent black
|
||||
/// screen that only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM
|
||||
/// 2026-07-24) — because a stopped DM cannot trip its own start limit, and every restore path
|
||||
/// unmasks BEFORE restarting the DM ([`do_restore_tv_session`]).
|
||||
///
|
||||
/// ⚠⚠ The mask DOES bite the user's own way back, which is easy to miss and was the 2026-08-10 field
|
||||
/// bug: a masked unit makes every entry into game mode — including a deliberate "Return to Gaming
|
||||
/// Mode" — fail instantly, with Steam left sitting on its "Switch to Desktop…" modal forever. The
|
||||
/// mask is therefore only sound while our managed session actually holds the box: the moment the
|
||||
/// box leaves it (a mid-stream switch to a desktop session), [`lift_autologin_mask`] must lift it, or
|
||||
/// the way back is barred until reboot (`--runtime` lives in tmpfs — which is exactly why "it works
|
||||
/// again after a reboot").
|
||||
@@ -2193,54 +2210,35 @@ fn display_manager_unit_under(base: &std::path::Path) -> Option<String> {
|
||||
target.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Does this display manager's autologin loop SURVIVE the gamescope unit being masked? This does
|
||||
/// NOT decide whether the DM keeps running — any DM relogin-loops against a killed live gaming
|
||||
/// session, so [`dm_plan`] stops the DM on every flavor — it decides whether masking is safe at
|
||||
/// all, and with it the DEGRADED takeover when the DM can't be stopped (no lingering / no
|
||||
/// privilege):
|
||||
/// * **SDDM** survives (a failing autologin leaves sddm itself running — .181 2026-07-07), so the
|
||||
/// degraded takeover is mask-only: Steam stays protected, and the cost is SDDM's relogin churn
|
||||
/// for the stream's duration — anything from logind/ACL flapping (.181, the audio-flap
|
||||
/// pathology) to a full fork storm on images whose sddm helper bypasses the unit (.41
|
||||
/// 2026-07-31, see [`mask_unit`]).
|
||||
/// * Nobara's `plasmalogin` (KDE's SDDM successor) is proven FATAL: against a masked unit
|
||||
/// its session Exec fails instantly, `Relogin=true` retries, and `plasmalogin.service` trips
|
||||
/// systemd's start limit within ~1 s — the DM dies and the box is a permanent black screen that
|
||||
/// only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM
|
||||
/// 2026-07-24). Unknown DMs are treated as fragile: the fragile path degrades gracefully, a wrong
|
||||
/// "safe" kills the seat.
|
||||
fn dm_survives_masked_unit(dm: &str) -> bool {
|
||||
dm == "sddm.service"
|
||||
}
|
||||
|
||||
/// The takeover's display-manager decision, derived purely from the DM flavor and whether any
|
||||
/// autologin gaming instance is LIVE (unit-tested; the runtime guards — lingering, privilege —
|
||||
/// stay with [`stop_autologin_sessions`]).
|
||||
/// The takeover's display-manager decision, derived purely from whether a display manager exists
|
||||
/// and whether any autologin gaming instance is LIVE (unit-tested; the runtime guards — lingering,
|
||||
/// privilege — stay with [`stop_autologin_sessions`]).
|
||||
///
|
||||
/// Killing a live autologin session starts its DM's `Relogin=true` loop, and no flavor tolerates
|
||||
/// that loop well: SDDM's churns logind sessions up to a fork storm ([`mask_unit`]), plasmalogin's
|
||||
/// start-limit-kills the DM. So whenever a DM drove a LIVE gaming session, the DM itself is
|
||||
/// stopped for the stream's duration; the restore ([`do_restore_tv_session`]) brings it back and
|
||||
/// its autologin restores gaming mode. The flavors differ only in masking and in the degraded
|
||||
/// mode ([`dm_survives_masked_unit`]).
|
||||
/// its autologin restores gaming mode.
|
||||
///
|
||||
/// There is no flavor-dependent degraded mode any more, and the DM flavor is no longer an input.
|
||||
/// It used to be: SDDM was classified as surviving a masked unit, so a failed DM stop degraded to
|
||||
/// **mask-only** there. That degrade is what starved the .41 box's input plane on 2026-08-18 —
|
||||
/// masking without the stop is not a weaker defense, it is the storm's engine ([`mask_unit`]). A
|
||||
/// planned DM stop that does not land now fails the takeover and the caller degrades to ATTACH.
|
||||
struct DmPlan {
|
||||
/// Touch nothing at all: a mask-fragile DM with no live gaming instance — killing
|
||||
/// loaded-but-inactive leftovers frees nothing, and stopping the DM would kill the user's
|
||||
/// live desktop for it.
|
||||
/// Touch nothing at all: no live gaming instance. Killing loaded-but-inactive leftovers frees
|
||||
/// no Steam, masking them while a DM is up is the relogin storm ([`mask_unit`]), and stopping
|
||||
/// the DM would kill the user's live desktop for it.
|
||||
skip: bool,
|
||||
/// Mask the units before killing them (safe only where the DM survives a masked unit; also
|
||||
/// the whole of the degraded takeover when the DM can't be stopped).
|
||||
mask: bool,
|
||||
/// Stop the DM for the stream's duration (only a live instance justifies it).
|
||||
/// Stop the DM for the stream's duration (only a live instance justifies it). Masking is not
|
||||
/// a plan input: it is laid only once this stop has LANDED, so it can never substitute for it.
|
||||
stop_dm: bool,
|
||||
}
|
||||
|
||||
/// See [`DmPlan`].
|
||||
fn dm_plan(dm: Option<&str>, any_live: bool) -> DmPlan {
|
||||
let mask = dm.is_none_or(dm_survives_masked_unit);
|
||||
DmPlan {
|
||||
skip: !mask && !any_live,
|
||||
mask,
|
||||
skip: !any_live,
|
||||
stop_dm: dm.is_some() && any_live,
|
||||
}
|
||||
}
|
||||
@@ -2311,6 +2309,23 @@ enum DmHelperError {
|
||||
},
|
||||
}
|
||||
|
||||
impl DmHelperError {
|
||||
/// The variant as a stable one-word tag, for the `shape` log field. The [`Display`] text is
|
||||
/// prose aimed at whoever reads the line; this is what makes the four cases greppable and
|
||||
/// countable across boxes, since each needs a different fix (package it / install polkit /
|
||||
/// fix the action / join the group).
|
||||
///
|
||||
/// [`Display`]: std::fmt::Display
|
||||
fn shape(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotInstalled => "not-installed",
|
||||
Self::NotExecutable { .. } => "not-executable",
|
||||
Self::Denied { .. } => "denied",
|
||||
Self::Refused { .. } => "refused",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DmHelperError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -2568,12 +2583,29 @@ fn user_in_group(user: &str, group: &str) -> bool {
|
||||
/// wait — a system manager mid-shutdown can still take the request and never answer. A timeout
|
||||
/// reads as `false`, which is the same answer an unauthorized call already gives, so every caller
|
||||
/// falls through to the pkexec helper exactly as it does today.
|
||||
///
|
||||
/// Its stderr is **captured and logged at DEBUG**, not inherited. On an unprivileged host this verb
|
||||
/// is EXPECTED to fail — it is the cheap probe that runs before the pkexec helper — so systemctl's
|
||||
/// own "Access denied … requires interactive authentication" went to the journal on the normal,
|
||||
/// successful path: two of them immediately before `INFO restored the display manager`. That shape
|
||||
/// cost two debugging sessions on its own (2026-08-18), each spent explaining a failure that had
|
||||
/// already succeeded one line later. A `--no-ask-password` refusal is not news; it is the design.
|
||||
fn systemctl_system(args: &[&str]) -> bool {
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--no-ask-password").args(args);
|
||||
crate::proc::status_within(&mut cmd, DM_VERB_BUDGET)
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
let Ok(out) = crate::proc::output_within(&mut cmd, DM_VERB_BUDGET) else {
|
||||
return false; // timed out / could not spawn — the helper path is next either way
|
||||
};
|
||||
if !out.status.success() {
|
||||
tracing::debug!(
|
||||
?args,
|
||||
status = ?out.status.code(),
|
||||
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
|
||||
"systemctl on the system bus was refused — falling through to the packaged pkexec \
|
||||
helper (expected on an unprivileged host)"
|
||||
);
|
||||
}
|
||||
out.status.success()
|
||||
}
|
||||
|
||||
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
|
||||
@@ -2878,22 +2910,24 @@ fn honor_session_select_switch(dm: String) {
|
||||
///
|
||||
/// When a display manager drove a LIVE gaming session, it is **stopped for the stream** on every
|
||||
/// flavor ([`dm_plan`]): killing the session otherwise starts the DM's `Relogin=true` loop, which
|
||||
/// at best churns logind sessions/ACLs and at worst is a full fork storm — f43 bazzite-deck's sddm
|
||||
/// helper execs the session script directly, so the masked unit never enters the picture (328
|
||||
/// forks/s, load 6+, live-diagnosed on the .41 VM 2026-07-31 — see [`mask_unit`]). The units
|
||||
/// themselves are torn down with **SIGKILL** ([`kill_unit`]) to avoid the F44 GPU-context leak
|
||||
/// that the autologin's SIGTERM stop triggers. The flavors differ in masking and in the degraded
|
||||
/// mode when the DM can't be stopped (no lingering / no privilege):
|
||||
/// * **SDDM / no DM**: each unit is **masked first** ([`mask_unit`] — belt-and-braces under a
|
||||
/// stopped DM, and the whole defense on images that DO route the relogin through the unit).
|
||||
/// Matches every loaded instance, not just `running` ones — under a relogin churn the unit
|
||||
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters
|
||||
/// the fight the moment the supervisor restarts it. A failed DM stop **degrades to mask-only**
|
||||
/// with a warning, never to attach: the mask still protects Steam, at the storm-tax price.
|
||||
/// * **Mask-fragile DM** (Nobara's `plasmalogin`, unknown DMs): masking start-limit-kills the DM
|
||||
/// itself (permanent black screen), so the units are killed unmasked, and a failed DM stop
|
||||
/// **fails the takeover** — the error tells the caller to degrade to ATTACH (mirror the box's
|
||||
/// own session) rather than destabilize the seat.
|
||||
/// at best churns logind sessions/ACLs and at worst is a full fork storm. The units themselves are
|
||||
/// torn down with **SIGKILL** ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's
|
||||
/// SIGTERM stop triggers, and each is **masked first** ([`mask_unit`]) so the supervisor cannot
|
||||
/// restart it underneath us. Masking matches every loaded instance, not just `running` ones — under
|
||||
/// a relogin churn the unit flaps through `activating`/`failed` between cycles, and an unmasked
|
||||
/// flapping unit re-enters the fight the moment the supervisor restarts it.
|
||||
///
|
||||
/// **A planned DM stop that does not land fails the takeover, on every flavor** — the `Err` tells
|
||||
/// the caller to degrade to ATTACH (mirror the box's own session) instead. There is deliberately no
|
||||
/// mask-only degrade any more. SDDM used to get one, on the reasoning that "the mask still protects
|
||||
/// Steam, at the storm-tax price"; the storm tax was then measured on `.41` (2026-08-18) and it is
|
||||
/// not a tax, it is a **4–5 logins/s fork storm that costs the user their input plane** — the pad
|
||||
/// reads at 1.4 Hz instead of 250 Hz, because the mask sits inside SDDM's relogin path and makes
|
||||
/// every retry fail instantly ([`mask_unit`] has the full chain). Fighting an autologin we cannot
|
||||
/// stop is strictly worse than not taking over at all, and attach is a fully working stream.
|
||||
///
|
||||
/// The ORDER is therefore load-bearing and not a style choice: stop the DM, bail if it did not
|
||||
/// land, and only then mask. A mask laid before a stop that never arrives is the storm.
|
||||
fn stop_autologin_sessions() -> Result<()> {
|
||||
let Ok(out) = crate::proc::output_within(
|
||||
Command::new("systemctl").args([
|
||||
@@ -2927,9 +2961,15 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
|
||||
// leftover (the box switched back to the desktop earlier) must not stop the DM — that
|
||||
// would kill the user's live desktop to free nothing.
|
||||
// Stated as the NEGATIVE — systemd has exactly two not-running ACTIVE states, and the other
|
||||
// four all mean the unit still owns Steam and the GPU. Listing the live ones instead missed
|
||||
// `deactivating` (and `reloading`): a unit caught mid-teardown read as a dead leftover, so a
|
||||
// box that IS in gaming mode could be sampled as idle and skipped, leaving the autologin's
|
||||
// Steam holding the single instance our own launch then collides with. The window is small on
|
||||
// an idle box and wide open on a churning one — which is exactly when this is sampled.
|
||||
let any_live = listed
|
||||
.iter()
|
||||
.any(|(_, active)| matches!(active.as_str(), "active" | "activating"));
|
||||
.any(|(_, active)| !matches!(active.as_str(), "inactive" | "failed"));
|
||||
let plan = dm_plan(dm.as_deref(), any_live);
|
||||
if plan.skip {
|
||||
return Ok(());
|
||||
@@ -2938,99 +2978,167 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
let dm = dm.expect("stop_dm ⇒ Some");
|
||||
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
|
||||
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
|
||||
// box's display manager down and nobody left to bring it back. On a mask-fragile flavor,
|
||||
// degrading to attach is strictly better than a black screen that needs a VT to recover;
|
||||
// where masking is safe, mask-only (the storm tax) is strictly better than attach.
|
||||
// box's display manager down and nobody left to bring it back.
|
||||
//
|
||||
// Both failure arms below quote the REASON they were handed rather than describing one.
|
||||
// BOTH arms below now BAIL, on every DM flavor. They did not always: SDDM used to degrade
|
||||
// to mask-only here, on the reasoning that the mask still protects Steam and the cost is
|
||||
// just relogin churn. It is not just churn — the mask is IN sddm's relogin path, so a
|
||||
// mask without the stop is a 4–5 logins/s fork storm that drops the pad from 250 Hz to
|
||||
// 1.4 Hz ([`mask_unit`]). Degrading to attach costs the client's mode; degrading to
|
||||
// mask-only costs the user their input plane. Attach wins.
|
||||
//
|
||||
// Both bails quote the REASON they were handed rather than describing one.
|
||||
// 0.26.0/0.27.0 described one — "the packaged pf-dm-helper polkit action is missing or was
|
||||
// denied (reinstall the punktfunk package, or install the display-manager polkit rule from
|
||||
// the docs)" — and on the box that produced it the action was installed, permissive,
|
||||
// correctly annotated, and pkexec had already RUN the helper; the helper's refusal ("user
|
||||
// 'x' is not in the 'punktfunk' group") was thrown away with its stderr. Both suggested
|
||||
// remedies were dead ends: neither a reinstall nor a polkit rule adds anyone to a group.
|
||||
let dm_stopped = if let Err(why) = ensure_host_survives_dm_stop() {
|
||||
if !plan.mask {
|
||||
// The reason goes LAST in both bails: the helper's own refusal ends in a command
|
||||
// to paste, and burying that mid-sentence is how it stops being read.
|
||||
bail!(
|
||||
"stopping {dm} ends this user's last login session, and without lingering \
|
||||
logind would stop the user manager — and this host with it — about 10s \
|
||||
later, leaving the box with no display manager and nothing to restore it; \
|
||||
lingering could not be enabled, so the managed takeover is unavailable. \
|
||||
Either run `sudo loginctl enable-linger $USER` once, as the setup docs ask, \
|
||||
and reconnect — or fix the privileged path: {why}"
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
reason = %why,
|
||||
"cannot stop the display manager for this stream (lingering could not be \
|
||||
enabled, and without it the DM stop would take this host down ~10s later) — \
|
||||
leaving it running: its autologin Relogin loop will churn logind sessions for \
|
||||
the whole stream, up to a fork storm that starves the game and encoder; run \
|
||||
`sudo loginctl enable-linger $USER` once, as the setup docs ask"
|
||||
if let Err(why) = ensure_host_survives_dm_stop() {
|
||||
// The reason goes LAST in both bails: the helper's own refusal ends in a command
|
||||
// to paste, and burying that mid-sentence is how it stops being read.
|
||||
bail!(
|
||||
"stopping {dm} ends this user's last login session, and without lingering \
|
||||
logind would stop the user manager — and this host with it — about 10s \
|
||||
later, leaving the box with no display manager and nothing to restore it; \
|
||||
lingering could not be enabled, so the managed takeover is unavailable. \
|
||||
Either run `sudo loginctl enable-linger $USER` once, as the setup docs ask, \
|
||||
and reconnect — or fix the privileged path: {why}"
|
||||
);
|
||||
false
|
||||
} else if let Err(why) = try_stop_display_manager(&dm) {
|
||||
if !plan.mask {
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||
session unit, and stopping it needs privilege, so the managed takeover is \
|
||||
unavailable — {why}"
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
reason = %why,
|
||||
"stopping the display manager for this stream needs privilege and the privileged \
|
||||
path failed — leaving it running: its autologin Relogin loop will churn logind \
|
||||
sessions for the whole stream, up to a fork storm that starves the game and \
|
||||
encoder"
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if dm_stopped {
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"freed Steam: stopped the display manager for this stream (its autologin \
|
||||
Relogin loop would otherwise churn against the takeover)"
|
||||
);
|
||||
// Baseline the switch sentinel HERE, not just at a successful launch: setting
|
||||
// STOPPED_DM is what arms the honor gate, so from this instant an unbaselined
|
||||
// sentinel would read as an in-stream "Switch to Desktop" — including the write from
|
||||
// the switch that just brought the box INTO game mode. A successful launch
|
||||
// re-baselines (tighter still).
|
||||
record_session_select_baseline();
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||
}
|
||||
if let Err(why) = try_stop_display_manager(&dm) {
|
||||
// ERROR, not WARN, and it names the SHAPE: this is the branch whose silence cost an
|
||||
// evening on .41 — the takeover degraded, nothing failed loudly, and the storm that
|
||||
// followed read as a pad bug. The `bail!` below reaches the caller's own warn line;
|
||||
// this one exists so the shape survives into the journal even if the caller's does
|
||||
// not, because the four shapes need four different fixes.
|
||||
tracing::error!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"the managed takeover planned to stop the display manager and could not — \
|
||||
degrading to ATTACH rather than fighting its autologin: a killed session under \
|
||||
a running DM relogin-loops at 4-5/s and starves the box's input plane"
|
||||
);
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, and stopping it for the stream needs \
|
||||
privilege this host does not have; taking over without stopping it would leave \
|
||||
its autologin relogin-looping against us for the whole stream, so the managed \
|
||||
takeover is unavailable — {why}"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"freed Steam: stopped the display manager for this stream (its autologin \
|
||||
Relogin loop would otherwise churn against the takeover)"
|
||||
);
|
||||
// Baseline the switch sentinel HERE, not just at a successful launch: setting
|
||||
// STOPPED_DM is what arms the honor gate, so from this instant an unbaselined
|
||||
// sentinel would read as an in-stream "Switch to Desktop" — including the write from
|
||||
// the switch that just brought the box INTO game mode. A successful launch
|
||||
// re-baselines (tighter still).
|
||||
record_session_select_baseline();
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||
}
|
||||
// Reaching here means no display manager can relogin against us: either there is none
|
||||
// (`!plan.stop_dm` with `dm == None`), or the stop above LANDED — both failure arms bail. That
|
||||
// is the precondition the mask needs, and the only one under which it is a defense rather than
|
||||
// the storm's accelerator ([`mask_unit`]).
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
if plan.mask {
|
||||
// Record that a mask is outstanding BEFORE laying it: every hand-back path lifts it off this
|
||||
// flag, and one that ran between the mask and an unrecorded flag would leave it on forever.
|
||||
*AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
}
|
||||
// Record that a mask is outstanding BEFORE laying it: every hand-back path lifts it off this
|
||||
// flag, and one that ran between the mask and an unrecorded flag would leave it on forever.
|
||||
*AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
for unit in units {
|
||||
if plan.mask {
|
||||
mask_unit(&unit); // belt-and-braces under a stopped DM; the whole defense otherwise
|
||||
}
|
||||
mask_unit(&unit); // belt-and-braces: no DM is up to relogin through it
|
||||
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||
tracing::info!(
|
||||
%unit,
|
||||
masked = plan.mask,
|
||||
"freed Steam: stopped the autologin gaming session for this stream"
|
||||
dm_stopped = plan.stop_dm,
|
||||
"freed Steam: masked and stopped the autologin gaming session for this stream"
|
||||
);
|
||||
stopped.push(unit);
|
||||
}
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
||||
persist_takeover(); // A3: survive a host crash mid-stream
|
||||
watch_for_relogin_storm(); // §5.4: no measurement taken during a storm is valid — say so
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long the post-takeover storm probe samples logind's session counter for. Long enough that
|
||||
/// one legitimate login racing our teardown cannot reach the threshold, short enough that the line
|
||||
/// lands in the journal while the operator is still looking at the connect that produced it.
|
||||
const STORM_PROBE_WINDOW: Duration = Duration::from_secs(5);
|
||||
|
||||
/// New logind sessions per second above which the box is relogin-storming rather than merely busy.
|
||||
/// A healthy takeover creates **zero** (the display manager is stopped for the stream); the
|
||||
/// measured storm ran at 4–5/s. An order of magnitude clear of both.
|
||||
const STORM_LOGINS_PER_SEC: f64 = 1.0;
|
||||
|
||||
/// The highest logind session id on the box right now. logind names its per-session state files
|
||||
/// after the id in `/run/systemd/sessions/` and hands ids out monotonically, so the maximum is a
|
||||
/// free monotonic LOGIN COUNTER — no `journalctl` grep, no D-Bus, just a `read_dir`. `None` on a
|
||||
/// box with no logind at all.
|
||||
fn max_logind_session_id() -> Option<u64> {
|
||||
std::fs::read_dir("/run/systemd/sessions")
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter_map(|e| e.file_name().to_str().and_then(|n| n.parse::<u64>().ok()))
|
||||
.max()
|
||||
}
|
||||
|
||||
/// Watch for a display-manager relogin storm just after a takeover, and say so at ERROR if one is
|
||||
/// running.
|
||||
///
|
||||
/// This exists because of what a storm costs to DIAGNOSE, not what it costs to run. A box relogging
|
||||
/// at 4–5/s re-fires logind's seat scan on every cycle, which re-fires udev `uaccess` across every
|
||||
/// subsystem at ~20/s; `winebus` then re-enumerates udev instead of reading `hidraw` and the pad
|
||||
/// delivers **~1.4 Hz instead of 250 Hz**, WirePlumber re-enumerates at 72 % CPU, and
|
||||
/// `iio-sensor-proxy` crash-loops at ~16 starts/s as a udev-activated amplifier. None of that names
|
||||
/// the display manager. It presents as "my controller is not detected in the game", and an evening
|
||||
/// was spent on 2026-08-18 disproving the pad stack, the ALSA UCM, PipeWire and GE-Proton before
|
||||
/// the DM was suspected at all. **Every audio, input and PipeWire measurement taken during a storm
|
||||
/// is invalid**, and that is worth one loud line before anyone starts measuring.
|
||||
///
|
||||
/// ponytail: detect-and-report only, no self-mitigation. The mitigation would be tearing our own
|
||||
/// session down mid-stream and re-connecting in attach mode, which is a worse failure than the one
|
||||
/// it fixes if the detector is ever wrong. Now that the mask can no longer outlive the DM stop
|
||||
/// ([`stop_autologin_sessions`]) this host does not create storms, so what is left to catch is
|
||||
/// somebody else's — a hand-masked unit, a third-party session switcher, a distro change. If one of
|
||||
/// those turns up in the field with a reliable signature, self-mitigate then.
|
||||
fn watch_for_relogin_storm() {
|
||||
let Some(before) = max_logind_session_id() else {
|
||||
return; // no logind — nothing relogins here
|
||||
};
|
||||
// Detached: the takeover path is already the slowest part of a connect and the answer is worth
|
||||
// nothing to it (it only ever logs). Dies with the process, which is fine — a storm outlives
|
||||
// any single 5 s window and the next connect probes again.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(STORM_PROBE_WINDOW);
|
||||
let Some(after) = max_logind_session_id() else {
|
||||
return;
|
||||
};
|
||||
let logins = after.saturating_sub(before);
|
||||
let per_sec = logins as f64 / STORM_PROBE_WINDOW.as_secs_f64();
|
||||
if per_sec < STORM_LOGINS_PER_SEC {
|
||||
return;
|
||||
}
|
||||
tracing::error!(
|
||||
logins,
|
||||
window_s = STORM_PROBE_WINDOW.as_secs(),
|
||||
rate = %format!("{per_sec:.1}/s"),
|
||||
"this box is in a display-manager RELOGIN STORM — logind is opening sessions faster \
|
||||
than once a second. Every udev consumer on the box is drowning in the fallout: \
|
||||
expect the gamepad to read at a few Hz instead of 250, WirePlumber to burn CPU \
|
||||
re-enumerating, and iio-sensor-proxy to crash-loop. NO audio, input or PipeWire \
|
||||
measurement taken now is valid — find what is relogging first. Usual cause: a \
|
||||
gamescope session unit left masked while the display manager is running, so every \
|
||||
autologin fails instantly (`systemctl --user list-unit-files 'gamescope-session*'`); \
|
||||
`systemctl --user unmask --runtime <unit>` clears it, a reboot clears it too"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
||||
/// down a running game (Proton/wineserver included) on the way out, so this is generous.
|
||||
const STEAM_SHUTDOWN_WAIT: Duration = Duration::from_secs(20);
|
||||
@@ -3515,6 +3623,7 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
Err(why) if crate::try_recover_session() => tracing::warn!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"display-manager restart lost its privilege — fired PUNKTFUNK_RECOVER_SESSION_CMD \
|
||||
to bring the session back"
|
||||
@@ -3524,6 +3633,7 @@ fn do_restore_tv_session() {
|
||||
// symptom once and the reason is what stops it happening again.
|
||||
Err(why) => tracing::error!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"could not restart the display manager and no PUNKTFUNK_RECOVER_SESSION_CMD is \
|
||||
configured — the box has no graphical session until someone runs \
|
||||
@@ -5169,14 +5279,14 @@ mod tests {
|
||||
use super::{
|
||||
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
|
||||
cgroup_under_user_manager, classify_output_size, connected_connector_under,
|
||||
display_manager_unit_under, dm_plan, dm_survives_masked_unit, 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,
|
||||
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,
|
||||
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
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, 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, STOPPED_AUTOLOGIN, WSI_OFF_ENV,
|
||||
X11_SOCKET_DIR,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -5423,11 +5533,6 @@ mod tests {
|
||||
display_manager_unit_under(&base).as_deref(),
|
||||
Some("plasmalogin.service")
|
||||
);
|
||||
// Only SDDM is proven to survive a masked session unit; plasmalogin start-limit-kills
|
||||
// itself (live-proven), and unknown DMs default to fragile.
|
||||
assert!(dm_survives_masked_unit("sddm.service"));
|
||||
assert!(!dm_survives_masked_unit("plasmalogin.service"));
|
||||
assert!(!dm_survives_masked_unit("gdm.service"));
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
|
||||
@@ -5479,23 +5584,55 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dm_plan_stops_any_dm_that_drove_a_live_session() {
|
||||
// SDDM, live gaming session: mask (belt-and-braces) AND stop the DM — the mask alone
|
||||
// does not stop the relogin loop on images whose sddm helper execs the session script
|
||||
// directly, bypassing the unit (fork storm, .41 VM 2026-07-31).
|
||||
// A live gaming session behind a DM: stop the DM, whatever the flavor. The mask alone does
|
||||
// NOT stop the relogin loop — on .41 it is what makes the loop fast, because the session
|
||||
// script's last act is `systemctl --user --wait start gamescope-session-plus@…` and a
|
||||
// masked unit fails that in milliseconds (4-5 logins/s, pad at 1.4 Hz, 2026-08-18).
|
||||
let p = dm_plan(Some("sddm.service"), true);
|
||||
assert!(!p.skip && p.mask && p.stop_dm);
|
||||
// SDDM, only inactive leftovers: nothing live justifies touching the DM — mask+kill only.
|
||||
let p = dm_plan(Some("sddm.service"), false);
|
||||
assert!(!p.skip && p.mask && !p.stop_dm);
|
||||
// Mask-fragile flavor, live: stop the DM, never mask (masking start-limit-kills the DM).
|
||||
let p = dm_plan(Some("plasmalogin.service"), true);
|
||||
assert!(!p.skip && !p.mask && p.stop_dm);
|
||||
// Mask-fragile flavor, nothing live: hands off entirely — stopping the DM here would
|
||||
// kill the user's live desktop to free nothing.
|
||||
assert!(!p.skip && p.stop_dm);
|
||||
// Flavor is no longer an input: plasmalogin gets the same plan as sddm. It used to differ
|
||||
// only to pick a DEGRADED mode (mask-only for sddm), and that degrade is now gone —
|
||||
// `stop_autologin_sessions` bails to ATTACH instead.
|
||||
let q = dm_plan(Some("plasmalogin.service"), true);
|
||||
assert!(q.skip == p.skip && q.stop_dm == p.stop_dm);
|
||||
// Nothing live, DM present: hands off entirely, on EVERY flavor. Killing loaded-but-
|
||||
// inactive leftovers frees no Steam; masking them while the DM is up is the storm; and
|
||||
// stopping the DM would kill the user's live desktop for it.
|
||||
assert!(dm_plan(Some("sddm.service"), false).skip);
|
||||
assert!(dm_plan(Some("plasmalogin.service"), false).skip);
|
||||
// No DM at all (getty autologin): mask+kill, nothing to stop.
|
||||
// No DM at all (getty autologin), live: mask+kill, nothing to stop — masking is sound
|
||||
// here precisely because no relogin loop exists to run into it.
|
||||
let p = dm_plan(None, true);
|
||||
assert!(!p.skip && p.mask && !p.stop_dm);
|
||||
assert!(!p.skip && !p.stop_dm);
|
||||
assert!(dm_plan(None, false).skip);
|
||||
}
|
||||
|
||||
/// The four [`DmHelperError`] shapes need four different fixes, so the `shape` field must keep
|
||||
/// them apart — a helper that could not be EXECUTED must never read as one that ran and refused.
|
||||
#[test]
|
||||
fn dm_helper_error_shapes_stay_distinct() {
|
||||
let shapes = [
|
||||
DmHelperError::NotInstalled.shape(),
|
||||
DmHelperError::NotExecutable {
|
||||
helper: "h",
|
||||
io: String::new(),
|
||||
}
|
||||
.shape(),
|
||||
DmHelperError::Denied {
|
||||
helper: "h",
|
||||
code: 127,
|
||||
stderr: String::new(),
|
||||
}
|
||||
.shape(),
|
||||
DmHelperError::Refused {
|
||||
helper: "h",
|
||||
code: Some(1),
|
||||
stderr: String::new(),
|
||||
}
|
||||
.shape(),
|
||||
];
|
||||
let unique: std::collections::HashSet<_> = shapes.iter().collect();
|
||||
assert_eq!(unique.len(), shapes.len(), "shapes collided: {shapes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -268,7 +268,16 @@ impl VirtualDisplay for KwinDisplay {
|
||||
.context("spawn KWin virtual-output thread")?;
|
||||
match setup_rx.recv_timeout(OPENER_BUDGET) {
|
||||
Ok(Ok(v)) => Ok((v, stop)),
|
||||
Ok(Err(e)) => bail!("KWin virtual output failed: {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!(
|
||||
"KWin virtual output failed: {e} — KWin declined to create the output. It \
|
||||
needs a Plasma WAYLAND session on KWin's DRM backend; a nested or \
|
||||
`kwin_wayland --virtual` KWin can only do this since 6.5.6, and on KWin 6.6+ \
|
||||
an output KWin creates but leaves DISABLED (stored \
|
||||
~/.config/kwinoutputconfig.json, or a display config it refused to apply) \
|
||||
reports the same. kwin_wayland's own journal says which"
|
||||
),
|
||||
Err(_) => {
|
||||
// Nothing else will ever flip this `stop`: it is dropped with the error, and
|
||||
// the `StopGuard` that normally owns it is only built on the success path. So
|
||||
|
||||
@@ -2326,12 +2326,15 @@ pub unsafe extern "C" fn punktfunk_connect_ex10(
|
||||
/// `audio_rate_hz` — `48000`, `96000`, or the 44.1 kHz family `44100` / `88200` / `176400` — and
|
||||
/// `audio_bits` (`16` or `24`).
|
||||
///
|
||||
/// Passing anything other than `48000`/`16` sets `CLIENT_CAP_AUDIO_HIRES` in the `Hello` and asks
|
||||
/// the host for the LOSSLESS `0xD3` plane — bit-exact PCM instead of Opus. That is an opt-in on
|
||||
/// both ends, and it is meant to be: it costs **1.5–4.6 Mbps** taken off the top of the link
|
||||
/// (audio rides QUIC datagrams outside the ABR loop, so ABR can neither see it nor reclaim it),
|
||||
/// against the ~256 kbps Opus this replaces. Only call it with a non-default format when the
|
||||
/// user turned the feature on AND this embedder can genuinely open an output device at it.
|
||||
/// Passing a format AT ALL — any non-zero `audio_rate_hz`/`audio_bits`, `48000`/`16` included —
|
||||
/// sets `CLIENT_CAP_AUDIO_HIRES` in the `Hello` and asks the host for the LOSSLESS `0xD3` plane,
|
||||
/// bit-exact PCM instead of Opus. (This line once said "anything other than `48000`/`16`", which
|
||||
/// was the rule until the cheapest rung turned out to be the one nobody could ask for; the ⚠ below
|
||||
/// is the whole story.) That is an opt-in on both ends, and it is meant to be: it costs
|
||||
/// **1.5–4.6 Mbps** taken off the top of the link (audio rides QUIC datagrams outside the ABR
|
||||
/// loop, so ABR can neither see it nor reclaim it), against the ~256 kbps Opus this replaces. Only
|
||||
/// pass a format when the user turned the feature on AND this embedder can genuinely open an
|
||||
/// output device at it.
|
||||
///
|
||||
/// **The request is not the answer.** The host runs a five-condition gate
|
||||
/// (`design/hi-res-audio.md` §8.4 — client asked, operator policy allows, stereo, the capture
|
||||
@@ -2619,6 +2622,10 @@ unsafe fn connect_ex_impl(
|
||||
pin,
|
||||
identity,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
// No abort switch in the C ABI: `punktfunk_connect*` is a blocking call with
|
||||
// nothing to poll a flag from. An `ex` variant can take one when an ABI embedder
|
||||
// grows a cancelable connect screen.
|
||||
None,
|
||||
) {
|
||||
Ok(c) => {
|
||||
if !observed_sha256_out.is_null() {
|
||||
|
||||
@@ -750,6 +750,7 @@ impl NativeClient {
|
||||
pin,
|
||||
identity,
|
||||
timeout,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -810,6 +811,16 @@ impl NativeClient {
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
timeout: Duration,
|
||||
// The caller's abort switch, polled while this call is still blocked: setting it returns
|
||||
// [`PunktfunkError::Timeout`] straight away instead of parking the caller for the rest of
|
||||
// `timeout` — which is 185 s on a request-access dial the host has PARKED pending an
|
||||
// operator's approval, and a UI that offers Cancel cannot honour it while its dialing
|
||||
// thread is stuck in here. Taking it is the same give-up as running out of budget (quit
|
||||
// close + shutdown), so the worker stops re-dialing and the host tears down rather than
|
||||
// lingering for a reconnect nobody wants. Read ONLY here — deliberately not aliased onto
|
||||
// the client's own `shutdown`, which the pump uses to mean "this connection died" and
|
||||
// whose end reason a caller-set flag would race. `None` = a connect nobody can cancel.
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
) -> Result<NativeClient> {
|
||||
let frame_chan = Arc::new(FrameChannel::new());
|
||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||
@@ -967,18 +978,34 @@ impl NativeClient {
|
||||
})
|
||||
.map_err(PunktfunkError::Io)?;
|
||||
|
||||
let negotiated = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
// A connect we already reported as failed must not leave a lingering host
|
||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||
// instead of holding the session (and its virtual display) for a reconnect
|
||||
// that will never come.
|
||||
quit.store(true, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
// Polled rather than one long `recv_timeout(timeout)`: the wait has to end on the
|
||||
// caller's `cancel` as well as on the budget, and a handshake the host has PARKED
|
||||
// (request-access, pending approval) produces nothing to wake on for minutes.
|
||||
const READY_POLL: Duration = Duration::from_millis(50);
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let negotiated = loop {
|
||||
match ready_rx.recv_timeout(READY_POLL) {
|
||||
Ok(Ok(t)) => break t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
// Timed out with the worker still going: keep waiting unless the budget is
|
||||
// spent or the caller cancelled. Disconnected means the worker died without
|
||||
// reporting — the give-up path below covers it, same as it always did.
|
||||
// Both give-ups land in one arm on purpose: a cancel and an expiry owe the
|
||||
// host the same close, and the caller that cancelled is not listening to the
|
||||
// error it gets back anyway.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if std::time::Instant::now() < deadline
|
||||
&& !cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) => {}
|
||||
Err(_) => {
|
||||
// A connect we already reported as failed must not leave a lingering host
|
||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||
// instead of holding the session (and its virtual display) for a reconnect
|
||||
// that will never come.
|
||||
quit.store(true, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
}
|
||||
}
|
||||
};
|
||||
*mode_slot.lock().unwrap() = negotiated.mode;
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
//! link head-blocks the daemon).
|
||||
|
||||
mod monitor_rate;
|
||||
mod pad_card_volume;
|
||||
pub(crate) mod pad_sink;
|
||||
pub(crate) mod pad_usb;
|
||||
mod stream_sink;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Put the usbip pad's REAL sound card at unity gain — the host half of the attenuation the
|
||||
//! client fixes in `pf_client_core::pad_audio::pin_sink_volume`.
|
||||
//!
|
||||
//! ## The defect
|
||||
//!
|
||||
//! WirePlumber starts every new card's sink at `device.routes.default-sink-volume`. That is 0.4,
|
||||
//! and 0.4 is a *cubed* number: what a mixer shows as 40 % is 0.4³ = 0.064 of linear amplitude,
|
||||
//! −23.88 dB. The setting is global — it cannot be scoped to one device from configuration — so
|
||||
//! there is no config file we could ship to exempt the pad, and it fires again on every fresh
|
||||
//! card, which for a usbip pad means every single attach.
|
||||
//!
|
||||
//! It is a reasonable default for a laptop speaker somebody is about to turn up. It is wrong
|
||||
//! here twice:
|
||||
//!
|
||||
//! - nobody chose it, and nobody would think to look for it: the pad's sink is not a listening
|
||||
//! volume anyone reaches for, so it reads as weak hardware rather than as a slider; and
|
||||
//! - **both ends of a session mint one.** The game's samples cross this sink on the host and the
|
||||
//! pad's own sink on the client, so the two multiply: 0.064² = −47.8 dB by the time a game's
|
||||
//! haptics reach a voice coil. That is the difference between "the haptics are subtle" and
|
||||
//! "I'm not sure the haptics are connected".
|
||||
//!
|
||||
//! ## Why it lands on the host at all
|
||||
//!
|
||||
//! [`super::pad_usb`] captures at the pad's isochronous OUT endpoint, which is DOWNSTREAM of
|
||||
//! this sink: PipeWire applies the sink's volume when it mixes into the ALSA device, and what
|
||||
//! reaches the wire — and therefore what we encode and send — is already attenuated. Fixing it
|
||||
//! on the client cannot recover what the host threw away before the encoder saw it.
|
||||
//!
|
||||
//! Nothing here is restored on the way out, deliberately. The client restores a *profile* it
|
||||
//! borrowed, because that overrides a choice the user made; this overrides a default nobody
|
||||
//! made, and putting −24 dB back would be restoring the bug.
|
||||
//!
|
||||
//! Best effort throughout: this is a volume, and every failure costs loudness rather than audio.
|
||||
//! `PUNKTFUNK_PAD_SINK_VOLUME=0` skips it entirely, for bisecting a box where something else is
|
||||
//! doing the attenuating.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The pad's USB identity, as its ALSA card publishes it.
|
||||
const DS5_VENDOR: u32 = 0x054c;
|
||||
const DS5_PRODUCTS: [u32; 2] = [0x0ce6, 0x0df2];
|
||||
|
||||
/// How long to keep looking for the card after the pad attaches, and how often.
|
||||
///
|
||||
/// The USB device is live well before its sink is: `snd-usb-audio` has to probe it, PipeWire has
|
||||
/// to build the device, and WirePlumber has to apply the very default we are here to undo — and
|
||||
/// pinning BEFORE that lands would simply be overwritten. So this retries rather than firing
|
||||
/// once, and gives up quietly: a pad whose card never appears is a pad with no sink to pin.
|
||||
const ATTEMPTS: u32 = 15;
|
||||
const INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Pin every DualSense card sink in the graph to unity, in the background.
|
||||
///
|
||||
/// Detached on purpose. The caller is the pad-audio capture thread's open path, and a second of
|
||||
/// waiting for a card to appear there is a second of missing pad audio.
|
||||
pub(crate) fn spawn_pin(pad: u8) {
|
||||
if matches!(
|
||||
std::env::var("PUNKTFUNK_PAD_SINK_VOLUME").as_deref(),
|
||||
Ok("0" | "false" | "off" | "no")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-padvol{pad}"))
|
||||
.spawn(move || {
|
||||
// An error retries like an absent card does: the pad attaching is exactly the moment
|
||||
// the graph is busy, and giving up on one transient connect failure would leave the
|
||||
// attenuation in place for the whole session. Only the last one is reported.
|
||||
let mut last_err = None;
|
||||
for _ in 0..ATTEMPTS {
|
||||
match pin_pad_sinks() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => {
|
||||
tracing::info!(
|
||||
pad,
|
||||
sinks = n,
|
||||
"pad card sink pinned to 0 dB (WirePlumber starts every new card at \
|
||||
40% = -23.88 dB, and host+client stack)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => last_err = Some(format!("{e:#}")),
|
||||
}
|
||||
std::thread::sleep(INTERVAL);
|
||||
}
|
||||
tracing::debug!(
|
||||
pad,
|
||||
error = last_err.unwrap_or_else(|| "no DualSense card sink in the graph".into()),
|
||||
"pad sink volume not pinned — pad audio may be quiet if this box attenuates it"
|
||||
);
|
||||
})
|
||||
{
|
||||
tracing::debug!(pad, error = %e, "pad sink volume thread not spawned");
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass: walk the graph, and set every DualSense CARD sink to unity. Returns how many were
|
||||
/// pinned, so the caller can tell "the card is not here yet" from "done".
|
||||
fn pin_pad_sinks() -> Result<usize> {
|
||||
use pipewire as pw;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
static PW_INIT: std::sync::Once = std::sync::Once::new();
|
||||
PW_INIT.call_once(pw::init);
|
||||
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
|
||||
let core = context.connect_rc(None).context("pw connect")?;
|
||||
let registry = core.get_registry_rc().context("pw registry")?;
|
||||
|
||||
/// A bound sink and what the set_param needs to know about it.
|
||||
struct Sink {
|
||||
node: pw::node::Node,
|
||||
_listener: pw::node::NodeListener,
|
||||
/// `device.id` from the announce props — `None` for a node that belongs to no card.
|
||||
card: Option<u32>,
|
||||
/// `audio.channels`, which arrives only with the bound node's `info`. Zero until then.
|
||||
channels: Rc<Cell<u32>>,
|
||||
}
|
||||
|
||||
let sinks: Rc<RefCell<Vec<Sink>>> = Rc::default();
|
||||
let ds5_cards: Rc<RefCell<Vec<u32>>> = Rc::default();
|
||||
|
||||
let _reg_listener = registry
|
||||
.add_listener_local()
|
||||
.global({
|
||||
let (registry, sinks, ds5_cards) = (registry.clone(), sinks.clone(), ds5_cards.clone());
|
||||
move |g| {
|
||||
let Some(props) = g.props else { return };
|
||||
let usb_id = |k: &str| {
|
||||
props.get(k).and_then(|v| {
|
||||
let v = v.trim();
|
||||
// The specimen publishes `0x054c`; a bare `054c` read with base 0
|
||||
// is octal and yields nonsense, so the radix is chosen explicitly.
|
||||
v.strip_prefix("0x")
|
||||
.or_else(|| v.strip_prefix("0X"))
|
||||
.map(|h| u32::from_str_radix(h, 16))
|
||||
.unwrap_or_else(|| u32::from_str_radix(v, 16))
|
||||
.ok()
|
||||
})
|
||||
};
|
||||
match g.type_ {
|
||||
// Cards announce their identity keys, so no second round is needed for them.
|
||||
pw::types::ObjectType::Device => {
|
||||
let vendor = usb_id("device.vendor.id");
|
||||
let product = usb_id("device.product.id");
|
||||
if vendor == Some(DS5_VENDOR)
|
||||
&& product.is_some_and(|p| DS5_PRODUCTS.contains(&p))
|
||||
{
|
||||
ds5_cards.borrow_mut().push(g.id);
|
||||
}
|
||||
}
|
||||
pw::types::ObjectType::Node => {
|
||||
if !props
|
||||
.get("media.class")
|
||||
.is_some_and(|c| c.starts_with("Audio/Sink"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Ok(node) = registry.bind::<pw::node::Node, _>(g) else {
|
||||
return;
|
||||
};
|
||||
// `audio.channels` is NOT in the announce subset — reading it there looks
|
||||
// like it works and returns zero on every real machine (the same trap
|
||||
// `pf_client_core::pad_audio::walk_graph` documents). Bind for it.
|
||||
let channels = Rc::new(Cell::new(0u32));
|
||||
let listener = node
|
||||
.add_listener_local()
|
||||
.info({
|
||||
let channels = channels.clone();
|
||||
move |info| {
|
||||
let Some(p) = info.props() else { return };
|
||||
if let Some(c) =
|
||||
p.get("audio.channels").and_then(|v| v.parse().ok())
|
||||
{
|
||||
channels.set(c);
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
sinks.borrow_mut().push(Sink {
|
||||
node,
|
||||
_listener: listener,
|
||||
card: props.get("device.id").and_then(|v| v.parse().ok()),
|
||||
channels,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
let awaited: Rc<Cell<Option<pw::spa::utils::result::AsyncSeq>>> = Rc::new(Cell::new(None));
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.done({
|
||||
let (mainloop, awaited) = (mainloop.clone(), awaited.clone());
|
||||
move |_, seq| {
|
||||
if awaited.get() == Some(seq) {
|
||||
mainloop.quit();
|
||||
}
|
||||
}
|
||||
})
|
||||
.register();
|
||||
let round = |issue: &dyn Fn() -> Result<()>| -> Result<()> {
|
||||
issue()?;
|
||||
awaited.set(Some(core.sync(0).context("pw sync")?));
|
||||
mainloop.run();
|
||||
Ok(())
|
||||
};
|
||||
|
||||
round(&|| Ok(()))?; // 1: globals replay; sinks get bound
|
||||
round(&|| Ok(()))?; // 2: the binds' `info` events land, carrying audio.channels
|
||||
|
||||
// A `Cell` because `round` takes an `Fn` — the set_params have to be issued from inside it,
|
||||
// and a closure that incremented a plain counter would be `FnMut`.
|
||||
let pinned = Cell::new(0usize);
|
||||
round(&|| {
|
||||
let cards = ds5_cards.borrow();
|
||||
for s in sinks.borrow().iter() {
|
||||
// A CARD's sink only. A Punktfunk host minting its own pad sink on this same box
|
||||
// publishes the full DualSense identity on purpose (that is how Proton finds it) and
|
||||
// is not a thing to set a hardware volume on; `device.id` is what tells them apart.
|
||||
let Some(card) = s.card else { continue };
|
||||
if !cards.contains(&card) {
|
||||
continue;
|
||||
}
|
||||
let channels = s.channels.get();
|
||||
if channels == 0 {
|
||||
continue;
|
||||
}
|
||||
let pod = unity_volume_pod(channels)?;
|
||||
let Some(pod) = pw::spa::pod::Pod::from_bytes(&pod) else {
|
||||
continue;
|
||||
};
|
||||
s.node.set_param(pw::spa::param::ParamType::Props, 0, pod);
|
||||
pinned.set(pinned.get() + 1);
|
||||
}
|
||||
Ok(())
|
||||
})?; // 3: flush the set_params before the loop and its proxies drop
|
||||
Ok(pinned.get())
|
||||
}
|
||||
|
||||
/// The `Props` object pod that puts every channel of a sink at unity gain (1.0 linear = 0 dB;
|
||||
/// see the module docs for why that is not the same number a mixer would call 100 %).
|
||||
fn unity_volume_pod(channels: u32) -> Result<Vec<u8>> {
|
||||
use pipewire::spa;
|
||||
use spa::pod::{Object, Property, PropertyFlags, Value, ValueArray};
|
||||
let obj = Object {
|
||||
type_: spa::utils::SpaTypes::ObjectParamProps.as_raw(),
|
||||
id: spa::param::ParamType::Props.as_raw(),
|
||||
properties: vec![
|
||||
Property {
|
||||
key: spa::sys::SPA_PROP_volume,
|
||||
flags: PropertyFlags::empty(),
|
||||
value: Value::Float(1.0),
|
||||
},
|
||||
Property {
|
||||
key: spa::sys::SPA_PROP_channelVolumes,
|
||||
flags: PropertyFlags::empty(),
|
||||
value: Value::ValueArray(ValueArray::Float(vec![1.0; channels.max(1) as usize])),
|
||||
},
|
||||
],
|
||||
};
|
||||
Ok(spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&Value::Object(obj),
|
||||
)
|
||||
.context("serialize Props pod")?
|
||||
.0
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The pod is what the fix IS, so it has to be the shape PipeWire reads: a `Props` object
|
||||
/// carrying one unity float per channel. A pod whose array is the wrong length is the
|
||||
/// failure this guards — PipeWire ignores a `channelVolumes` that does not match the port
|
||||
/// count, which would look exactly like the pin silently not working.
|
||||
#[test]
|
||||
fn unity_pod_is_one_float_per_channel() {
|
||||
use pipewire::spa::pod::{deserialize::PodDeserializer, Value, ValueArray};
|
||||
for channels in [1u32, 2, 4] {
|
||||
let bytes = unity_volume_pod(channels).expect("serialize");
|
||||
let (_, value) = PodDeserializer::deserialize_any_from(&bytes).expect("parse");
|
||||
let Value::Object(obj) = value else {
|
||||
panic!("not an object pod");
|
||||
};
|
||||
let vols = obj
|
||||
.properties
|
||||
.iter()
|
||||
.find(|p| p.key == pipewire::spa::sys::SPA_PROP_channelVolumes)
|
||||
.map(|p| p.value.clone())
|
||||
.expect("channelVolumes");
|
||||
let Value::ValueArray(ValueArray::Float(v)) = vols else {
|
||||
panic!("channelVolumes is not a float array");
|
||||
};
|
||||
assert_eq!(v.len(), channels as usize);
|
||||
assert!(v.iter().all(|&x| x == 1.0), "every channel must be unity");
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero channels must not serialize an empty array — an empty `channelVolumes` is not
|
||||
/// "leave it alone", it is a pod PipeWire may take literally.
|
||||
#[test]
|
||||
fn unity_pod_never_empty() {
|
||||
let bytes = unity_volume_pod(0).expect("serialize");
|
||||
assert!(!bytes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,11 @@ impl PadUsbCapturer {
|
||||
let rx = pf_inject::dualsense_usbip::take_audio_rx(pad)
|
||||
.ok_or_else(|| anyhow!("no usbip pad audio published for pad {pad} (not attached?)"))?;
|
||||
tracing::info!(pad, "pad audio capturing from the USB isochronous endpoint");
|
||||
// The pad's ALSA card is real, so WirePlumber greets it with its global 40 % default —
|
||||
// which is -23.88 dB applied BEFORE the isochronous endpoint we capture from, and which
|
||||
// stacks with the same default on the client. Undo it once the card shows up; see
|
||||
// [`super::pad_card_volume`]. Best effort, off-thread, never fatal.
|
||||
super::pad_card_volume::spawn_pin(pad);
|
||||
Ok(PadUsbCapturer { rx, pad })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +153,12 @@ fn percent_decode(s: &str) -> String {
|
||||
/// Default: the users base (`C:\Users`), where the launchers that install per-user keep their art —
|
||||
/// Playnite stores covers under `%APPDATA%\Playnite`, Heroic under `%APPDATA%\heroic`. Derived from
|
||||
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus
|
||||
/// the Steam install root ([`steam_art_roots`]), which is the one launcher that does NOT live under
|
||||
/// the users base. `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the whole default for an
|
||||
/// operator whose library is somewhere else again.
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus the
|
||||
/// two launchers that need NOT live under the users base: the Steam install root
|
||||
/// ([`steam_art_roots`]), and every Playnite root this box can find
|
||||
/// ([`super::launch::playnite_art_roots`]) — a PORTABLE Playnite keeps its whole library, covers and
|
||||
/// all, beside the exe, wherever the operator unzipped it. `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
/// (`;`-separated) replaces the whole default for an operator whose library is somewhere else again.
|
||||
fn art_roots() -> Vec<PathBuf> {
|
||||
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
|
||||
return std::env::split_paths(&configured)
|
||||
@@ -177,6 +179,11 @@ fn art_roots() -> Vec<PathBuf> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
roots.extend(steam_art_roots());
|
||||
// Playnite, for the same reason: a portable install (`D:\Apps\Playnite`) puts `library\files\…`
|
||||
// — every cover it exports — outside every profile. An installed Playnite adds a root that is
|
||||
// already inside the users base, which costs nothing.
|
||||
#[cfg(windows)]
|
||||
roots.extend(super::launch::playnite_art_roots());
|
||||
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
|
||||
// where every launcher this host reads art from actually keeps it. Steam's
|
||||
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
|
||||
@@ -1047,6 +1054,25 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Whatever Playnite roots this box has, the confinement must be told about them with NO
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` set. That `extend` is the whole fix for the portable-install
|
||||
/// report (`D:\Apps\Playnite\library\files\…`, 70 covers dropped), and it is one line a
|
||||
/// refactor can silently drop. Vacuous on a box with no Playnite — the registry half cannot be
|
||||
/// faked from a test, so `launch::exe_from_shell_command`'s own test carries that load instead.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn playnite_roots_reach_the_art_confinement() {
|
||||
let _env = ArtRootsEnv::set(&[("PUNKTFUNK_LIBRARY_ART_ROOTS", None)]);
|
||||
let roots = art_roots();
|
||||
for root in crate::library::launch::playnite_art_roots() {
|
||||
assert!(root.is_dir(), "{root:?} is offered as an art root");
|
||||
assert!(
|
||||
roots.contains(&root),
|
||||
"{root:?} must be an allowed art root with no env var set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
|
||||
assert_eq!(sniff_image_type(PNG), Some("image/png"));
|
||||
|
||||
@@ -633,6 +633,11 @@ fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
|
||||
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
|
||||
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
|
||||
///
|
||||
/// A **portable** Playnite is none of those: it is unzipped wherever the operator wanted it
|
||||
/// (`D:\Apps\Playnite`), registers no uninstall entry, and is not under any profile. Its one
|
||||
/// registry trace is the `playnite://` handler Playnite registers for itself
|
||||
/// ([`playnite_dir_from_uri_handler`]) — the same registration this host's own launch path follows.
|
||||
///
|
||||
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
|
||||
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
|
||||
/// a stale entry costs one `is_file` and nothing else.
|
||||
@@ -645,22 +650,33 @@ fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
|
||||
// so the WOW view is a machine-hive concern only.
|
||||
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
// Playnite's own `playnite://` registration, in both spellings: bare inside a `…_Classes` hive,
|
||||
// and via the `Software\Classes` link everywhere else.
|
||||
const URI_COMMAND: &str = r"playnite\shell\open\command";
|
||||
const CLASSES_URI_COMMAND: &str = r"Software\Classes\playnite\shell\open\command";
|
||||
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
|
||||
playnite_dir_from_uri_handler(&hklm, CLASSES_URI_COMMAND, &mut dirs);
|
||||
|
||||
let users = RegKey::predef(HKEY_USERS);
|
||||
for sid in users.enum_keys().flatten() {
|
||||
// The `…_Classes` companion hives carry file associations, never uninstall entries.
|
||||
let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) else {
|
||||
continue;
|
||||
};
|
||||
// The `…_Classes` companion hives carry file associations — which is exactly where the
|
||||
// `playnite://` handler lives, `HKCU\Software\Classes` BEING that hive — and never uninstall
|
||||
// entries. Both spellings are probed rather than reasoned about: the in-hive `Software\Classes`
|
||||
// link is a link, and a probe that misses costs one failed `open_subkey`.
|
||||
if sid.ends_with("_Classes") {
|
||||
playnite_dir_from_uri_handler(&hive, URI_COMMAND, &mut dirs);
|
||||
continue;
|
||||
}
|
||||
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
}
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
playnite_dir_from_uri_handler(&hive, CLASSES_URI_COMMAND, &mut dirs);
|
||||
}
|
||||
|
||||
// The conventional per-user location, for every profile on the box — this is where Playnite's
|
||||
@@ -705,6 +721,80 @@ fn playnite_dirs_from_uninstall(
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the directory of Playnite's registered `playnite://` handler from `root\path`, if there is one.
|
||||
///
|
||||
/// This is what finds a **portable** Playnite. It leaves no uninstall entry and lives under no user
|
||||
/// profile, so every other probe here is blind to it — but Playnite registers its own URI scheme,
|
||||
/// and that registration is the very one `explorer.exe "playnite://…"` follows when this host starts
|
||||
/// a Playnite title. If it resolves, this box already opens games with that copy.
|
||||
#[cfg(windows)]
|
||||
fn playnite_dir_from_uri_handler(
|
||||
root: &winreg::RegKey,
|
||||
path: &str,
|
||||
out: &mut Vec<std::path::PathBuf>,
|
||||
) {
|
||||
use winreg::enums::KEY_READ;
|
||||
|
||||
let Ok(command) = root
|
||||
.open_subkey_with_flags(path, KEY_READ)
|
||||
.and_then(|k| k.get_value::<String, _>(""))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = exe_from_shell_command(&command)
|
||||
.map(std::path::Path::new)
|
||||
.and_then(std::path::Path::parent)
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
{
|
||||
push_unique(out, dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// The executable out of a registered shell-open command line:
|
||||
/// `"D:\Apps\Playnite\Playnite.DesktopApp.exe" --uridata "%1"` → `D:\Apps\Playnite\Playnite.DesktopApp.exe`.
|
||||
///
|
||||
/// Quoted form first, because that is what a registrar writes. The cut at the first `.exe` is the
|
||||
/// fallback for the unquoted spelling, whose path may itself contain spaces and so cannot be split on
|
||||
/// whitespace. `None` when neither shape matches; the result is only ever a directory to probe for an
|
||||
/// exe, so a miss costs one `is_file` and nothing else.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn exe_from_shell_command(command: &str) -> Option<&str> {
|
||||
let command = command.trim();
|
||||
if let Some(rest) = command.strip_prefix('"') {
|
||||
return rest.split('"').next().filter(|p| !p.is_empty());
|
||||
}
|
||||
let end = command.to_ascii_lowercase().find(".exe")? + ".exe".len();
|
||||
Some(&command[..end])
|
||||
}
|
||||
|
||||
/// Windows: every Playnite root on this box, as an **art** root.
|
||||
///
|
||||
/// A portable Playnite keeps its library beside the exe — covers land in
|
||||
/// `<PlayniteDir>\library\files\…` — so for that layout the install dir IS where the art lives, and
|
||||
/// the users base can never cover it: the whole point of portable is that it sits wherever the
|
||||
/// operator put it (`D:\Apps\Playnite` in the report that prompted this). Without it a portable
|
||||
/// install synced its games and had EVERY cover dropped by the confinement. An installed Playnite
|
||||
/// keeps the same tree under `%APPDATA%\Playnite`, already inside the users base; naming that
|
||||
/// directory twice costs one `canonicalize` in [`super::art::art_path_is_confined`].
|
||||
///
|
||||
/// Same shape and same reasoning as [`super::art::steam_art_roots`], and it does not widen what the
|
||||
/// host can be *tricked* into reading: every candidate comes from the host's own registry and
|
||||
/// filesystem probes, never from the plugin lane that supplies the art path, and the extension,
|
||||
/// regular-file, magic-byte and config-dir gates all still apply on top.
|
||||
///
|
||||
/// The per-user hives these candidates partly come from are writable by that user — which is a bar
|
||||
/// this host already stands on, and one rung lower here than where it already stood: the same
|
||||
/// lookup picks the `Playnite.FullscreenApp.exe` a launcher tile SPAWNS. Trusting it to name a
|
||||
/// directory whose image files may be read is strictly weaker than trusting it to name a program to
|
||||
/// run.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn playnite_art_roots() -> Vec<std::path::PathBuf> {
|
||||
playnite_install_dirs()
|
||||
.into_iter()
|
||||
.filter(|d| d.is_dir())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
|
||||
///
|
||||
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
|
||||
@@ -1086,6 +1176,36 @@ mod tests {
|
||||
assert!(!valid_aumid("Foo Bar!Game"));
|
||||
}
|
||||
|
||||
/// The portable-Playnite probe, at the only part of it that can be wrong off-Windows: pulling the
|
||||
/// exe out of the registered `playnite://` command line. A miss here is a portable install the
|
||||
/// host cannot find — no launcher tile, and (through [`playnite_art_roots`]) every cover dropped.
|
||||
#[test]
|
||||
fn exe_is_read_out_of_a_registered_shell_command() {
|
||||
// What Playnite actually registers, portable install on a second drive.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r#""D:\Apps\Playnite\Playnite.DesktopApp.exe" --uridata "%1""#),
|
||||
Some(r"D:\Apps\Playnite\Playnite.DesktopApp.exe")
|
||||
);
|
||||
// Unquoted, with a space in the path — which is why this cannot split on whitespace.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r"C:\Program Files\Playnite\Playnite.DesktopApp.exe %1"),
|
||||
Some(r"C:\Program Files\Playnite\Playnite.DesktopApp.exe")
|
||||
);
|
||||
// Case is the registrar's business, not ours.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r"D:\Apps\Playnite\PLAYNITE.DESKTOPAPP.EXE"),
|
||||
Some(r"D:\Apps\Playnite\PLAYNITE.DESKTOPAPP.EXE")
|
||||
);
|
||||
// Nothing exe-shaped, and the empty quoted form: no candidate beats a bogus one, because a
|
||||
// bogus one would become an allowed art root.
|
||||
assert_eq!(
|
||||
exe_from_shell_command("rundll32 shell32.dll,Control_RunDLL"),
|
||||
None
|
||||
);
|
||||
assert_eq!(exe_from_shell_command(r#""" %1"#), None);
|
||||
assert_eq!(exe_from_shell_command(""), None);
|
||||
}
|
||||
|
||||
/// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the
|
||||
/// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be
|
||||
/// used because it is registered to the desktop app (verified on .173, 2026-08-06).
|
||||
|
||||
@@ -94,7 +94,10 @@ pub fn effective_port() -> u16 {
|
||||
/// console's own default. Moving the listener therefore silently broke the console, because nothing
|
||||
/// downstream had any way to learn the new port. Now the host is the single source of truth and
|
||||
/// publishes what it actually bound; consumers keep a 47990 fallback purely so an OLD host with a
|
||||
/// NEW console still works.
|
||||
/// NEW console still works. The plugin runner / SDK (`sdk/src/config.ts::publishedMgmtUrl`) and
|
||||
/// the tray (`pf_paths::published_mgmt_port`) read the same file — both used to be a sixth and
|
||||
/// seventh literal 47990, and a moved port left every plugin dialing the old one in silence
|
||||
/// (field report 2026-08-18).
|
||||
///
|
||||
/// Always loopback, never `bind`'s own address: the console proxies over loopback by design (see
|
||||
/// the module docs — the bearer-token admin surface is confined to loopback peers), so a wide
|
||||
|
||||
@@ -4611,9 +4611,14 @@ fn is_permanent_build_error(chain: &str) -> bool {
|
||||
"virtual displays require linux",
|
||||
"unknown punktfunk_compositor",
|
||||
"could not detect compositor",
|
||||
"could not find output", // KWin < 6.5.6: createVirtualOutput unsupported
|
||||
"must be a node id", // PUNKTFUNK_GAMESCOPE_NODE not an integer
|
||||
"is it installed", // gamescope / kscreen-doctor not on PATH
|
||||
// KWin refused the virtual output. Its own reason arrives TRANSLATED (a field report read
|
||||
// "Não foi possível encontrar saída" and burned all 8 retries), so match OUR English
|
||||
// prefix, not KWin's payload. Every `failed` KWin sends on this path is a config/backend
|
||||
// fact — unsupported compositing type, a backend without `createVirtualOutput`, an output
|
||||
// the workspace declined to enable — none of which a retry 500 ms later changes.
|
||||
"kwin virtual output failed",
|
||||
"must be a node id", // PUNKTFUNK_GAMESCOPE_NODE not an integer
|
||||
"is it installed", // gamescope / kscreen-doctor not on PATH
|
||||
// 4:4:4 NVENC got a CUDA frame — should never happen now the Linux capturer honors gpu=false,
|
||||
// but fail fast instead of 8× retry (~90 s) rather than wedge the session if it ever recurs.
|
||||
"capture/encoder negotiation mismatch",
|
||||
@@ -5329,6 +5334,10 @@ mod tests {
|
||||
assert!(is_permanent_build_error(
|
||||
"create virtual output: KWin virtual output failed: Could not find output"
|
||||
));
|
||||
// Same refusal from a localized KWin — the reason is translated, our prefix is not.
|
||||
assert!(is_permanent_build_error(
|
||||
"create virtual output: KWin virtual output failed: Não foi possível encontrar saída"
|
||||
));
|
||||
assert!(is_permanent_build_error(
|
||||
"unknown PUNKTFUNK_COMPOSITOR 'foo' (kwin|wlroots|mutter|gamescope)"
|
||||
));
|
||||
|
||||
@@ -18,6 +18,9 @@ path = "src/main.rs"
|
||||
# stub main (same pattern as the platform-gated clients).
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
# `config_dir()` + `published_mgmt_port()`: the tray follows the mgmt port the host actually bound
|
||||
# (`<config_dir>/mgmt-endpoint`) instead of assuming 47990. Std-only leaf, no I/O stack.
|
||||
pf-paths = { path = "../pf-paths" }
|
||||
|
||||
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -22,10 +22,13 @@ mod win;
|
||||
#[cfg(windows)]
|
||||
mod win_theme;
|
||||
|
||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
|
||||
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
|
||||
/// DACL-locked to SYSTEM/Administrators), so an operator who moved `--mgmt-bind` adjusts the
|
||||
/// autostart command line instead.
|
||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address defaults to loopback; the
|
||||
/// port, when not given, follows what the host PUBLISHED (`<config_dir>/mgmt-endpoint`, rewritten
|
||||
/// on every host start — see `pf_paths::published_mgmt_port`), falling back to 47990. That file is
|
||||
/// how a moved `PUNKTFUNK_MGMT_BIND` reaches the tray: it cannot read `host.env` on Windows (DACL-
|
||||
/// locked to SYSTEM/Administrators), and before this an operator who moved the port had to know to
|
||||
/// edit the autostart command line — nobody did, and the tray reported a running host as
|
||||
/// unreachable (field report 2026-08-18). `--mgmt-port` still pins it explicitly.
|
||||
pub struct Args {
|
||||
/// Ask an already-running tray instance to exit (Windows; used by the uninstaller).
|
||||
pub quit: bool,
|
||||
@@ -34,7 +37,9 @@ pub struct Args {
|
||||
pub autostart: bool,
|
||||
/// Management API address to poll (loopback only; the summary route rejects anything else).
|
||||
pub mgmt_addr: String,
|
||||
pub mgmt_port: u16,
|
||||
/// `None` = follow the published endpoint (re-read on every poll, so a host restarted on a new
|
||||
/// port is picked up without relaunching the tray).
|
||||
pub mgmt_port: Option<u16>,
|
||||
/// Web console port for the "Open web console" action.
|
||||
pub web_port: u16,
|
||||
}
|
||||
@@ -45,7 +50,7 @@ impl Default for Args {
|
||||
quit: false,
|
||||
autostart: false,
|
||||
mgmt_addr: "127.0.0.1".into(),
|
||||
mgmt_port: 47990,
|
||||
mgmt_port: None,
|
||||
web_port: 47992,
|
||||
}
|
||||
}
|
||||
@@ -63,7 +68,7 @@ fn parse_args() -> anyhow::Result<Args> {
|
||||
"--quit" => args.quit = true,
|
||||
"--autostart" => args.autostart = true,
|
||||
"--mgmt-addr" => args.mgmt_addr = value("--mgmt-addr")?,
|
||||
"--mgmt-port" => args.mgmt_port = value("--mgmt-port")?.parse()?,
|
||||
"--mgmt-port" => args.mgmt_port = Some(value("--mgmt-port")?.parse()?),
|
||||
"--web-port" => args.web_port = value("--web-port")?.parse()?,
|
||||
"--version" | "-V" => {
|
||||
println!("punktfunk-tray {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
@@ -158,7 +158,7 @@ impl Poller {
|
||||
/// had one.
|
||||
pub fn spawn(
|
||||
mgmt_addr: String,
|
||||
mgmt_port: u16,
|
||||
mgmt_port: Option<u16>,
|
||||
web_port: u16,
|
||||
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||
) -> Poller {
|
||||
@@ -184,15 +184,23 @@ impl Poller {
|
||||
fn poll_loop(
|
||||
shared: &Shared,
|
||||
mgmt_addr: &str,
|
||||
mgmt_port: u16,
|
||||
mgmt_port: Option<u16>,
|
||||
web_port: u16,
|
||||
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
|
||||
) {
|
||||
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||
let url = if mgmt_addr.contains(':') {
|
||||
format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary")
|
||||
} else {
|
||||
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
|
||||
// Resolved PER TICK, not once: with no `--mgmt-port` the port is whatever the host last
|
||||
// published, and a host restarted on a moved `PUNKTFUNK_MGMT_BIND` must not leave the tray
|
||||
// polling the old one until the next login. One tiny file read every 3 s is nothing.
|
||||
let summary_url = || {
|
||||
let port = mgmt_port
|
||||
.or_else(pf_paths::published_mgmt_port)
|
||||
.unwrap_or(47990);
|
||||
// IPv6 literals bracketed, like the Linux client's `base_url`.
|
||||
if mgmt_addr.contains(':') {
|
||||
format!("https://[{mgmt_addr}]:{port}/api/v1/local/summary")
|
||||
} else {
|
||||
format!("https://{mgmt_addr}:{port}/api/v1/local/summary")
|
||||
}
|
||||
};
|
||||
// `/login`, not `/`: `/` is auth-gated and 302s to `/login`, and ureq follows redirects by
|
||||
// default — so probing `/` spent TLS + `/` + a full cold `/login` SSR render inside one 2 s
|
||||
@@ -212,7 +220,7 @@ fn poll_loop(
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
let s = fetch_summary(&agent, &url);
|
||||
let s = fetch_summary(&agent, &summary_url());
|
||||
match s {
|
||||
Some(_) => unreachable_since = None,
|
||||
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
|
||||
|
||||
@@ -222,10 +222,10 @@ it — leave it or delete it, it makes no difference.
|
||||
| `PUNKTFUNK_MGMT_TOKEN` | token | Bearer token for the management API. If unset it's auto-generated and persisted to `~/.config/punktfunk/mgmt-token` (the bundled web console sources it). Set only to pin a specific token. |
|
||||
| `PUNKTFUNK_UI_PASSWORD` | password | Web-console login password. Normally generated on first start and stored in `~/.config/punktfunk/web-password` — see [Forgot your Password?](/docs/forgot-password). |
|
||||
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
|
||||
| `PUNKTFUNK_MGMT_BIND` | `IP:PORT` *(default: `0.0.0.0:47990`)* | Where the management API listens. The `--mgmt-bind` flag overrides it. Two reasons to set it: pin `127.0.0.1:47990` to keep the API off the LAN entirely (paired clients then can't browse your library), or **move the port to share the machine with Sunshine, Apollo or Vibeshine** — 47990 is their web UI as well as our management API, and it's the only port the two still share once GameStream compat is off. Everything downstream follows the port you pick: native clients learn it from discovery, and the web console reads it from `~/.config/punktfunk/mgmt-endpoint`, which the host writes on every start. See [another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed). |
|
||||
| `PUNKTFUNK_MGMT_BIND` | `IP:PORT` *(default: `0.0.0.0:47990`)* | Where the management API listens. The `--mgmt-bind` flag overrides it. Two reasons to set it: pin `127.0.0.1:47990` to keep the API off the LAN entirely (paired clients then can't browse your library), or **move the port to share the machine with Sunshine, Apollo or Vibeshine** — 47990 is their web UI as well as our management API, and it's the only port the two still share once GameStream compat is off. Everything downstream follows the port you pick: native clients learn it from discovery, and the web console, the plugin runner (and so every library plugin) and the status tray read it from `~/.config/punktfunk/mgmt-endpoint` (`%ProgramData%\punktfunk\mgmt-endpoint` on Windows), which the host writes on every start. See [another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed). |
|
||||
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam and Playnite installs, wherever they are — including a portable Playnite on another drive, which keeps its covers next to the program. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
|
||||
## Updates
|
||||
|
||||
|
||||
@@ -129,12 +129,24 @@ If you would rather manage the card yourself, set `PUNKTFUNK_PAD_AUDIO_PROFILE=0
|
||||
Punktfunk uses a four-channel profile if you have already selected one and logs what it needs if you
|
||||
have not.
|
||||
|
||||
Most systems never reach the switch at all. Where your distribution ships a recent `alsa-ucm-conf` —
|
||||
Bazzite and SteamOS among them — a DualSense already exposes its four channels behind its split
|
||||
speaker and headphone outputs, and Punktfunk finds them there. The switch is the fallback for
|
||||
systems that only offer the older stereo profile. **If you run the client as a Flatpak**, your audio
|
||||
manager may not let a sandboxed app change a card's profile; if the log says so, switch the
|
||||
controller to Pro Audio yourself, which is the same fix.
|
||||
Many systems never reach the switch at all. On **SteamOS** a DualSense already exposes its four
|
||||
channels behind a combined speaker-and-haptics output, and Punktfunk finds them there. That is a
|
||||
Valve addition, though, not something every up-to-date system has: `alsa-ucm-conf` upstream — and
|
||||
so Fedora, Bazzite and Arch — describes the pad as a *mono speaker plus stereo headphones* and
|
||||
nothing else, which is precisely the shape that folds the coils away. The switch is the fallback
|
||||
for those. **If you run the client as a Flatpak**, your audio manager may not let a sandboxed app
|
||||
change a card's profile; if the log says so, switch the controller to Pro Audio yourself, which is
|
||||
the same fix.
|
||||
|
||||
Punktfunk's **host** packages (rpm, deb, Arch, and the Bazzite sysext) close that gap at the
|
||||
source: they install a small ALSA profile for the DualSense that adds the combined
|
||||
speaker-and-haptics output SteamOS has, and give it priority over the mono one. It adds files
|
||||
rather than replacing any your distribution owns, so it upgrades cleanly and can be removed by
|
||||
uninstalling Punktfunk. A pad plugged into the host then presents four channels on its own, with
|
||||
no profile switching by anyone — and, because the lone mono output stops existing, games that
|
||||
crashed when they opened it stop crashing. A card reads its profile once, when it appears, so
|
||||
replug the pad after installing (or restart PipeWire) rather than expecting a pad that was
|
||||
already plugged in to pick it up.
|
||||
|
||||
### Checking the client side without a host
|
||||
|
||||
|
||||
@@ -32,8 +32,9 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
|
||||
|
||||
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
|
||||
one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
|
||||
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds. On **NixOS** there
|
||||
is no repo to add — you add the flake as an input and enable its module, see [NixOS](#nixos).
|
||||
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds. On **NixOS** you add
|
||||
the flake as an input and enable its module rather than adding a package repo — but do add the
|
||||
[binary cache](#nixos), or every build compiles from source.
|
||||
|
||||
> **Stable vs canary.** The repos in the per-distro guides are the **stable** channel — it only
|
||||
> moves when a `vX.Y.Z` release is cut. For the latest `main` build (fast, possibly broken), point
|
||||
@@ -93,6 +94,22 @@ The repo's `flake.nix` is a supported install path: it builds `punktfunk-host`,
|
||||
`punktfunk-web` and `punktfunk-scripting`, and ships a NixOS module. **`x86_64-linux` only**, and
|
||||
NixOS **24.11 or newer**.
|
||||
|
||||
**Add the binary cache first.** Without it, a build compiles the whole Rust workspace *and*
|
||||
gamescope from source — about an hour. With it you get prebuilt binaries:
|
||||
|
||||
```nix
|
||||
nix.settings = {
|
||||
substituters = [ "https://nix.unom.io" ];
|
||||
trusted-public-keys = [ "punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=" ]; # curl https://nix.unom.io/punktfunk-cache.pub
|
||||
};
|
||||
```
|
||||
|
||||
Off NixOS, put the same two values in `/etc/nix/nix.conf` as `extra-substituters` /
|
||||
`extra-trusted-public-keys`. One caveat worth knowing before you copy a flake snippet from
|
||||
elsewhere: setting `inputs.punktfunk.inputs.nixpkgs.follows = "nixpkgs"` changes every store path
|
||||
and so misses the cache entirely — details in
|
||||
[packaging/nix](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/nix/README.md#binary-cache-do-this-before-your-first-build).
|
||||
|
||||
You can run it straight from the flake without NixOS (on other distros, wrap it in
|
||||
[nixGL](https://github.com/nix-community/nixGL) so the GPU drivers resolve):
|
||||
|
||||
|
||||
@@ -144,6 +144,21 @@ The session unit brings up headless KWin; the host unit follows it and starts li
|
||||
print **nothing at all**. A host binary carrying a Linux capability cannot be identified by KWin
|
||||
and is never offered the protocol, however correctly its grant is installed — see
|
||||
[GPU scheduling priority](/docs/running-as-a-service#gpu-scheduling-priority).
|
||||
- **"KWin virtual output failed: Could not find output"** (the message arrives translated — a
|
||||
Brazilian session reads *"Não foi possível encontrar saída"*): KWin got the request and refused
|
||||
it, so the grant above is fine and only the output creation is not. Two shapes. Its **backend**
|
||||
can't create one — a nested KWin, or `kwin_wayland --virtual` below 6.5.6; a normal Plasma
|
||||
session on the DRM backend always can. Or, on **KWin 6.6+**, KWin created the output and then
|
||||
left it **disabled**. That reports identically, and it is new: 6.6 put a
|
||||
`workspace()->findOutput()` hop in front of the stream, so from 6.6 on the output must also be
|
||||
*enabled and workspace-managed*, where 6.5 and earlier streamed it either way. Check
|
||||
`punktfunk-host list-monitors` for a
|
||||
`Virtual-punktfunk-*` marked *disabled*, a stale entry in `~/.config/kwinoutputconfig.json`, and
|
||||
`journalctl --user -b -t kwin_wayland` for *"Applying output configuration failed!"* — KWin logs
|
||||
that when it declines to enable one more output next to your current monitors, and keeps the old
|
||||
configuration instead. Streaming a real monitor
|
||||
([`PUNKTFUNK_CAPTURE_MONITOR`](/docs/configuration), or **Streamed screen** in the console) skips
|
||||
virtual-output creation entirely and is the workaround while you sort the above out.
|
||||
- **Black screen / no picture:** confirm you're on a Wayland session (not X11) and, on NVIDIA, that
|
||||
the GL userspace is installed. More in [Troubleshooting](/docs/troubleshooting).
|
||||
|
||||
|
||||
@@ -344,8 +344,19 @@ journalctl --user -u punktfunk-scripting -f
|
||||
</Tab>
|
||||
<Tab value="Windows">
|
||||
|
||||
The runner task doesn't write a log file, so run it in the foreground to watch it start your
|
||||
plugins (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
|
||||
The runner task writes its output to `%ProgramData%\punktfunk\plugin-state\runner.log` (the
|
||||
previous run is kept as `runner.log.1`). This is the file to read — or send — when the console's
|
||||
Plugins view stays empty although the runner is running: everything the runner and its plugins
|
||||
printed lands here even when they can't reach the host.
|
||||
|
||||
```powershell
|
||||
Get-Content "$env:ProgramData\punktfunk\plugin-state\runner.log" -Tail 100
|
||||
```
|
||||
|
||||
If the file doesn't exist, the task started before `punktfunk-host plugins enable` ever ran (which
|
||||
is what makes `plugin-state` writable for the runner's `LocalService` account) — run it from an
|
||||
elevated prompt, then read the file. To watch a start live instead, run the runner in the
|
||||
foreground (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
|
||||
|
||||
```powershell
|
||||
& "$env:ProgramFiles\punktfunk\bun\bun.exe" "$env:ProgramFiles\punktfunk\scripting\runner-cli.js"
|
||||
|
||||
@@ -46,8 +46,10 @@ GameStream compat **off** (the default), the overlap narrows to two things you c
|
||||
PUNKTFUNK_MGMT_BIND=0.0.0.0:47991
|
||||
```
|
||||
|
||||
Nothing else needs changing: clients learn the port from discovery, and the web console reads it
|
||||
from `~/.config/punktfunk/mgmt-endpoint`, which the host rewrites on every start. A host added
|
||||
Nothing else needs changing: clients learn the port from discovery, and the web console, the
|
||||
plugin runner (so every library plugin) and the status tray read it from
|
||||
`~/.config/punktfunk/mgmt-endpoint` (`%ProgramData%\punktfunk\mgmt-endpoint` on Windows), which
|
||||
the host rewrites on every start. A host added
|
||||
manually **by IP address** is the exception — it assumes 47990 and its library will stop loading,
|
||||
so re-add it from discovery. (You can move the other host instead: Sunshine and its forks derive
|
||||
every port from one base setting.)
|
||||
|
||||
@@ -376,7 +376,9 @@ an empty extension. Use **Primary** or **Exclusive** so your desktop actually la
|
||||
**KWin can't create the virtual output.** On a normal Plasma session KWin runs its **DRM backend**,
|
||||
which creates virtual outputs at any version. The 6.5.6 floor applies only to the **virtual backend**
|
||||
(`kwin_wayland --virtual`, used for headless and test sessions) — below that the request fails with
|
||||
"Could not find output". See [requirements](/docs/requirements).
|
||||
"Could not find output". On **KWin 6.6+** that same message also covers an output KWin *did* create
|
||||
and then left disabled; [KDE Plasma](/docs/kde#troubleshooting) walks that one. See
|
||||
[requirements](/docs/requirements).
|
||||
|
||||
**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box, disconnecting
|
||||
and reconnecting within game mode reuses the still-warm session (or cleanly recreates it) instead of
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
Wire-compatible with 0.30.x — everything you have already paired keeps working, and you can update one side at a time. Nothing in this release changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
|
||||
|
||||
Most of this release is things that were wrong in ways nothing announced. A DualSense's rumble and speaker never actually worked in a game streamed from a Linux host, for five separate reasons stacked on top of each other, and every one of them is fixed. On Windows, ending a session was silently killing the whole host, and launching a game from the library on 0.30 could drop your stream a second later. Android phones and TVs now show the same controller console the desktop does — one interface on three platforms — and the picture on a phone arrives markedly earlier with no dropped frames. On Linux hosts, another device on the box can no longer make your desktop audio stutter, and a Gaming Mode takeover on some machines was starving your controller with a login storm of our own making.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **DualSense rumble and speaker work in-game from a Linux host.** The controller was dying 400 ms after it appeared, every message the game sent it was reported as failed, its sound card was invisible to the sound system, and what got through arrived 48 dB too quiet. All fixed; the details are below.
|
||||
- **Windows: ending a session no longer kills the host, and launching a game no longer drops your stream.** The first looked like a mystery reconnect; the second arrived with 0.30.
|
||||
- **Android has the desktop's console, and a much faster picture.** One controller interface across Windows, Linux and Android — including 32-bit TV boxes — and on the reference phone end-to-end latency went from 30 ms to about 18 ms with dropped frames going from 40–50 a second to none.
|
||||
- **Desktop audio on a Linux host no longer stutters because of somebody else's device.** In one 15-minute session, 15 % of what the listener heard was silence the host had papered over gaps with — because a controller's sound card, attached over the network and connected to nothing, was setting the pace for the whole box.
|
||||
- **A Gaming Mode takeover on a machine that logs itself in was choking its own controller input** — the pad enumerated fine and then reported at about 1.4 times a second instead of 250. The cause was ours, and it is gone.
|
||||
- **Hyprland and Sway users: the default display mode now really turns your desk monitors off during a session.** It said it did before and did nothing. Read *Before you update*.
|
||||
|
||||
## Before you update
|
||||
|
||||
- **Hyprland and Sway hosts:** the "exclusive" display setting — which is what the default resolves to on these desktops — now genuinely disables your own monitors for the length of a session and brings them back afterwards, exactly as it always has on KDE. Until now it was accepted, reported back as active, and quietly behaved as "extend", so your monitors stayed on. If you would rather keep them on, set the display topology to *extend*. Two things to know: on Hyprland, bringing the monitors back reloads your Hyprland configuration, which also drops any other runtime tweaks you have applied since login and re-runs its startup commands; and the Sway half is written to the same contract but has not been exercised on a live Sway machine, so if it misbehaves, please say so.
|
||||
- **NixOS hosts:** there is now a binary cache, so a host no longer takes an hour to build. Add the cache and its public key to your configuration — the install guide has the snippet, and the cache serves its own public key. If your flake overrides Punktfunk's `nixpkgs` input, the cache cannot help you: every package changes and rebuilds. Separately, one of the controller-audio fixes below is a sound-configuration file the other Linux packages install into a system location NixOS does not have; it needs a package override there.
|
||||
- **Other Linux hosts:** several controller-audio fixes arrive as system files inside the host package rather than as code — a device rule, a sound-system policy and a sound-card configuration. They take effect after the package is installed and the controller is reattached (or the machine rebooted).
|
||||
- **Linux hosts, if you look in your sound settings mid-session:** you will now see a virtual output *and* a recording stream both named after Punktfunk. That is the host's own audio output, and it is not a leak — the troubleshooting guide has a new section on it and on what to do when it tells you another device is clocking your audio.
|
||||
- **If you moved the host's management port and hand-edited the tray's start-up command to match, you can undo that.** The tray, every plugin and the plugin runner now follow the port the host actually bound, so nothing needs telling.
|
||||
|
||||
## New
|
||||
|
||||
- **NixOS users get prebuilt binaries.** Every other install channel shipped binaries; Nix compiled the whole workspace and our patched compositor from source — roughly an hour, on the critical path of enabling the host at all. A signed binary cache is now published on every change to the main branch, so a build that once took an hour takes the time it takes to download.
|
||||
- **Hyprland and Sway hosts can run a session on the virtual display alone.** With the display topology on *exclusive*, your own monitors are switched off while the session runs and switched back on when its display goes away — never all at once, so the desktop is never left with nothing to show, and never a monitor belonging to a second session or a second host on the same machine. *Primary* is still treated as *extend* on these desktops, and now says so on its own rather than sharing a warning with *exclusive*: Wayland has no notion of a primary output, only a focused one, and the streamed display already holds that.
|
||||
- **The Android app's controller interface is the desktop's.** Plug in or pair a controller and the phone or TV shows the same console Windows and Linux do — same screens, same navigation, same motion — instead of an Android-only recreation of it that had to be fixed three times over. It runs on every Android device, including the 32-bit TV boxes that were previously left out. On a phone the console now takes the whole panel, tucking the system bars away for the duration and bringing them back on a swipe, and the library gives the sort bar's height back to the covers unless you have actually pulled it down. Connected controllers is a page of the console itself now, with the permission prompts, the rumble test and the controller-audio self-test where they were before.
|
||||
- **The Apple gamepad library grows up.** On Mac, iPhone, iPad and Apple TV the controller-driven library gets the desktop console's poster grid alongside the shelf, a live sort and view bar (Default · A–Z · Platform · Store; Shelf · Grid) that writes the same setting the settings rows do so the two can never disagree, and **Collections** — group by platform or store, walk the groups, open one, and back out the same way — presented as tiles carrying a fanned deck of up to three covers. The plain touch library learned to sort and group from a toolbar menu too. Every title's platform, which the host had been sending all along and the app was throwing away, now shows in the detail band and drives the grouping.
|
||||
- **The audio buffer can grow without going silent.** Every client keeps a small buffer of sound and adjusts its depth to what the link and the picture are doing. It could shrink gently, one crossfaded frame at a time — but the only way it could *grow* was to throw everything away and refill from silence. So whenever the picture's timing wandered and the sync loop asked for a little more audio depth, the very next late packet cost a 15–60 ms gap. This shape has been with us since roughly 0.24. The buffer now moves toward its target in both directions with the same gentle instrument, on Windows, Linux, Android, Mac, iPhone, iPad and Apple TV alike.
|
||||
- **The audio threads that feed the speakers run at real priority on the client.** The device callback itself already did; the threads decoding audio and rendering controller audio did not, and on a Steam Deck decoding a 1440p120 stream on the same four cores that was a source of clicks. The previous attempt at this was a no-op on the Deck (its user account is not allowed to ask), so the client now goes through whichever door the machine actually offers — including from inside a Flatpak. Windows clients get the same through the system's pro-audio scheduling.
|
||||
- **A host that is losing audio now says why.** The Linux host logs, by name, whichever device is setting the pace for its audio whenever that changes, and warns when it is not the host's own output — because those stalls become your holes. The capture summary also breaks its lost audio down by how long each gap was, so a periodic scheduler on the box and one long outage stop reading identically.
|
||||
- **Plugins stop re-scanning your library while a game runs.** Steam writes to its folders the whole time a game is open, and every write was re-walking the library — 102 times in one 27-minute log. There is now a floor of one re-scan per half-minute per plugin, with nothing lost: whatever changed during the hold is picked up in exactly one trailing scan.
|
||||
|
||||
## Improved
|
||||
|
||||
- **Windows: the plugin runner writes a log file you can read.** A field report on a 0.30 host had plugins installed, the runner running, an empty library and "no logs at all" — and that was by design, since the runner's only way to speak was through the host it could not reach. It now writes a plain log file next to its plugin state, and the console's empty-library hint tells you where it is.
|
||||
- **Apple gamepad screens move like the desktop's.** Screen transitions in the Mac, iPhone, iPad and Apple TV controller shell use the same spring the desktop console uses, and they can be interrupted — press B mid-flight and the same spring carries you back. Reduce Motion crossfades instead of snapping.
|
||||
- **The Apple library fits a phone.** The grid fills the width instead of leaving a fifth of it empty on a phone; in a landscape phone's height it holds two rows instead of one; the shoulder-button hint hides on any phone and the sort bar has become a tray you pull down with ▲ and dismiss with ▼, A or B, so the field keeps every point of height it has. Navigating the grid no longer scrolls twice for one move, and a diagonal flick of the stick is one move, not two.
|
||||
- **The on-screen keyboard on Mac and iPad behaves.** The row you are typing into flies from its place in the list to a seat directly above the keys and back, instead of the empty row doing the flying while the real one appeared from nowhere; a hardware keyboard types straight into the field while the tray is up, including characters the on-screen grid does not offer; and Esc means Done rather than closing the whole screen.
|
||||
- **The controller speaker sounds like a speaker.** Both controller-audio lanes were compressed with the low-delay voice profile that suits rumble — on the speaker it sounded, in the words of the person who heard it, insanely compressed. The speaker lane now uses the full-quality music coder at a higher bitrate; the rumble lane is unchanged.
|
||||
- **A silent controller speaker is no longer indistinguishable from broken hardware.** On Android the controller speaker is off by default — deliberately, it is a small loudspeaker in your hands — but nothing said so, and one field session spent an evening measuring the host for a speaker that was simply switched off on the phone. The setting now states its default, and the host's controller-audio test tells you up front whether your client would even be asked to play what the test is about to prove works.
|
||||
- **The virtual controller looks exactly like a real one to the system.** It carried a placeholder serial number no real pad has, which leaked into every device name derived from it. Cleared, so anything that matches on those names sees the same text a physical pad produces.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Ending a session killed the entire host on Windows.** The service restarted it about six seconds later, so it read as a mystery reconnect rather than a crash — four times on one machine, every one of them a session teardown. The cause was a single log line written at a moment when the logging machinery could already be gone, and the crash handler then hid the evidence by trying to log the same way and failing the same way. Neither happens now, and if anything of that shape ever recurs, the message naming it will actually be written down.
|
||||
- **Launching a game from a Windows host on 0.30 could drop your stream a second later**, with the console reporting nothing running. Almost every Windows launch is a hand-off to Steam, Playnite or the shell — a process that quits a second after the launcher takes over. 0.30 learned to track the process it started, and for a title without any way to recognise its window it took that hand-off's exit as the game's and closed the connection. It would also, in one shape, have asked your whole Steam client to close when the game ended. Both fixed; a title the host cannot track shows as untracked instead of "launching" forever.
|
||||
- **DualSense rumble and speaker in a game streamed from a Linux host — five faults, in order:**
|
||||
- **The controller vanished 400 ms after it appeared** when the newer USB-style transport was on. One of its setup reports was one byte longer than the driver asks for, and USB treats an over-long reply as hostile and tears the whole device down. The report is the right length now, every reply is trimmed to what was asked for, and the host waits for the system to actually adopt the pad before it calls the pad ready.
|
||||
- **Every message the game sent to the pad was reported as failed**, so the one command that switches on rumble, adaptive triggers and the speaker never took, and nothing downstream could show a result. The pad was acknowledging every write with "0 bytes accepted". It acknowledges the bytes it took now — and the Steam Controller 2 shares that path, so its writes were being reported wrong too.
|
||||
- **The pad's sound card was only readable by the administrator**, because it appears at a moment when no desktop session is active to grant access, so the sound system never saw it and games had nowhere to route controller audio. A device rule fixes it, for virtual and physically plugged pads alike.
|
||||
- **What reached the voice coils was 48 dB too quiet.** The sound system starts every new device at what a mixer shows as 40 %, which in real amplitude is about −24 dB — and both the host and the client minted one, so the two stacked. Both ends now pin the controller's volume to unity.
|
||||
- **The controller's only playback route was a mono output that at least one game overran and crashed on**, about 74 seconds in. The pad now presents its proper multi-channel profile from the moment it appears, so that route — and the crash path — never exists, and the coils get their own channels into the bargain.
|
||||
- Also on this path: the speaker channel was being forwarded to the wrong side, so you felt rumble but heard nothing; the pad's audio clock ran about 26 % slow under load, backing its own audio up into dropouts and dragging desktop capture down to half delivery with it; and the sound-system policy that keeps a controller from setting the pace for the whole box was set to a value that made it merely last in line rather than excluded, which on a Punktfunk host is still elected. All corrected.
|
||||
- **Desktop audio on a Linux host stuttered because another device was setting its pace.** The host's audio output was a stream pretending to be an output, and a stream never keeps its own time — so the sound system handed the job to the highest-priority running device on the box. On one reporter's host that was a DualSense forwarded over the network: connected to nothing, never sleeping, and unable to keep steady time for something arriving over a link. Every cycle that ran was healthy; the loss was in the gaps between them — 3.9 holes a second, the worst 142 ms, and 15.4 % of a 15-minute session filled with silence the host generated to cover them. The host now creates a real virtual output that keeps its own time inside the sound system, so no hardware or network device can ever be chosen for it. It also turns out the host's audio callback has been running at 2.67 ms rather than the 5 ms it was designed around on every stock Linux host since it was written, because of a rounding rule nobody had accounted for; that is corrected on the same path.
|
||||
- **A Gaming Mode takeover on a machine that logs itself in flooded the box with logins.** On a host set to log its user in automatically, the way the takeover held the desktop's login manager back sat squarely in that manager's retry path — so every automatic login failed in milliseconds and it tried again, with no back-off: 962 logins in under four minutes, the system's buttons re-scanned 5,688 times, a load of 26 on 12 cores, and every program that listens for new devices drowning in the noise. To you it looked like "my DualSense is not detected, or only with an insane delay": the pad enumerated perfectly and then delivered input at about 1.4 Hz. That method of holding back is gone; the takeover now stops the login manager first and only then steps in. If it cannot stop it, it no longer tries anyway — it falls back to streaming the desktop's own session, which is a working stream, rather than fighting a login loop that costs you your controller.
|
||||
- **Audio hiccups on the client that nothing counted.** On the Linux desktop client and the Steam Deck the audio callback was running on the wrong thread at ordinary priority, and when that thread was late the sound system played silence for us and moved on — an underrun no counter ever saw. On the host, the audio pacer measured its schedule against the wall clock while the audio it carried did not, so every small hole left it a little further behind until a bigger hole repaid the debt as a burst of silence frames — one field log showed 33–72 % of departures late and the worst nearly 100 ms behind. Both fixed, and holes no longer open and close with a click.
|
||||
- **A Steam Deck's support bundle covered three seconds instead of the whole session.** The video decoder writes a dozen bookkeeping lines per frame, and at 120 fps that flushed the entire log ring — 2,037,456 lines evicted from a 27-minute session, including the one audio line three rounds of investigation had been waiting for. The chatter is filtered before it reaches the ring.
|
||||
- **Android: the picture arrived late and dropped frames on a phone that could easily keep up.** The reference phone decodes a frame in 4–5 ms and was still showing 30 ms end to end with 40–50 skipped frames a second, because it was pacing against a display clock that Android quietly slows down for game processes. Frames are now composited straight onto the display on the panel's real timing, and each one reports back exactly when it landed. On glass: end-to-end 30 ms → about 18 ms, skipped frames 40–50 a second → 0. Whether the panel *holds* 120 Hz turns out to be the phone maker's power policy, not the app's — nothing an app can ask lifts it — so if you want 120 on such a phone, set your phone's minimum refresh rate.
|
||||
- **Android: the controller went dead after opening the Controllers or Licences page from the console** — every press was dropped until you force-stopped the app. Also, plugging in a controller could leave you on a grey screen for the rest of the session if the console could not draw; it now hands you back to the touch interface instead.
|
||||
- **Mac, iPad and Apple TV: clicking a host connects to it again.** A change earlier in this cycle had made the host card open the game library instead, with "connect" pushed into the menu — the opposite of every other client. Reverted: tap to connect, "Browse Library…" back in the menu, everything else the library work landed kept.
|
||||
- **The Apple grid's first day on real hardware.** A single stick flick was read as up-then-right on the way out of the dead zone; the grid drew over the pinned title; rows vanished while still in view; one step down scrolled the row above half away; and "Copy link" was a face button on a gamepad interface. All addressed — X now opens a small options menu for the title.
|
||||
- **RPM builds were broken by one stray line**, so for a short window the main branch produced no Fedora packages at all. Fixed the same day; no release was affected.
|
||||
|
||||
## Thanks
|
||||
|
||||
Several of these were found because someone sent a log detailed enough to disprove the obvious. The Steam Deck bundle that turned out to hold three seconds of decoder chatter is what made the log-ring bug visible; the audio-clock investigation went through four field logs and a purpose-built probe before a single column in a diagnostic tool named a controller's sound card; and the report of a Windows host with plugins installed, a runner running and nothing to show for it described exactly the failure the runner could not report on its own. Thank you.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and embedder detail — including the version table and the notes on what moved — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.0/CHANGELOG.md).
|
||||
|
||||
The short version: nothing versioned moves — no wire, ABI, driver-protocol or plugin-contract change — and the C header is byte-identical to 0.30.0's. Three things are worth reading before you package or embed this release: the Linux host package now installs three new system files (a device rule, a sound-system policy and a sound-card configuration) that the controller-audio fixes depend on; the Linux desktop-audio capture changed topology by default, with a one-release escape hatch back to the 0.30 shape; and the Android app's Compose console is deleted outright, which removes its screenshot scenes.
|
||||
@@ -0,0 +1,3 @@
|
||||
• The controller interface is now the same console the desktop app shows — on every phone and TV, 32-bit boxes included — with a Controllers page of its own.
|
||||
• A much faster picture: frames land on the panel's real timing, so on the reference phone latency fell from 30 to about 18 ms and dropped frames from 40–50 a second to none.
|
||||
• Fixed: the gamepad going dead after opening Controllers or Licences, a grey screen when the console could not draw, and audio gaps when the buffer needed to grow.
|
||||
Generated
+3
-3
@@ -62,11 +62,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1784120854,
|
||||
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
||||
"lastModified": 1787070829,
|
||||
"narHash": "sha256-vXNVDVtvfiQuXthP0NHPFdNvvMTkGpx0UP8oddIWbNk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
||||
"rev": "0ae2bc1419c3f345984c2629e72e7a631820fa4d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -2983,12 +2983,15 @@ PunktfunkConnection *punktfunk_connect_ex10(const char *host,
|
||||
// `audio_rate_hz` — `48000`, `96000`, or the 44.1 kHz family `44100` / `88200` / `176400` — and
|
||||
// `audio_bits` (`16` or `24`).
|
||||
//
|
||||
// Passing anything other than `48000`/`16` sets `CLIENT_CAP_AUDIO_HIRES` in the `Hello` and asks
|
||||
// the host for the LOSSLESS `0xD3` plane — bit-exact PCM instead of Opus. That is an opt-in on
|
||||
// both ends, and it is meant to be: it costs **1.5–4.6 Mbps** taken off the top of the link
|
||||
// (audio rides QUIC datagrams outside the ABR loop, so ABR can neither see it nor reclaim it),
|
||||
// against the ~256 kbps Opus this replaces. Only call it with a non-default format when the
|
||||
// user turned the feature on AND this embedder can genuinely open an output device at it.
|
||||
// Passing a format AT ALL — any non-zero `audio_rate_hz`/`audio_bits`, `48000`/`16` included —
|
||||
// sets `CLIENT_CAP_AUDIO_HIRES` in the `Hello` and asks the host for the LOSSLESS `0xD3` plane,
|
||||
// bit-exact PCM instead of Opus. (This line once said "anything other than `48000`/`16`", which
|
||||
// was the rule until the cheapest rung turned out to be the one nobody could ask for; the ⚠ below
|
||||
// is the whole story.) That is an opt-in on both ends, and it is meant to be: it costs
|
||||
// **1.5–4.6 Mbps** taken off the top of the link (audio rides QUIC datagrams outside the ABR
|
||||
// loop, so ABR can neither see it nor reclaim it), against the ~256 kbps Opus this replaces. Only
|
||||
// pass a format when the user turned the feature on AND this embedder can genuinely open an
|
||||
// output device at it.
|
||||
//
|
||||
// **The request is not the answer.** The host runs a five-condition gate
|
||||
// (`design/hi-res-audio.md` §8.4 — client asked, operator policy allows, stereo, the capture
|
||||
|
||||
@@ -215,6 +215,18 @@ package_punktfunk-host() {
|
||||
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||
install -Dm0644 "$R/scripts/60-punktfunk-dualsense.conf" \
|
||||
"$pkgdir/usr/share/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf"
|
||||
# ALSA UCM for the DualSense's own sound card — the `SpeakerHaptic` device alsa-ucm-conf has
|
||||
# never carried. Without it the pad's only playback route is a 1-channel `Speaker` split, and a
|
||||
# game that opens GE-Proton's "Sony controller speaker" endpoint overruns it. Keyed by USB
|
||||
# vid:pid, these only redefine which profile the pad resolves to, so nothing alsa-ucm-conf owns
|
||||
# is replaced — no `conflicts=`, no `backup=`. Complements the WirePlumber rules above rather
|
||||
# than overlapping them: those hold the device open and keep it off the graph-driver election,
|
||||
# this decides which sinks the card offers at all. See scripts/alsa-ucm2/.
|
||||
for f in USB-Audio/conf.d/054c-0ce6.conf USB-Audio/conf.d/054c-0df2.conf \
|
||||
USB-Audio/Punktfunk/DualSense-PS5-Haptic.conf \
|
||||
USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.conf; do
|
||||
install -Dm0644 "$R/scripts/alsa-ucm2/$f" "$pkgdir/usr/share/alsa/ucm2/$f"
|
||||
done
|
||||
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
|
||||
# stop/restore the display manager for the stream. Arch has no /usr/libexec — install under
|
||||
# /usr/lib/punktfunk and rewrite the policy's exec.path annotation to match (the host probes both).
|
||||
|
||||
@@ -83,6 +83,18 @@ install -Dm0644 packaging/linux/49-punktfunk-update.rules \
|
||||
"$STAGE/usr/share/polkit-1/rules.d/49-punktfunk-update.rules"
|
||||
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||
install -Dm0644 scripts/60-punktfunk-dualsense.conf "$STAGE/usr/share/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf"
|
||||
# ALSA UCM for the DualSense's own sound card — the `SpeakerHaptic` device alsa-ucm-conf has
|
||||
# never carried. Without it the card's only playback route is a 1-channel `Speaker` split and a
|
||||
# game that opens GE-Proton's "Sony controller speaker" endpoint overruns it. The conf.d files
|
||||
# are keyed by USB vid:pid and only redefine which profile the pad resolves to, so this REPLACES
|
||||
# NOTHING alsa-ucm-conf owns — no diversion, no Conflicts. Complements the WirePlumber rules
|
||||
# above rather than overlapping them: those hold the device open and keep it off the graph-driver
|
||||
# election, this decides which sinks the card offers in the first place. See scripts/alsa-ucm2/.
|
||||
for f in USB-Audio/conf.d/054c-0ce6.conf USB-Audio/conf.d/054c-0df2.conf \
|
||||
USB-Audio/Punktfunk/DualSense-PS5-Haptic.conf \
|
||||
USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.conf; do
|
||||
install -Dm0644 "scripts/alsa-ucm2/$f" "$STAGE/usr/share/alsa/ucm2/$f"
|
||||
done
|
||||
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
|
||||
# stop/restore the display manager for the stream (the helper derives the DM unit itself).
|
||||
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
|
||||
|
||||
+168
-5
@@ -45,6 +45,69 @@ GPU drivers are resolved at runtime from `/run/opengl-driver/lib`. On non-NixOS
|
||||
[nixGL](https://github.com/nix-community/nixGL) so that path is populated (`nixGL nix run …`); on
|
||||
NixOS the module (below) sets `hardware.graphics.enable = true` for you.
|
||||
|
||||
> **Do this first, or the commands above compile the world:** without the binary cache, `nix build`
|
||||
> here means the whole Rust workspace *and* a gamescope build from source — roughly an hour on a
|
||||
> fast machine. See below.
|
||||
|
||||
---
|
||||
|
||||
## Binary cache (do this before your first build)
|
||||
|
||||
CI publishes every punktfunk package to **`https://nix.unom.io`** on each push to `main` that moves
|
||||
the flake, so you get prebuilt binaries instead of an hour of `rustc`. It covers
|
||||
`punktfunk-host`, `-client`, `-tray`, `-web`, `-scripting` and `-gamescope` — everything the flake
|
||||
builds from source. Everything else in the closure is stock nixpkgs and comes from `cache.nixos.org`
|
||||
as usual, so the cache is deliberately small and adding it costs you nothing on unrelated builds.
|
||||
|
||||
**NixOS** — in your system configuration:
|
||||
|
||||
```nix
|
||||
nix.settings = {
|
||||
substituters = [ "https://nix.unom.io" ];
|
||||
trusted-public-keys = [ "punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=" ];
|
||||
};
|
||||
```
|
||||
|
||||
**Anywhere else** — in `/etc/nix/nix.conf` (or `~/.config/nix/nix.conf` if you are a trusted user):
|
||||
|
||||
```conf
|
||||
extra-substituters = https://nix.unom.io
|
||||
extra-trusted-public-keys = punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=
|
||||
```
|
||||
|
||||
The current public key is served by the cache itself, so you can always check it against the source
|
||||
of truth:
|
||||
|
||||
```sh
|
||||
curl https://nix.unom.io/punktfunk-cache.pub
|
||||
```
|
||||
|
||||
Verify the cache is being used — this should print the store paths without compiling anything:
|
||||
|
||||
```sh
|
||||
nix build --dry-run git+https://git.unom.io/unom/punktfunk#punktfunk-host
|
||||
```
|
||||
|
||||
### ⚠ `nixpkgs.follows` turns the cache off
|
||||
|
||||
Every store path is keyed by the exact inputs it was built from. Pointing punktfunk's nixpkgs at
|
||||
yours changes those inputs, so **every** path misses and you compile the workspace anyway:
|
||||
|
||||
```nix
|
||||
# Convenient, but it costs you the entire binary cache:
|
||||
inputs.punktfunk.inputs.nixpkgs.follows = "nixpkgs";
|
||||
```
|
||||
|
||||
That is a real trade, not a bug — `follows` buys you one shared nixpkgs in the closure instead of
|
||||
two. Take it if closure size matters more to you than build time; leave it out to get binaries.
|
||||
|
||||
### Why not `cachix`?
|
||||
|
||||
Nothing against it — punktfunk simply self-hosts every other channel (flatpak, deb, rpm, Arch,
|
||||
docker, winget), and a Nix cache is static files behind a web server, so it rides the same unom-1
|
||||
box and the same deploy key as the rest. Nothing about the cache is punktfunk-specific: it speaks
|
||||
plain HTTP binary-cache protocol, so any nix client works with it.
|
||||
|
||||
---
|
||||
|
||||
## NixOS module
|
||||
@@ -55,6 +118,8 @@ Add the flake and enable the host and/or client:
|
||||
{
|
||||
inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
|
||||
# (optional) share your nixpkgs: inputs.punktfunk.inputs.nixpkgs.follows = "nixpkgs";
|
||||
# ⚠ this DISABLES the binary cache — different inputs, different store paths, so every
|
||||
# package is rebuilt from source (~1h). See "Binary cache" above.
|
||||
|
||||
outputs = { self, nixpkgs, punktfunk, ... }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
@@ -369,8 +434,106 @@ RUNPATH (`/run/opengl-driver/lib`) and the GTK GApps wrapper (GSettings schemas
|
||||
are present. Fixes discovered during that bring-up: `CMAKE_POLICY_VERSION_MINIMUM=3.5` (CMake ≥ 4),
|
||||
system `libopus` (audiopus_sys), and the session Skia note above.
|
||||
|
||||
In CI (`.gitea/workflows/nix.yml`): `nix flake check --no-build` evaluates every output *including*
|
||||
the module check above, and `punktfunk-web` + `punktfunk-scripting` are built for real. The Rust
|
||||
packages and `punktfunk-gamescope` are `workflow_dispatch` opt-ins (`build-rust`,
|
||||
`build-gamescope`) — run the latter after a `flake.lock` bump, since it patches whatever gamescope
|
||||
the pinned nixpkgs carries.
|
||||
In CI (`.gitea/workflows/nix.yml`), three tiers: `nix flake check --no-build` evaluates every output
|
||||
*including* the module check above; `punktfunk-web` + `punktfunk-scripting` are built for real on
|
||||
every PR; and on a push to `main` the Rust packages and `punktfunk-gamescope` are built and
|
||||
published to the binary cache. A `flake.lock` bump that breaks the gamescope patches therefore goes
|
||||
red on main rather than in an operator's rebuild. The `build-rust` / `build-gamescope`
|
||||
`workflow_dispatch` inputs remain, for checking those on a branch before merging.
|
||||
|
||||
---
|
||||
|
||||
## Cache infrastructure (maintainers)
|
||||
|
||||
`https://nix.unom.io` is a `caddy:2-alpine` container on unom-1 serving a static directory —
|
||||
`packaging/nix/server/` — exactly like the flatpak repo (3230) and the winget source (3240). A Nix
|
||||
binary cache *is* just `nix-cache-info` + `<hash>.narinfo` + `nar/<hash>.nar.xz` behind a web
|
||||
server; there is no cache daemon to run.
|
||||
|
||||
**Why not Gitea, and why not `storage.unom.io`:**
|
||||
|
||||
- Gitea has 23 package registry types and none is Nix. It is not a missing label — the protocol
|
||||
needs fixed anonymous paths at a URL *root* (`/nix-cache-info`, `/<hash>.narinfo`,
|
||||
`/nar/…`), and `/api/packages/{owner}/generic/{name}/{version}/{file}` cannot express them.
|
||||
- The RustFS S3 at `storage.unom.io` *would* work mechanically (nix speaks `s3://…?endpoint=`, and
|
||||
the sccache credentials already exist), but it is a local box on the home uplink with no CDN, so
|
||||
every user download competes with CI. It also answers **403** for a missing key unless the bucket
|
||||
policy grants anonymous `ListBucket` — and nix treats anything other than **404** as a hard error
|
||||
rather than a cache miss, which would break users' builds for packages the cache never held.
|
||||
|
||||
**One-time setup — in this order.** The publish step ends by fetching `nix.unom.io` to prove the
|
||||
cache really answers (and answers **404**, not 403, for a path it does not hold), so stand the
|
||||
service up *before* you set the secret that switches publishing on. The secret is the last step for
|
||||
exactly that reason: until it exists the publish no-ops with a warning and `main` stays green,
|
||||
the same way flatpak.yml's repo deploy does.
|
||||
|
||||
1. **Ingress — both halves live in `unom/infra`, and they must move together.** Neither the DNS
|
||||
record nor the vhost is a click: `terraform/cloudflare/records.tf` owns the zone and
|
||||
`caddy/Caddyfile` owns the vhosts, and that file says so itself — *"a name here with no vhost
|
||||
404s, a vhost with no name here never cuts over."*
|
||||
|
||||
- `terraform/cloudflare/records.tf` — add `"nix"` to `local.hostnames`. It inherits
|
||||
`proxied = false`, which this service specifically needs: a proxy that masked the origin's
|
||||
404s would fail users' builds for every package the cache does not hold.
|
||||
- `caddy/Caddyfile` — next to the `docs.punktfunk.unom.io` block:
|
||||
|
||||
```caddyfile
|
||||
nix.unom.io {
|
||||
import security_headers
|
||||
reverse_proxy localhost:3250
|
||||
}
|
||||
```
|
||||
|
||||
Apply with the **`dns-cutover.yml`** workflow (`target=hcloud`, `action=plan` first — expect a
|
||||
single added `cloudflare_record.a["nix"]`, stop if it shows anything else) and `deploy-all` for
|
||||
the Caddyfile.
|
||||
|
||||
⚠ **Neither by hand.** A record added in the Cloudflare dashboard is out-of-band and risks the
|
||||
duplicate-record round-robin `records.tf` documents; `~/caddy/Caddyfile` on unom-1 looks like the
|
||||
config but is a copy `deploy-all.sh` rsyncs from the repo, with no `.git` to warn you — a vhost
|
||||
added only on the box survives until the next deploy and no longer (this bit the winget source on
|
||||
2026-07-26; see `packaging/winget/server/README.md`).
|
||||
|
||||
Until both land the hostname fails the TLS handshake, because Caddy has no certificate for a name
|
||||
it does not serve. Expected on first setup — and also exactly how a later clobber presents.
|
||||
Diagnose by SNI, not by port 80 (Caddy 308s every Host to https, including names it has never
|
||||
heard of, so a redirect proves nothing):
|
||||
|
||||
```sh
|
||||
openssl s_client -connect nix.unom.io:443 -servername nix.unom.io </dev/null 2>&1 \
|
||||
| grep -E '^subject=|alert'
|
||||
```
|
||||
2. Dispatch `deploy-services.yml` (or `unom/infra`'s `deploy-all`) to bring the container up. It
|
||||
serves an empty cache — every path 404s, which is exactly what a healthy empty cache does.
|
||||
3. **Signing key — done.** `NIX_CACHE_SIGNING_KEY` is installed as a repo Actions secret, and its
|
||||
public half is pinned in the "Binary cache" section above and in
|
||||
`docs-site/content/docs/install.md`. Regenerate only deliberately: a new key invalidates every
|
||||
signature already published, and every user pinning the old one starts failing. If you ever must:
|
||||
|
||||
```sh
|
||||
nix key generate-secret --key-name punktfunk-cache-1 # on a Nix box
|
||||
docker run --rm nixos/nix nix --extra-experimental-features nix-command \
|
||||
key generate-secret --key-name punktfunk-cache-1 # or anywhere with docker
|
||||
```
|
||||
4. Push to `main` touching the flake. The publish step also writes the public key to
|
||||
`https://nix.unom.io/punktfunk-cache.pub`, so users can always check the docs against the cache.
|
||||
|
||||
`scripts/setup-nix-cache.sh` walks through it interactively, and each stage detects work already
|
||||
done — so it is safe to run now that the key exists.
|
||||
|
||||
**Operational notes:**
|
||||
|
||||
- Only punktfunk's own store paths are published (`nix path-info -r … | grep -- '-punktfunk'`).
|
||||
Everything else in a closure is stock nixpkgs, already on `cache.nixos.org` behind a real CDN;
|
||||
mirroring it would cost disk and home-to-cloud bandwidth to serve a worse copy. The publish step
|
||||
asserts every built output is matched by that filter, so a future `pname` change fails the build
|
||||
instead of silently dropping a package from the cache.
|
||||
- `rsync` runs **without** `--delete` (a client mid-download is never pulled out from under), and
|
||||
NARs are uploaded *before* narinfos — a narinfo whose NAR has not landed is a hard download
|
||||
failure for whoever fetches it in that window, while an unreferenced NAR is merely invisible.
|
||||
- Growth is bounded by `packaging/nix/server/prune.sh` (evicts narinfos untouched for 180 days,
|
||||
then sweeps NARs nothing references). The flatpak repo next door reached 3.84 GB publishing the
|
||||
same way with no sweep, on a box that has run out of disk before — hence the sweep from the first
|
||||
publish. Run its self-check with `sh packaging/nix/server/prune.sh --self-test`.
|
||||
- A user on a pinned rev older than the eviction window falls back to building from source, which
|
||||
is the pre-cache status quo.
|
||||
|
||||
@@ -46,7 +46,23 @@ let
|
||||
# our own name — the single worst outcome here, because the host reads the name as a promise of
|
||||
# HDR. (`installCheckPhase` below greps for the marker as the second line of defence; this one
|
||||
# fails at eval, before anything is built.)
|
||||
base = gamescope.unwrapped or gamescope;
|
||||
# `enableWsi = true` is NOT optional here, and it is a FUNCTION ARGUMENT — `overrideAttrs`
|
||||
# cannot reach it. nixpkgs defaults `enableWsi ? false` and feeds it to
|
||||
# `mesonBool "enable_gamescope_wsi_layer"`, so the plain derivation installs the compositor and
|
||||
# no layer at all; nixpkgs gets its layer by instantiating a SECOND copy inside the wrapper.
|
||||
# Without the override the build gets all the way through compile, link and install before
|
||||
# postInstall's find turns up nothing and fails with "built no WSI layer" (MEASURED 2026-08-19,
|
||||
# run 19323) — an expensive way to discover a default.
|
||||
#
|
||||
# `.override` before `.overrideAttrs`: the former re-invokes the package function with the new
|
||||
# argument, so the latter must come after or it would be applied to the derivation being
|
||||
# replaced. The `? override` test only skips the call for something that is not overridable at
|
||||
# all (a symlinkJoin) — it does NOT make an unknown argument safe: a nixpkgs whose gamescope
|
||||
# dropped `enableWsi` fails at EVAL with "function has no argument named 'enableWsi'". That is
|
||||
# the right failure. It names the cause outright, and it costs nothing, where the alternative is
|
||||
# discovering the same fact after a full compositor build.
|
||||
raw = gamescope.unwrapped or gamescope;
|
||||
base = if raw ? override then raw.override { enableWsi = true; } else raw;
|
||||
unwrapped =
|
||||
if base ? src then
|
||||
base
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Inner Caddy (plain HTTP on :3250); the edge Caddy on the same unom-1 box does TLS for
|
||||
# nix.unom.io (its vhost lives in unom/infra `caddy/Caddyfile`, NOT on the box — see the README).
|
||||
# Serves the punktfunk Nix binary cache — which is nothing but a static tree:
|
||||
#
|
||||
# /nix-cache-info store dir + priority, fetched once per substituter
|
||||
# /<32-char-hash>.narinfo one per store path
|
||||
# /nar/<hash>.nar.xz the archives themselves
|
||||
#
|
||||
# ⚠ A MISSING PATH MUST 404, NOT 403. Nix reads 404 as "not in this cache, try the next
|
||||
# substituter" and treats anything else as a hard error that fails the build — so a cache
|
||||
# answering 403 for unknown hashes breaks every user who adds it, including for packages it
|
||||
# was never meant to serve. `file_server` 404s correctly; do not put an auth wrapper in front
|
||||
# of this without preserving that. This is also the concrete reason the cache is NOT a bucket
|
||||
# on storage.unom.io: S3 answers 403 for a missing key unless the bucket policy grants
|
||||
# anonymous ListBucket, and that box is on the home uplink besides.
|
||||
:3250 {
|
||||
root * /srv
|
||||
file_server browse
|
||||
|
||||
# Everything except nix-cache-info is content-addressed by the store hash and can never
|
||||
# change meaning — a narinfo for a given hash is as immutable as the NAR it points at.
|
||||
@immutable path /nar/* *.narinfo
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# The only mutable file, and cheap to revalidate: nix reads it once per substituter per run.
|
||||
@info path /nix-cache-info
|
||||
header @info Cache-Control "public, max-age=300"
|
||||
|
||||
# nix does not care about Content-Type, but a browser poking at the cache should not be
|
||||
# offered a download for what is a two-line text file.
|
||||
@text path *.narinfo /nix-cache-info
|
||||
header @text Content-Type "text/plain; charset=utf-8"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Static file server for the punktfunk Nix binary cache, on unom-1 (the Hetzner Cloud box) —
|
||||
# the same shape as packaging/flatpak/server, because a Nix cache and an OSTree repo are both
|
||||
# just immutable files behind a web server. Caddy on that SAME box terminates TLS for
|
||||
# nix.unom.io and reverse_proxies to localhost:3250, exactly as it already does for
|
||||
# docs.punktfunk.unom.io -> :3220 and winget.punktfunk.unom.io -> :3240. This inner Caddy serves
|
||||
# the bind-mounted ./site tree over plain HTTP. nix.yml rsyncs into ./site and runs
|
||||
# `docker compose up -d` (idempotent).
|
||||
#
|
||||
# (The sibling docs/flatpak compose files still describe a `home-reverse-proxy-1` and a
|
||||
# 192.168.50.50 from an earlier home-lab topology. That is stale — see the note in
|
||||
# packaging/winget/server/compose.production.yml. The public hostnames resolve straight to the
|
||||
# hcloud box; no local proxy is involved. Do not copy those comments into new services.)
|
||||
#
|
||||
# Port 3250: docs is 3220, flatpak 3230, winget 3240 — keep the run going.
|
||||
services:
|
||||
nix-cache:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3250:3250"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./site:/srv:ro
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/bin/sh
|
||||
# Bound the published Nix binary cache on unom-1.
|
||||
#
|
||||
# WHY THIS EXISTS UP FRONT, rather than being added after the box fills: the flatpak repo next
|
||||
# door taught this exact lesson the expensive way. It publishes with rsync WITHOUT --delete (so
|
||||
# a client mid-download is never broken), nothing ever removed the superseded objects, and it
|
||||
# reached 3.84 GB on a box that had already run out of disk once. This cache has the same
|
||||
# publish model and the same growth shape — every build whose inputs moved adds a fresh set of
|
||||
# store paths and keeps the old ones — so it gets the sweep from day one.
|
||||
#
|
||||
# The order below is the whole correctness argument:
|
||||
#
|
||||
# 1. Delete narinfos older than $DAYS. rsync -a carries the CI-side mtime over, and `nix copy`
|
||||
# rewrites every narinfo it publishes on every run, so a path that is still being published
|
||||
# keeps getting a fresh mtime. Age therefore means "no publish has referenced this in
|
||||
# $DAYS", which is exactly the eviction signal wanted.
|
||||
# 2. THEN delete NARs no surviving narinfo points at.
|
||||
#
|
||||
# Doing it the other way round — or aging the NARs independently — can strand a live narinfo
|
||||
# pointing at a deleted NAR, and that is strictly worse than a cache miss: nix reports a missing
|
||||
# NAR as a hard download failure, not as "not cached, build it yourself".
|
||||
#
|
||||
# POSIX sh: this runs over ssh on unom-1 (Debian), invoked by .gitea/workflows/nix.yml.
|
||||
#
|
||||
# Usage: sh prune.sh <cache-dir> [max-age-days] (default 180)
|
||||
# sh prune.sh --self-test
|
||||
set -eu
|
||||
|
||||
self_test() {
|
||||
# Smallest thing that fails if the ordering or the reference sweep breaks.
|
||||
t="$(mktemp -d)"
|
||||
trap 'rm -rf "$t"' EXIT
|
||||
mkdir -p "$t/nar"
|
||||
printf 'StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 41\n' >"$t/nix-cache-info"
|
||||
|
||||
# A live path, a stale one, and a NAR nothing ever pointed at.
|
||||
printf 'StorePath: /nix/store/aaa-live\nURL: nar/live.nar.xz\n' >"$t/aaa.narinfo"
|
||||
printf 'StorePath: /nix/store/bbb-stale\nURL: nar/stale.nar.xz\n' >"$t/bbb.narinfo"
|
||||
: >"$t/nar/live.nar.xz"
|
||||
: >"$t/nar/stale.nar.xz"
|
||||
: >"$t/nar/orphan.nar.xz"
|
||||
# Two paths that SHARE a NAR, one stale and one live: the shared NAR must survive. A sweep
|
||||
# that deleted NARs per-evicted-narinfo instead of by surviving references would drop it.
|
||||
printf 'StorePath: /nix/store/ccc-live\nURL: nar/shared.nar.xz\n' >"$t/ccc.narinfo"
|
||||
printf 'StorePath: /nix/store/ddd-stale\nURL: nar/shared.nar.xz\n' >"$t/ddd.narinfo"
|
||||
: >"$t/nar/shared.nar.xz"
|
||||
|
||||
# Age the stale ones well past any plausible threshold (portable -t form: YYYYMMDDhhmm).
|
||||
touch -t 200001010000 "$t/bbb.narinfo" "$t/ddd.narinfo" "$t/nar/stale.nar.xz"
|
||||
|
||||
prune "$t" 180
|
||||
|
||||
fail() { echo "SELF-TEST FAILED: $1" >&2; exit 1; }
|
||||
[ -f "$t/aaa.narinfo" ] || fail "evicted a fresh narinfo"
|
||||
[ -f "$t/nar/live.nar.xz" ] || fail "evicted a referenced NAR"
|
||||
[ -f "$t/nix-cache-info" ] || fail "deleted nix-cache-info"
|
||||
[ ! -f "$t/bbb.narinfo" ] || fail "kept a stale narinfo"
|
||||
[ ! -f "$t/nar/stale.nar.xz" ] || fail "kept a NAR nothing references any more"
|
||||
[ ! -f "$t/nar/orphan.nar.xz" ] || fail "kept an orphan NAR"
|
||||
[ -f "$t/nar/shared.nar.xz" ] || fail "deleted a NAR a surviving narinfo still references"
|
||||
echo "prune.sh self-test OK"
|
||||
}
|
||||
|
||||
prune() {
|
||||
root="$1"
|
||||
days="$2"
|
||||
cd "$root"
|
||||
|
||||
before="$(du -sh . 2>/dev/null | cut -f1)"
|
||||
|
||||
# 1. Age out the narinfos.
|
||||
find . -maxdepth 1 -name '*.narinfo' -mtime "+$days" -delete
|
||||
|
||||
# 2. Sweep NARs nothing points at any more. Both lists are relative to $root and spelled the
|
||||
# same way ("nar/<file>") so `comm` can diff them.
|
||||
keep="$(mktemp)"
|
||||
have="$(mktemp)"
|
||||
# `|| true`: an empty cache (or one whose narinfos were all just evicted) makes the glob
|
||||
# match nothing, and an empty keep-list is the correct answer there, not an error.
|
||||
cat ./*.narinfo 2>/dev/null | sed -n 's|^URL: ||p' | sort -u >"$keep" || true
|
||||
find nar -type f 2>/dev/null | sed 's|^\./||' | sort >"$have" || true
|
||||
comm -13 "$keep" "$have" | tr '\n' '\0' | xargs -0 -r rm -f
|
||||
rm -f "$keep" "$have"
|
||||
|
||||
# Leave the numbers in the deploy log — this is the only place the published size is visible.
|
||||
echo "cache pruned (narinfos older than ${days}d): ${before:-?} -> $(du -sh . 2>/dev/null | cut -f1) in $(pwd)"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--self-test) self_test ;;
|
||||
"") echo "usage: prune.sh <cache-dir> [max-age-days] | --self-test" >&2; exit 2 ;;
|
||||
*) prune "$1" "${2:-180}" ;;
|
||||
esac
|
||||
@@ -328,6 +328,23 @@ install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punkt
|
||||
# keep it from driving the graph clock. See the file's own comments.
|
||||
install -Dm0644 scripts/60-punktfunk-dualsense.conf %{buildroot}%{_datadir}/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf
|
||||
|
||||
# ALSA UCM for the DualSense's own sound card — the `SpeakerHaptic` device `alsa-ucm-conf` has
|
||||
# never carried. Without it the card's only playback route is a 1-channel `Speaker` split,
|
||||
# GE-Proton mints its "Sony controller speaker" endpoint from that lone mono sink, and games
|
||||
# that open it overrun it (a reliable EXCEPTION_ACCESS_VIOLATION in Spider-Man Remastered).
|
||||
# Nothing here replaces an `alsa-ucm-conf` file: the vid:pid drop-ins under conf.d/ only
|
||||
# redefine which profile the DualSense resolves to. Complements the WirePlumber policy above
|
||||
# rather than overlapping it — that one governs how the card's nodes BEHAVE, this one governs
|
||||
# which nodes exist. See scripts/alsa-ucm2/ for the mechanism.
|
||||
install -Dm0644 scripts/alsa-ucm2/USB-Audio/conf.d/054c-0ce6.conf \
|
||||
%{buildroot}%{_datadir}/alsa/ucm2/USB-Audio/conf.d/054c-0ce6.conf
|
||||
install -Dm0644 scripts/alsa-ucm2/USB-Audio/conf.d/054c-0df2.conf \
|
||||
%{buildroot}%{_datadir}/alsa/ucm2/USB-Audio/conf.d/054c-0df2.conf
|
||||
install -Dm0644 scripts/alsa-ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic.conf \
|
||||
%{buildroot}%{_datadir}/alsa/ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic.conf
|
||||
install -Dm0644 scripts/alsa-ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.conf \
|
||||
%{buildroot}%{_datadir}/alsa/ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.conf
|
||||
|
||||
# Managed gamescope takeover on DM-autologin boxes (Nobara's plasmalogin): a root helper + polkit
|
||||
# action let the host stop/restore the display manager for the stream without a hand-installed
|
||||
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
|
||||
@@ -582,6 +599,14 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
|
||||
%{_bindir}/punktfunk-tray
|
||||
%{_udevrulesdir}/60-punktfunk.rules
|
||||
%{_datadir}/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf
|
||||
# The DualSense UCM drop-in. Both directories are ours: alsa-ucm-conf ships neither
|
||||
# USB-Audio/conf.d nor USB-Audio/Punktfunk, so owning them collides with nothing.
|
||||
%dir %{_datadir}/alsa/ucm2/USB-Audio/conf.d
|
||||
%dir %{_datadir}/alsa/ucm2/USB-Audio/Punktfunk
|
||||
%{_datadir}/alsa/ucm2/USB-Audio/conf.d/054c-0ce6.conf
|
||||
%{_datadir}/alsa/ucm2/USB-Audio/conf.d/054c-0df2.conf
|
||||
%{_datadir}/alsa/ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic.conf
|
||||
%{_datadir}/alsa/ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.conf
|
||||
%dir %{_libexecdir}/punktfunk
|
||||
%{_libexecdir}/punktfunk/pf-dm-helper
|
||||
%{_libexecdir}/punktfunk/pf-update
|
||||
|
||||
@@ -241,7 +241,8 @@ Type: files; Name: "{app}\web\web-run.cmd"
|
||||
|
||||
[Registry]
|
||||
; Auto-start the status tray at sign-in (all users of this host box; uninsdeletevalue removes it
|
||||
; with the app). Operators who moved --mgmt-bind can append --mgmt-addr/--mgmt-port here.
|
||||
; with the app). No --mgmt-port needed for a moved --mgmt-bind: the tray follows the port the host
|
||||
; publishes in %ProgramData%\punktfunk\mgmt-endpoint (pf_paths::published_mgmt_port); the flag pins it.
|
||||
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
|
||||
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
|
||||
; Toast identity for the tray's notifications ("client connected"). The tray process tags itself
|
||||
|
||||
@@ -46,7 +46,7 @@ export default definePluginKit({
|
||||
| `makeConfigService` | Schema-driven config: raw shape on disk, defaults ONLY in the Schema (`withDecodingDefaultKey` + `encodingStrategy: "omit"`), atomic writes, world-writable refusal, `changes` stream |
|
||||
| `makeCacheStore` | disposable derived state (corrupt/absent → empty, write-through) |
|
||||
| `ProviderClient` + wire schemas | typed library-provider reconcile over the untyped wire — including the optional `detect` hint (see below) |
|
||||
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip + status feed |
|
||||
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip (loop triggers only — `startup` and `manual` always publish) + status feed |
|
||||
| `serveUi` / `httpApiEnv` | an `effect/unstable/httpapi` HttpApi behind the SDK's `servePluginUi`, core-only layers |
|
||||
| `sseRoute` | the status SSE endpoint (httpapi has no event-stream media type) |
|
||||
| `runPluginCli` | `<bin> <command>` dispatcher reusing the plugin's layer graph (deliberately not `effect/unstable/cli` — that would drag platform packages into every plugin) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// Semantics are a faithful port of the original Engine guard:
|
||||
// - single-flight: a sync while one runs records a pending trigger and returns
|
||||
// AlreadyRunning; the running pass re-fires once ("coalesced") when it finishes
|
||||
// - content fingerprint (sha256 of the entries JSON) skips the apply when unchanged
|
||||
// - content fingerprint (sha256 of the entries JSON) skips the apply when unchanged —
|
||||
// except for the two reasons a person is waiting on the answer (`ALWAYS_APPLY`)
|
||||
// - interval poll + best-effort fs watchers (recursive where the OS supports it, top-dir
|
||||
// fallback on Linux) with debounce; the poll is the real safety net on SMB/NFS
|
||||
// - every transition publishes a SyncStatus (the UI's SSE feed)
|
||||
@@ -69,6 +70,28 @@ export interface SyncSettings {
|
||||
readonly minInterval?: Duration.Duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync reasons that push to the host even when the fingerprint says nothing changed.
|
||||
*
|
||||
* A fingerprint match means WE would compute the same entries again — NOT that the host still
|
||||
* holds them. The host may accept a payload and store less of it than was sent: an art path
|
||||
* outside its allowed roots is stripped and the games kept (deliberately — a cover must not cost
|
||||
* a library), and a launcher tile it cannot open is dropped the same way. Once that happens the
|
||||
* plugin's fingerprint is a permanent "no changes": the operator fixes the host side, nothing
|
||||
* re-publishes, and the only way out is to delete the plugin's cache file. That was real
|
||||
* field advice for a portable-Playnite library whose 70 covers were dropped.
|
||||
*
|
||||
* So the two triggers with a person behind them always apply. `startup` is the restart every
|
||||
* operator reaches for, and `manual` is the console's Sync-now button and the CLI's `sync` —
|
||||
* both mean "publish my library NOW", and answering "no changes" to that is the trap. The loop
|
||||
* reasons (`poll`, `fs-change`, `config-change`, `coalesced`) keep the short-circuit, which is
|
||||
* where it earns its keep: they are what would otherwise PUT the whole library every few minutes.
|
||||
*/
|
||||
const ALWAYS_APPLY: ReadonlySet<SyncReason> = new Set<SyncReason>([
|
||||
"startup",
|
||||
"manual",
|
||||
]);
|
||||
|
||||
/** `SyncSettings.minInterval` when a plugin does not set one. */
|
||||
export const DEFAULT_FS_CHANGE_MIN_INTERVAL: Duration.Duration =
|
||||
Duration.seconds(30);
|
||||
@@ -175,7 +198,7 @@ export const makeSyncEngine = <
|
||||
yield* Ref.set(lastReport, report);
|
||||
const fp = fingerprint(entries);
|
||||
const prev = yield* run(opts.lastSync.get);
|
||||
if (prev?.fingerprint === fp) {
|
||||
if (!ALWAYS_APPLY.has(reason) && prev?.fingerprint === fp) {
|
||||
yield* Effect.log(
|
||||
`sync (${reason}): no changes (${entries.length} entries)`,
|
||||
);
|
||||
|
||||
@@ -52,11 +52,13 @@ const run = <A>(eff: Effect.Effect<A, unknown, Scope.Scope>): Promise<A> =>
|
||||
|
||||
describe("SyncEngine", () => {
|
||||
test("first sync applies; unchanged content skips the apply", async () => {
|
||||
// A LOOP reason, deliberately: the fingerprint skip is what keeps a 5-minute poll from
|
||||
// PUTting the whole library forever. `startup`/`manual` opt out of it (below).
|
||||
const { first, second, count } = await run(
|
||||
Effect.gen(function* () {
|
||||
const h = yield* harness();
|
||||
const first = yield* h.engine.sync("manual");
|
||||
const second = yield* h.engine.sync("manual");
|
||||
const first = yield* h.engine.sync("poll");
|
||||
const second = yield* h.engine.sync("poll");
|
||||
return {
|
||||
first,
|
||||
second,
|
||||
@@ -70,6 +72,34 @@ describe("SyncEngine", () => {
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* The host can store LESS than it was sent (an out-of-root cover is stripped, the games kept),
|
||||
* and the fingerprint cannot see that — it only says we would compute the same thing again. So
|
||||
* the two triggers a person is waiting on re-publish regardless, and the fix for a mangled
|
||||
* host-side copy is a restart or the Sync button rather than deleting the plugin's cache.
|
||||
*/
|
||||
test("startup and manual re-apply even when nothing changed", async () => {
|
||||
const counts = await run(
|
||||
Effect.gen(function* () {
|
||||
const h = yield* harness();
|
||||
yield* h.engine.sync("poll"); // first apply, fingerprint stored
|
||||
const afterPoll = yield* Ref.get(h.applied);
|
||||
const startup = yield* h.engine.sync("startup");
|
||||
const manual = yield* h.engine.sync("manual");
|
||||
// …and the loops still skip, with the same fingerprint in place.
|
||||
const loop = yield* h.engine.sync("fs-change");
|
||||
return {
|
||||
afterPoll,
|
||||
tags: [startup._tag, manual._tag, loop._tag],
|
||||
total: yield* Ref.get(h.applied),
|
||||
};
|
||||
}),
|
||||
);
|
||||
expect(counts.afterPoll).toBe(1);
|
||||
expect(counts.tags).toEqual(["Applied", "Applied", "Unchanged"]);
|
||||
expect(counts.total).toBe(3);
|
||||
});
|
||||
|
||||
test("changed content re-applies", async () => {
|
||||
let call = 0;
|
||||
const { outcomes, count } = await run(
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# punktfunk: `USB-Audio/Sony/DualSense-PS5-HiFi.conf` PLUS the `SpeakerHaptic` /
|
||||
# `HeadphonesHaptic` devices, taken verbatim from SteamOS's `alsa-ucm-conf` (jupiter
|
||||
# 1.2.16.1-1.1) — a Valve downstream patch upstream has never carried.
|
||||
#
|
||||
# Why we ship it: without a `SpeakerHaptic` device the card's only playback route is the
|
||||
# 1-channel `Speaker` split. PipeWire then offers exactly one sink, GE-Proton mints its
|
||||
# synthetic "Sony controller speaker" endpoint from it, and games that open that endpoint
|
||||
# overrun it — measured as a reliable `EXCEPTION_ACCESS_VIOLATION` ~74 s into Marvel's
|
||||
# Spider-Man Remastered. `SpeakerHaptic` carries `PlaybackPriority 200` against `Speaker`'s
|
||||
# 100, so the card takes `HiFi (Mic, SpeakerHaptic)`, the sink is the 4-channel one, and the
|
||||
# mono sink the crash needs never exists. The voice coils reach their own channels as a
|
||||
# bonus: this is also the profile the pad's haptics want.
|
||||
#
|
||||
# The four HARDWARE channels are ch0 = headphone L, ch1 = headphone R and the internal mono
|
||||
# speaker, ch2/ch3 = the two voice coils — which is why `SpeakerHaptic` folds BOTH of its
|
||||
# front channels onto hw ch1 while `HeadphonesHaptic` keeps ch0/ch1 apart.
|
||||
#
|
||||
# Keep this file byte-identical to Valve's apart from this header, so re-syncing is a diff.
|
||||
|
||||
Include.pcm_split.File "/common/pcm/split.conf"
|
||||
|
||||
Macro [
|
||||
{
|
||||
SplitPCM {
|
||||
Name "dualsense_mono_out"
|
||||
Direction Playback
|
||||
Channels 1
|
||||
HWChannels 4
|
||||
HWChannelPos0 MONO
|
||||
HWChannelPos1 MONO
|
||||
HWChannelPos2 MONO
|
||||
HWChannelPos3 MONO
|
||||
}
|
||||
}
|
||||
{
|
||||
SplitPCM {
|
||||
Name "dualsense_stereo_out"
|
||||
Direction Playback
|
||||
Channels 2
|
||||
HWChannels 4
|
||||
HWChannelPos0 FL
|
||||
HWChannelPos1 FR
|
||||
HWChannelPos2 FL
|
||||
HWChannelPos3 FR
|
||||
}
|
||||
}
|
||||
{
|
||||
SplitPCM {
|
||||
Name "dualsense_haptic_out"
|
||||
Direction Playback
|
||||
Channels 4
|
||||
HWChannels 4
|
||||
HWChannelPos0 FL
|
||||
HWChannelPos1 FR
|
||||
HWChannelPos2 RL
|
||||
HWChannelPos3 RR
|
||||
}
|
||||
}
|
||||
{
|
||||
SplitPCM {
|
||||
Name "dualsense_mono_in"
|
||||
Direction Capture
|
||||
Channels 1
|
||||
HWChannels 2
|
||||
HWChannelPos0 MONO
|
||||
HWChannelPos1 MONO
|
||||
}
|
||||
}
|
||||
{
|
||||
SplitPCM {
|
||||
Name "dualsense_stereo_in"
|
||||
Direction Capture
|
||||
Channels 2
|
||||
HWChannels 2
|
||||
HWChannelPos0 FL
|
||||
HWChannelPos1 FR
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
SectionDevice."Speaker" {
|
||||
Comment "Internal Mono Speaker"
|
||||
|
||||
ConflictingDevice [
|
||||
"Headphones"
|
||||
"HeadphonesHaptic"
|
||||
]
|
||||
|
||||
Value {
|
||||
PlaybackPriority 100
|
||||
PlaybackPCM "hw:${CardId}"
|
||||
}
|
||||
|
||||
Macro.pcm_split.SplitPCMDevice {
|
||||
Name "dualsense_mono_out"
|
||||
Direction Playback
|
||||
HWChannels 4
|
||||
Channels 1
|
||||
Channel0 1
|
||||
ChannelPos0 MONO
|
||||
}
|
||||
}
|
||||
|
||||
SectionDevice."SpeakerHaptic" {
|
||||
Comment "Internal Mono Speaker + Haptic Feedback"
|
||||
|
||||
ConflictingDevice [
|
||||
"Headphones"
|
||||
"HeadphonesHaptic"
|
||||
]
|
||||
|
||||
Value {
|
||||
PlaybackPriority 200
|
||||
PlaybackPCM "hw:${CardId}"
|
||||
}
|
||||
|
||||
Macro.pcm_split.SplitPCMDevice {
|
||||
Name "dualsense_haptic_out"
|
||||
Direction Playback
|
||||
HWChannels 4
|
||||
Channels 4
|
||||
Channel0 1
|
||||
Channel1 1
|
||||
Channel2 2
|
||||
Channel3 3
|
||||
ChannelPos0 FL
|
||||
ChannelPos1 FR
|
||||
ChannelPos2 RL
|
||||
ChannelPos3 RR
|
||||
}
|
||||
}
|
||||
|
||||
SectionDevice."Headphones" {
|
||||
Comment "3.5mm Headphones"
|
||||
|
||||
ConflictingDevice [
|
||||
"Speaker"
|
||||
"SpeakerHaptic"
|
||||
]
|
||||
|
||||
Value {
|
||||
PlaybackPriority 100
|
||||
PlaybackPCM "hw:${CardId}"
|
||||
JackControl "Headphone Jack"
|
||||
}
|
||||
|
||||
Macro.pcm_split.SplitPCMDevice {
|
||||
Name "dualsense_stereo_out"
|
||||
Direction Playback
|
||||
HWChannels 4
|
||||
Channels 2
|
||||
Channel0 0
|
||||
Channel1 1
|
||||
ChannelPos0 FL
|
||||
ChannelPos1 FR
|
||||
}
|
||||
}
|
||||
|
||||
SectionDevice."HeadphonesHaptic" {
|
||||
Comment "3.5mm Headphones + Haptic Feedback"
|
||||
|
||||
ConflictingDevice [
|
||||
"Speaker"
|
||||
"SpeakerHaptic"
|
||||
]
|
||||
|
||||
Value {
|
||||
PlaybackPriority 200
|
||||
PlaybackPCM "hw:${CardId}"
|
||||
JackControl "Headphone Jack"
|
||||
}
|
||||
|
||||
Macro.pcm_split.SplitPCMDevice {
|
||||
Name "dualsense_haptic_out"
|
||||
Direction Playback
|
||||
HWChannels 4
|
||||
Channels 4
|
||||
Channel0 0
|
||||
Channel1 1
|
||||
Channel2 2
|
||||
Channel3 3
|
||||
ChannelPos0 FL
|
||||
ChannelPos1 FR
|
||||
ChannelPos2 RL
|
||||
ChannelPos3 RR
|
||||
}
|
||||
}
|
||||
|
||||
SectionDevice."Mic" {
|
||||
Comment "Internal Microphone"
|
||||
|
||||
ConflictingDevice [
|
||||
"Headset"
|
||||
]
|
||||
|
||||
Value {
|
||||
CapturePriority 100
|
||||
CapturePCM "hw:${CardId}"
|
||||
}
|
||||
|
||||
Macro.pcm_split.SplitPCMDevice {
|
||||
Name "dualsense_mono_in"
|
||||
Direction Capture
|
||||
HWChannels 2
|
||||
Channels 1
|
||||
Channel0 0
|
||||
ChannelPos0 MONO
|
||||
}
|
||||
}
|
||||
|
||||
SectionDevice."Headset" {
|
||||
Comment "Headset Microphone"
|
||||
|
||||
ConflictingDevice [
|
||||
"Mic"
|
||||
]
|
||||
|
||||
Value {
|
||||
CapturePriority 100
|
||||
CapturePCM "hw:${CardId}"
|
||||
JackControl "Headset Mic Jack"
|
||||
}
|
||||
|
||||
Macro.pcm_split.SplitPCMDevice {
|
||||
Name "dualsense_stereo_in"
|
||||
Direction Capture
|
||||
HWChannels 2
|
||||
Channels 2
|
||||
Channel0 0
|
||||
Channel1 1
|
||||
ChannelPos0 FL
|
||||
ChannelPos1 FR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# punktfunk: the DualSense profile with the haptic devices, selected in place of the
|
||||
# distribution's `Sony/DualSense-PS5` by `conf.d/054c-0ce6.conf`. Verbatim
|
||||
# `USB-Audio/Sony/DualSense-PS5.conf` from alsa-ucm-conf apart from the HiFi file it names —
|
||||
# ALL of the difference lives in `DualSense-PS5-Haptic-HiFi.conf`.
|
||||
|
||||
Comment "Sony Corp. DualSense wireless controller (PS5)"
|
||||
|
||||
Include.dhw.File "/common/directm.conf"
|
||||
|
||||
# keep this use case first - wine compatibility
|
||||
Macro.0.DirectUseCase { Id="Direct" PlaybackChannels=4 CaptureChannels=2 }
|
||||
|
||||
If.default.Prepend.SectionUseCase."Default" {
|
||||
Comment "Default"
|
||||
File "/USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.conf"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# punktfunk: point a DualSense (054c:0ce6) at our haptic-capable profile.
|
||||
#
|
||||
# `USB-Audio/USB-Audio.conf` includes `USB-Audio/conf.d/{vid}-{pid}.conf` unconditionally and
|
||||
# OPTIONALLY, after its device table has chosen `ProfileName` and before it includes the
|
||||
# profile that name resolves to — so redefining the variable here swaps the profile without
|
||||
# editing, diverting, or conflicting with a single file owned by `alsa-ucm-conf`. The hook and
|
||||
# the DualSense profile landed in the same release (1.2.15), so every tree that has the bug
|
||||
# also has the hook.
|
||||
#
|
||||
# See `Punktfunk/DualSense-PS5-Haptic-HiFi.conf` for what the swap buys.
|
||||
|
||||
Define.ProfileName "Punktfunk/DualSense-PS5-Haptic"
|
||||
@@ -0,0 +1,7 @@
|
||||
# punktfunk: point a DualSense Edge (054c:0df2) at our haptic-capable profile.
|
||||
#
|
||||
# The Edge shares the DualSense's audio function and the same `Sony/DualSense-PS5` profile, so
|
||||
# it shares the missing-`SpeakerHaptic` defect too. See `054c-0ce6.conf` for why this hook is
|
||||
# the drop-in it looks like, and `Punktfunk/DualSense-PS5-Haptic-HiFi.conf` for the fix itself.
|
||||
|
||||
Define.ProfileName "Punktfunk/DualSense-PS5-Haptic"
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/sh
|
||||
# Prove that scripts/alsa-ucm2/ still puts a `SpeakerHaptic` device on the DualSense's card —
|
||||
# WITHOUT a DualSense, and without replacing a single file the distro's `alsa-ucm-conf` owns.
|
||||
#
|
||||
# Why this check exists
|
||||
# --------------------
|
||||
# The fix it guards is a hook into ANOTHER project's config tree, so it can rot silently:
|
||||
# `USB-Audio/USB-Audio.conf` includes `USB-Audio/conf.d/{vid}-{pid}.conf` after its device table
|
||||
# has chosen `ProfileName` and before it includes that profile, and our drop-in redefines the
|
||||
# variable in between. Rename the profile, drop the include, reorder the two, and our files stop
|
||||
# doing anything at all — with no error anywhere. What comes back is not a silent downgrade but
|
||||
# the crash: with no `SpeakerHaptic` the card's only playback route is the 1-channel `Speaker`
|
||||
# split, PipeWire offers exactly one sink, GE-Proton mints its synthetic "Sony controller
|
||||
# speaker" endpoint from it, and a game that opens that endpoint overruns it
|
||||
# (EXCEPTION_ACCESS_VIOLATION, reliably ~74 s into Marvel's Spider-Man Remastered).
|
||||
#
|
||||
# How it runs without hardware
|
||||
# ----------------------------
|
||||
# UCM's card-less path (`conf.virt.d/${OpenName}.conf`) can drive the whole include chain; the
|
||||
# only things missing are the four built-ins a real card publishes. So the harness copies the
|
||||
# distro tree to a scratch dir and substitutes literals for exactly those four — the USB id the
|
||||
# dispatcher matches on and the three cosmetic name/id strings — and changes NOTHING else. The
|
||||
# include ordering, the profile resolution and the priorities under test are the real ones.
|
||||
#
|
||||
# Skips (exit 0) where it cannot run: no `alsaucm`, or no distro ucm2 tree with the DualSense
|
||||
# profile in it. Meant for the Fedora RPM builder image; harmless anywhere else.
|
||||
set -eu
|
||||
|
||||
REPO="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
OVERLAY="$REPO/scripts/alsa-ucm2"
|
||||
UCM2="${UCM2_DIR:-/usr/share/alsa/ucm2}"
|
||||
# The pad the profile is keyed to; the Edge (0df2) resolves through the same profile.
|
||||
USBID="USB054c:0ce6"
|
||||
|
||||
[ -d "$OVERLAY/USB-Audio" ] || { echo "check-dualsense-ucm: no $OVERLAY — wrong repo?" >&2; exit 1; }
|
||||
|
||||
if ! command -v alsaucm >/dev/null 2>&1; then
|
||||
echo "check-dualsense-ucm: no alsaucm on PATH (Fedora: dnf install alsa-ucm-utils) — skipped"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f "$UCM2/USB-Audio/Sony/DualSense-PS5.conf" ]; then
|
||||
echo "check-dualsense-ucm: $UCM2 has no DualSense profile (Fedora: dnf install alsa-ucm) — skipped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TREE="$(mktemp -d)"
|
||||
trap 'rm -rf "$TREE"' EXIT INT TERM
|
||||
cp -a "$UCM2/." "$TREE/"
|
||||
|
||||
# Feed the dispatcher the pad's USB id, which normally comes off the card.
|
||||
sed -i.bak \
|
||||
-e "s|String \"\${CardComponents}\"|String \"$USBID\"|" \
|
||||
-e "s|Haystack \"\${CardComponents}\"|Haystack \"$USBID\"|" \
|
||||
"$TREE/USB-Audio/USB-Audio.conf"
|
||||
# ...and the three name/id strings the device sections interpolate. Cosmetic to this test: they
|
||||
# only ever land in a Comment or in the `hw:` PCM name, never in a priority or a channel map.
|
||||
find "$TREE/USB-Audio" "$TREE/common" -name '*.conf' -exec sed -i.bak \
|
||||
-e 's|${CardName}|DualSense|g' \
|
||||
-e 's|${CardId}|Controller|g' \
|
||||
-e 's|${CardLongName}|DualSenseLong|g' {} +
|
||||
find "$TREE" -name '*.conf.bak' -delete
|
||||
|
||||
mkdir -p "$TREE/conf.virt.d"
|
||||
printf 'Syntax 8\nInclude.a.File "/USB-Audio/USB-Audio.conf"\n' > "$TREE/conf.virt.d/pftest.conf"
|
||||
|
||||
ucm() { ALSA_CONFIG_UCM2="$TREE" alsaucm -c pftest "$@" 2>&1 | grep -v 'no soundcards found' || true; }
|
||||
|
||||
# Baseline: informational only. Upstream adopting the device would make our drop-in redundant
|
||||
# rather than wrong, and that is worth seeing in the log rather than failing on.
|
||||
before="$(ucm set _verb Default list _devices | grep -c 'SpeakerHaptic' || true)"
|
||||
|
||||
cp -a "$OVERLAY/USB-Audio/." "$TREE/USB-Audio/"
|
||||
find "$TREE/USB-Audio/Punktfunk" -name '*.conf' -exec sed -i.bak -e 's|${CardId}|Controller|g' {} +
|
||||
find "$TREE" -name '*.conf.bak' -delete
|
||||
|
||||
after="$(ucm set _verb Default list _devices || true)"
|
||||
echo "$after" | grep -q 'SpeakerHaptic' || {
|
||||
echo "check-dualsense-ucm: FAIL — the drop-in did not add a SpeakerHaptic device." >&2
|
||||
echo " The distro's $UCM2/USB-Audio/USB-Audio.conf probably no longer resolves the" >&2
|
||||
echo " DualSense through \${var:ProfileName}, or no longer includes USB-Audio/conf.d/." >&2
|
||||
echo " Devices seen:" >&2
|
||||
echo "$after" | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Existing is not enough: it is OUTRANKING the mono `Speaker` that keeps the 1-channel sink —
|
||||
# and the crash path with it — from ever being minted.
|
||||
prios="$(ucm set _verb Default get PlaybackPriority/SpeakerHaptic get PlaybackPriority/Speaker)"
|
||||
haptic="$(echo "$prios" | sed -n 's|.*PlaybackPriority/SpeakerHaptic=\([0-9]*\).*|\1|p')"
|
||||
mono="$(echo "$prios" | sed -n 's|.*PlaybackPriority/Speaker=\([0-9]*\).*|\1|p')"
|
||||
[ -n "$haptic" ] && [ -n "$mono" ] || {
|
||||
echo "check-dualsense-ucm: FAIL — could not read both playback priorities:" >&2
|
||||
echo "$prios" | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$haptic" -gt "$mono" ] || {
|
||||
echo "check-dualsense-ucm: FAIL — SpeakerHaptic ($haptic) does not outrank Speaker ($mono)," >&2
|
||||
echo " so the card can still land on the 1-channel sink that games overrun." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "check-dualsense-ucm: ok — SpeakerHaptic present at priority $haptic over Speaker's $mono" \
|
||||
"(distro tree alone had $before)"
|
||||
@@ -25,8 +25,9 @@
|
||||
# * 127.0.0.1:47990 keeps it off the LAN — at the cost of paired clients browsing your library.
|
||||
# * MOVING THE PORT is how you share a machine with Sunshine/Apollo/Vibeshine: 47990 is their web
|
||||
# UI as well as our management API, and with PUNKTFUNK_GAMESTREAM off it is the ONLY port the
|
||||
# two still share. Nothing else needs editing — clients learn the port from discovery and the
|
||||
# web console reads it from ~/.config/punktfunk/mgmt-endpoint, which the host rewrites on start.
|
||||
# two still share. Nothing else needs editing — clients learn the port from discovery; the web
|
||||
# console, the plugin runner (so every plugin) and the tray read it from
|
||||
# ~/.config/punktfunk/mgmt-endpoint, which the host rewrites on start.
|
||||
# Running two Moonlight-compatible hosts at once is still unsupported; see the troubleshooting
|
||||
# page. On Windows also see PUNKTFUNK_NO_ISOLATE — the display topology is the second conflict.
|
||||
#PUNKTFUNK_MGMT_BIND=0.0.0.0:47991
|
||||
|
||||
Executable
+421
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# A wizard — walks a human through a manual procedure step by step.
|
||||
# Generated by the /wizard skill.
|
||||
#
|
||||
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
|
||||
# it. Author the per-step stages below the marker.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Wizard library — delightful, consistent UX. Identical across every wizard.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
|
||||
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
|
||||
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
|
||||
else
|
||||
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
|
||||
fi
|
||||
|
||||
# Author sets this at the top of the stages section.
|
||||
TOTAL_STAGES=0
|
||||
|
||||
_STAGE_INDEX=0
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
|
||||
WRITTEN_SECRET=() # secret NAMEs set this run
|
||||
SKIPPED=() # things we couldn't do (e.g. gh missing)
|
||||
|
||||
# _clear — wipe the terminal so only the current step is on screen. No-op when
|
||||
# output isn't a terminal, so piped logs stay readable.
|
||||
_clear() {
|
||||
[[ -t 1 ]] || return 0
|
||||
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
|
||||
}
|
||||
|
||||
# banner "Title" — opening frame: what this wizard does.
|
||||
banner() {
|
||||
_clear
|
||||
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
|
||||
printf '%s %s stages%s\n\n' "$DIM" "$TOTAL_STAGES" "$RESET"
|
||||
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
|
||||
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
|
||||
printf ' later — it remembers values already saved.%s\n' "$RESET"
|
||||
pause "Ready to start?"
|
||||
}
|
||||
|
||||
# stage "Name" — clear the screen, then announce a stage and show progress.
|
||||
# Clearing keeps only the current step on screen.
|
||||
stage() {
|
||||
_clear
|
||||
_STAGE_INDEX=$((_STAGE_INDEX + 1))
|
||||
printf '\n%s%s▸ Stage %s/%s · %s%s\n' \
|
||||
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET"
|
||||
}
|
||||
|
||||
# say "..." — a plain instruction line.
|
||||
say() { printf ' %s\n' "$1"; }
|
||||
# step "..." — a numbered-feeling action the human takes in the browser.
|
||||
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
|
||||
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
|
||||
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
|
||||
|
||||
# open_url URL — open in the human's browser, cross-platform incl. WSL.
|
||||
open_url() {
|
||||
local url="$1"
|
||||
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
|
||||
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
|
||||
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
|
||||
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
|
||||
elif command -v open >/dev/null 2>&1; then open "$url"
|
||||
else warn "couldn't open a browser — visit it manually: $url"; fi
|
||||
} >/dev/null 2>&1 || warn "couldn't open a browser — visit it manually: $url"
|
||||
}
|
||||
|
||||
# pause "msg" — wait for the human to confirm they've done the manual part.
|
||||
pause() {
|
||||
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
|
||||
read -r _ || true
|
||||
}
|
||||
|
||||
# confirm "question" — y/N gate; returns success on yes.
|
||||
confirm() {
|
||||
local reply=""
|
||||
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
|
||||
read -r reply || true
|
||||
[[ "$reply" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
# _existing KEY — current value of KEY in ENV_FILE, if any.
|
||||
_existing() {
|
||||
[[ -f "$ENV_FILE" ]] || return 1
|
||||
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
|
||||
printf '%s' "${line#*=}"
|
||||
}
|
||||
|
||||
# ask KEY "Prompt" — read a value into $KEY. Offers the existing .env value as
|
||||
# a default on re-runs (Enter keeps it). Visible input (non-secret).
|
||||
ask() {
|
||||
local key="$1" prompt="$2" current input
|
||||
current=$(_existing "$key" || true)
|
||||
if [[ -n "$current" ]]; then
|
||||
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
|
||||
else
|
||||
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
|
||||
fi
|
||||
read -r input || true
|
||||
[[ -z "$input" && -n "$current" ]] && input="$current"
|
||||
printf -v "$key" '%s' "$input"
|
||||
}
|
||||
|
||||
# ask_secret KEY "Prompt" — like ask, but input is hidden.
|
||||
ask_secret() {
|
||||
local key="$1" prompt="$2" current input
|
||||
current=$(_existing "$key" || true)
|
||||
if [[ -n "$current" ]]; then
|
||||
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
|
||||
else
|
||||
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
|
||||
fi
|
||||
read -rs input || true
|
||||
printf '\n'
|
||||
[[ -z "$input" && -n "$current" ]] && input="$current"
|
||||
printf -v "$key" '%s' "$input"
|
||||
}
|
||||
|
||||
# write_env KEY VALUE — upsert KEY=VALUE into ENV_FILE (creates it; replaces
|
||||
# any existing line). Idempotent.
|
||||
write_env() {
|
||||
local key="$1" value="$2" tmp
|
||||
touch "$ENV_FILE"
|
||||
tmp=$(mktemp)
|
||||
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
|
||||
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||
mv "$tmp" "$ENV_FILE"
|
||||
WRITTEN_ENV+=("$key")
|
||||
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
|
||||
}
|
||||
|
||||
# set_secret NAME VALUE — set a GitHub Actions repo secret via gh. Falls back
|
||||
# to a warning (and records it) if gh is unavailable or unauthenticated.
|
||||
set_secret() {
|
||||
local name="$1" value="$2"
|
||||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
|
||||
WRITTEN_SECRET+=("$name")
|
||||
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
|
||||
warn "skipped GitHub secret $name — gh not ready; set it later"
|
||||
}
|
||||
|
||||
# set_var NAME VALUE — set a GitHub Actions repo variable (non-secret).
|
||||
set_var() {
|
||||
local name="$1" value="$2"
|
||||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
|
||||
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
SKIPPED+=("GitHub variable $name")
|
||||
warn "skipped GitHub variable $name — gh not ready; set it later"
|
||||
}
|
||||
|
||||
# finish — clear, then a closing summary of everything configured.
|
||||
finish() {
|
||||
_clear
|
||||
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
|
||||
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
|
||||
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
|
||||
if (( ${#SKIPPED[@]} )); then
|
||||
printf '\n'; warn "still to do by hand:"
|
||||
for s in "${SKIPPED[@]}"; do note " - $s"; done
|
||||
fi
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# STAGES — bring the punktfunk Nix binary cache at https://nix.unom.io live.
|
||||
#
|
||||
# Everything here is a step only a human can take: merging an infra PR, running an apply,
|
||||
# dispatching a deploy. The wizard opens each page, says exactly what to do, and then
|
||||
# VERIFIES the result before moving on — the failure signatures are easy to confuse:
|
||||
#
|
||||
# TLS handshake failure -> the vhost is not applied (Caddy has no cert for that name)
|
||||
# 502 / 503 -> vhost fine, the container behind :3250 is not running
|
||||
# 404 -> healthy, the cache is simply empty
|
||||
# 200 -> serving content
|
||||
#
|
||||
# Safe to re-run: every stage detects work already done and skips it.
|
||||
# Full context: packaging/nix/README.md § "Cache infrastructure (maintainers)".
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TOTAL_STAGES=4
|
||||
|
||||
CACHE_HOST="nix.unom.io"
|
||||
CACHE_PORT=3250
|
||||
KEY_NAME="punktfunk-cache-1"
|
||||
GITEA="https://git.unom.io"
|
||||
GITEA_REPO="$GITEA/unom/punktfunk"
|
||||
INFRA_REPO="$GITEA/unom/infra"
|
||||
|
||||
# probe PATH — HTTP status for a path on the cache, or 000 if it cannot be reached at all.
|
||||
probe() { curl -sS -o /dev/null -w '%{http_code}' -m 15 "https://$CACHE_HOST$1" 2>/dev/null || echo 000; }
|
||||
|
||||
# nix_key ARGS… — run `nix key …` from a local nix if there is one, otherwise from the
|
||||
# official image. Keeps this usable on a machine with no nix (the maintainer box is macOS).
|
||||
nix_key() {
|
||||
if command -v nix >/dev/null 2>&1; then
|
||||
nix --extra-experimental-features nix-command key "$@"
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
docker run --rm -i nixos/nix nix --extra-experimental-features nix-command key "$@"
|
||||
else
|
||||
return 127
|
||||
fi
|
||||
}
|
||||
|
||||
banner "punktfunk Nix binary cache — bring-up"
|
||||
|
||||
# ── 1 ─────────────────────────────────────────────────────────────────────
|
||||
stage "Ingress — DNS + the Caddy vhost (unom/infra)"
|
||||
|
||||
say "Both halves live in unom/infra and must move together: terraform/cloudflare/records.tf"
|
||||
say "owns the DNS record, caddy/Caddyfile owns the vhost. That file's own rule:"
|
||||
note " \"a name here with no vhost 404s, a vhost with no name here never cuts over.\""
|
||||
printf '\n'
|
||||
warn "Neither is a click."
|
||||
note " A record added in the Cloudflare dashboard is out-of-band and risks the duplicate-record"
|
||||
note " round-robin records.tf documents. ~/caddy/Caddyfile on unom-1 looks like the config but is"
|
||||
note " a copy deploy-all.sh rsyncs from the repo — a vhost added there lasts until the next deploy."
|
||||
printf '\n'
|
||||
|
||||
TARGET_IP="$(dig +short flatpak.unom.io | tail -n1)"
|
||||
[ -n "$TARGET_IP" ] || TARGET_IP="167.233.145.172"
|
||||
CURRENT="$(dig +short "$CACHE_HOST" | tail -n1)"
|
||||
|
||||
if [ -n "$CURRENT" ]; then
|
||||
printf ' %s✓%s %s already resolves to %s\n' "$GREEN" "$RESET" "$CACHE_HOST" "$CURRENT"
|
||||
[ "$CURRENT" = "$TARGET_IP" ] || warn "expected $TARGET_IP (where flatpak.unom.io points) — check for a stale duplicate record"
|
||||
else
|
||||
open_url "$INFRA_REPO/pulls"
|
||||
step "Merge the 'Serve nix.unom.io' PR (adds \"nix\" to local.hostnames + the vhost)."
|
||||
step "Run dns-cutover.yml with target=hcloud, action=plan."
|
||||
step "The plan must show exactly ONE added record: cloudflare_record.a[\"nix\"]."
|
||||
warn "If it shows anything else, stop — that zone config is shared with every unom site."
|
||||
step "Re-run it with action=apply."
|
||||
step "Then run deploy-all so the box picks up the new Caddyfile."
|
||||
pause "Applied? Press Enter to verify DNS"
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt 10 ]; do
|
||||
CURRENT="$(dig +short "$CACHE_HOST" | tail -n1)"
|
||||
[ -n "$CURRENT" ] && break
|
||||
printf ' %swaiting for DNS (TTL is 300s)…%s\n' "$DIM" "$RESET"
|
||||
sleep 10
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ -n "$CURRENT" ]; then
|
||||
printf ' %s✓%s %s -> %s\n' "$GREEN" "$RESET" "$CACHE_HOST" "$CURRENT"
|
||||
else
|
||||
warn "$CACHE_HOST still does not resolve."
|
||||
SKIPPED+=("DNS record for $CACHE_HOST (unom/infra records.tf + dns-cutover apply)")
|
||||
confirm "Continue anyway?" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# The certificate is the proof the vhost half landed. Diagnose by SNI: Caddy 308s EVERY Host
|
||||
# on :80 to https, including names it has never heard of, so probing port 80 proves nothing.
|
||||
printf ' %schecking for a certificate…%s\n' "$DIM" "$RESET"
|
||||
TLS_OUT="$(openssl s_client -connect "${CACHE_HOST}:443" -servername "$CACHE_HOST" \
|
||||
</dev/null 2>&1 | grep -E '^subject=|alert' | head -n3 || true)"
|
||||
if printf '%s' "$TLS_OUT" | grep -q '^subject='; then
|
||||
printf ' %s✓%s Caddy is serving a certificate for %s\n' "$GREEN" "$RESET" "$CACHE_HOST"
|
||||
else
|
||||
warn "No certificate for $CACHE_HOST yet:"
|
||||
printf ' %s%s%s\n' "$DIM" "${TLS_OUT:-(no response)}" "$RESET"
|
||||
note " Caddy issues one automatically once the name resolves AND the vhost is deployed."
|
||||
note " If DNS is good, the Caddyfile half has not reached the box — re-run deploy-all."
|
||||
SKIPPED+=("Caddy vhost for $CACHE_HOST")
|
||||
confirm "Continue anyway?" || exit 1
|
||||
fi
|
||||
|
||||
# ── 2 ─────────────────────────────────────────────────────────────────────
|
||||
stage "Start the cache container on unom-1"
|
||||
|
||||
CODE="$(probe /nix-cache-info)"
|
||||
if [ "$CODE" = 404 ] || [ "$CODE" = 200 ]; then
|
||||
printf ' %s✓%s Container already answering (HTTP %s)\n' "$GREEN" "$RESET" "$CODE"
|
||||
else
|
||||
say "deploy-services.yml ships the compose file + Caddyfile + prune.sh and starts the"
|
||||
say "container on port $CACHE_PORT. It serves an EMPTY cache until the first publish."
|
||||
open_url "$GITEA_REPO/actions?workflow=deploy-services.yml"
|
||||
step "Run workflow -> leave the input blank -> Run."
|
||||
step "Wait for the nix-cache job to go green."
|
||||
pause "Green? Press Enter to verify"
|
||||
|
||||
CODE="$(probe /nix-cache-info)"
|
||||
case "$CODE" in
|
||||
404) printf ' %s✓%s Up and empty — 404 on every path, exactly right for an empty cache\n' "$GREEN" "$RESET" ;;
|
||||
200) printf ' %s✓%s Up and already holding content\n' "$GREEN" "$RESET" ;;
|
||||
502|503)
|
||||
warn "Caddy answered $CODE — the vhost is live but nothing is listening on :$CACHE_PORT."
|
||||
note " Check the nix-cache job, or docker compose ps on unom-1."
|
||||
SKIPPED+=("cache container on unom-1:$CACHE_PORT")
|
||||
confirm "Continue anyway?" || exit 1 ;;
|
||||
*)
|
||||
warn "Unexpected response ($CODE) from https://$CACHE_HOST/nix-cache-info"
|
||||
SKIPPED+=("cache container on unom-1:$CACHE_PORT")
|
||||
confirm "Continue anyway?" || exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 3 ─────────────────────────────────────────────────────────────────────
|
||||
stage "Signing key"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
README_MD="$REPO_ROOT/packaging/nix/README.md"
|
||||
INSTALL_MD="$REPO_ROOT/docs-site/content/docs/install.md"
|
||||
|
||||
if grep -q "$KEY_NAME:<" "$README_MD" 2>/dev/null; then
|
||||
say "The docs still carry a placeholder, so no key has been installed yet."
|
||||
say "Generating an ed25519 key pair…"
|
||||
SECRET_KEY="$(nix_key generate-secret --key-name "$KEY_NAME" 2>/dev/null || true)"
|
||||
if [ -z "$SECRET_KEY" ]; then
|
||||
warn "Could not run nix here (no local nix, and no docker to fall back to)."
|
||||
note " nix key generate-secret --key-name $KEY_NAME"
|
||||
ask_secret SECRET_KEY "Paste the secret key line:"
|
||||
fi
|
||||
|
||||
if [ -z "$SECRET_KEY" ]; then
|
||||
SKIPPED+=("NIX_CACHE_SIGNING_KEY + the public key in the docs")
|
||||
else
|
||||
PUBLIC_KEY="$(printf '%s' "$SECRET_KEY" | nix_key convert-secret-to-public 2>/dev/null || true)"
|
||||
printf '\n %sSecret key — paste into Gitea now; nothing here keeps a copy:%s\n\n' "$BOLD" "$RESET"
|
||||
printf ' %s\n\n' "$SECRET_KEY"
|
||||
open_url "$GITEA_REPO/settings/actions/secrets"
|
||||
step "Add Secret -> Name: NIX_CACHE_SIGNING_KEY"
|
||||
step "Value: the whole line above, including the '$KEY_NAME:' prefix."
|
||||
pause "Stored? Press Enter"
|
||||
WRITTEN_SECRET+=("NIX_CACHE_SIGNING_KEY (Gitea)")
|
||||
SECRET_KEY=""
|
||||
|
||||
if [ -n "$PUBLIC_KEY" ]; then
|
||||
printf '\n %sPublic key%s — what users pin:\n\n %s\n\n' "$BOLD" "$RESET" "$PUBLIC_KEY"
|
||||
for f in "$README_MD" "$INSTALL_MD"; do
|
||||
[ -f "$f" ] || continue
|
||||
tmp="$(mktemp)"
|
||||
sed "s|${KEY_NAME}:<[^>]*>|${PUBLIC_KEY}|g" "$f" > "$tmp" && mv "$tmp" "$f"
|
||||
printf ' %s✓ pinned in%s %s\n' "$GREEN" "$RESET" "${f#"$REPO_ROOT"/}"
|
||||
done
|
||||
say "Commit those two files — without the key nobody can trust the cache."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
PUBLIC_KEY="$(grep -om1 "$KEY_NAME:[A-Za-z0-9+/=]*" "$README_MD" 2>/dev/null || true)"
|
||||
printf ' %s✓%s A key is already installed and pinned in the docs\n' "$GREEN" "$RESET"
|
||||
[ -n "$PUBLIC_KEY" ] && printf ' %s\n' "$PUBLIC_KEY"
|
||||
printf '\n'
|
||||
warn "Do not regenerate it casually."
|
||||
note " A new key invalidates every signature already published, and every user pinning the"
|
||||
note " old one starts failing. Rotating means updating the docs and telling users."
|
||||
pause "Press Enter for the last stage"
|
||||
fi
|
||||
|
||||
# ── 4 ─────────────────────────────────────────────────────────────────────
|
||||
stage "Publish — land the flake on main and verify"
|
||||
|
||||
say "The publish tier runs on a push to main touching the flake, Cargo.*, or packaging/nix."
|
||||
printf '\n'
|
||||
note " It builds the whole Rust workspace AND gamescope inside the nix sandbox — sccache"
|
||||
note " cannot reach in there, so budget roughly an hour for the first run."
|
||||
note " If it reddens on 'Build the bun packages', that is the known intermittent OOM"
|
||||
note " (exit 137) rather than a real break — re-run the job."
|
||||
printf '\n'
|
||||
open_url "$GITEA_REPO/actions?workflow=nix.yml"
|
||||
step "Merge any outstanding cache PR, or push a flake-touching commit to main."
|
||||
step "Watch the nix workflow's 'Sign + publish to nix.unom.io' step."
|
||||
pause "Published? Press Enter to verify the live cache"
|
||||
|
||||
CODE="$(probe /nix-cache-info)"
|
||||
if [ "$CODE" = 200 ]; then
|
||||
printf ' %s✓%s nix-cache-info is being served\n' "$GREEN" "$RESET"
|
||||
LIVE_PUB="$(curl -sS -m 15 "https://$CACHE_HOST/punktfunk-cache.pub" 2>/dev/null || true)"
|
||||
if [ -n "$LIVE_PUB" ]; then
|
||||
printf ' %s✓%s published key: %s\n' "$GREEN" "$RESET" "$LIVE_PUB"
|
||||
if [ -n "${PUBLIC_KEY:-}" ] && [ "$LIVE_PUB" != "$PUBLIC_KEY" ]; then
|
||||
warn "That does NOT match the key pinned in the docs:"
|
||||
note " docs: ${PUBLIC_KEY}"
|
||||
note " cache: ${LIVE_PUB}"
|
||||
note " Users following the docs would reject everything this cache serves."
|
||||
SKIPPED+=("public key mismatch between the docs and $CACHE_HOST")
|
||||
fi
|
||||
fi
|
||||
# The one failure mode that breaks USERS rather than us: nix reads any non-404 as a hard
|
||||
# error, not as a cache miss, so a miss MUST 404.
|
||||
MISS="$(probe /0000000000000000000000000000000000.narinfo)"
|
||||
if [ "$MISS" = 404 ]; then
|
||||
printf ' %s✓%s a miss returns 404 — nix falls through to cache.nixos.org correctly\n' "$GREEN" "$RESET"
|
||||
else
|
||||
warn "a miss returns $MISS, not 404 — every user build would fail on any package this"
|
||||
warn "cache does not hold. Check for a proxy or auth layer in front of Caddy."
|
||||
SKIPPED+=("404-on-miss behaviour at $CACHE_HOST")
|
||||
fi
|
||||
else
|
||||
warn "https://$CACHE_HOST/nix-cache-info returned $CODE — nothing published yet."
|
||||
SKIPPED+=("first publish to $CACHE_HOST")
|
||||
fi
|
||||
|
||||
finish
|
||||
|
||||
printf ' %sUsers now add, on NixOS:%s\n\n' "$BOLD" "$RESET"
|
||||
printf ' nix.settings = {\n'
|
||||
printf ' substituters = [ "https://%s" ];\n' "$CACHE_HOST"
|
||||
printf ' trusted-public-keys = [ "%s" ];\n' "${PUBLIC_KEY:-$KEY_NAME:…}"
|
||||
printf ' };\n\n'
|
||||
note " Full instructions: packaging/nix/README.md § Binary cache"
|
||||
printf '\n'
|
||||
@@ -26,4 +26,22 @@ if not exist "%BUN%" (
|
||||
|
||||
rem The runner import()s the operator's .ts plugin files, so it runs on the bundled bun. SIGTERM (task
|
||||
rem End) interrupts the whole unit tree structurally so plugin finalizers run before exit.
|
||||
"%BUN%" "%RUNNER%"
|
||||
rem
|
||||
rem Its stdout/stderr go to a file: a scheduled task has no console, and the runner's other log door
|
||||
rem (shipping lines to the host's Logs page) needs the very connection whose failure is what you'd
|
||||
rem be trying to read about - a runner that can't reach the host was silent everywhere (field report
|
||||
rem 2026-08-18: task Running, plugins installed, "no logs at all"). plugin-state is the one dir
|
||||
rem `plugins enable` makes writable for LocalService; the file inherits Users-read from the config
|
||||
rem dir, so `type` it from any prompt. One previous run is kept as runner.log.1. If the dir isn't
|
||||
rem writable (task started before `plugins enable` ever ran), start unlogged rather than not at all.
|
||||
rem ponytail: no size cap within one run - rotate on size if a chatty plugin ever fills a disk.
|
||||
set "LOG=%ProgramData%\punktfunk\plugin-state\runner.log"
|
||||
set "LOGGED="
|
||||
if exist "%LOG%" move /y "%LOG%" "%LOG%.1" >nul 2>&1
|
||||
copy /y nul "%LOG%" >nul 2>&1 && set "LOGGED=1"
|
||||
if defined LOGGED (
|
||||
>> "%LOG%" echo [punktfunk-scripting] %DATE% %TIME% starting "%BUN%" "%RUNNER%" as %USERNAME%
|
||||
"%BUN%" "%RUNNER%" >> "%LOG%" 2>&1
|
||||
) else (
|
||||
"%BUN%" "%RUNNER%"
|
||||
)
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ Plus a real-world recipe:
|
||||
|
||||
| What | Source |
|
||||
|---|---|
|
||||
| URL | `{ url }` → `PUNKTFUNK_MGMT_URL` → `https://127.0.0.1:47990` |
|
||||
| URL | `{ url }` → `PUNKTFUNK_MGMT_URL` → `<config_dir>/mgmt-endpoint` (the URL the host actually bound, rewritten on every start — a moved `PUNKTFUNK_MGMT_BIND` is followed here) → `https://127.0.0.1:47990` |
|
||||
| Token | `{ token }` → `PUNKTFUNK_MGMT_TOKEN` → `PUNKTFUNK_PLUGIN_TOKEN` → `<config_dir>/plugin-token` → `<config_dir>/mgmt-token` |
|
||||
| TLS pin | `{ ca }` → `PUNKTFUNK_MGMT_CA` (path) → `<config_dir>/cert.pem` |
|
||||
|
||||
|
||||
+16
-1
@@ -2,7 +2,8 @@
|
||||
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
|
||||
// needs zero configuration.
|
||||
//
|
||||
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
|
||||
// PUNKTFUNK_MGMT_URL else <config_dir>/mgmt-endpoint (the URL the host actually bound,
|
||||
// rewritten on every start), else https://127.0.0.1:47990
|
||||
// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN,
|
||||
// else <config_dir>/plugin-token, else <config_dir>/mgmt-token
|
||||
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/native-cert.pem, else cert.pem when present)
|
||||
@@ -111,12 +112,26 @@ const parseTokenFile = (raw: string): string | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The mgmt URL the host published: `<config_dir>/mgmt-endpoint`, one
|
||||
* `PUNKTFUNK_MGMT_URL=https://127.0.0.1:<port>` line the host rewrites on every start with the port
|
||||
* it REALLY bound. This is how a `PUNKTFUNK_MGMT_BIND` move (the supported way to share a box with
|
||||
* Sunshine/Apollo, whose web UI owns 47990) reaches a plugin: the runner is a scheduled task /
|
||||
* systemd unit that inherits nothing from `host.env` (which on Windows it can't even read), so
|
||||
* before this a moved port left every plugin — and the runner's own log shipper — dialing
|
||||
* `127.0.0.1:47990` forever, silently. Field report 2026-08-18. `undefined` when the file is absent
|
||||
* (an old host, or a plugin CLI run on another machine); the caller falls back to the default.
|
||||
*/
|
||||
export const publishedMgmtUrl = (): string | undefined =>
|
||||
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-endpoint")) ?? "");
|
||||
|
||||
export const resolveConfig = async (
|
||||
options?: ConnectOptions,
|
||||
): Promise<ResolvedConfig> => {
|
||||
const url = (
|
||||
options?.url ??
|
||||
process.env.PUNKTFUNK_MGMT_URL ??
|
||||
publishedMgmtUrl() ??
|
||||
"https://127.0.0.1:47990"
|
||||
).replace(/\/+$/, "");
|
||||
const token =
|
||||
|
||||
+5
-4
@@ -4,10 +4,11 @@
|
||||
// `import()`s each plugin in-process, so a plugin's output is THIS process's stdout and the host's
|
||||
// `tracing` ring — the thing `GET /api/v1/logs` and the console's Logs page serve — never sees a
|
||||
// byte of it. On Linux the fallback was `journalctl --user -u punktfunk-scripting`; on Windows the
|
||||
// runner scheduled task writes no log file AT ALL, so a failing plugin could only be diagnosed by
|
||||
// stopping the task and re-running the runner by hand. Both need shell access on the host box,
|
||||
// which is the exact thing the console exists to avoid. A user hitting a plugin misconfiguration
|
||||
// therefore had no way to see the error explaining it.
|
||||
// runner scheduled task wrote no log file at all (it does now — `scripting-run.cmd` tees to
|
||||
// `%ProgramData%\punktfunk\plugin-state\runner.log`, because THIS door needs the very connection
|
||||
// whose failure you'd be reading about; field report 2026-08-18). Both need shell access on the
|
||||
// host box, which is the exact thing the console exists to avoid. A user hitting a plugin
|
||||
// misconfiguration therefore had no way to see the error explaining it.
|
||||
//
|
||||
// So: tee every console line to `POST /api/v1/plugins/logs`, which lands it in the host's ring
|
||||
// alongside the host's own lines under the target `plugin:<source>`.
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
// plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a
|
||||
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { publishedMgmtUrl } from "./config.js";
|
||||
import { installLogShipper } from "./log-ship.js";
|
||||
import {
|
||||
addPlugins,
|
||||
@@ -164,6 +165,16 @@ if (process.argv.includes("--list")) {
|
||||
// showing a bare `clock_gettime` loop and nothing else. One idle handle is the whole fix.
|
||||
const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
|
||||
|
||||
// Follow the host's REAL mgmt port before anything dials it. Plugins run in this process and
|
||||
// resolve their connection from `process.env` first, so setting it here reaches every plugin —
|
||||
// including one whose vendored `@punktfunk/host` predates `publishedMgmtUrl` (on Windows
|
||||
// `reconcileSharedSdk` cannot refresh a read-only tree, so an old copy can outlive several host
|
||||
// upgrades). An explicit PUNKTFUNK_MGMT_URL from the operator still wins.
|
||||
if (!process.env.PUNKTFUNK_MGMT_URL) {
|
||||
const published = publishedMgmtUrl();
|
||||
if (published) process.env.PUNKTFUNK_MGMT_URL = published;
|
||||
}
|
||||
|
||||
// Tee this process's output to the host so the console's Logs page can show it. Installed HERE and
|
||||
// not in `runner.ts`, so it covers the supervised run only: a plugin's own CLI builds the same
|
||||
// layer graph, and an operator running `punktfunk-plugin-x doctor` in their terminal is not asking
|
||||
|
||||
+60
-1
@@ -1,8 +1,15 @@
|
||||
// Connection/config resolution helpers. `pluginStateDir` is the writable location a supervised
|
||||
// plugin persists into — the one dir the de-privileged Windows runner may write.
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { pluginIngestDir, pluginStateDir } from "../src/config.js";
|
||||
import {
|
||||
pluginIngestDir,
|
||||
pluginStateDir,
|
||||
publishedMgmtUrl,
|
||||
resolveConfig,
|
||||
} from "../src/config.js";
|
||||
|
||||
describe("pluginStateDir", () => {
|
||||
let saved: string | undefined;
|
||||
@@ -48,3 +55,55 @@ describe("pluginIngestDir", () => {
|
||||
expect(pluginIngestDir("playnite")).not.toBe(pluginStateDir("playnite"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishedMgmtUrl / resolveConfig url", () => {
|
||||
let saved: Record<string, string | undefined>;
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
saved = {
|
||||
PUNKTFUNK_CONFIG_DIR: process.env.PUNKTFUNK_CONFIG_DIR,
|
||||
PUNKTFUNK_MGMT_URL: process.env.PUNKTFUNK_MGMT_URL,
|
||||
PUNKTFUNK_MGMT_TOKEN: process.env.PUNKTFUNK_MGMT_TOKEN,
|
||||
};
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-endpoint-"));
|
||||
process.env.PUNKTFUNK_CONFIG_DIR = dir;
|
||||
delete process.env.PUNKTFUNK_MGMT_URL;
|
||||
process.env.PUNKTFUNK_MGMT_TOKEN = "t"; // resolveConfig needs SOME token
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("absent file → undefined, and resolveConfig keeps the 47990 default", async () => {
|
||||
expect(publishedMgmtUrl()).toBeUndefined();
|
||||
expect((await resolveConfig()).url).toBe("https://127.0.0.1:47990");
|
||||
});
|
||||
|
||||
test("the host's mgmt-endpoint line is followed — a moved port reaches every plugin", async () => {
|
||||
// exactly what `mgmt::endpoint_line` writes (KEY=VALUE, one line)
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "mgmt-endpoint"),
|
||||
"PUNKTFUNK_MGMT_URL=https://127.0.0.1:47995\n",
|
||||
);
|
||||
expect(publishedMgmtUrl()).toBe("https://127.0.0.1:47995");
|
||||
expect((await resolveConfig()).url).toBe("https://127.0.0.1:47995");
|
||||
});
|
||||
|
||||
test("an explicit PUNKTFUNK_MGMT_URL still wins over the published file", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "mgmt-endpoint"),
|
||||
"PUNKTFUNK_MGMT_URL=https://127.0.0.1:47995\n",
|
||||
);
|
||||
process.env.PUNKTFUNK_MGMT_URL = "https://127.0.0.1:50000/";
|
||||
expect((await resolveConfig()).url).toBe("https://127.0.0.1:50000");
|
||||
});
|
||||
|
||||
test("a blank file reads as unset, not as an empty URL", () => {
|
||||
fs.writeFileSync(path.join(dir, "mgmt-endpoint"), "\n");
|
||||
expect(publishedMgmtUrl()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@
|
||||
"logs_export_all": "Alles exportieren",
|
||||
"logs_export_all_working": "Wird gesammelt…",
|
||||
"logs_export_all_failed": "Export konnte nicht erstellt werden",
|
||||
"logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`.",
|
||||
"logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`. Läuft er und bleibt trotzdem stumm, erreicht er den Host nicht; warum, steht im eigenen Log des Runners: `journalctl --user -u punktfunk-scripting` unter Linux, `%ProgramData%\\punktfunk\\plugin-state\\runner.log` unter Windows.",
|
||||
"logs_follow": "Folgen",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Leeren",
|
||||
|
||||
@@ -447,7 +447,7 @@
|
||||
"logs_export_all": "Export all",
|
||||
"logs_export_all_working": "Collecting…",
|
||||
"logs_export_all_failed": "Couldn't assemble the export",
|
||||
"logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`.",
|
||||
"logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`. If it is running and still silent, it can't reach the host; the runner's own log says why: `journalctl --user -u punktfunk-scripting` on Linux, `%ProgramData%\\punktfunk\\plugin-state\\runner.log` on Windows.",
|
||||
"logs_follow": "Follow",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Clear",
|
||||
|
||||
Reference in New Issue
Block a user