Compare commits

...
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 59ef285f08 process: the PR template asks whether a user-facing fact changed, and the release flow gets a docs-freshness step
ci / bun-nix (pull_request) Successful in 25s
ci / docs-drift (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m32s
ci / web (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 2m9s
installer-smoke / smoke (arch) (pull_request) Successful in 32s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m26s
installer-smoke / smoke (debian-13) (pull_request) Successful in 2m47s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 2m8s
android / android (pull_request) Successful in 8m1s
ci / rust (pull_request) Successful in 8m18s
WP5 of the docs-and-onboarding overhaul, the two riders the RFC attaches to WP2–WP4:

- .gitea/PULL_REQUEST_TEMPLATE.md — one question: did a user-facing fact change, and is the
  docs-site page that owns it updated in this PR (install/repo/port facts in data/platforms.json).
  CI's docs-drift only catches the mechanical half; this is the reminder for the rest.
- docs/releases/README.md step 1 — while the release diff is in front of you, check docs freshness,
  and if platforms.json changed, run `bun run sync-platforms` in punktfunk-website and commit,
  because the download page vendors that file and only refreshes when someone does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:01:00 +02:00
enricobuehler 5bec450402 install.sh: probe /dev/tty by opening it — a container has the node but no controlling terminal, so -r/-w said yes and the redirect failed (first installer-smoke run); name the matrix jobs by family
ci / bun-nix (pull_request) Successful in 41s
ci / docs-drift (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m49s
ci / rust-arm64 (pull_request) Successful in 1m54s
installer-smoke / smoke (arch) (pull_request) Successful in 49s
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (fedora-44) (pull_request) Successful in 1m22s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m45s
android / android (pull_request) Canceled after 3m55s
ci / rust (pull_request) Canceled after 3m56s
2026-08-19 20:57:02 +02:00
enricobuehlerandClaude Fable 5 cd6ce34892 install.sh: a guided Linux installer (preview) that runs exactly the commands platforms.json states, with a CI smoke test per package family
ci / bun-nix (pull_request) Successful in 35s
ci / docs-drift (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m21s
installer-smoke / smoke (arch, archlinux:base, pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)) (pull_request) Failing after 23s
installer-smoke / smoke (fedora-44, fedora:44, dnf install -y -q curl git nodejs) (pull_request) Failing after 0s
ci / web (pull_request) Successful in 1m27s
ci / rust-arm64 (pull_request) Successful in 1m31s
installer-smoke / smoke (debian-13, debian:trixie, apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs) (pull_request) Failing after 21s
apple / swift (pull_request) Successful in 2m19s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 2m37s
ci / rust (pull_request) Canceled after 2m38s
WP4 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md).

scripts/install.sh — plain POSIX sh (dash-clean), ~380 lines, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`:
detect the distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows
and unknown distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora
45 hit the documented floors with the right docs link) → install with the platforms.json lines
VERBATIM (channel and the Fedora group are edited into the string at run time; `--yes` rewrites
them non-interactive, a tty hands the package manager its own prompt; stdin is never read, because
under `curl | sh` stdin is the script) → `punktfunk-host detect-conflicts` (exit 1 = active
Sunshine-family host) → offer to keep both by moving the management API port (PUNKTFUNK_MGMT_BIND,
default 47991, the firewall step opens it) → input group (ujust on Bazzite; no-op if already in) →
optional punktfunk group, GameStream compat, shared clipboard (all default no) → firewalld/ufw
profiles → enable host + console (+ the plugin runner where it isn't) → optional linger → verify
(unit active, UDP 9777 bound) and print the console URL, the password command and the pairing
steps. `--dry-run` prints every command and changes nothing; every prompt has a PUNKTFUNK_INSTALL_*
environment twin; re-running is safe (install skipped when the binary exists). Running under sudo
is refused (host.env and the units belong to the user); root without sudo gets a shim so the
verbatim lines still work.

Decisions: the canonical URL is punktfunk.unom.io/install.sh, a 302 on the website to the script at
raw/branch/main (versioned with the code it installs; precedent: the Bazzite sysext bootstrap) —
the website half is punktfunk-website PR #4. GPU drivers stay the docs pages' job; the one silent
failure (Fedora + NVIDIA without RPM Fusion's ffmpeg-libs → no NVENC) is called out at the end.

Gates: check-docs-drift.sh gate 6 — every apt/pacman/dnf/sysext install line in data/platforms.json
must appear verbatim in the script, and the script must parse (shown to fail on a planted drift).
New path-filtered workflow installer-smoke.yml runs the script unattended in debian:trixie,
fedora:44 and archlinux:base against the real registry, then `punktfunk-host --version`,
`detect-conflicts`, and a re-run that must say "already installed".

Docs: install hub gains "Guided install (preview)" rendered from platforms.json's new `installer`
block via an <Installer/> component (one-liner + inspect-first form + flags); CONTRIBUTING names
the new gate. Verified locally: sh/dash -n, both docs gates, docs-site build + lint, and a
--dry-run matrix over 16 faked os-release files (all four families, every floor, canary, every
option, piped stdin). The container run itself is the CI job's to report — Docker on this machine
was wedged under another session's emulated build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:54:13 +02:00
enricobuehler 2a60f94f74 Merge pull request 'Land the WP2 docs rewrite on main (#340 merged into the already-merged #337 branch) and make docs-drift green again' (#343) from docs-wp2-to-main into main
apple / swift (push) Successful in 2m8s
ci / rust-arm64 (push) Successful in 2m29s
ci / docs-site (push) Successful in 1m24s
ci / web (push) Successful in 1m33s
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 26s
deb / build-publish-gamescope (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 1m42s
decky / build-publish (push) Successful in 38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
arch / build-publish (push) Successful in 10m27s
deb / build-publish (push) Successful in 5m7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
android / android (push) Successful in 11m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
deb / build-publish-host (push) Successful in 5m46s
apple / distribute (push) Successful in 11m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / deploy-docs (push) Successful in 55s
docker / builders-arm64cross (push) Successful in 13s
ci / rust (push) Successful in 22m5s
apple / screenshots (push) Successful in 9m53s
deb / smoke-install (push) Successful in 7m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m7s
2026-08-19 18:23:48 +00:00
enricobuehler 60406c9d72 Merge origin/main into the docs WP2 branch — bazzite.md was rewritten as bazzite.mdx here and re-touched by #342 there; #342's fact (the Gaming Mode takeover no longer needs the punktfunk group) is ported into bazzite.mdx and the four distro pages, and the Apple client's PUNKTFUNK_CORE_LOG_LEVEL (from #338) is baselined so docs-drift is green on main again.
ci / web (pull_request) Successful in 2m8s
apple / swift (pull_request) Successful in 2m18s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 36s
ci / rust-arm64 (pull_request) Successful in 2m57s
ci / docs-site (pull_request) Successful in 1m43s
android / android (pull_request) Successful in 6m54s
ci / rust (pull_request) Successful in 8m37s
2026-08-19 20:21:44 +02:00
enricobuehler 65f697651e Merge pull request 'The bun OOM is a 7 GiB cgroup cap, not a big build — measure it in the log' (#341) from nix-serialise-bun-builds into main
ci / docs-drift (push) Failing after 28s
ci / bun-nix (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / docs-site (push) Successful in 1m9s
ci / web (push) Successful in 1m18s
ci / rust-arm64 (push) Successful in 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / builders-arm64cross (push) Successful in 39s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
docker / deploy-docs (push) Successful in 1m15s
ci / rust (push) Canceled after 5m30s
nix / flake (push) Failing after 15m16s
Reviewed-on: #341
2026-08-19 18:18:28 +00:00
enricobuehler a00c4d2a6a Merge pull request 'Docs for two audiences: a get-started track that fits on one screen, a Switching-from-Sunshine page, and install commands quoted from platforms.json' (#340) from worktree-docs-wp2-rewrite into worktree-docs-wp0-wp1-anti-drift
Reviewed-on: #340
2026-08-19 18:16:28 +00:00
enricobuehler e12ef6633c Merge pull request 'A stopped display manager left the box unable to switch sessions — idle its autologin instead' (#342) from worktree-kwin-vout-failed-permanent into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Failing after 26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish-gamescope (push) Successful in 33s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 1m19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 47s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
deb / build-publish-client-arm64 (push) Successful in 2m37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
deb / build-publish-host (push) Successful in 7m10s
android / android (push) Successful in 9m56s
ci / rust-arm64 (push) Successful in 3m39s
arch / build-publish (push) Successful in 9m45s
ci / web (push) Successful in 1m45s
windows-host / package (push) Successful in 13m41s
windows-host / winget-source (push) Skipped
docker / builders-arm64cross (push) Successful in 17s
docker / deploy-docs (push) Successful in 43s
windows-host / canary-manifest (push) Successful in 30s
ci / rust (push) Failing after 11m34s
deb / build-publish (push) Successful in 5m49s
deb / smoke-install (push) Successful in 4m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m25s
2026-08-19 17:40:07 +00:00
enricobuehler 38a0f54b09 refactor(gamescope): delete the display-manager stop chain the idled takeover retired
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 6m52s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 9m30s
`cargo check` on Linux flagged six functions the previous commit orphaned. The
linger machinery existed for one reason — stopping the display manager ends the
user's last login session, and logind would take the host down with it about 10 s
later — and nothing stops a display manager any more, so the whole chain goes:
`try_stop_display_manager`, `ensure_host_survives_dm_stop`,
`host_is_under_user_manager`, `cgroup_under_user_manager`, `linger_enabled`, and
the one test that covered only the cgroup predicate. 142 lines out, 17 in.

`mask_unit` becomes `#[cfg(test)]` rather than going with them. Its only caller
now is the mask-lift test, and that test still guards live code:
`lift_autologin_mask` cleans up a takeover adopted from a host old enough to have
laid a mask, so the state it undoes has to stay constructible.

`pf-dm-helper` keeps its `stop` and `linger` verbs even though no caller is left —
only `restore` is still reached — because removing them changes the shipped polkit
action's surface, which is a packaging decision rather than a cleanup.
2026-08-19 19:38:57 +02:00
enricobuehler c420ae4676 Merge pull request 'The Apple client can send its logs to the host — and finally keeps a log worth sending' (#338) from worktree-apple-send-logs into main
audit / bun-audit (sdk) (push) Successful in 23s
audit / bun-audit (plugin-kit) (push) Successful in 30s
audit / bun-audit (web) (push) Successful in 29s
audit / docs-site-audit (push) Successful in 21s
audit / pnpm-audit (push) Successful in 21s
audit / cargo-audit (push) Failing after 57s
apple / swift (push) Successful in 2m13s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
android / android (push) Canceled after 4m34s
arch / build-publish (push) Canceled after 4m40s
ci / rust (push) Canceled after 3m47s
ci / rust-arm64 (push) Canceled after 38s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 15s
deb / build-publish-host (push) Canceled after 10s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 13s
audit / license-gate (push) Successful in 5m3s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 28s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / c-abi-asan (push) Successful in 5m35s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m11s
audit / miri (push) Successful in 11m8s
apple / distribute (push) Successful in 11m13s
flatpak / build-publish (push) Successful in 6m6s
apple / screenshots (push) Successful in 9m46s
nix / flake (push) Failing after 23m42s
2026-08-19 17:35:44 +00:00
enricobuehlerandClaude Fable 5 bfd0de8973 docs: two audiences, one home per fact — get-started rewrite, a Switching-from-Sunshine page, and install commands quoted from platforms.json
ci / rust-arm64 (pull_request) Successful in 1m30s
apple / swift (pull_request) Successful in 1m59s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m5s
ci / bun-nix (pull_request) Successful in 46s
ci / docs-site (pull_request) Successful in 1m25s
ci / docs-drift (pull_request) Successful in 46s
android / android (pull_request) Successful in 8m17s
ci / rust (pull_request) Successful in 22m6s
WP2 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md),
stacked on #337 (WP0+WP1).

Nav (meta.json) is now Get started / Guides / Switching from Sunshine / Reference / Troubleshooting.

Get-started track, rewritten for someone with no Linux expertise — one task per page, happy path,
under two minutes each: quickstart (5 steps), install (a pick-your-system hub), ubuntu, debian,
fedora, arch, bazzite (+ a firewall step it was missing), a new nixos page (moved out of install),
pairing. Every distro page quotes its install snippet through a new <Install platform="…"/> MDX
component that reads docs-site/src/data/platforms.json — a byte-identical snapshot of
data/platforms.json (the Docker build context is docs-site/ alone, same arrangement as
openapi.json) which scripts/ci/check-docs-drift.sh now gates. <Ports/> renders the port table the
same way on a new reference page, ports.mdx, so the four distro pages stop restating port lists.

platforms.json itself was fixed while wiring it up: the apt snippet lacked the keyring lines, the
winget one lacked `winget source add`, the Arch repo-add is now idempotent (grep guard — the
documented double-append gotcha disappears at the source), names match the real floors (Ubuntu
26.04+, Windows 11 22H2+), a web-console port entry and the firewall profile names were added, and
client platforms (Windows MSIX, macOS, TestFlight, Play, Steam Deck) so the website can render its
cards from the same file.

Dense material moved down rather than deleted: build-from-source.md (the three distro appendices),
the Mint/LMDE/Cinnamon analysis → requirements.md, TOFU/`--open` → security.md, Secure Boot MOK
paths and the Arch libavcodec soname refusal → troubleshooting, sysext channels/rollback/rebase →
updating.md, the 0.28.1 cert note → windows-host.

New page switching-from-sunshine.md: coexistence via the 47990 move (PUNKTFUNK_MGMT_BIND), the
Windows display-topology overlap, detect-conflicts exit semantics (1 only for an active host — the
troubleshooting page said "non-zero if any"), what maps to what, migration steps. The
troubleshooting Sunshine section is tightened and points there.

The three facts whose only home was a README now live in troubleshooting — the ffmpeg-libs weak
dependency (no NVENC on Fedora), the pacman double-append error, the ds_inhibit SELinux storm with
DualSense pads on Bazzite — and packaging/bazzite + packaging/arch READMEs are pointers.

Stale claims corrected against the tree: the packaged unit runs the native-only `serve` and
GameStream is opt-in on every route (kde, hyprland, security, steamos-host said otherwise);
host.env is optional (EnvironmentFile=-); Fedora 43 uses the `bazzite` group; the iOS clipboard
bridge exists; PUNKTFUNK_GAMESCOPE_SDR_NITS defaults to 203.

Guides trimmed for verbosity (~5% by words — they are fact-dense; every heading, command, number,
env var and link target is preserved, verified by token-inventory diffs). Tooling: docs-site README
and CONTRIBUTING describe the platforms snapshot; input/uninstall/support-matrix links retargeted.

Verified: check-docs-drift.sh and check-docs-links.sh green, docs-site `bun run build` + `bun run
lint` green, and the built site served locally to confirm every cross-page anchor the rewrite
links to (37) renders, plus the <Install/> blocks and the <Ports/> table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:08:38 +02:00
enricobuehlerandClaude Fable 5 1dd5df0127 ci: the drift gate matched its own comment, and the OpenAPI snapshot was two versions stale
Two reds on #337's first CI run, both self-inflicted:

- Gate 3 greps every quoted "PUNKTFUNK_*" literal outside docs-site — including the gate's own
  explanatory comment, which spelled out ("PUNKTFUNK_X") as an example and so reported PUNKTFUNK_X
  as a new undocumented knob. Reworded the comment; the regex is unchanged.
- The `rust` job's regenerate-and-diff found `api/openapi.json` still saying `"version": "0.29.0"`
  while the crate is 0.31.0 — the only difference in the whole spec. Regenerated with
  `cargo run -p punktfunk-host -- openapi` (in the punktfunk-rust-ci image) and copied the
  docs-site snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:07:56 +02:00
enricobuehler 08c45b96eb fix(nix): measure the cgroup cap instead of guessing at the bun OOM
ci / web (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 2m11s
ci / docs-drift (pull_request) Failing after 23s
ci / docs-site (pull_request) Successful in 1m42s
ci / bun-nix (pull_request) Successful in 1m55s
ci / rust (pull_request) Failing after 7m38s
nix / flake (pull_request) Failing after 14m44s
Reverts the --max-jobs 1 from the previous commit. The hypothesis it encoded —
that punktfunk-web and punktfunk-scripting bundling concurrently exhausted the
box — is DISPROVEN: run 19444 failed identically with it, and the Environment
step it added shows why the premise was wrong:

    Mem: 125Gi total, 48Gi available     Swap: 8.0Gi     nproc: 16
    overlay 172G, 108G avail

Nothing was scarce. Serialising also would have cost real wall-clock on the
publish tier, which builds six derivations including an hour of Rust, so it does
not stay on an unproven basis.

Also ruled out: bun is 1.3.13 in BOTH the old and new nixpkgs, so the lock bump
did not change the bundler.

What remains is what those numbers cannot see. Inside a container /proc/meminfo
and free(1) report the HOST, so a cgroup memory cap is invisible to them — and a
cap is the only explanation left for a SIGKILL with 48Gi apparently free. So
read it directly: cgroup v2 memory.max, falling back to v1
memory.limit_in_bytes.

No fix in this commit, deliberately. Two guesses have already cost a day of red
runs; the next change should follow the number.
2026-08-19 17:06:24 +00:00
enricobuehler a52d60e242 test(core): the log-sink collector asserts nothing itself — an extern "C" fn must not panic (hygiene gate B)
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 2m3s
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m5s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m36s
android / android (pull_request) Successful in 9m4s
ci / rust (pull_request) Successful in 24m47s
nix / flake (pull_request) Successful in 15m51s
2026-08-19 19:02:21 +02:00
enricobuehler c2f5e91b3d fix(gamescope): a stopped display manager left the box unable to switch sessions — idle its autologin instead
The managed takeover freed Steam by stopping the box's gaming session and,
because a display-manager autologin puts that session straight back, by stopping
the display manager too. That worked until the user asked for a desktop session:
with no DM there is nothing on the box able to start one, so Steam's own "Switch
to Desktop" sat on its modal until a reboot (field report 2026-08-18, .41).

It also could not be detected and worked around. On a steamos-manager box the
switch is a D-Bus call whose every trace is written by the display manager we had
just stopped — measured on .41: the `~/.config/steamos-session-select` sentinel is
never written (that is the ChimeraOS/Nobara layout), `/var/lib/sddm/state.conf`
only advances when sddm actually STARTS a session, `get-default-login-mode` stays
`game` for a non-persistent switch, and `graphical-session.target` going inactive
fires at takeover time too. There is nothing to watch, because the component that
would produce the signal is the one we disabled.

So stop disabling it. The takeover now idles the box's autologin session for the
stream's duration — a drop-in over the `gamescope-session-plus@` template that
replaces `ExecStart` with a process that sleeps — and leaves the DM running. The
autologin still SUCCEEDS, so there is no failed unit to relogin against (a masked
one fails in milliseconds, which is the storm's engine); the session runs nothing,
so Steam is free; and the DM is alive, so the box can service the switch itself.
No privilege, no DM-flavor matrix, and no detection.

Measured on .41, both directions: takeover leaves `steam` down, `sddm` active and
the unit `active (running)` with NRestarts=0; the switch that used to hang brings
Plasma up in ~10 s; the restore puts Steam back within 5 s.

The drop-in lives under $XDG_RUNTIME_DIR for the same reason the bind one does —
it replaces the box's game-mode ExecStart, so a copy outliving the host would be a
box whose Game Mode silently does nothing — and is swept unconditionally at
startup. Its removal sits above every early return in the restore, next to the
bind drop-in's, because the desktop-active return is exactly the path that would
leak it. The restore restarts rather than starts: the unit is active-but-idle, and
`start` on an active unit is a no-op that would log success over it.

Docs and the shipped Bazzite host.env said the takeover has to stop the display
manager and needs the `punktfunk` group; both are now false. The group still gates
the usbip nodes the virtual Steam Deck pad attaches through, which is what the
advice narrows to.
2026-08-19 18:58:18 +02:00
enricobuehler 44cb7f7815 test(core): keep the SAFETY comment on the line before its unsafe block (rustfmt had split them)
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m54s
ci / rust-arm64 (pull_request) Successful in 3m43s
ci / web (pull_request) Successful in 3m20s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust (pull_request) Failing after 6m0s
ci / bun-nix (pull_request) Successful in 2m24s
android / android (pull_request) Canceled after 8m49s
nix / flake (pull_request) Canceled after 3m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 6m9s
2026-08-19 18:53:20 +02:00
enricobuehler 52b89a1592 style(core): rustfmt the log-sink additions
apple / swift (pull_request) Successful in 2m6s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m51s
ci / rust-arm64 (pull_request) Failing after 2m19s
ci / rust (pull_request) Failing after 4m2s
ci / docs-site (pull_request) Successful in 1m22s
android / android (pull_request) Canceled after 4m58s
ci / web (pull_request) Canceled after 2m11s
ci / bun-nix (pull_request) Canceled after 40s
nix / flake (pull_request) Canceled after 27s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 2m28s
2026-08-19 18:47:47 +02:00
enricobuehler 57446f9ed9 feat(core): ABI v25 — punktfunk_set_log_callback, so the Apple client hears the core's log lines
ci / rust (pull_request) Failing after 5m3s
android / android (pull_request) Canceled after 6m35s
apple / swift (pull_request) Canceled after 1m41s
apple / distribute (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 5m46s
ci / web (pull_request) Canceled after 1m39s
ci / docs-site (pull_request) Canceled after 1m29s
ci / bun-nix (pull_request) Canceled after 0s
nix / flake (pull_request) Canceled after 2s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 40s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
The core logs through tracing; the desktop and Android shells install a subscriber/logger and
see those lines, the Swift client installed none and saw nothing — every transport warning,
quinn connection event and rustls handshake note vanished, and a client log bundle sent to the
host carried the shell's half only.

- punktfunk_set_log_callback(max_level, cb, user): a log::Log backend behind a C callback
  (level, target, message, user), level-gated by log::set_max_level so anything above the
  ceiling costs no formatting; NULL detaches; Unsupported when another log backend already owns
  the process (android_logger). tracing's `log` feature declared explicitly by the core — it was
  on transitively via quinn, which an ABI promise must not rest on. ABI_VERSION 24 → 25, header
  regenerated, embedding doc §2.6.
- Apple: CoreLog.install() at app start routes the lines into ClientLog under core.<crate>
  (os_log + the send-to-host ring), info ceiling by default (PUNKTFUNK_CORE_LOG_LEVEL raises it).
- Test: a log record and a tracing event both reach the callback with level/target/message,
  interior NUL dropped not truncated, ceiling honoured, detach silences.
2026-08-19 18:33:59 +02:00
enricobuehler a4f6e259e3 fix(nix): serialise the bun builds — punktfunk-web is OOM-killed beside scripting
The nix job's recurring red is not a broken bundle, it is memory:

    punktfunk-web> error: script "build" was terminated by signal SIGKILL (Forced quit)
    Reason: builder failed with exit code 137

MEASURED 2026-08-19: five runs across main and two branches died exactly there,
while other runs on the SAME commits passed — the signature of pressure, not of
a defect. nix builds punktfunk-web and punktfunk-scripting concurrently, and
each one runs a memory-hungry bundler; that pairing is what the runner cannot
afford.

`--max-jobs 1` caps how many DERIVATIONS build at once, not the cores inside
one, so two bundles that each pin a core simply run in sequence. Applied to the
publish tier too, which builds six derivations including those same two.

This step sits BEFORE the publish tier, so every one of those failures also
stopped the binary cache from filling — it has blocked more runs today than
every real bug combined.

Also prints memory in the Environment step. An exit 137 cannot be explained by
the disk numbers that step used to gather on their own.
2026-08-19 16:30:41 +00:00
enricobuehler 6d82716598 Merge pull request 'Android "Send logs to host": the logcat-teed ring uploads over the client's own mTLS' (#339) from worktree-console-ui-sendlogs-android into main
ci / rust-arm64 (push) Successful in 2m3s
ci / web (push) Successful in 2m23s
ci / docs-site (push) Successful in 2m31s
ci / bun-nix (push) Successful in 23s
ci / docs-drift (push) Failing after 25s
deb / build-publish-gamescope (push) Successful in 50s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
ci / rust (push) Failing after 7m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m27s
deb / build-publish-host (push) Successful in 5m2s
arch / build-publish (push) Successful in 11m31s
deb / build-publish (push) Successful in 5m53s
android / android (push) Successful in 13m17s
flatpak / build-publish (push) Successful in 7m7s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m15s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 48s
deb / smoke-install (push) Successful in 3m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m57s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m9s
Reviewed-on: #339
2026-08-19 16:28:27 +00:00
enricobuehler f584eebb92 feat(android): "Send logs to host" works from the console — the ring, teed from logcat, uploads over the client's own mTLS
ci / bun-nix (pull_request) Successful in 40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m36s
ci / web (pull_request) Successful in 1m38s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 3m54s
ci / rust (pull_request) Successful in 6m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m40s
android / android (pull_request) Successful in 9m5s
WP5b of punktfunk-planning design/console-ui-sweep-2026-08-19.md (the last open item):

- pf-client-core: the logring's RING half (note/render/wallclock — std only) is
  Android-enabled; `send_to_host` stays desktop-gated with the rest of the ureq
  fetches. `wallclock` moves in from the session's ring_layer so every ring feeder
  stamps lines the same way.
- Android native: JNI_OnLoad installs a RingTee — every `log` record goes to logcat
  AND into the ring, in the desktop ring_layer's line shape. `nativeRenderLogs(header)`
  hands Kotlin the rendered bundle.
- Kotlin: `SkiaConsole.sendLogs` replaces the not-available stub — renders the ring
  and POSTs it to /api/v1/client-logs over `mtlsHttpClient` (the library/art path),
  noticing the desktop wording on success/failure. The upload deliberately stays on
  the Kotlin side: OkHttp already owns HTTPS-to-the-pinned-host on this platform,
  and pulling ureq+rustls into the .so for one POST would be a dependency change,
  not a feature.
- console-ui: the host menu's "Send logs" desktop-only gate is gone — paired and
  reachable is the whole condition again; the pinning test flips to assert both
  platforms offer it.
2026-08-19 18:26:13 +02:00
enricobuehler cd0a370229 Merge pull request 'One home per fact: IA rules, docs drift gates in CI, and the READMEs stop restating the docs' (#337) from worktree-docs-wp0-wp1-anti-drift into main
ci / bun-nix (push) Successful in 58s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m34s
ci / docs-drift (push) Failing after 43s
apple / swift (push) Successful in 2m11s
ci / rust-arm64 (push) Successful in 2m8s
deb / build-publish-gamescope (push) Successful in 56s
decky / build-publish (push) Successful in 51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 2m33s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / deploy-docs (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
deb / build-publish (push) Successful in 4m21s
android / android (push) Canceled after 6m11s
arch / build-publish (push) Canceled after 5m59s
ci / rust (push) Canceled after 4m32s
deb / build-publish-host (push) Canceled after 4m19s
deb / smoke-install (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 44s
flatpak / build-publish (push) Canceled after 26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 16s
apple / distribute (push) Successful in 11m33s
apple / screenshots (push) Successful in 10m3s
Reviewed-on: #337
2026-08-19 16:22:04 +00:00
enricobuehler 3301f5aa60 feat(clients/apple): Send logs to host — a bounded in-app log ring, posted to the paired host from the card menu and the gamepad host options
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / docs-site (pull_request) Successful in 1m26s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 5m20s
The Gaming Mode console got "Send logs to host" in 0.30 (pf_client_core::logring +
POST /api/v1/client-logs); the Apple client had no ring to send and no POST on its mgmt
transport, and its own session lifecycle was never logged at all — a field report from an
Apple TV carried only the host's half.

- ClientLog: drop-in for Logger(subsystem: "io.unom.punktfunk", category:) — same call
  shape (privacy:/format: interpolations accepted), writes os_log AND a process-global ring
  bounded by 4096 lines / 768 KiB (under the host's 1 MiB cap), stamped with wall-clock
  ISO-8601 so a bundle lines up with the host log. .debug stays out of the ring (per-key
  input chatter would flush it — the Steam Deck DPB lesson). 13 Logger declarations swapped.
- MgmtTransport/MgmtConnection: POST with a length-framed body on the same pooled, pinned
  mTLS connection; LibraryClient.sendLogs posts the ring and returns the bundle id.
- SendLogs.toHost (app): identity + pinned fingerprint required, same gates as the library.
- UI: "Send Logs to Host" in the host card's context menu (paired hosts) with an alert for
  the outcome; a "Send logs to host" row on the gamepad host options screen whose label and
  detail band report Sending…/Logs sent/Couldn't send in place, like Copy link.
- SessionModel logs connect asked/landed/refused, disconnect and session-ended reason.
- ClientLogTests: ring bounds + eviction note + truncation, logger format, POST framing.
2026-08-19 18:17:59 +02:00
enricobuehler 1f0b12de6d Merge pull request 'The gamescope prune can't delete reshade — it installs under read-only directories' (#336) from gamescope-prune-writable into main
ci / bun-nix (push) Successful in 1m24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
ci / docs-site (push) Successful in 1m51s
ci / web (push) Successful in 1m58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
ci / rust-arm64 (push) Successful in 2m18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 55s
ci / rust (push) Successful in 6m43s
nix / flake (push) Failing after 13m24s
Reviewed-on: #336
2026-08-19 14:42:29 +00:00
enricobuehler 3def50a88a Merge pull request '"Capture system shortcuts" on Mac reaches ⌘Space and ⌘Tab, with Accessibility' (#335) from worktree-apple-system-shortcut-tap into main
ci / bun-nix (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
ci / web (push) Successful in 1m13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
ci / docs-site (push) Successful in 1m43s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
ci / rust (push) Canceled after 1m55s
ci / rust-arm64 (push) Canceled after 2m6s
apple / swift (push) Successful in 2m16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 28s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 26s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / distribute (push) Successful in 11m55s
apple / screenshots (push) Successful in 9m58s
Reviewed-on: #335
2026-08-19 14:40:15 +00:00
enricobuehlerandClaude Opus 5 8f9e451395 docs: one home per fact — IA rules, CI drift gates, and READMEs stop restating the docs
ci / web (pull_request) Successful in 1m3s
ci / bun-nix (pull_request) Successful in 41s
ci / rust-arm64 (pull_request) Successful in 1m58s
ci / docs-drift (pull_request) Failing after 47s
ci / docs-site (pull_request) Successful in 1m48s
android / android (pull_request) Successful in 7m26s
ci / rust (pull_request) Failing after 8m7s
apple / swift (pull_request) Successful in 2m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
WP0+WP1 of the docs-and-onboarding overhaul (punktfunk-planning
design/docs-and-onboarding-overhaul.md):

- CONTRIBUTING.md + docs-site/README.md state the ownership rule (docs-site
  owns user-facing facts; READMEs keep rationale + pointers; the website
  deep-links) and the get-started vs reference audience split.
- ci.yml gains a docs-drift job (scripts/ci/check-docs-drift.sh +
  check-docs-links.sh): the OpenAPI snapshot must be a byte copy of
  api/openapi.json, PUNKTFUNK_* vars the docs mention must exist in the tree,
  the undocumented-var set is ratcheted against a named baseline file,
  host-cli.md commands must exist in the host crate, data/platforms.json must
  parse, and internal doc/README links must resolve. The rust job additionally
  regenerates the spec from the built host and diffs it — closing the 'not
  gated' hole CONTRIBUTING itself confessed to. All seven gates were shown to
  FAIL on planted violations before landing (the gate-of-the-gate rule).
- data/platforms.json: single source for install commands, repo URLs, ports
  and the Sunshine/Apollo/Vibeshine conflict facts — the docs rewrite (WP2),
  website download page (WP3) and guided installer (WP4) consume it.
- READMEs (root, bazzite, debian, arch, rpm, flatpak) stop restating docs
  walkthroughs (net -444 lines); facts whose only home a README was (the
  ds_inhibit SELinux storm, the ffmpeg-libs weak dep, the pacman.conf
  double-append) stay put, marked for the WP2 migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:39:39 +02:00
enricobuehler 0d22333831 Merge pull request 'Console-UI sweep: touch gestures, controller-audio rows, host-menu bind/clipboard, Android idle gates' (#333) from worktree-console-ui-sweep-wps into main
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 2m18s
deb / build-publish-gamescope (push) Successful in 47s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m12s
deb / build-publish-client-arm64 (push) Successful in 1m52s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 25s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
deb / build-publish (push) Successful in 4m29s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 34s
ci / rust (push) Successful in 6m39s
deb / build-publish-host (push) Successful in 5m54s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 35s
arch / build-publish (push) Successful in 10m24s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 6m42s
android / android (push) Successful in 12m1s
flatpak / build-publish (push) Successful in 7m11s
deb / smoke-install (push) Successful in 3m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m48s
Reviewed-on: #333
2026-08-19 14:26:32 +00:00
enricobuehler 47602f7e59 feat(apple): "Capture system shortcuts" reaches ⌘Space and ⌘Tab, with Accessibility
ci / docs-site (pull_request) Successful in 1m27s
ci / web (pull_request) Successful in 1m30s
ci / bun-nix (pull_request) Successful in 1m32s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m15s
ci / rust (pull_request) Successful in 6m56s
A 0.30.0 user reported the setting does nothing for ⌘Space. It never could:
the macOS implementation (0.29, b2146f33) is an NSEvent local monitor, which
only sees the keys AppKit delivers to the app — ⌘Q, ⌘W and their like. The
shortcuts macOS itself owns (⌘Space → Spotlight, ⌘Tab → the Dock, ⌃↑ →
Mission Control, everything under System Settings › Keyboard › Shortcuts) are
consumed by WindowServer before any app is asked. The SDL clients take those
via the private CGSSetGlobalHotKeyOperatingMode, which SDL only compiles in
outside the sandbox; this app is sandboxed on both channels.

The sandbox-legal way is a session-level CGEventTap, which needs Accessibility.
`InputCapture` now installs one while forwarding (and only then — it comes
down with setForwarding(false)/stop(), and capture already releases on any
focus loss, so the tap is never live with another app frontmost). The tap
forwards nothing itself: it takes each keyDown/keyUp off the system and
re-posts it into this app's own queue, addressed to the key window, so it
arrives exactly where the same key would have had macOS not claimed it — the
monitor first (client chords, ⌘ chords → host), then StreamLayerView (the
rest). One key path, no second VK table, no second release bookkeeping; and
the ⌘-chord keyUps macOS used to swallow now arrive too.

Two things verified in standalone harnesses rather than assumed: a reposted
event does reach a local monitor, and a windowless NSEvent(cgEvent:) does NOT
reach the first responder — NSApp.sendEvent routes key events by
event.window — hence the re-stamp onto the key window's number. The
intercept half (tap ahead of Spotlight, inside the sandbox) needs a granted
Accessibility switch this machine doesn't have; that is the live test left.

Gating per event: forwarding, capture mouse model (⌃⌥⇧M flips it
mid-capture, so it is read live rather than at install), app active. Any
other state passes the key through untouched — a tap that swallows keys for
the whole Mac is the failure mode designed against. Installed on the main
run loop on purpose: a hung main thread trips the tap timeout and macOS hands
the keyboard back; the callback re-arms on kCGEventTapDisabledBy* otherwise.

The Accessibility prompt is asked only from Settings — on a genuine off→on
flip of the toggle, or an explicit "Allow Accessibility access…" button that
also opens the pane — never at stream start, and never for the default-on
users this update lands on. Without the grant the setting keeps doing what it
did in 0.29, and its caption now says exactly which half works. App Review
notes carry the justification.
2026-08-19 15:55:02 +02:00
enricobuehler f415c7d090 Merge pull request 'Cut plugin-kit 0.4.3 — the sync-engine changes plugins cannot pick up otherwise' (#334) from release-plugin-kit-0.4.3 into main
ci / rust-arm64 (push) Successful in 3m40s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
ci / web (push) Successful in 1m17s
ci / bun-nix (push) Successful in 36s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / docs-site (push) Successful in 1m11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 20s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 28s
docker / builders-arm64cross (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m15s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Successful in 7m38s
plugin-kit-publish / publish (push) Successful in 44s
nix / flake (push) Failing after 11m57s
2026-08-19 13:51:03 +00:00
enricobuehler e86c6367e1 chore(plugin-kit): cut 0.4.3 — the sync-engine changes plugins cannot pick up otherwise
ci / web (pull_request) Successful in 1m12s
ci / bun-nix (pull_request) Successful in 53s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 1m35s
ci / rust (pull_request) Successful in 7m35s
nix / flake (pull_request) Failing after 12m52s
Two changes have been sitting in `sync-engine.ts` with no way to reach a
plugin: `minInterval` (the fs-change rate cap), which v0.31.0's changelog
already recorded as owing a cut, and the always-apply sync reasons — `startup`
and `manual` publish even when the fingerprint matches, so an operator who
fixes a host-side art-root problem recovers by restarting the runner instead
of deleting the plugin's cache file.

The registry skips 0.4.2 deliberately. `plugin-kit-v0.4.2` was tagged at the
Steam cover-art commit but its publish never landed — the registry's newest kit
is still 0.4.1 — and the tag is left exactly where it is rather than moved onto
newer code. Every consumer's range is a caret (`^0.4.1`, `^0.4.2`), so 0.4.3
satisfies all of them, and the plugin that asked for `^0.4.2` gets the
cover-art scan it was waiting for in the same package.
2026-08-19 15:47:34 +02:00
enricobuehler 0fd44d8242 fix(console-ui): drop_non_drop in the bind-profile test — NLL already ends the borrow
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 3m12s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m32s
ci / rust (pull_request) Successful in 8m5s
android / android (pull_request) Successful in 8m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m51s
2026-08-19 15:45:53 +02:00
enricobuehler 6807d7951c fix(nix): make $out writable before the prune — reshade installs read-only
ci / bun-nix (pull_request) Successful in 25s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m43s
ci / rust (pull_request) Successful in 5m13s
nix / flake (pull_request) Failing after 10m41s
With the WSI layer building, the derivation reaches the prune and dies on every
reshade file it tries to remove:

    rm: cannot remove '.../share/gamescope/reshade/Shaders/lilium__tone_mapping.fx':
        Permission denied

gamescope's own default_extras_install.sh installs those under read-only
DIRECTORIES (mode 555), and rm needs write permission on the CONTAINING
directory rather than on the file. The compositor is fully built and installed
by that point, so the log reads as finished right up to the failure.

Only reachable now that #332 got the build past the layer assertion — the third
latent fault in this install path, each one having masked the next: patch drift
(#328), enableWsi defaulting off (#332), and now this.

Nix seals $out read-only after the builder exits, so widening it mid-build costs
nothing and changes nothing in the output.
2026-08-19 13:39:04 +00:00
enricobuehler f50721aedb fix(console-ui): the two HostRow literals the field sweep missed (pair test, settings test)
ci / bun-nix (pull_request) Successful in 44s
ci / rust-arm64 (pull_request) Failing after 1m9s
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Failing after 3m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 4m8s
android / android (pull_request) Successful in 5m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m10s
2026-08-19 15:32:37 +02:00
enricobuehler 5d3301ed9b feat(console-ui): touch gestures, controller-audio rows, host-menu bind/clipboard, Android idle gates
ci / bun-nix (pull_request) Successful in 27s
ci / rust-arm64 (pull_request) Failing after 57s
ci / web (pull_request) Successful in 1m2s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust (pull_request) Failing after 3m7s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 3m31s
android / android (pull_request) Successful in 6m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m57s
The 2026-08-19 console-ui sweep (punktfunk-planning design/console-ui-sweep-2026-08-19.md),
WP1-WP6:

- WP1 — touch deferred-tap + drag-to-scroll. PointerInput::Down grows a touch flag
  (SDL Finger* arms and Android toolType feed it; SDL's touch-synthesized mouse events
  are dropped); the shell tracks the gesture: within slop a lift is a tap delivered at
  the anchor, past it drags emit one synthetic scroll tick per 56dp of dominant-axis
  travel. Fixes the on-glass defect where any swipe across the settings list cycled the
  value it landed on (MenuList presses focus AND activate). Mouse behavior unchanged.
  Fling deliberately not included; the Release edge stays for it.
- WP2 — Controller haptics + Controller speaker rows (trust::Settings::{pad_haptics,
  pad_speaker}) in the Controller tab, forwarding-gated like their siblings; the
  speaker row speaks the GTK switch's dialect over the stored string. Detail strings
  are platform-aware now (the Stats/Mouse desktop chords are not taught on Android).
- WP3 — Send logs was already desktop-gated in actions(); pinned by a test.
- WP4 — ConsoleCmd::BindProfile lands (the port design's WP5 leftover): a Default
  profile… host-menu action opens a pin_hosts-style chooser; desktop arm writes
  KnownHost::profile_id, Kotlin arm mirrors it.
- WP5 — per-host Shared clipboard toggle on the host menu (HostRow::clipboard_sync,
  ConsoleCmd::SetClipboard, both service arms). Platform defaults stay divergent on
  purpose; the console shows and writes the stored value only.
- WP6 — Android idle gates: the reachability sweep only probes while the console is
  attached, and the render thread drops to half rate after 60s without input.
2026-08-19 15:14:20 +02:00
enricobuehler bd140bd232 Merge pull request 'gamescope builds no WSI layer, so nothing under it could get an HDR10 swapchain' (#332) from gamescope-enable-wsi-layer into main
ci / rust-arm64 (push) Successful in 3m24s
ci / bun-nix (push) Successful in 22s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / web (push) Successful in 2m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
ci / docs-site (push) Successful in 2m25s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
ci / rust (push) Successful in 7m46s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 56s
nix / flake (push) Failing after 14m23s
Reviewed-on: #332
2026-08-19 12:51:39 +00:00
enricobuehler d161c12680 Merge pull request 'A translated KWin refusal burned all 8 retries, and ex11's docs still stated a rule we replaced' (#331) from worktree-kwin-vout-failed-permanent into main
ci / bun-nix (push) Successful in 40s
apple / swift (push) Successful in 2m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 27s
ci / docs-site (push) Successful in 1m33s
ci / web (push) Successful in 1m46s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
ci / rust-arm64 (push) Successful in 2m29s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 45s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
deb / build-publish-gamescope (push) Successful in 54s
deb / build-publish-client-arm64 (push) Successful in 1m31s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
deb / build-publish-host (push) Successful in 5m30s
deb / build-publish (push) Successful in 3m49s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 33s
android / android (push) Successful in 10m12s
flatpak / build-publish (push) Successful in 7m22s
deb / smoke-install (push) Successful in 3m1s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m54s
arch / build-publish (push) Successful in 11m6s
ci / rust (push) Successful in 7m36s
apple / distribute (push) Successful in 12m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m53s
apple / screenshots (push) Successful in 9m35s
windows-host / package (push) Successful in 13m13s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m20s
Reviewed-on: #331
2026-08-19 12:33:20 +00:00
enricobuehler 7537e8e6b2 Merge pull request 'A portable Playnite's covers were dropped, and nothing republished them once you fixed it' (#330) from worktree-playnite-art-roots-and-blank-sources into main
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m17s
ci / docs-site (push) Successful in 1m54s
ci / rust-arm64 (push) Successful in 2m43s
deb / build-publish-gamescope (push) Successful in 52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
android / android (push) Canceled after 3m55s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 28s
arch / build-publish (push) Canceled after 4m10s
ci / rust (push) Canceled after 4m9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
deb / build-publish-client-arm64 (push) Successful in 1m39s
deb / build-publish (push) Canceled after 4m6s
deb / build-publish-host (push) Canceled after 3m19s
deb / smoke-install (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 18s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 20s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 11s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 58s
windows-host / package (push) Canceled after 5m31s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #330
2026-08-19 12:29:25 +00:00
enricobuehler 5711fafa38 fix(nix): build gamescope's WSI layer — nixpkgs defaults enableWsi to false
ci / rust-arm64 (pull_request) Successful in 2m10s
ci / bun-nix (pull_request) Successful in 18s
ci / web (pull_request) Successful in 1m56s
ci / docs-site (pull_request) Successful in 2m39s
ci / rust (pull_request) Successful in 9m5s
nix / flake (pull_request) Successful in 14m27s
With the lock bumped, all ten patches apply and the compositor compiles, links
and installs cleanly. It then fails our own postInstall assertion:

    punktfunk-gamescope: this nixpkgs' gamescope built no WSI layer, so no game
                         under the compositor could ever obtain an HDR10 swapchain

nixpkgs takes `enableWsi ? false` and feeds it to
`mesonBool "enable_gamescope_wsi_layer"`, so the plain derivation ships the
compositor and no layer at all; nixpkgs gets its own layer by instantiating a
SECOND copy inside the wrapper. `enableWsi` is a FUNCTION ARGUMENT, so
overrideAttrs cannot reach it — it needs `.override`, applied before
`.overrideAttrs` so the latter is not applied to the derivation being replaced.

The assertion did its job: it caught a compositor that would have installed
under our name and then denied every nested game an HDR10 swapchain, which is
the one outcome the header calls the worst. But it only fires after a full
build (MEASURED: run 19323), which is an expensive way to learn a default.
2026-08-19 11:44:05 +00:00
enricobuehler fa946a16b9 Merge pull request 'Canceling a connect brings the console straight back, instead of sticking on "Canceling…"' (#329) from worktree-console-cancel-connect-stuck into main
apple / swift (push) Successful in 1m58s
ci / rust-arm64 (push) Successful in 3m24s
ci / web (push) Successful in 2m7s
ci / bun-nix (push) Successful in 45s
ci / docs-site (push) Successful in 1m32s
deb / build-publish-gamescope (push) Successful in 47s
ci / rust (push) Successful in 6m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 1m28s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
arch / build-publish (push) Successful in 8m21s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
docker / builders-arm64cross (push) Successful in 12s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
docker / deploy-docs (push) Successful in 38s
deb / build-publish (push) Successful in 4m31s
android / android (push) Successful in 12m9s
flatpak / build-publish (push) Successful in 5m11s
apple / distribute (push) Successful in 11m23s
deb / build-publish-host (push) Successful in 9m28s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m57s
deb / smoke-install (push) Successful in 2m33s
apple / screenshots (push) Successful in 9m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m20s
windows-host / package (push) Successful in 13m19s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 19s
Reviewed-on: #329
2026-08-19 11:29:22 +00:00
enricobuehler 9a29eb4a7a Merge pull request 'Every NixOS host build was failing: nixpkgs' gamescope 3.16.24 no longer takes our patches' (#328) from nixpkgs-bump-gamescope-3-16-25 into main
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
ci / bun-nix (push) Successful in 26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
ci / web (push) Successful in 1m1s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
ci / docs-site (push) Successful in 1m27s
docker / builders-arm64cross (push) Successful in 17s
ci / rust-arm64 (push) Successful in 2m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m34s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m6s
docker / deploy-docs (push) Successful in 31s
ci / rust (push) Successful in 5m59s
nix / flake (push) Failing after 13m46s
Reviewed-on: #328
2026-08-19 11:10:40 +00:00
enricobuehler 1b52942bf8 fix(nix): the dispatch opt-ins skipped silently on an API dispatch
ci / bun-nix (pull_request) Successful in 19s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / rust (pull_request) Successful in 5m20s
nix / flake (pull_request) Failing after 10m59s
`build-rust` / `build-gamescope` were compared only against the STRING 'true'.
A checkbox from the Gitea UI arrives that way, but an API dispatch can deliver a
real JSON boolean, and the comparison then misses — the step is skipped, the job
still reports success, and the log is indistinguishable from a run that had
nothing to do.

MEASURED 2026-08-19: dispatched with build-gamescope=true to verify the flake.lock
bump actually fixes the gamescope patch set. Run 19320 went green with 'Build the
patched gamescope' SKIPPED — a green that proved nothing about the one package
being fixed, on the exact check the header recommends before merging a lock bump.

Accepts both shapes now. Deliberately still no `inputs.*`: that context is what
Gitea's parser is least reliable about, which is why this file used
github.event.inputs in the first place.
2026-08-19 10:56:41 +00:00
enricobuehler 6bec7c7cc6 fix(nix): bump nixpkgs — its gamescope 3.16.24 no longer takes our patches
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m13s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Successful in 5m31s
nix / flake (pull_request) Failing after 11m25s
The publish tier went red on punktfunk-gamescope-3.16.24:

    patching file src/steamcompmgr.cpp
    Hunk #1 FAILED at 9321.
    1 out of 1 hunk FAILED -- saving rejects to src/steamcompmgr.cpp.rej

0009 rewrites the pipewire call site so dead capture textures are reaped on the
compositor thread, and its context is upstream's vblank-driven form:

    // Drive on vblank, not the timer: under VRR the timer starves (page flips re-arm it).
    if ( vblank && pipewire_is_streaming() )

MEASURED against three upstream trees: that context is absent from the 3.16.24
tag and present in both 3.16.25 and master. packaging/gamescope/README.md pins
upstream at 5fb8dce4 (3.16.25-11) and gamescope.nix's header says it was checked
against 3.16.25 — but the lock still carried a 2026-07-15 nixpkgs shipping
3.16.24, five weeks behind. The earlier patches were already only just holding
on: 0004 and 0008 applied at offsets of -51, -48, -35, and one hunk at fuzz 2.

This is not a CI-only failure. gamescope.nix patches whatever gamescope the
pinned nixpkgs carries, and host.gamescopeHdr defaults true, so every
`services.punktfunk.host.enable = true` build was failing the same way. The
cache tier is what surfaced it — exactly what nix.yml's header predicted would
happen, and the reason that tier compiles gamescope on every main push.

Only the nixpkgs node moves (verified: 8 inputs compared, 1 changed).

NOTE: this fixes the instance, not the class. The nix channel is the only one
that does not pin gamescope's src — the RPM spec, the PKGBUILD and
build-punktfunk-gamescope.sh all ship 5fb8dce4 — so the next nixpkgs move can
break it again in either direction.
2026-08-19 10:29:13 +00:00
enricobuehler 8f4e71f8dc fix(console-ui): canceling a connect returns the console immediately, and aborts the dial
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m19s
ci / web (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 2m13s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m4s
android / android (pull_request) Successful in 6m35s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m36s
ci / rust (pull_request) Successful in 7m56s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 14m36s
The connect takeover could only be dismissed by a session phase coming back from the
embedder, and nothing guaranteed one would. Pressing B latched "Canceling…" and waited:

- Android's console shell never sent a phase at all on the canceled path — `launch()`
  returns early when `Dial.cancelled` is set — so the takeover stayed up until the app
  was killed.
- The desktop shell waited for the pump's `Failed`/`Ended`, but the pump was parked
  inside the blocking `NativeClient::connect*`, which had no abort. That is 15 s on a
  normal dial and 185 s on a request-access connect the host holds pending approval.

Two halves, both at the point every caller routes through:

`pf-console-ui` drops the takeover itself on Back. Cancel is the user's decision and
needs no confirmation from the wire; the `CancelConnect` action still goes out, and
every embedder already handles a dial that lands afterwards (quit-close the connector,
route the end back silently). With no waiting state left to render, `Connecting.canceling`
and the "Canceling…" card go with it.

`NativeClient::connect_with_audio_format` takes an optional abort switch, polled while
the call is blocked, and the session pump passes its stop flag — so the embedder's cancel
now reaches a dial that has not landed yet instead of being answered whenever it does.
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. Deliberately NOT aliased onto the client's own `shutdown`: the pump reads
that to mean "this connection died" and derives the session's end reason from it, which a
caller-set flag would race — a dropped link would have reported no reason at all.
2026-08-19 12:24:25 +02:00
enricobuehler 9ddf802665 fix(plugin-kit): a restart or a Sync-now republishes, instead of answering "no changes"
ci / web (pull_request) Successful in 1m20s
ci / rust-arm64 (pull_request) Successful in 2m18s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 2m15s
ci / rust (pull_request) Successful in 7m2s
android / android (pull_request) Successful in 7m21s
The sync engine's fingerprint says we would compute the same entries again. It does
NOT say the host still holds them — and the host may accept a payload and store less
of it than was sent: an art path outside its allowed roots is stripped and the games
kept (deliberately, a cover must not cost a library), a launcher tile it cannot open
is dropped the same way.

Once that happens the fingerprint is a permanent "no changes". The operator fixes the
host side, nothing republishes, and the only way out is to delete the plugin's cache
file — which is exactly the advice a portable-Playnite library with 70 dropped covers
was given.

So the two triggers with a person behind them always apply: `startup`, the restart
everyone reaches for, and `manual`, the console's Sync-now button and the CLI's
`sync`. Both mean "publish my library NOW", and "no changes" is the wrong answer to
that. The loop reasons keep the short-circuit, which is where it earns its keep —
they are what would otherwise PUT the whole library every few minutes.
2026-08-19 12:20:00 +02:00
enricobuehler b6ca692c13 docs(abi): ex11's summary still stated the pre-2026-08-16 rule its own warning corrects — a format at all sets the hi-res cap
ci / web (pull_request) Successful in 1m53s
ci / rust-arm64 (pull_request) Successful in 2m19s
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 25s
ci / docs-site (pull_request) Successful in 1m20s
android / android (pull_request) Successful in 5m46s
ci / rust (pull_request) Successful in 6m41s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m52s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m49s
2026-08-19 11:54:45 +02:00
enricobuehler f7eb844274 fix(host/library): a portable Playnite's covers survive the art confinement
A Playnite unzipped outside the users base (D:\Apps\Playnite) keeps its whole
library beside the exe, so every cover it exports lives at
`<PlayniteDir>\library\files\…` — outside every default art root. The games
synced and all 70 covers were dropped, with only PUNKTFUNK_LIBRARY_ART_ROOTS
as a way out.

The Playnite install dirs are now art roots too, exactly as Steam's install
root already is, and `playnite_install_dirs` learned to find a portable copy at
all: it registers no uninstall entry and sits under no profile, but it does
register the `playnite://` handler — the very registration this host's launch
path already follows to start a Playnite title. So the same probe also gives a
portable install its Fullscreen launcher tile, which it never had.

The confinement is not loosened: the roots come 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.
2026-08-19 09:03:57 +02:00
enricobuehler a75ed71428 docs(kde): name the 6.6 change behind the KWin virtual-output failure — the output must now be enabled, not just created 2026-08-19 09:01:12 +02:00
enricobuehler b551f7dae8 fix(host): a translated KWin refusal burned all 8 retries — match our own prefix, not KWin's message
KWin sends the `failed` reason on zkde_screencast localized. The retry
short-circuit matched the English "could not find output", so a pt-BR session
("Não foi possível encontrar saída") fell through to 8 attempts over ~11 s and
then reported "out of retries" — a config fact dressed up as a flaky one.

Match "KWin virtual output failed" instead: our own prefix, never translated.
Every `failed` KWin sends on this path is a backend/config fact (unsupported
compositing type, a backend without createVirtualOutput, an output the
workspace declined to enable), none of which a retry 500 ms later changes.

The message itself now says what KWin's untranslatable reason means, and the
KDE docs gain the KWin 6.6+ shape of it: KWin creates the output, leaves it
disabled, and workspace()->findOutput() returns null — reported identically to
a backend that cannot create one at all.
2026-08-19 08:54:02 +02:00
enricobuehler e5046a2811 Merge pull request 'Android streaming: HDR and SDR colours through the ASC presenter, and the system bars stop parking over the video' (#319) from worktree-android-asc-hdr-dataspace into main
ci / web (push) Successful in 1m30s
ci / docs-site (push) Successful in 2m9s
ci / bun-nix (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / rust-arm64 (push) Successful in 5m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m55s
android / android (push) Successful in 10m58s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 34s
ci / rust (push) Successful in 13m22s
2026-08-19 06:24:23 +00:00
enricobuehler 19d37c44b3 style(client/android): rustfmt the display import block
ci / web (pull_request) Successful in 1m25s
ci / docs-site (pull_request) Successful in 1m44s
ci / bun-nix (pull_request) Successful in 35s
ci / rust-arm64 (pull_request) Successful in 5m31s
android / android (pull_request) Successful in 7m16s
ci / rust (pull_request) Successful in 17m28s
2026-08-19 08:21:35 +02:00
enricobuehler 5be399a4f6 Merge remote-tracking branch 'origin/main' into worktree-android-asc-hdr-dataspace 2026-08-19 08:21:22 +02:00
enricobuehler 6c32890014 Merge pull request 'The controllers test compiles again: a closure can't name the lifetime its Ctx borrows' (#321) from worktree-console-ui-ctx-lifetime into main
android / android (push) Canceled after 3m56s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 2m21s
deb / build-publish (push) Successful in 6m11s
deb / build-publish-host (push) Successful in 6m25s
arch / build-publish (push) Successful in 8m20s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m15s
flatpak / build-publish (push) Successful in 4m54s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m10s
deb / smoke-install (push) Successful in 5m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m14s
Reviewed-on: #321
2026-08-19 06:18:40 +00:00
enricobuehler 9c33bc9397 Merge pull request '0.31.0' (#320) from worktree-release-next-prep into main
android / android (push) Canceled after 20s
arch / build-publish (push) Canceled after 30s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
audit / cargo-audit (push) Failing after 36s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
audit / bun-audit (web) (push) Successful in 26s
audit / pnpm-audit (push) Successful in 10s
audit / bun-audit (plugin-kit) (push) Successful in 1m31s
audit / docs-site-audit (push) Successful in 39s
audit / bun-audit (sdk) (push) Successful in 1m29s
apple / swift (push) Successful in 2m5s
audit / license-gate (push) Successful in 4m32s
audit / c-abi-asan (push) Successful in 6m18s
audit / miri (push) Successful in 7m50s
apple / distribute (push) Successful in 12m59s
windows-host / package (push) Successful in 13m48s
windows-host / winget-source (push) Skipped
nix / flake (push) Failing after 21m5s
apple / screenshots (push) Successful in 10m19s
windows-host / canary-manifest (push) Successful in 20s
Reviewed-on: #320
2026-08-19 06:18:19 +00:00
enricobuehler fcdb2a53de fix(console-ui): the controllers test builds its Ctx in a fn, not a closure
ci / bun-nix (pull_request) Successful in 31s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m16s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m36s
ci / rust-arm64 (pull_request) Successful in 4m13s
android / android (pull_request) Successful in 5m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m52s
ci / rust (pull_request) Successful in 19m45s
A closure cannot be generic over the lifetime in its return type, so the
`Ctx<'_>` this one built could never be tied to the `&mut Settings` it
borrows — `lifetime may not live long enough`, and the lib test target
failed to compile.

Same shape the add_host and library test modules already use: a plain
`fn ctx<'a>(…) -> Ctx<'a>` where the lifetime is written down once.
2026-08-19 08:17:10 +02:00
enricobuehler 601f040ffe release: 0.31.0 — re-cut on 790db5ed (#318 merged after the first cut)
apple / swift (pull_request) Successful in 2m1s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m58s
ci / rust-arm64 (pull_request) Failing after 1m54s
ci / web (pull_request) Successful in 1m54s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 3m24s
ci / rust (pull_request) Failing after 2m39s
ci / bun-nix (pull_request) Successful in 34s
ci / docs-site (pull_request) Successful in 1m44s
android / android (pull_request) Successful in 7m6s
nix / flake (pull_request) Successful in 13m47s
90 commits since v0.30.0 (65 non-merge), was 88/64. #318 installed the
Nix cache signing key (public half now pinned in the docs and served by
the cache) and moved its DNS provisioning into unom/infra's OpenTofu; the
CHANGELOG's Nix paragraph says so instead of listing the PR as absent.
The notes needed no change: their NixOS line already pointed at the
install guide and the cache-served key. Version table unaffected — #318
touches docs and the setup wizard only.
2026-08-19 00:20:40 +02:00
enricobuehler eac308412c Merge remote-tracking branch 'origin/main' into worktree-release-next-prep 2026-08-19 00:20:08 +02:00
enricobuehler 65c4b4b17e release: 0.31.0 — version bump, notes, CHANGELOG, Play notes
android / android (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 1m46s
apple / distribute (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
nix / flake (pull_request) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 1m47s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
88 commits since v0.30.0 (64 non-merge). Cut from origin/main 8a4eac4c
(PR #317 merged; PR #318, the Nix cache signing-key follow-up, is open
and NOT in this cut).

THE NUMBER: 0.31.0 is a judgement call, not forced. Nothing versioned
moved — WIRE_VERSION 2, C ABI 24 with include/punktfunk_core.h byte-
identical to the v0.30.0 tag, driver protocol 6 / min 3 (pf-driver-proto
has no diff against the tag), gamepad channel 3, plugin index schema 1,
host event schema 1, gamescope +pfhdr8 with no new patch files,
api/openapi.json still stamped 0.29.0 and unchanged, SDK 0.1.4,
plugin-kit 0.4.2. On that table alone this could be 0.30.1. It is a
minor because the cycle is feature-shaped and one commit carries a `!`:
the Android Compose console is deleted outright and pf-console-ui over
Skia/GL becomes the console on all three ABIs (#301, #317); the Android
present path moves to ASurfaceControl by default (#290); the Linux
desktop-audio capture flips to a host-owned null-audio-sink by default
with PUNKTFUNK_STREAM_SINK=stream as a one-release escape (#305);
`topology: exclusive` on Hyprland/sway goes from silently-extend to
genuinely disabling the operator's outputs, which is the default policy
on every auto-detected box (#304, closes #284); a Nix binary cache
ships (#313); and the Linux host package grows three new system files
(udev rule, WirePlumber policy, ALSA UCM drop-in) that the DualSense
audio path depends on (#291, #302, #315). scripts/ci/pf-version.sh
already derives the canary base as latest stable + one minor, so
canaries have sat at 0.31.x since the v0.30.0 tag and move to 0.32.x
after this one.

Version table re-measured on 8a4eac4c, not carried forward. Two rows
carry a warning rather than a change: sdk/ and plugin-kit/ both have
real code changes (the mgmt-endpoint follow in config.ts/runner-cli.ts;
SyncSettings.minInterval in sync-engine.ts) with package.json unbumped.
They version off their own sdk-v*/plugin-kit-v* tags and this commit
does not touch them; the CHANGELOG names sdk-v0.1.5 and
plugin-kit-v0.4.3 as owed so they are cut deliberately.

The release branch also carries one preceding whitespace-only commit:
crates/pf-console-ui/src/screens/controllers.rs and
crates/punktfunk-host/src/audio/linux/pad_card_volume.rs had landed on
main formatted differently from rustfmt 1.96.0 (the pinned toolchain),
so ci.yml's Format step was red on the tip this is cut from. `cargo fmt
--all` and nothing else.

Gates run on this tree (this MacBook, rustc/rustfmt 1.96.0): cargo fmt
--all --check clean; cargo metadata --offline ok; Cargo.lock diff
versions-only (36/36 lines); cargo test -p punktfunk-core unit suite
272 passed; the android.yml Play notes gate run verbatim — 498/500
characters and not byte-identical to any prior release's; notes voice
scan finds no internal names outside the For developers section (the
two hits, `nixpkgs` and Flatpak, are things those users configure by
name); both openapi copies cmp identical and unchanged since the tag;
include/punktfunk_core.h regenerated by the build and git-diff clean.

⚠ NOT run here: the C ABI harness (tests/c_abi.rs) — it links -lopus
and this machine has no libopus (`ld: library 'opus' not found`; the
Mac Studio has it). The header is byte-identical to v0.30.0's, where the
harness passed, and nothing in punktfunk-core's C surface changed; the
CI runner is its first execution for this tag. Named in the CHANGELOG's
Verification status rather than left to be discovered.

No outside contributor is credited by name: every attribution in the
64 bodies is a dated field report or "a reporter", and issue #284 was
filed by the maintainer. The Thanks section thanks the field reports
without naming anyone.
2026-08-19 00:18:08 +02:00
enricobuehler 329df4c1f4 fix(client/android): SDR through ASC presented untagged buffers — SurfaceFlinger's full-range guess elevated the blacks
ci / web (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Failing after 4m45s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust (pull_request) Failing after 6m15s
ci / bun-nix (pull_request) Successful in 1m19s
android / android (pull_request) Successful in 9m32s
The SurfaceView path never tagged SDR because MediaCodec tags its own window
buffers; with AImageReader → ASurfaceControl the transaction is the only
carrier, and dataspace 0 meant setBufferDataSpace was never called. An
untagged limited-range BT.709 buffer read as full range shows black (16) as
gray. SDR now maps to ADATASPACE_BT709 (limited-range video) in
color_dataspace — every ASC buffer is tagged.
2026-08-19 00:17:26 +02:00
enricobuehler 5fc5da3256 fix(client/android): one owner for the system bars — the console's dispose re-showed them over the stream
ci / rust-arm64 (pull_request) Failing after 1m18s
ci / docs-site (pull_request) Successful in 1m27s
android / android (pull_request) Canceled after 3m43s
ci / rust (pull_request) Canceled after 3m26s
ci / web (pull_request) Canceled after 1m45s
ci / bun-nix (pull_request) Canceled after 2s
Console → stream rides an AnimatedContent cross-fade, so the outgoing
console shell stays composed until the fade ends: its
onDispose { show(systemBars()) } fired AFTER StreamScreen's hide, parking
the status bar and the gesture bar over the video for the whole session.

The hide/show now lives once in App.kt, keyed on the resolved intent
(streaming or console fronting = immersive; touch shell = bars back), and
both screens' per-screen bar management is deleted.
2026-08-19 00:04:55 +02:00
enricobuehler a8922b454a fix(client/android): the ASC presenter tagged HDR from the codec's echo, not the negotiated colour
The negotiated ColorInfo is the wire contract's authority on the stream's
colour, but the ASC backend seeded a hardcoded BT2020_ITU_PQ guess and then,
on the first output-format change, overwrote it with whatever the codec
echoed — and a decoder that omits color-transfer (common) echoed None, which
clobbered the dataspace to 0 before the first present. Untagged P010 buffers
composited as sRGB: broken HDR colours. An HLG stream was also mis-seeded PQ.

Now the initial dataspace derives from client.color (PQ vs HLG, range), and
a format change only refines it when the codec actually reports an HDR
transfer — never resets it, matching the SurfaceView path's semantics.
2026-08-19 00:04:49 +02:00
enricobuehler 790db5edbb Merge pull request 'The cache signing key is installed, and its DNS was never a dashboard click' (#318) from worktree-nix-binary-cache into main
ci / web (push) Successful in 1m15s
ci / rust-arm64 (push) Failing after 1m45s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
ci / docs-site (push) Successful in 1m18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
ci / bun-nix (push) Successful in 1m35s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
ci / rust (push) Failing after 4m9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m25s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m35s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 32s
nix / flake (push) Failing after 18m59s
Reviewed-on: #318
2026-08-18 22:04:09 +00:00
enricobuehler 82d39011ce style: cargo fmt drift in pf-console-ui controllers screen and pad_card_volume
Two files landed on main formatted differently from rustfmt 1.96.0's
output, so ci.yml's Format step fails on the tip the release is cut
from. Whitespace only; no behaviour change.
2026-08-18 23:59:53 +02:00
enricobuehler 8a4eac4c41 Merge pull request 'The Android console stops losing the pad, owns its Controllers page, and takes the whole phone panel' (#317) from worktree-phone-console-ui into main
ci / rust-arm64 (push) Failing after 1m7s
ci / rust (push) Failing after 3m30s
ci / bun-nix (push) Successful in 30s
ci / docs-site (push) Failing after 1m0s
ci / web (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 51s
arch / build-publish (push) Successful in 10m22s
deb / build-publish-client-arm64 (push) Successful in 1m41s
android / android (push) Successful in 12m58s
deb / build-publish (push) Successful in 5m12s
deb / build-publish-host (push) Successful in 4m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 3m19s
flatpak / build-publish (push) Successful in 8m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m8s
deb / smoke-install (push) Successful in 3m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m0s
Reviewed-on: #317
2026-08-18 21:50:54 +00:00
enricobuehler 7e4fe80793 feat(nix): install the cache signing key and correct how its ingress is provisioned
ci / bun-nix (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 2m7s
ci / rust-arm64 (pull_request) Successful in 2m46s
ci / docs-site (pull_request) Successful in 2m43s
ci / rust (pull_request) Successful in 9m54s
nix / flake (pull_request) Successful in 16m31s
Two corrections and one thing actually done.

DNS here is not a dashboard click. unom/infra owns the unom.io zone in OpenTofu
(terraform/cloudflare/records.tf, applied by dns-cutover.yml), and that file's
`local.hostnames` set carries its own invariant: "a name here with no vhost 404s,
a vhost with no name here never cuts over." A record added by hand in Cloudflare
is out-of-band and risks the duplicate-record round-robin the file documents a few
lines further down — the same class of trap as hand-editing ~/caddy/Caddyfile on
the box. The setup steps said "in the unom.io Cloudflare zone" as though it were a
manual change; they now name both files, the workflow that applies them, and the
one-added-record check to expect from `plan`. unom/infra#20 makes the change.

The signing key is generated and `NIX_CACHE_SIGNING_KEY` is installed as a repo
Actions secret, so its public half is no longer a placeholder:

    punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=

pinned in both docs. The publish step still writes the same value to
/punktfunk-cache.pub, so the docs can always be checked against the cache itself —
and the wizard now compares the two and warns on a mismatch, because docs that
disagree with the cache mean users reject everything it serves.

The wizard drops to four stages. DNS and the vhost were separate stages when they
looked like separate manual steps; they are one PR against one repo, so they are
one stage. The key stage now detects the installed key, prints it, and refuses to
casually regenerate — a new key invalidates every signature already published and
breaks every user pinning the old one.

Verified: shellcheck + `bash -n` clean, 4 stages against TOTAL_STAGES=4, and the
already-installed path's key extraction tested against the real README.
2026-08-18 23:49:21 +02:00
enricobuehler c4cf53c1fc Merge pull request 'The Nix cache's setup steps pointed at a home-lab proxy that no longer exists' (#316) from worktree-nix-binary-cache into main
ci / web (push) Successful in 1m33s
ci / bun-nix (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
ci / rust (push) Failing after 4m42s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
ci / rust-arm64 (push) Successful in 3m44s
ci / docs-site (push) Successful in 3m23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m40s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
nix / flake (push) Canceled after 9m37s
Reviewed-on: #316
2026-08-18 21:32:53 +00:00
enricobuehler d59a1a9606 feat(console-ui): the console takes the whole phone panel, and the library stops spending it
ci / rust-arm64 (pull_request) Failing after 2m26s
ci / web (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 43s
ci / rust (pull_request) Failing after 3m41s
ci / docs-site (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 8m55s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m53s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 3m34s
Four phone-sized fixes, one theme — a small screen was paying for chrome it
never asked about:

- The Android shell hides the system bars while it fronts the app (transient
  by swipe, restored on dispose), the same contract as the stream. This is
  also the safe-area fix: hidden bars report zero insets, so scrolled content
  no longer slices off at the visible gesture-bar line with bare backdrop
  below — only the display cutout remains a real inset.
- The design-unit scale's phone density floor rises 0.6 -> 0.75: on a 460 dpi
  panel the floor is what sets the scale (the couch term only wins on tablets
  and TVs), and 0.6 read a step too small in the hand. Still the documented
  on-glass knob.
- The library's sort/view bar appears only while it holds the pad (up from
  the field / the legend's 'Sort & view'), the Apple client's behaviour; the
  field takes the band's height back as it fades.
- The store/platform subtitle under the focused title is gone — the cover
  badge already says it — and the detail band shrinks 84 -> 64 units, most of
  a grid row on a phone.

Plus a guard the new scale makes necessary: the grid's two-column minimum
shrinks its covers to fit rather than clipping at the edges when a narrow
viewport times a high density leaves less width than two full cells.
2026-08-18 23:27:44 +02:00
enricobuehler 7c411f7ef4 fix(nix): the cache's setup steps described a topology that no longer exists
ci / rust-arm64 (pull_request) Successful in 2m3s
ci / web (pull_request) Successful in 1m1s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 2m16s
ci / rust (pull_request) Successful in 7m17s
nix / flake (pull_request) Failing after 19m10s
The bring-up instructions were copied from packaging/flatpak/README.md, which
still describes an edge proxy on `home-reverse-proxy-1` forwarding to
192.168.50.50. That home-lab topology is gone. packaging/winget/server/
compose.production.yml — the newest of the three and the only one written since
the move — says so outright: "the sibling docs/flatpak compose files still carry
stale comments … the public hostnames resolve straight to the hcloud box and are
served by Caddy there — no local proxy is involved." flatpak.unom.io resolves to
167.233.145.172, which is unom-1 itself, confirming it.

So the steps now match how docs and winget were actually stood up:

  * DNS in the unom.io Cloudflare zone, DNS-only, straight at the hcloud box.
  * The vhost in unom/infra `caddy/Caddyfile`, proxying to localhost:3250 —
    NOT 192.168.50.50, and NOT hand-edited on the box. ~/caddy/Caddyfile there
    looks like the config but is an rsynced copy with no .git to warn you; a
    vhost added only on the box lasts until the next deploy. That is how the
    winget source vanished on 2026-07-26, and it is now called out here too.
  * `caddy_target_ports` + terraform is dropped. It was the home-lab firewall
    allowlist; winget's setup, written post-move, has no such step.

Also adds the SNI diagnostic winget's README hard-won: Caddy 308s every Host on
:80 to https, including names it has never heard of, so probing port 80 proves
nothing — check the certificate by SNI instead.

scripts/setup-nix-cache.sh walks the five steps interactively (built from the
/wizard template): it opens each page, says exactly what to click, and verifies
each stage before moving on, because the failure signatures are easy to confuse
— a TLS handshake failure means the vhost is missing, a 502 means the container
is down, and a 404 means the cache is healthy and empty.

It also closes the loop the first version left open: it generates the signing
key locally (a local nix, or the nixos/nix image — MEASURED: both produce the
`name:base64` line, and convert-secret-to-public round-trips), then writes the
PUBLIC half straight into the two docs that carried a `<fill-in>` placeholder.
Nobody has to wait an hour for the first publish to print a value we can derive
up front. The secret half is shown once for pasting into Gitea and never
touches disk. Re-running detects an installed key and refuses to silently
replace it, since that would invalidate every signature already published.

Verified: shellcheck clean, `bash -n` clean, 5 stages against TOTAL_STAGES=5,
and the doc substitution tested against a real generated key — public keys are
base64 and contain `/`, so the sed uses `|` as its delimiter.
2026-08-18 23:26:06 +02:00
enricobuehler 9e47f746ba Merge pull request 'The DualSense's only playback route was a mono sink games overran, and both ends met the pad at -24 dB' (#315) from worktree-dualsense-handoff into main
apple / swift (push) Successful in 2m8s
ci / rust (push) Failing after 3m7s
ci / web (push) Successful in 2m5s
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m28s
deb / build-publish-gamescope (push) Successful in 29s
ci / rust-arm64 (push) Canceled after 6m10s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Successful in 46s
deb / build-publish-client-arm64 (push) Successful in 2m22s
android / android (push) Successful in 10m6s
arch / build-publish (push) Successful in 10m46s
flatpak / build-publish (push) Successful in 5m20s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m10s
apple / distribute (push) Successful in 12m10s
deb / build-publish-host (push) Successful in 12m15s
deb / build-publish (push) Successful in 13m24s
apple / screenshots (push) Successful in 9m55s
deb / smoke-install (push) Canceled after 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 17m29s
windows-host / package (push) Successful in 17m43s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
Reviewed-on: #315
2026-08-18 21:22:08 +00:00
enricobuehler 13f8a1c5cd Merge remote-tracking branch 'origin/main' into worktree-dualsense-handoff
apple / swift (pull_request) Successful in 2m1s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 20s
ci / rust-arm64 (pull_request) Successful in 2m26s
ci / web (pull_request) Successful in 1m5s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust (pull_request) Failing after 3m47s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m33s
android / android (pull_request) Successful in 8m11s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m25s
2026-08-18 23:18:07 +02:00
enricobuehler d5f2c63367 Merge pull request 'A moved mgmt port left every plugin and the tray dialing 47990 in silence; the Windows runner task now also writes a log file' (#314) from worktree-windows-runner-log-file into main
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / bun-audit (web) (push) Successful in 21s
audit / pnpm-audit (push) Successful in 12s
audit / docs-site-audit (push) Successful in 23s
audit / cargo-audit (push) Failing after 1m22s
apple / swift (push) Successful in 2m8s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m10s
android / android (push) Canceled after 4m22s
apple / distribute (push) Canceled after 51s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m30s
ci / rust (push) Canceled after 3m7s
ci / rust-arm64 (push) Canceled after 55s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 58s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / license-gate (push) Successful in 6m27s
audit / miri (push) Successful in 6m40s
audit / c-abi-asan (push) Successful in 6m40s
nix / flake (push) Canceled after 10m57s
Reviewed-on: #314
2026-08-18 21:16:56 +00:00
enricobuehler 4e03dcc280 Merge pull request 'NixOS users compiled the whole workspace because we published no binaries' (#313) from worktree-nix-binary-cache into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
nix / flake (push) Canceled after 0s
Reviewed-on: #313
2026-08-18 21:15:54 +00:00
enricobuehlerandClaude Opus 5 37813199b5 Merge origin/main — the WirePlumber DualSense policy and the UCM drop-in are complements
Three packaging conflicts, all the same shape: #307 added a
`60-punktfunk-dualsense.conf` install at the exact line this branch added the
ALSA UCM install to. Both sides kept — they act on different layers and neither
subsumes the other:

  * the WirePlumber rules govern how the pad's nodes BEHAVE once they exist
    (`node.always-process` so GE-Proton's raw open cannot race itself,
    `priority.driver = 0` so a pad never clocks somebody else's graph);
  * the UCM drop-in governs WHICH nodes exist at all (a `SpeakerHaptic` device
    at priority 200, so the 1-channel sink games overrun is never minted).

Checked rather than assumed: the drop-in's node-name matchers
(`~alsa_output.usb-Sony_Interactive_Entertainment_DualSense.*`) still match the
sink the UCM change introduces — `…DualSense_Wireless_Controller-00.HiFi__
SpeakerHaptic__sink` — so the policy follows the pad onto the new profile. And
neither touches volume, so the 0 dB pin on this branch is untouched by both.

The Android side of #301 deleted the Compose gamepad mirror, not
`SettingsScreen.kt`, so the "Controller speaker" subtitle survives; the Skia
console that replaced it carries no speaker row of its own (it opens Android's
connected-controllers view instead), so there is no second place to say it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:15:17 +02:00
enricobuehler 9cefa0a3ea feat(console-ui): connected controllers is the console's own screen — input stays on the pad
The Controllers row used to raise the D7 platform-screen mechanism: Android drew
the Compose ConsoleControllersScreen over the surface and suspended the console's
input until it closed. Now the page is a shared Skia screen
(screens/controllers.rs) pushed like any other settings sub-screen, so the console
keeps its own navigation, transitions and haptics on it — and a future desktop
build gets it for free (the row itself stays Android-only in row_on).

What genuinely cannot move into Rust stays with the host, asked for by ONE
parameterised command, ConsoleCmd::PadAction { action, pad_key }: the USB and
Bluetooth grant dialogs (sc2_bluetooth / sc2_usb / ds_usb), the rumble pulse on
the real InputDevice, and the DualSense pad-audio self test. SkiaConsoleShell
handles them with the same helpers the touch Controllers screen uses (testRumble,
the grant intents, nativePadAudioSelfTest), reporting through the notice toast, so
the support answer cannot drift between interfaces. PadInfo carries the three
fields the screen needed and the aggregated list already lacked (detail line,
forwarded, rumble), filled by ConsoleJson.pads from the same padInfoOf reader the
touch screen renders from.

PlatformScreen::Controllers is gone; the mechanism itself stays for Licenses,
which still suspends the console's input correctly (the probes gate on platformUp
as before). The Compose console variant and its screenshot scenes are deleted;
the touch ControllersScreen keeps the full page INCLUDING the live input test,
which deliberately did not move — the console only receives the aggregated
MenuSample, nowhere near a per-device axis/trigger readout (ponytail note at the
top of controllers.rs records the upgrade path).
2026-08-18 23:14:23 +02:00
enricobuehler 83f6164027 Merge pull request 'The takeover's own mask was the relogin storm that starved pad input' (#312) from worktree-sddm-relogin-storm-fix into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
android / android (push) Canceled after 1m55s
deb / build-publish-gamescope (push) Successful in 48s
arch / build-publish (push) Canceled after 1m35s
deb / build-publish (push) Canceled after 1m20s
deb / build-publish-host (push) Canceled after 1m17s
deb / build-publish-client-arm64 (push) Canceled after 19s
deb / smoke-install (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 22s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #312
2026-08-18 21:14:11 +00:00
enricobuehler dd097d1ef2 chore(nix): record disk headroom after the publish build too
nix / flake (pull_request) Successful in 13m31s
ci / rust-arm64 (pull_request) Successful in 1m35s
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 2m17s
ci / bun-nix (pull_request) Successful in 1m39s
ci / rust (pull_request) Successful in 7m54s
This job is now the heaviest 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. The pre-existing Environment step reads df before any of
that happens, which is the less useful of the two moments.
2026-08-18 22:43:47 +02:00
enricobuehlerandClaude Opus 5 01946aa123 fix(pad-audio): a silent pad speaker looked like broken hardware, not a setting
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m5s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m3s
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m56s
ci / rust (pull_request) Failing after 3m27s
android / android (pull_request) Successful in 5m27s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 2m57s
A field session spent an evening measuring the HOST — sinks, tone injection,
per-channel peaks — for a pad speaker that was switched off at the other end.
A disabled toggle and dead hardware are indistinguishable from the pad, and
nothing said which one it was.

Two hints, at the two places somebody actually looks:

  * `punktfunk-session --pad-audio-test` now says, before the tone, whether
    `pad_speaker` / `pad_haptics` would stop a real session rendering the very
    thing the tone is about to prove works. The devtest drives the pad DIRECTLY
    and is otherwise blind to the settings, which is exactly what makes "the tone
    plays here but the game is silent" so confusing. It has to be said up front:
    the capability is never advertised when the toggle is off, so no later log
    line can catch it.
  * The Android settings row now states its default in the subtitle. Android is
    the one client that defaults this off, and that is a deliberate form-factor
    choice (a small, easily-startling loudspeaker in the user's hands, unlike
    haptics duplicating audio they already hear) — so the default stands and the
    row stops being silent about it. `pf_client_core`'s `default_pad_speaker` is
    `"pad"` and always has been, so the desktop clients were never the ones
    hiding it.

Worth recording for the next time: the host ALREADY had the signal. `native/
pad_audio.rs` logs `pad audio streaming … haptics=<bool> speaker=<bool>` at
stream start, and a client with the toggle off shows up there as
`speaker=false`. It was being printed on the host that was being measured all
evening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:42:18 +02:00
enricobuehler c0dcac7fa2 fix(sdk,tray): follow the mgmt port the host actually bound — a moved PUNKTFUNK_MGMT_BIND left every plugin and the tray dialing 47990
ci / bun-nix (pull_request) Successful in 27s
ci / rust (pull_request) Failing after 33s
ci / web (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m37s
nix / flake (pull_request) Failing after 1m28s
apple / swift (pull_request) Successful in 2m13s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m33s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m47s
android / android (pull_request) Successful in 7m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
Field report 2026-08-18, confirmed: the operator had moved the management API off 47990
(`PUNKTFUNK_MGMT_BIND` in host.env — the supported way to share a box with Sunshine/Apollo). The
web console followed, because it reads `<config_dir>/mgmt-endpoint`, the one line the host
publishes on every start with the port it REALLY bound. Nothing else did:

- The plugin runner / SDK resolved `PUNKTFUNK_MGMT_URL` → literal `https://127.0.0.1:47990`.
  The runner is a scheduled task (Windows) / systemd unit that inherits nothing from host.env —
  on Windows it cannot even read it — so every plugin, and the runner's own log shipper, dialed a
  dead port forever. Task Running, plugins never registering, empty library, and "no logs at all".
- The tray defaulted `--mgmt-port` to 47990 and told the operator to edit the autostart command
  line if they moved the bind. Nobody knows to do that; the tray reports a running host as
  unreachable.

One source, two readers, no new file:
- `sdk/src/config.ts::publishedMgmtUrl` reads `mgmt-endpoint`; `resolveConfig` uses it after the
  env override and before the 47990 default. Every plugin `connect()` follows, on every platform,
  with no unit/task changes. `runner-cli.ts` additionally exports it into `PUNKTFUNK_MGMT_URL`
  before any plugin loads, so a plugin still carrying an older vendored `@punktfunk/host` follows
  too (on Windows `reconcileSharedSdk` cannot refresh the read-only tree, so old copies can
  outlive several host upgrades). An explicit PUNKTFUNK_MGMT_URL still wins.
- `pf_paths::published_mgmt_port` (std-only leaf; the tray now depends on it) parses the same
  line. The tray's `mgmt_port` becomes `Option<u16>`: `--mgmt-port` pins, `None` re-reads the file
  on every poll tick, so a host restarted on a new port is picked up without relaunching the tray.

Swept the rest: the web console (`windows::service::spawn_web`, the systemd unit, NixOS module)
already sourced the file; the host CLI, plugin-kit (goes through the SDK), gaming-mode console
and native clients derive the port from discovery / the Welcome — no other literal remained on a
loopback path. The console's web port (47992) is not operator-configurable, so the tray's
literal there is not the same bug.

Verified: SDK 83 tests pass (4 new: absent file → default, published line followed, env wins,
blank = unset), `tsc` clean, biome clean; `pf-paths` unit test; `cargo fmt --check` clean;
`cargo clippy -p pf-paths -D warnings` clean; `cargo check -p punktfunk-tray -p pf-paths` on
Linux (docker rust:1.96) — the tray is cfg-gated off macOS. Not built on Windows from here.
2026-08-18 22:42:13 +02:00
enricobuehlerandClaude Opus 5 654c09d067 fix(pad-audio): WirePlumber met every new pad card at -23.88 dB, and both ends stacked
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^3 =
0.064 of linear amplitude, -23.88 dB. The setting is global — it cannot be
scoped to one device from configuration — so there was no config file to ship,
and it fires again on every fresh card, which for a usbip pad is every attach.

Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice:
nobody chose it and nobody would think to look for it (a pad's sink is not a
listening volume anyone reaches for, so it reads as weak hardware), 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^2 = -47.8 dB by the
time haptics reach a voice coil, which matches the -48 dB measured in the field.
That is the difference between "the haptics are subtle" and "I am not sure the
haptics are connected".

Both ends now set `channelVolumes` to unity — 1.0, which is unity in the linear
units the pod speaks AND in the cubed scale a mixer displays, the one value that
is unambiguous.

  * Client (`pin_sink_volume`, called from `correlate_pad_sink`): at every pick,
    so a card that re-minted its nodes after a profile change or a replug is
    re-pinned without anything having to notice. Skipped for the `split_parent`
    pick — that is a name lifted off another node's proplist, with no object of
    our own behind it, and pinning the sink that named it would pin the wrong
    node.
  * Host (`audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open`
    succeeds): the host half matters because `pad_usb` captures at the pad's
    isochronous OUT endpoint, DOWNSTREAM of this sink — PipeWire applies the
    volume when it mixes into the ALSA device, so what we encode and send is
    already attenuated and no client-side fix can recover it. It retries for 15 s
    rather than firing once: the USB device is live well before its ALSA card is,
    and pinning before WirePlumber has applied the default we are undoing would
    simply be overwritten. Only sinks belonging to a DualSense CARD are touched —
    `device.id` is what keeps it off a host's own minted pad sink, which
    publishes the full DualSense identity on purpose.

Neither end restores on the way out, deliberately: the profile swap next door
overrides a choice the user made, this overrides a default nobody made, and
putting -24 dB back would be restoring the bug. `PUNKTFUNK_PAD_SINK_VOLUME=0`
disables both, for bisecting a box where something else attenuates.

Both pods are unit-tested for the shape that actually matters — 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 back again and just as invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:42:06 +02:00
enricobuehlerandClaude Opus 5 ab88a8fb40 fix(pad-audio): the DualSense's only playback route was a mono sink, and games overran it
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback
sink: the 1-channel `…Default__Speaker__sink`. GE-Proton mints its synthetic
"Sony controller speaker" endpoint from that lone mono sink, and Marvel's
Spider-Man Remastered overruns it — reliably, ~74 s in:

    73.846 render_GetBuffer (…)->(5034, …)   <- GE's mono endpoint
    73.846 EXCEPTION_ACCESS_VIOLATION  info[0]=1 (WRITE)  info[1]=5CB9A000

Not a format mismatch: `GetMixFormat` and the game's `Initialize` both agree on
mono float32 `nBlockAlign 4`, and pulse sized `maxlength: 20136` = 5034 x 4
correctly. At the fault `rsi=rbp=0x13aa` (5034, the frame count) while
`rcx`/`rdx` are 5206/5207 — the copy loop had already run past the count. It is
a game/GE bug on a code path that ONLY EXISTS WHEN THE MONO SINK DOES.

So delete the mono sink rather than chase the overrun. `alsa-ucm-conf` describes
the pad as Speaker / Headphones / Mic / Headset and has never carried a
`SpeakerHaptic` device — the DualSense profile arrived upstream in 1.2.15
(36a111a) already without it, and the Deck's is a Valve downstream patch they
still carry on their own 1.2.16.1. With `SpeakerHaptic` at `PlaybackPriority
200` against `Speaker`'s 100 the card takes `HiFi (Mic, SpeakerHaptic)`, the
sink is the 4-channel one, and the mono sink — with the crash path — never
exists. The voice coils reach their own channels as a bonus.

Shipped WITHOUT replacing a file `alsa-ucm-conf` owns, which is what made this
awkward to package. `USB-Audio/USB-Audio.conf` ends with an unconditional,
optional include of `USB-Audio/conf.d/{vid}-{pid}.conf`, placed after its device
table has chosen `${var:ProfileName}` and before it includes the profile that
name resolves to — so a two-line drop-in keyed by 054c:0ce6 / 054c:0df2 swaps
the profile with no diversion, no `Conflicts`, and no `%config` fight. Verified
against alsa-lib rather than assumed: `ucm_cond.c` makes `Condition` optional
for a syntax-v8 `If` carrying `Append`, and `uc_mgr_evaluate_include` evaluates
each included subtree in place before moving to the next include, so the
`Define` lands before the profile include substitutes the variable. The hook and
the DualSense profile shipped in the SAME release (1.2.15), so every tree that
has the bug has the hook.

Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the
RPMs — deb, Arch). The client already has a working fallback in
`ensure_pro_audio`, and a shared file in two co-installable packages is a file
conflict for a nicety. NixOS is not covered: it has no /usr/share/alsa/ucm2 to
drop into and needs a package override instead.

`scripts/ci/check-dualsense-ucm.sh` runs the whole chain on a real distro tree
with no hardware, via UCM's card-less `conf.virt.d` path with only the four card
built-ins stubbed. Against pristine Fedora 44 alsa-ucm-conf 1.2.16.1: baseline
`Headphones/Headset/Mic/Speaker`; with the drop-in, `SpeakerHaptic` and
`HeadphonesHaptic` too, `PlaybackPriority/SpeakerHaptic=200` over `Speaker`'s
100, `PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3`. It exists
because this fix hooks another project's dispatcher: an upstream rename would
neuter it silently, and what comes back is the crash, not a quieter pad.
Negative-tested both ways (typo'd ProfileName, hook deleted).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:41:46 +02:00
enricobuehler 66249710b9 feat(nix): publish a binary cache so NixOS users stop compiling the workspace
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m21s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 5m19s
nix / flake (pull_request) Canceled after 3m6s
Every other channel ships prebuilt binaries; Nix was the exception — `nix build`
meant the whole Rust workspace *and* a gamescope build from source, roughly an
hour, and `host.gamescopeHdr` defaults true so that compositor build is on the
critical path of every `services.punktfunk.host.enable = true`.

nix.yml grows a third tier: on a push to main it builds the Rust packages plus
gamescope, signs them, and publishes to https://nix.unom.io. No new trigger is
needed for releases — a release bumps the workspace version in Cargo.toml, which
is already in the path filter.

Gitea cannot host this: it has 23 package registry types and none is Nix, and the
protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
/<hash>.narinfo, /nar/…) that /api/packages/{owner}/generic/… cannot express.
The RustFS 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 would compete with CI,
and S3 answers 403 for a missing key unless the bucket policy grants anonymous
ListBucket. Nix treats anything other than 404 as a hard error rather than a
cache miss, so that would break users' builds for packages the cache never held.
So it goes on unom-1 beside the flatpak repo, as a caddy:2-alpine container
serving a static tree — which is all a binary cache is.

Three decisions worth keeping:

* Only punktfunk's own store paths are published. The rest of a runtime closure
  is stock nixpkgs, already on cache.nixos.org behind a real CDN; mirroring it
  would spend disk and home-to-cloud bandwidth to serve a worse copy. That is
  ~300 MB per publish instead of several GB. The step asserts every built output
  is matched by the name filter, so a future pname change fails the build rather
  than silently dropping the most expensive package from the cache.
* NARs upload before narinfos, and rsync runs without --delete. A narinfo whose
  NAR has not landed is a hard download failure for whoever fetches it in that
  window; a NAR nothing points at is merely invisible.
* prune.sh from the first publish, not after the box fills. 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. It ages out narinfos, then sweeps unreferenced NARs —
  that order is the correctness argument, and it carries a self-check.

Verified locally: the Caddyfile serves a fixture cache with 200s on hits, 404 on
misses (the assertion the whole design rests on), and immutable cache headers;
prune.sh passes its self-test on Debian including empty and all-stale caches;
both workflows parse; the new run blocks pass shellcheck and dash -n. `set -eu`
rather than `-euo pipefail` — dash dies on the latter — with the two pipelines
whose left side must be able to fail rewritten as redirects.

Docs: README gains the substituter snippet, a maintainer runbook, and a warning
that inputs.punktfunk.inputs.nixpkgs.follows disables the cache entirely (every
store path changes, so every package rebuilds). The install guide gains the same
in short form.

The public key is a fill-in until the first publish prints it — see the setup
steps in packaging/nix/README.md.
2026-08-18 22:38:17 +02:00
enricobuehlerandClaude Opus 5 3717466594 fix(gamescope): the takeover's own mask was the relogin storm that starved pad input
ci / bun-nix (pull_request) Successful in 21s
ci / rust-arm64 (pull_request) Failing after 32s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 2m47s
ci / rust (pull_request) Successful in 5m56s
android / android (pull_request) Successful in 6m25s
A managed takeover on the .41 SDDM-autologin box entered a ~4-5 logins/s
relogin storm and every udev consumer drowned in the fallout. It presented
as "my DualSense is not detected in the game, or only with an insane delay":
the pad enumerated perfectly and then delivered input at ~1.4 Hz instead of
250 Hz, because `winebus` re-enumerates udev on every event and therefore
stops reading `hidraw`. An evening went into disproving the pad stack, the
ALSA UCM, PipeWire and GE-Proton before the display manager was suspected.

The storm was ours. Measured on .41 (2026-08-18):

    /usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop
      Exec=gamescope-session-plus ogui-steam
    /usr/share/gamescope-session-plus/gamescope-session-plus, last act:
      systemctl --user --wait start gamescope-session-plus@${CLIENT}.service

so the runtime mask this takeover lays sits *directly in sddm's relogin
path*. Every autologin then fails in milliseconds instead of taking the
seconds a real gamescope + Steam start costs, and sddm's `Relogin=true` has
no backoff: a slow, survivable relogin loop becomes a 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 as a udev-activated amplifier, load 26 on 12 cores.

The 2026-07-31 reading of the same box 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. That is
why masking looked inert, and why it was left as the *degraded* takeover for
SDDM when the DM stop could not be achieved. Masking without the stop is not
a weaker defense. It is the storm's engine.

So the mask no longer substitutes for the DM stop, on any flavor:

* `dm_plan` loses its `mask` input, and with it `dm_survives_masked_unit` —
  the SDDM/plasmalogin split existed only to pick a degraded mode, and there
  is no longer one to pick. A planned DM stop that does not land now fails
  the takeover and the caller degrades to ATTACH, which is a fully working
  stream at the session's own mode. Fighting an autologin we cannot stop is
  strictly worse than not taking over: it costs the user their input plane.
* The mask is laid only after the stop has LANDED. Both failure arms bail,
  so reaching the mask proves no DM is up to relogin through it. That is
  also what keeps mask-fragile flavors safe — a stopped plasmalogin cannot
  trip its own start limit, and every restore path unmasks before restarting.
* `skip` is now `!any_live` on every flavor: killing loaded-but-inactive
  leftovers frees no Steam, and masking them under a running DM is the storm.

Which of the two candidates in the handoff this was is now settled, on the
box: not (b). `pkexec /usr/libexec/punktfunk/pf-dm-helper linger` run from a
sessionless `systemd --user` context — the host's own context — exits 0;
lingering is on, bazzite is in group `punktfunk` (965), helper and polkit
action are installed and correctly annotated. The privileged path works
there. It is (a), and the fix is the invariant above rather than a better
`any_live`, because the DM ending up alive next to our mask is the fault
whatever put it there.

`any_live` is tightened anyway, stated as the negative: systemd has exactly
two not-running ACTIVE states and the other four all mean the unit still
owns Steam. The old list missed `deactivating` (and `reloading`), so a unit
caught mid-teardown read as a dead leftover — a box that IS in gaming mode
sampled as idle, its Steam left holding the single instance our own launch
then collides with. Small window on an idle box, wide open on a churning
one, which is exactly when this is sampled.

Two things the handoff asked for that are about never spending that evening
again:

* `DmHelperError::shape()` — the four shapes need four different fixes
  (package it / install polkit / fix the action / join the group), so they
  ride along as a greppable `shape` field, and a planned-but-unachieved DM
  stop now logs at ERROR naming it instead of vanishing into a WARN.
* `watch_for_relogin_storm()` — logind names its session files after a
  monotonic id in `/run/systemd/sessions`, so the max is a free login
  counter. Two `read_dir`s five seconds apart on a detached thread; above
  1/s it says so at ERROR, and says what it means: no audio, input or
  PipeWire measurement taken during a storm is valid. Detect-and-report
  only — self-mitigation would tear down a live stream on a detector that
  has never been wrong in the field because it has never run there.

Finally, `systemctl_system` captures its stderr and logs it at DEBUG. On an
unprivileged host that verb is *expected* to fail — it is the cheap probe
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 sessions on its own, each spent explaining a failure
that had already succeeded one line later.

Checked in the CI image (linux/amd64): `cargo fmt --check`, `cargo clippy
--all-targets -D warnings`, and `cargo test -p pf-vdisplay --lib gamescope`
— 52 passed, 1 ignored (needs a live `systemd --user` manager).

Refs: punktfunk-planning design/sddm-relogin-storm-starves-input-handoff.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:29:01 +02:00
enricobuehler c7c9500e89 fix(windows/scripting): the runner task writes a log file, so a runner that can't reach the host is no longer silent
Field report 2026-08-18, Windows host on 0.30: PunktfunkScripting task Running, Playnite and
Steam plugins installed, library empty, and "no logs at all for plugins" — nowhere on the box.

That is by construction, not by accident. The runner's only log door is the log shipper, which
tees console output to `POST /plugins/logs` over the mgmt API; the scheduled task itself had no
console and no file. So every failure that stops the runner reaching the host — LocalService
lost its read grant on plugin-token / native-cert.pem, a moved mgmt bind, a TLS pin miss, a
401 — is exactly the failure the shipper cannot report, and it leaves the same picture:
task Running, plugins never registering, an empty grid, and nothing to send when asked for logs.

`scripting-run.cmd` now redirects the runner's stdout+stderr to
`%ProgramData%\punktfunk\plugin-state\runner.log`, keeping the previous run as `runner.log.1`.
plugin-state is the one directory `plugins enable` makes writable for LocalService, and it
inherits Users-read from the config dir, so the operator can `type` it from any prompt.
Writability is probed with `copy /y nul` first; if the dir is not writable (the task was started
by the installer before `plugins enable` ever ran) the runner starts unlogged as before rather
than not at all. No `goto`: the file is stored LF and cmd's label scan is unreliable there.

The console's empty-Plugins hint (en/de) and the plugin docs now name the file; the log-ship
header no longer claims the task writes no file. Verified by reading only — no Windows box
reachable from here; the cmd semantics used (`copy nul` as a write probe, `if defined` blocks,
leading redirect on `echo`) are the boring ones.
2026-08-18 22:26:08 +02:00
205 changed files with 10628 additions and 4697 deletions
+6
View File
@@ -0,0 +1,6 @@
<!-- What and why — the diff says how. -->
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
live in `data/platforms.json`. (CONTRIBUTING.md "Where facts live"; `docs-drift` in CI only
catches the mechanical half.)
+38
View File
@@ -175,6 +175,19 @@ jobs:
- name: Test (unit + loopback + proptest + C ABI harness)
run: cargo test --workspace --locked
# The deep half of the docs-drift gates (the `docs-drift` job checks the docs-site copy
# and the textual rest): the committed spec must match what the binary actually serves.
# Build already compiled punktfunk-host with default features, so this re-links at worst.
# Byte diff on purpose — the generator is deterministic, and if that ever stops being
# true it deserves to surface here.
- name: OpenAPI spec drift gate
run: |
cargo run -p punktfunk-host --locked -- openapi > /tmp/openapi.regen.json
diff -u api/openapi.json /tmp/openapi.regen.json >/dev/null || {
echo "::error::api/openapi.json is stale — regenerate: cargo run -p punktfunk-host -- openapi > api/openapi.json && cp api/openapi.json docs-site/public/openapi.json"
exit 1
}
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
@@ -390,3 +403,28 @@ jobs:
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
- name: bun.nix drift gate
run: sh scripts/ci/check-bun-nix.sh
# Docs drift gates — pure git-grep textual checks, no cargo, no bun install (the deep half,
# regenerating the OpenAPI spec from the built host, rides in the `rust` job above). Same
# reasoning as bun-nix for being UNFILTERED: docs drift arrives through commits that look
# unrelated to docs — a renamed env var, a removed subcommand, a moved page.
docs-drift:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
# still exist, data/platforms.json parses.
- name: Docs drift gates
run: sh scripts/ci/check-docs-drift.sh
# Internal links only: /docs/* page links in docs-site content, relative file links in
# the repo's markdown. External URLs and #anchors are deliberately not checked.
- name: Docs link check
run: sh scripts/ci/check-docs-links.sh
+59 -13
View File
@@ -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
+63
View File
@@ -0,0 +1,63 @@
# Smoke test for the guided installer (scripts/install.sh, docs-and-onboarding overhaul WP4).
# Runs the script unattended inside a clean container per package family against the REAL
# package registry — the one path a textual gate can't cover: does the repo line, the key import
# and the install actually work today on a fresh box. `--no-start` because a container has no
# user systemd; the script degrades to printing the enable command, which is also under test.
#
# Path-filtered on purpose: it pulls ~100 MB of packages per family, so it runs when the script
# or its fact source changes, not on every push (check-docs-drift.sh gate 6 covers the cheap
# half — the install lines in the script must match data/platforms.json verbatim — on every push).
name: installer-smoke
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
pull_request:
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
jobs:
smoke:
name: smoke (${{ matrix.family }})
runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# actions/checkout needs git + node + CA certs in the container; curl is the
# script's own prerequisite (it says so and stops without it).
- family: debian-13
image: debian:trixie
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
- family: fedora-44
image: fedora:44
prep: dnf install -y -q curl git nodejs
- family: arch
image: archlinux:base
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
container:
image: ${{ matrix.image }}
steps:
- name: Prepare the container (${{ matrix.family }})
run: ${{ matrix.prep }}
- uses: actions/checkout@v4
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
# Root without sudo → the script's sudo shim, another path under test.
- name: Run the installer unattended
run: sh scripts/install.sh --yes --no-start
- name: The host is installed and conflict-free
run: |
punktfunk-host --version
punktfunk-host detect-conflicts
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
+187 -19
View File
@@ -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
@@ -151,7 +173,19 @@ jobs:
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
# here — so record the headroom, or a future failure is a guess.
- name: Environment
run: df -h / /nix /tmp || true
# Disk AND memory. This job's recurring failure is an OOM kill, and `df` cannot explain
# one — a run that dies at exit 137 with only disk numbers in the log is a guess.
run: |
df -h / /nix /tmp || true
free -h 2>/dev/null || grep -E '^(MemTotal|MemAvailable|SwapTotal)' /proc/meminfo || true
nproc 2>/dev/null || true
# THE number for this job's recurring exit 137. `free` and /proc/meminfo report the HOST
# inside a container, so they showed 125Gi total / 48Gi available on a run that then got
# bun SIGKILLed (19444) — a cgroup cap is invisible to them and is the only remaining
# explanation. cgroup v2 first, then v1; "max" means uncapped.
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|| echo "no cgroup memory limit readable"
# Evaluates + instantiates every flake output without building any of it.
- name: nix flake check (eval only)
@@ -183,10 +217,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 +364,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
+10
View File
@@ -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
View File
@@ -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: 3372 % 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 1560 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 4050/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).
+29 -3
View File
@@ -83,15 +83,41 @@ Two more gates that only apply to some changes:
instead of waiting for the CI job that compiles it.
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
and copy the snapshot the docs site serves:
and CI fails if the committed copy drifts. `api/openapi.json` is gated the same way: the `rust` job
regenerates the spec and diffs it against the committed file, and the `docs-drift` job checks that
`docs-site/public/openapi.json` — the snapshot the docs site serves — is a byte-for-byte copy of it.
Touch the management API and CI stays red until you regenerate and re-copy:
```sh
cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
```
## Where facts live (docs vs READMEs vs website)
Every user-facing fact has exactly one canonical home; everything else links to it. Duplicated
walkthroughs are how the docs drifted before — don't add new ones.
| Surface | Owns | Never contains |
|---|---|---|
| [docs-site](https://docs.punktfunk.unom.io) (`docs-site/content/`) | All user-facing facts: install, config, features, troubleshooting | Design rationale |
| READMEs (root, `packaging/*`, `scripts/*`) | Dev/packager rationale and pointers into the docs | User walkthroughs duplicated from docs-site |
| [punktfunk.unom.io](https://punktfunk.unom.io) (separate repo) | Marketing, downloads, blog | Instructions — it deep-links the docs instead |
| punktfunk-planning (private) | Design rationale, RFCs, plans | Anything user-facing |
Docs pages are written for one of two audiences, not both at once: the **get-started track**
(quickstart, install, pairing — short, one task per page, happy path only) assumes no Linux
expertise; the **reference track** (configuration, CLI, API, per-compositor pages) is allowed to be
dense. When a change touches a user-facing fact, update the docs-site page that owns it in the same
PR.
CI enforces the cheap half of this (`scripts/ci/check-docs-drift.sh` and `check-docs-links.sh`):
the OpenAPI snapshot must match `api/openapi.json`, the docs-site copy of `data/platforms.json` must
match the canonical one, `scripts/install.sh` must carry the file's install lines verbatim, every `PUNKTFUNK_*` variable the docs mention
must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables and undocumented
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
baseline in the script), and internal docs links must resolve.
Match the surrounding code's comment density and naming. Commit messages end with the
`Co-Authored-By` trailer (see `git log`).
Generated
+38 -36
View File
@@ -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",
@@ -3582,6 +3582,7 @@ dependencies = [
"hmac 0.13.0",
"if-addrs",
"libc",
"log",
"opus",
"proptest",
"quinn",
@@ -3604,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.30.0"
version = "0.31.0"
dependencies = [
"pf-encode",
"tracing",
@@ -3613,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.30.0"
version = "0.31.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3683,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.30.0"
version = "0.31.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3697,11 +3698,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 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.30.0"
version = "0.31.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -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"
+4 -29
View File
@@ -109,36 +109,11 @@ installer (all-vendor: NVIDIA, AMD, Intel).
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
installed first:
```sh
mkdir -p ~/.config/punktfunk
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
# (on Bazzite take host.env.bazzite instead)
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
systemctl --user enable --now punktfunk-host # the streaming host
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
```
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
line before the replacement — the install guide has the snippet). Then open
`https://<host-ip>:47992` and pair.
How the virtual display and input are wired up depends on your desktop — see
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
The per-platform guide walks you through the rest — first run, the web console, pairing, and the
desktop-specific wiring ([KDE](https://docs.punktfunk.unom.io/docs/kde) ·
[GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
[Sway](https://docs.punktfunk.unom.io/docs/sway).
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
nothing to run by hand — open the web console and pair. Use
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
`setup.exe` over the old install; uninstall from Add/Remove Programs.
[Sway](https://docs.punktfunk.unom.io/docs/sway)).
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.29.0"
"version": "0.31.0"
},
"paths": {
"/api/v1/client-logs": {
@@ -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
@@ -64,6 +65,7 @@ internal object ConsoleJson {
.put("online", online)
.put("mgmt_port", advert?.mgmtPort ?: h.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", !online && h.mac.isNotEmpty())
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os)
.put("pin", JSONObject.NULL)
@@ -105,6 +107,7 @@ internal object ConsoleJson {
.put("online", true)
.put("mgmt_port", d.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", false)
.put("clipboard_sync", false)
.put("last_used", JSONObject.NULL)
.put("os", d.os)
.put("pin", JSONObject.NULL)
@@ -128,6 +131,7 @@ internal object ConsoleJson {
.put("online", true)
.put("mgmt_port", h.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", false)
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", h.os)
.put("pin", pin?.let(::profileChip) ?: JSONObject.NULL)
@@ -229,16 +233,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) {
@@ -37,8 +37,10 @@ import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
@@ -104,6 +106,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`. */
@@ -212,6 +215,14 @@ object SkiaConsole {
main.post(object : Runnable {
override fun run() {
if (handle == 0L) return
// Only while the console is ON SCREEN (attached): parked behind the touch UI
// or a stream there is nobody to show the presence pips to — and mid-stream
// the radio belongs to the session, which is exactly why discovery stops for
// it. The timer keeps ticking so probes resume within a cadence of re-attach.
if (onConnected == null) {
main.postDelayed(this, 12_000)
return
}
val targets = knownHostStore.all().filter { kh -> discovered.none { kh.matches(it) } }
ioPool.execute {
val up = targets.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
@@ -251,12 +262,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 +283,7 @@ object SkiaConsole {
onSettingsChange = null
onQuit = null
onPlatformScreen = null
onPadAction = null
onPulse = null
}
@@ -388,7 +402,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)
}
@@ -532,13 +546,16 @@ object SkiaConsole {
c.optJSONObject("FetchLibrary")?.let { fetchLibrary(it, refreshOnly = false) }
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
c.optJSONObject("Pair")?.let(::pair)
c.optJSONObject("SendLogs")?.let { notice("Sending logs isn't available on this device yet") }
c.optJSONObject("SendLogs")?.let(::sendLogs)
c.optJSONObject("SaveHost")?.let(::saveHost)
c.optJSONObject("UpdateHost")?.let(::updateHost)
c.optJSONObject("ForgetHost")?.let(::forgetHost)
c.optJSONObject("Wake")?.let(::wake)
c.optJSONObject("SetPin")?.let(::setPin)
c.optJSONObject("BindProfile")?.let(::bindProfile)
c.optJSONObject("SetClipboard")?.let(::setClipboard)
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) }
}
@@ -577,6 +594,22 @@ object SkiaConsole {
pushHosts(); pushKnownHosts()
}
/** `ConsoleCmd::BindProfile` — the host's default binding (`KnownHost.profileId`); null clears. */
private fun bindProfile(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val pid = c.optString("profile_id")
.takeIf { c.has("profile_id") && !c.isNull("profile_id") && it.isNotEmpty() }
knownHostStore.save(kh.copy(profileId = pid))
pushHosts(); pushKnownHosts()
}
/** `ConsoleCmd::SetClipboard` — the per-host clipboard trust toggle. */
private fun setClipboard(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
knownHostStore.save(kh.copy(clipboardSync = c.optBoolean("on")))
pushHosts(); pushKnownHosts()
}
private fun setPin(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val pid = c.optString("profile_id"); val pin = c.optBoolean("pin")
@@ -586,6 +619,52 @@ object SkiaConsole {
pushHosts(); pushKnownHosts()
}
/**
* `ConsoleCmd::SendLogs` the native log ring (`nativeRenderLogs`) posted to this
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
* uses; the result comes back as a notice, in the desktop console's wording. The header
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) client
* log bundle`).
*/
private fun sendLogs(c: JSONObject) {
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
val hostName = c.optString("host_name").ifEmpty { addr }
val id = identity
if (id == null) {
notice("Identity not ready yet — try again in a moment")
return
}
val version = appContext?.let { app ->
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
} ?: "?"
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
ioPool.execute {
val err = runCatching {
val body = NativeBridge.nativeRenderLogs(header)
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
id.certPem, id.privateKeyPem, addr, fp,
)
val req = Request.Builder()
.url("https://$addr:$mgmt/api/v1/client-logs")
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { resp ->
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
}
}.getOrElse { it.message ?: "upload failed" }
main.post {
notice(
if (err.isEmpty()) {
"Logs sent to $hostName — download them from its web console's Logs page"
} else {
"Couldn't send logs — $err"
},
)
}
}
}
private fun pair(c: JSONObject) {
val addr = c.optString("addr"); val port = c.optInt("port")
val pin = c.optString("pin"); val name = c.optString("device_name")
@@ -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)
@@ -267,10 +287,14 @@ fun SkiaConsoleShell(
})
// Touch → the console's pointer (surface pixels): the escape hatch when no
// pad is attached, and the natural way to press a legend hint on a phone.
// A finger's down is kind 6 (the shell defers it so a swipe scrolls); a
// mouse — which Android delivers through this same listener — keeps kind 1
// and acts on the press, as a mouse should.
setOnTouchListener { v, ev ->
if (handle == 0L) return@setOnTouchListener false
val kind = when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> 1
MotionEvent.ACTION_DOWN ->
if (ev.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) 1 else 6
MotionEvent.ACTION_MOVE -> 0
MotionEvent.ACTION_UP -> 2
MotionEvent.ACTION_CANCEL -> 5
@@ -293,16 +317,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
@@ -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() }
}
@@ -146,6 +146,14 @@ object NativeBridge {
name: String,
): String
/**
* The native client's recent log ring rendered as one text bundle, oldest first,
* prefixed by [header] (this app's identity line) the body for "Send logs to host"
* (`POST /api/v1/client-logs` over the same mTLS client the library fetch uses).
* Never empty; cheap (string copy, no I/O).
*/
external fun nativeRenderLogs(header: String): String
/**
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
* (`""` when none) call right after a `0` handle / `""` fingerprint. A typed host rejection
+24 -1
View File
@@ -249,6 +249,13 @@ impl ConsoleHost {
}
}
/// No input for this long = the console is being looked at, not used — halve the redraw
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
/// afterglow at full smoothness and only calms a genuinely parked screen.
const IDLE_AFTER: Duration = Duration::from_secs(60);
/// One extra ~vsync period per frame while idle: 60 Hz → ~30, 120 Hz → ~40.
const IDLE_FRAME_STEP: Duration = Duration::from_millis(16);
/// The render thread. Owns EGL + Skia + the console; runs until `Cmd::Quit`.
fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotStore>) -> Result<()> {
let egl = EglContext::new()?;
@@ -267,6 +274,8 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
let mut was_editing = console.editing();
let mut saved_gen = store.saved_gen();
let mut menu_out: Vec<MenuEvent> = Vec::new();
// When the last input arrived — the idle throttle's clock (see the draw site below).
let mut last_input = Instant::now();
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
// torn down mid-create); a run of them is a context that is not coming back — most likely
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
@@ -304,21 +313,28 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
return Ok(());
}
Cmd::Menu(ev) => {
last_input = Instant::now();
if let Some(p) = console.menu(ev) {
shared.emit(HostEvent::Pulse(p));
}
}
Cmd::PadSample(s) => {
last_input = Instant::now();
sample = s;
poll_now = true;
}
Cmd::Pointer(p) => {
last_input = Instant::now();
console.pointer(p);
}
Cmd::Key { key, shift, repeat } => {
last_input = Instant::now();
console.key(key, shift, repeat);
}
Cmd::Text(t) => console.text(&t),
Cmd::Text(t) => {
last_input = Instant::now();
console.text(&t);
}
Cmd::Phase(ph) => {
match &ph {
Phase::Connecting => console.session_phase(SessionPhase::Connecting),
@@ -413,6 +429,13 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
}
// Draw, if there is somewhere to draw.
// ponytail: half-rate after 60 s without input — one extra frame period between
// swaps, so an idle carousel stops redrawing a phone's panel at its full rate
// (the aurora still breathes, at half tempo). Any input restores full rate on
// its own frame; damage-driven rendering if a TV box ever needs more.
if last_input.elapsed() >= IDLE_AFTER {
std::thread::sleep(IDLE_FRAME_STEP);
}
if let (Some(s), Some(g)) = (surface.as_mut(), gpu.as_mut()) {
let (w, h) = (s.width, s.height);
let need_wrap = match &skia {
+24 -4
View File
@@ -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)]
@@ -316,8 +323,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleMenu
}
/// `NativeBridge.nativeConsolePointer(handle, kind, x, y, dy)` — touch/mouse in surface pixels:
/// kind 0 move, 1 primary down, 2 primary up, 3 secondary down (= Back), 4 wheel (`dy` steps,
/// + = up), 5 cancel.
/// kind 0 move, 1 primary down (a mouse — acts immediately), 2 primary up, 3 secondary down
/// (= Back), 4 wheel (`dy` steps, + = up), 5 cancel, 6 primary down from a finger/stylus on
/// the glass — the shell defers it so a swipe scrolls instead of acting on contact.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePointer(
_env: EnvUnowned,
@@ -334,6 +342,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePoin
x,
y,
button: PointerButton::Primary,
touch: false,
},
2 => PointerInput::Up {
x,
@@ -344,9 +353,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePoin
x,
y,
button: PointerButton::Secondary,
touch: false,
},
4 => PointerInput::Wheel { x, y, dy },
5 => PointerInput::Cancel,
6 => PointerInput::Down {
x,
y,
button: PointerButton::Primary,
touch: true,
},
_ => return,
};
if let Some(h) = host(handle) {
@@ -454,8 +470,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 +496,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)]
+45 -6
View File
@@ -34,6 +34,9 @@ mod audio;
// shell over EGL/GLES, on every ABI (the armv7 Skia archive is self-hosted — see Cargo.toml).
#[cfg(target_os = "android")]
mod console;
// "Send logs to host": the log-ring upload (`pf-client-core` is Android-target-only here).
#[cfg(target_os = "android")]
mod logs;
// The RESOLVED audio format + its ms ⇄ sample arithmetic, split out of `audio` and — unlike it —
// ungated, because that arithmetic is what a rate the ladder does not divide gets wrong (44 100 Hz
// used to come out 2.3 % off in every direction at once) and it must be provable without a phone.
@@ -60,22 +63,58 @@ mod wol;
// it off the main thread to light saved-host "online" pips independently of mDNS.
mod probe;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
/// Every `log` record, teed: to logcat (via [`android_logger::AndroidLogger`]) AND into
/// `pf_client_core::logring` — the source for the console's "Send logs to host" action
/// ([`logs`]). The ring line mirrors the desktop `ring_layer`'s shape (wallclock, level,
/// target, message) so a bundle reads the same on the host's Logs page whichever client
/// sent it. Both sinks share the crate's Info ceiling — the field ring gets exactly what
/// logcat gets, which also keeps per-frame DEBUG chatter out of it by construction.
#[cfg(target_os = "android")]
struct RingTee(android_logger::AndroidLogger);
#[cfg(target_os = "android")]
impl log::Log for RingTee {
fn enabled(&self, metadata: &log::Metadata) -> bool {
self.0.enabled(metadata)
}
fn log(&self, record: &log::Record) {
self.0.log(record);
pf_client_core::logring::note(format!(
"{} {:5} {} {}",
pf_client_core::logring::wallclock(),
record.level().as_str(),
record.target(),
record.args()
));
}
fn flush(&self) {
self.0.flush();
}
}
/// Initialize logging once when the JVM loads the library: logcat under the `punktfunk` tag,
/// teed into the client log ring (see [`RingTee`]). Core `tracing` events (transport warnings:
/// socket-buffer clamp, QoS failures) arrive here too: tracing's "log" feature — declared
/// explicitly in Cargo.toml rather than relied on via quinn's defaults — forwards them as
/// `log` records since no tracing subscriber is ever installed. Android-only — there is no
/// JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn JNI_OnLoad(
_vm: *mut jni::sys::JavaVM,
_reserved: *mut std::ffi::c_void,
) -> jint {
android_logger::init_once(
let logcat = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("punktfunk"),
);
// `set_boxed_logger` (unlike `init_once`) does not set the max level itself.
if log::set_boxed_logger(Box::new(RingTee(logcat))).is_ok() {
log::set_max_level(log::LevelFilter::Info);
}
log::info!(
"punktfunk_android loaded (core ABI v{})",
punktfunk_core::ABI_VERSION
+27
View File
@@ -0,0 +1,27 @@
//! JNI seam for "Send logs to host": hand Kotlin the client's recent log ring (fed by the
//! [`crate::RingTee`] logcat tee) rendered as one text bundle. The UPLOAD stays on the
//! Kotlin side — its mTLS OkHttp client (`mtlsHttpClient`, the library/art path) already
//! owns HTTPS-to-the-pinned-host on this platform, and `logring::send_to_host`'s ureq
//! agent is deliberately desktop-only. Android-gated (unlike [`crate::wol`]/[`crate::probe`])
//! because `pf-client-core` is an Android-target dependency of this crate.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::EnvUnowned;
/// `NativeBridge.nativeRenderLogs(header): String` — the ring as one text bundle, oldest
/// first, prefixed by `header` (the Kotlin side's identity line) and an eviction note when
/// the ring wrapped. Never empty (the header line is always present); cheap enough for any
/// thread, though the caller is about to do network anyway.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeRenderLogs<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
header: JString<'local>,
) -> JString<'local> {
env.with_env(|env| {
let header: String = header.try_to_string(env)?;
env.new_string(pf_client_core::logring::render(&header))
})
.resolve::<LogErrorAndDefault>()
}
@@ -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 {
+4
View File
@@ -31,6 +31,10 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
Keychain-stored identity.
- **Tune the stream** — a fps / Mb·s / **latency** HUD (skew-corrected across machines), a bitrate
control, a per-host **network speed test** with a recommended bitrate, and a host-compositor picker.
- **Send logs to host** — the app keeps its recent log in a bounded in-memory ring (`ClientLog`, a
drop-in for `os.Logger` that also writes the unified log); a host card's menu (or the gamepad
UI's host options) posts it to the paired host's `/api/v1/client-logs`, where the web console's
Logs page shows it next to the host's own — the same action the Gaming Mode console has.
Runs from one shared codebase across **macOS, iOS, iPadOS, and tvOS**.
@@ -654,6 +654,7 @@ struct GamepadHomeView: View {
guard let profile = target.profile else { return }
store.setPinned(host.id, profileID: profile.id, pinned: false)
},
onSendLogs: host.pinnedSHA256 != nil ? { await SendLogs.toHost(host) } : nil,
close: { if !transitioning { hostOptionsTarget = nil } },
controllerActive: active)
}
@@ -64,6 +64,9 @@ struct GamepadHostOptionsView: View {
/// Delete the saved record outright.
let onRemove: () -> Void
let onUnpin: () -> Void
/// Upload this device's recent log to the host; answers with what to tell the user. nil on an
/// unpaired host the upload rides the pairing, so there is nothing to offer before it.
var onSendLogs: (() async -> (ok: Bool, message: String))?
var close: (() -> Void)?
var controllerActive = true
@@ -81,13 +84,21 @@ struct GamepadHostOptionsView: View {
/// strict as it is, and none at all to be looser.
@State private var armed = false
@State private var copied = false
/// The send-logs row's own state: its label and the detail band report the outcome in place,
/// the same way Copy link says "Copied" this surface has no toast.
@State private var sendLogs: SendLogsState = .idle
@State private var focusID: String?
private enum SendLogsState: Equatable {
case idle, sending, done(ok: Bool, message: String)
}
private enum Action: String {
case wake
case copyLink
case edit
case forgetPairing
case sendLogs
case remove
case unpin
case cancel
@@ -195,6 +206,15 @@ struct GamepadHostOptionsView: View {
}
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
if onSendLogs != nil {
let label: String
switch sendLogs {
case .idle: label = "Send logs to host"
case .sending: label = "Sending logs\u{2026}"
case .done(let ok, _): label = ok ? "Logs sent" : "Couldn't send logs"
}
list.append(Row(action: .sendLogs, label: label, icon: "doc.text"))
}
// Only a paired host has a pairing to drop.
if host.pinnedSHA256 != nil {
list.append(Row(
@@ -221,6 +241,9 @@ struct GamepadHostOptionsView: View {
case .forgetPairing:
return "Drop the stored fingerprint. The host stays saved and the next connect "
+ "pairs again."
case .sendLogs:
if case .done(_, let message) = sendLogs { return message }
return "Upload this device's recent log to the host, for its web console's Logs page."
case .remove:
return armed
? "Press again to remove — this cannot be undone."
@@ -263,6 +286,15 @@ struct GamepadHostOptionsView: View {
case .forgetPairing:
onForgetPairing()
performClose()
case .sendLogs:
guard let onSendLogs, sendLogs != .sending else { return }
withAnimation(.smooth(duration: 0.2)) { sendLogs = .sending }
Task {
let outcome = await onSendLogs()
withAnimation(.smooth(duration: 0.2)) {
sendLogs = .done(ok: outcome.ok, message: outcome.message)
}
}
case .remove:
guard armed else {
withAnimation(.smooth(duration: 0.2)) { armed = true }
@@ -45,6 +45,8 @@ struct HomeView: View {
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet.
@State private var editTarget: StoredHost?
/// The outcome of the last "Send Logs to Host" drives its alert.
@State private var sendLogsResult: (ok: Bool, message: String)?
// How this device shows its own list. `.added` is the default because it is what the grid
// did before it could sort at all an update should not rearrange anyone's hosts.
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
@@ -194,6 +196,16 @@ struct HomeView: View {
}
#endif
}
.alert(
sendLogsResult?.ok == true ? "Logs Sent" : "Couldn't Send Logs",
isPresented: Binding(
get: { sendLogsResult != nil },
set: { if !$0 { sendLogsResult = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(sendLogsResult?.message ?? "")
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 360)
#endif
@@ -292,6 +304,8 @@ struct HomeView: View {
onBrowseLibrary: onBrowseLibrary,
onWake: { wake(host) },
onEdit: { editTarget = host },
onSendLogs: host.pinnedSHA256 != nil
? { Task { sendLogsResult = await SendLogs.toHost(host) } } : nil,
profileMenu: profileMenu(for: host),
pinnedProfile: pinned)
}
@@ -137,6 +137,9 @@ struct HostCardView: View {
var onWake: (() -> Void)? = nil
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
var onEdit: (() -> Void)? = nil
/// Upload this device's recent log to the host (`SendLogs`). `nil` when the host is unpaired
/// the upload is authenticated by the pairing, so there is nothing to offer before it.
var onSendLogs: (() -> Void)? = nil
/// This card's profile affordances nil on surfaces that don't offer them.
var profileMenu: HostProfileMenu? = nil
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
@@ -252,6 +255,9 @@ struct HostCardView: View {
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if let onSendLogs {
Button("Send Logs to Host", action: onSendLogs)
}
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
Button("Wake Host", systemImage: "power", action: onWake)
}
@@ -4,6 +4,7 @@
#if os(macOS)
import AppKit
#endif
import PunktfunkKit
import SwiftUI
@main
@@ -13,6 +14,9 @@ struct PunktfunkClientApp: App {
#endif
init() {
// Before anything touches the core, so its first lines (identity load, the first connect's
// transport setup) land in the log ring "Send logs to host" uploads.
CoreLog.install()
#if os(iOS)
// Put Geist on the navigation titles before any bar is built.
BrandTheme.apply()
@@ -19,7 +19,11 @@ import SwiftUI
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
/// refresh period on the vsync-latched platforms this is how to measure with it off.
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
private let statsLog = ClientLog(category: "stats")
/// The session's lifecycle connect asked/landed/refused, how it ended. Until this existed a
/// client log bundle had a 1 Hz stats line and no sentence saying which host it was streaming
/// from, with what, or why it stopped; the host's own log has always said all three.
private let sessionLog = ClientLog(category: "session")
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
///
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
@@ -448,6 +452,11 @@ final class SessionModel: ObservableObject {
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
// hardware-decode probe below still gates what can actually be advertised.
let want444 = effective.enable444
let connectLine = "connect \(host.displayName) \(host.address):\(host.port) "
+ "mode=\(width)x\(height)@\(hz) codec=\(effective.codec) bitrate=\(bitrateKbps)kbps "
+ "hdr=\(hdrCapable) 444=\(want444) audio=\(audioChannels)ch/\(audioRateHz)Hz/\(audioBits)bit "
+ "pinned=\(pin != nil) tofu=\(allowTofu) launch=\(launchID ?? "-")"
sessionLog.info("\(connectLine, privacy: .public)")
Task.detached(priority: .userInitiated) {
// PunktfunkConnection.init blocks on the QUIC handshake keep it off the main
// actor. The persistent identity is presented on every connect so a paired
@@ -530,6 +539,14 @@ final class SessionModel: ObservableObject {
}
switch result {
case .success(let conn):
let landed = "connected \(host.displayName) "
+ "mode=\(conn.width)x\(conn.height)@\(conn.refreshHz) "
+ "codec=\(conn.videoCodec) bitrate=\(conn.resolvedBitrateKbps)kbps "
+ "depth=\(conn.bitDepth) chroma=\(conn.isChroma444 ? "444" : "420") hdr=\(conn.isHDR) "
+ "audio=\(conn.resolvedAudioChannels)ch/\(conn.resolvedAudioRateHz)Hz/\(conn.resolvedAudioBits)bit "
+ "shard=\(conn.shardPayload) compositor=\(conn.resolvedCompositor.rawValue) "
+ "gamepad=\(conn.resolvedGamepad.rawValue) mgmt=\(conn.hostMgmtPort)"
sessionLog.info("\(landed, privacy: .public)")
if pin != nil || autoTrust || requestAccess {
// requestAccess: the operator approved this device on the host, so the
// session is trusted stream directly (the caller pins it as paired).
@@ -553,6 +570,8 @@ final class SessionModel: ObservableObject {
+ "Pair with its PIN before streaming."
}
case .failure(let error):
sessionLog.warning(
"connect \(host.displayName, privacy: .public) failed: \(String(describing: error), privacy: .public)")
self.phase = .idle
self.activeHost = nil
SessionSettings.end() // the dial failed back to the plain globals
@@ -782,6 +801,10 @@ final class SessionModel: ObservableObject {
/// `disconnectQuit()` so the host skips the keep-alive linger; `sessionEnded()` (a host-ended /
/// dropped session) passes `false` to leave the linger intact.
func disconnect(deliberate: Bool = true) {
if connection != nil {
let line = "disconnect \(activeHost?.displayName ?? "-") deliberate=\(deliberate) phase=\(phase)"
sessionLog.info("\(line, privacy: .public)")
}
statsTimer?.invalidate()
statsTimer = nil
// Release the session's resolved settings: from here every reader falls back to the plain
@@ -902,6 +925,9 @@ final class SessionModel: ObservableObject {
// The shelf it came off falling back to the host's own if a caller launched a title
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
let endLine = "session ended by \(name) reason=\(reason) "
+ "rejection=\(rejection.map { String(describing: $0) } ?? "-")"
sessionLog.info("\(endLine, privacy: .public)")
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
if let rejection {
// The shared typed-rejection wording ("Your access to this host has expired").
@@ -522,7 +522,25 @@ extension SettingsView {
}
described(inhibitShortcutsDescription, field: "inhibit_shortcuts") {
Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts))
// Turning it ON is the moment to ask for Accessibility never at stream start,
// where a TCC dialog over a captured stream would be the surprise.
.onChange(of: effective.inhibitShortcuts) { was, on in
if on, !was, !accessibilityTrusted { InputCapture.requestSystemShortcutAccess() }
}
if effective.inhibitShortcuts, !accessibilityTrusted {
Button("Allow Accessibility access…") {
InputCapture.requestSystemShortcutAccess()
// The prompt's own "Open System Settings" only shows the FIRST time the system
// asks; after that the user has to find the pane themselves open it for them.
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
}
}
}
.onReceive(NotificationCenter.default.publisher(
for: NSApplication.didBecomeActiveNotification
)) { _ in accessibilityTrusted = InputCapture.systemShortcutsAvailable }
#endif
described(
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
@@ -549,8 +567,13 @@ extension SettingsView {
if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop {
return "No effect under the desktop mouse model — switch Mouse input to Capture."
}
return "Sends ⌘ shortcuts to the host while captured. ⌘⎋ always stays local — it "
+ "releases capture."
if accessibilityTrusted {
return "Sends ⌘ shortcuts — ⌘Space, ⌘Tab and Mission Control included — to the host "
+ "while captured. ⌘⎋ always stays local — it releases capture."
}
return "Sends the app's ⌘ shortcuts (⌘Q, ⌘W, ⌘H…) to the host while captured. ⌘Space, "
+ "⌘Tab and Mission Control need Accessibility access — macOS claims them before any "
+ "app sees them. ⌘⎋ always stays local — it releases capture."
}
/// The SELECTED mouse model explained dynamic, like the touch-mode caption.
@@ -124,6 +124,10 @@ struct SettingsView: View {
/// instead of the app menu while captured). macOS-only: it is the one platform whose window
/// system hands a plain app no keyboard grab, so the client has to claim the chords itself.
@AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true
/// Accessibility granted? Gates the system-shortcut half of `inhibit_shortcuts` (Space, Tab
/// need the event tap). Re-read whenever the app comes back to the front that is when the
/// user returns from flipping the switch in System Settings.
@State var accessibilityTrusted = InputCapture.systemShortcutsAvailable
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
@AppStorage(DefaultsKey.micUID) var micUID = ""
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
@@ -0,0 +1,39 @@
// "Send logs to host" the one action behind the host card's menu item and the gamepad options
// row. Posts `ClientLogRing` to the PAIRED host (`LibraryClient.sendLogs`), where the web
// console's Logs page shows it next to the host's own log. The Apple port of the Gaming Mode
// console's `ConsoleCmd::SendLogs` (clients/session/src/console.rs), same wording on success.
import Foundation
import PunktfunkKit
private let log = ClientLog(category: "logs")
enum SendLogs {
/// Upload this device's recent log to `host`. Never throws: the caller shows `message` either
/// way, and the outcome is itself the last line of the NEXT bundle.
static func toHost(_ host: StoredHost) async -> (ok: Bool, message: String) {
// The same two preconditions the library screen applies: this device's mTLS identity
// (minted on the first connect) and the host's pinned fingerprint (pairing) an upload
// is an outbound write carrying the device's diagnostics, and it goes to a host the user
// has actually paired with, not to whoever answers on that port.
guard let identity = (try? ClientIdentityStore.shared.load())?.identity else {
return (false, "Connect to this host once first — sending logs uses the identity "
+ "created on the first connect.")
}
guard let pin = host.pinnedSHA256 else {
return (false, "Pair with \(host.displayName) first — logs are only sent to a paired host.")
}
do {
let id = try await LibraryClient.sendLogs(
address: host.address, port: host.effectiveMgmtPort,
certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: pin)
log.info("client logs uploaded to \(host.displayName, privacy: .public) id=\(id, privacy: .public)")
return (true, "Logs sent to \(host.displayName) — download them from its web console's "
+ "Logs page.")
} catch {
let why = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
log.warning("client log upload to \(host.displayName, privacy: .public) failed: \(why, privacy: .public)")
return (false, "Couldn't send logs — \(why)")
}
}
}
@@ -16,7 +16,7 @@ import os
import CoreAudio
#endif
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
private let log = ClientLog(category: "audio")
final class AudioDeviceWatcher {
/// Why the owner is being told. Only for the log line every reason leads to the same
@@ -38,7 +38,7 @@
import AVFoundation
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
private let log = ClientLog(category: "audio")
/// Render-block-owned scratch storage: freed exactly when the closure (and thus the
/// last possible render call) is released never racing CoreAudio.
@@ -235,6 +235,40 @@ public enum LibraryClient {
return status.games ?? []
}
/// Upload this client's recent log (`ClientLogRing`) to the host `POST /api/v1/client-logs`,
/// the one WRITE a paired certificate may make (the host's `mgmt/client_logs.rs`). Same lane
/// and identity as the library; the host files the bundle under this device and shows it on
/// its web console's Logs page next to its own log. Returns the stored bundle id (empty for a
/// host that predates the id in the reply).
///
/// Why it exists: on an Apple TV (or a phone, for anyone who is not a developer) there is no
/// way to get the client's log off the device, so every fault report arrived with only the
/// host's half of the story. `hostFingerprint` is required, not optional: this is an outbound
/// write carrying the device's diagnostics, and it goes to the host the user paired with.
public static func sendLogs(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data
) async throws -> String {
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
let body = Data(ClientLogRing.render(header: ClientLogRing.header()).utf8)
let response = try await send(
path: "/api/v1/client-logs", address: address, port: port,
identity: identity, hostFingerprint: hostFingerprint,
body: (body, "text/plain; charset=utf-8"))
switch response.status {
case 200, 201:
let json = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any]
return json?["id"] as? String ?? ""
case 401, 403:
throw LibraryError.unauthorized
default:
throw LibraryError.http(response.status)
}
}
/// Just the slice of `/status` this client reads. Everything else on that payload is the
/// operator console's business, and decoding only what we use keeps an unrelated schema change
/// on the host from breaking the library screen.
@@ -259,12 +293,20 @@ public enum LibraryClient {
}
}
/// One GET against the host, with transport failures mapped onto `LibraryError`.
/// One request against the host a GET, or a POST when `body` is given with transport
/// failures mapped onto `LibraryError`.
static func send(
path: String, address: String, port: UInt16,
identity: SecIdentity, hostFingerprint: Data?
identity: SecIdentity, hostFingerprint: Data?,
body: (data: Data, contentType: String)? = nil
) async throws -> HTTPResponse {
do {
if let body {
return try await MgmtTransport.post(
host: address, port: port, path: path, body: body.data,
contentType: body.contentType,
identity: identity, pinnedHostFingerprint: hostFingerprint)
}
return try await MgmtTransport.get(
host: address, port: port, path: path,
identity: identity, pinnedHostFingerprint: hostFingerprint)
@@ -56,6 +56,41 @@ enum MgmtTransport {
identity: SecIdentity,
pinnedHostFingerprint: Data?,
timeout: TimeInterval = 15
) async throws -> HTTPResponse {
try await request(
host: host, port: port, method: "GET", path: path, body: nil, contentType: nil,
identity: identity, pinnedHostFingerprint: pinnedHostFingerprint, timeout: timeout)
}
/// `POST https://host:port/path` with a body same transport, trust and retry rule as `get`.
/// The one write a paired device may make is the client-log upload, which is idempotent in
/// the only sense that matters (a retried bundle is a second bundle, not a corrupted one).
static func post(
host: String,
port: UInt16,
path: String,
body: Data,
contentType: String,
identity: SecIdentity,
pinnedHostFingerprint: Data?,
timeout: TimeInterval = 15
) async throws -> HTTPResponse {
try await request(
host: host, port: port, method: "POST", path: path, body: body,
contentType: contentType, identity: identity,
pinnedHostFingerprint: pinnedHostFingerprint, timeout: timeout)
}
private static func request(
host: String,
port: UInt16,
method: String,
path: String,
body: Data?,
contentType: String?,
identity: SecIdentity,
pinnedHostFingerprint: Data?,
timeout: TimeInterval
) async throws -> HTTPResponse {
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
throw MgmtTransportError.invalidPort(port)
@@ -70,7 +105,9 @@ enum MgmtTransport {
}
let wasReused = connection.hasServedRequest
do {
let response = try await connection.perform(path: path, timeout: timeout)
let response = try await connection.perform(
method: method, path: path, body: body, contentType: contentType,
timeout: timeout)
await MgmtConnectionPool.shared.release(connection, key: key)
return response
} catch {
@@ -230,7 +267,10 @@ final class MgmtConnection: @unchecked Sendable {
private let rejection: RejectionFlag
private final class RejectionFlag: @unchecked Sendable { var value = false }
func perform(path: String, timeout: TimeInterval) async throws -> HTTPResponse {
func perform(
method: String = "GET", path: String, body: Data? = nil, contentType: String? = nil,
timeout: TimeInterval
) async throws -> HTTPResponse {
try await withCheckedThrowingContinuation { continuation in
queue.async {
guard self.phase != .dead else {
@@ -240,7 +280,9 @@ final class MgmtConnection: @unchecked Sendable {
self.operation += 1
let op = self.operation
self.pending = continuation
self.pendingRequest = self.requestBytes(path: path)
self.pendingRequest = Self.requestBytes(
host: self.host, port: self.port,
method: method, path: path, body: body, contentType: contentType)
self.buffer.removeAll(keepingCapacity: true)
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
guard let self, self.operation == op else { return }
@@ -361,17 +403,24 @@ final class MgmtConnection: @unchecked Sendable {
rejection.value ? .pinMismatch : .connection(String(describing: error))
}
private func requestBytes(path: String) -> Data {
/// The wire bytes of one request. Pure (and `static`) so the framing is unit-testable.
static func requestBytes(
host: String, port: UInt16,
method: String, path: String, body: Data?, contentType: String?
) -> Data {
// An IPv6 literal is bracketed in the Host header (RFC 9110 §7.2); a name or IPv4 is not.
let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
let request = """
GET \(path) HTTP/1.1\r
Host: \(authority)\r
User-Agent: punktfunk-apple\r
Accept: */*\r
\r
"""
return Data(request.utf8)
var head = "\(method) \(path) HTTP/1.1\r\nHost: \(authority)\r\n"
+ "User-Agent: punktfunk-apple\r\nAccept: */*\r\n"
if let body {
// Always framed by length a request body has no EOF to end it on a kept-alive
// connection, and the host's axum would otherwise wait for one.
head += "Content-Type: \(contentType ?? "application/octet-stream")\r\n"
head += "Content-Length: \(body.count)\r\n"
}
head += "\r\n"
var request = Data(head.utf8)
if let body { request.append(body) }
return request
}
}
@@ -19,7 +19,7 @@ import IOKit
import IOKit.hid
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
private let log = ClientLog(category: "gamepad")
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
///
@@ -3,7 +3,7 @@ import Foundation
import GameController
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
private let log = ClientLog(category: "gamepad")
/// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
/// policy is unit-testable without a `CHHapticEngine` or a physical pad.
@@ -48,7 +48,7 @@ import os
/// PUNKTFUNK_INPUT_DEBUG=1 in the environment to surface whether relative motion + buttons
/// are actually being SENT to the host without needing host-side logs. Motion is throttled
/// to once per second (see `motionDebugTick`); buttons log every transition.
private let inputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
private let inputLog = ClientLog(category: "input")
private let inputDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
public final class InputCapture {
@@ -60,6 +60,10 @@ public final class InputCapture {
private var keyboards: [GCKeyboard] = []
#if os(macOS)
private var keyEventMonitor: Any?
/// The system-shortcut tap (see `installSystemKeyTap`) and its run-loop source. Live only
/// while forwarding with `inhibit_shortcuts` on AND Accessibility granted; nil otherwise.
private var systemKeyTap: CFMachPort?
private var systemKeyTapSource: CFRunLoopSource?
#endif
// Main-queue-only state (see header comment).
@@ -194,7 +198,13 @@ public final class InputCapture {
if on {
forwarding = true
suppressedButton = suppressClick ? 1 : nil
#if os(macOS)
installSystemKeyTap()
#endif
} else if forwarding {
#if os(macOS)
removeSystemKeyTap()
#endif
releaseAll()
forwarding = false
suppressedButton = nil
@@ -369,6 +379,7 @@ public final class InputCapture {
NSEvent.removeMonitor(monitor)
keyEventMonitor = nil
}
removeSystemKeyTap()
#endif
// Don't clobber the handlers if a newer capture has taken the global devices.
if Self.activeCapture === self || Self.activeCapture == nil {
@@ -672,6 +683,128 @@ public final class InputCapture {
}
commandChordVKs.removeAll()
}
// MARK: - System shortcut tap
/// Whether the system-shortcut tap CAN run: Accessibility granted to this process. Read live
/// (the user flips it in System Settings while the app runs); never prompts the prompt is the
/// Settings toggle's job (`requestSystemShortcutAccess`), not something a stream start springs.
public static var systemShortcutsAvailable: Bool { AXIsProcessTrusted() }
/// Show the one-time Accessibility prompt (a no-op once granted). Called from Settings when the
/// user turns "Capture system shortcuts" on or presses the grant button.
public static func requestSystemShortcutAccess() {
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
}
/// The other half of `inhibit_shortcuts` on macOS. The keyDown monitor above claims the
/// chords that REACH the app but Space, Tab, and the rest of System Settings Keyboard
/// Shortcuts never do: WindowServer hands them to Spotlight / the Dock / Mission Control before
/// any app sees them. The SDL clients get those through a private CGS hotkey-mode call that a
/// sandboxed app cannot make; the sandbox-legal way is a session-level event tap, which sees
/// every key ahead of the hotkey dispatch and only exists with Accessibility granted.
///
/// The tap does NOT forward anything itself. It takes each keyDown/keyUp off the system and
/// re-posts it, addressed to the key window, into THIS app's event queue (`NSApp.postEvent`), so
/// it arrives exactly where the same key would have arrived had macOS not claimed it the
/// monitor first (client chords, chords host), then `StreamLayerView.keyDown/keyUp`
/// (everything else host). One key path, no second VK table, no second release bookkeeping.
/// In-process posts don't re-enter the tap, so there is no loop. Keys the system would have
/// delivered anyway are unaffected (we drop the original and deliver the copy) the tap only
/// changes what happens to the ones it wouldn't. Bonus: the keyUp of a -chord key now arrives
/// too (the tap sees HID, which never stopped delivering it), so `flushCommandChord` has less
/// to synthesize.
///
/// Gating, every event: `forwarding` (capture engaged and capture releases on any focus loss,
/// so this is never true with another app frontmost), `!desktopMouse` (system chords stay local
/// under the desktop model, like every other client), `NSApp.isActive` as belt-and-braces.
/// Anything else passes through untouched a tap that swallows keys for the whole Mac is the
/// failure mode to design against. Installed on the main run loop on purpose: a hung main thread
/// trips the tap's timeout and macOS disables it, handing the keyboard back.
private func installSystemKeyTap() {
// `desktopMouse` is NOT an install condition: M flips it mid-capture, so the callback
// reads it per event instead and the tap simply idles under the desktop model.
guard systemKeyTap == nil, SessionSettings.current.inhibitShortcuts, AXIsProcessTrusted()
else { return }
let mask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
let callback: CGEventTapCallBack = { _, type, event, userInfo in
guard let userInfo else { return Unmanaged.passUnretained(event) }
let capture = Unmanaged<InputCapture>.fromOpaque(userInfo).takeUnretainedValue()
return capture.handleTapped(type: type, event: event)
}
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap,
eventsOfInterest: CGEventMask(mask), callback: callback,
userInfo: Unmanaged.passUnretained(self).toOpaque())
else {
inputLog.error("system shortcut tap: tapCreate failed (Accessibility revoked?)")
return
}
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
systemKeyTap = tap
systemKeyTapSource = source
if inputDebug { inputLog.debug("system shortcut tap installed") }
}
private func removeSystemKeyTap() {
guard let tap = systemKeyTap else { return }
CGEvent.tapEnable(tap: tap, enable: false)
if let source = systemKeyTapSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
CFMachPortInvalidate(tap)
systemKeyTap = nil
systemKeyTapSource = nil
if inputDebug { inputLog.debug("system shortcut tap removed") }
}
/// The tap callback body (main run loop). Returns the event to let it through, nil to swallow.
private func handleTapped(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
switch type {
case .tapDisabledByTimeout, .tapDisabledByUserInput:
// macOS switched us off (main thread stalled past the tap's deadline, or a
// system-level interruption); re-arm if still wanted, else stay down.
if let tap = systemKeyTap, forwarding { CGEvent.tapEnable(tap: tap, enable: true) }
return Unmanaged.passUnretained(event)
case .keyDown, .keyUp:
// Stamped with the KEY window: `NSApp.sendEvent` routes a key event by `event.window`,
// and an NSEvent wrapped straight from the CGEvent has none it reaches the local
// monitor but not the first responder (verified in a harness). The key window is the
// stream window whenever `forwarding` is true (capture releases on resignKey); if there
// somehow is none, let the key go rather than swallow it into nothing.
guard Self.tapClaims(forwarding: forwarding, desktopMouse: desktopMouse,
appActive: NSApp.isActive),
let windowNumber = NSApp.keyWindow?.windowNumber,
let copy = event.copy(), let raw = NSEvent(cgEvent: copy),
let stamped = Self.restamp(raw, windowNumber: windowNumber)
else { return Unmanaged.passUnretained(event) }
NSApp.postEvent(stamped, atStart: false)
return nil
default:
return Unmanaged.passUnretained(event)
}
}
/// The same key event, addressed to `windowNumber` (see `handleTapped`).
static func restamp(_ raw: NSEvent, windowNumber: Int) -> NSEvent? {
NSEvent.keyEvent(
with: raw.type, location: .zero, modifierFlags: raw.modifierFlags,
timestamp: raw.timestamp, windowNumber: windowNumber, context: nil,
characters: raw.characters ?? "",
charactersIgnoringModifiers: raw.charactersIgnoringModifiers ?? "",
isARepeat: raw.type == .keyDown && raw.isARepeat, keyCode: raw.keyCode)
}
/// Does the system-shortcut tap take this key off macOS and hand it to the app's own key path?
/// Pure, for the tests: only while captured, only under the capture mouse model, only with the
/// app frontmost. The `inhibit_shortcuts` setting is checked once at install time (the tap does
/// not exist with it off).
static func tapClaims(forwarding: Bool, desktopMouse: Bool, appActive: Bool) -> Bool {
forwarding && !desktopMouse && appActive
}
#endif
private func attach(mouse: GCMouse) {
@@ -0,0 +1,195 @@
// The client's own recent-log ring + the drop-in logger that feeds it the source for the
// "Send logs to host" action (`LibraryClient.sendLogs`), the Apple port of
// `pf_client_core::logring` + `punktfunk-session`'s `ring_layer`.
//
// WHY A RING AND NOT `OSLogStore`. The unified log already holds everything these loggers write,
// and `OSLogStore(scope: .currentProcessIdentifier)` can read it back but only the levels the
// system PERSISTS (`.notice`/`.error`/`.fault`). `.info` is memory-only and purged under pressure,
// and `.info` is exactly where the lines a field report needs live: the 1 Hz stats line, the
// decoder/presenter setup, the audio underrun notes. On an Apple TV there is no Console.app to
// read any of it on either. So every `ClientLog` call goes to os_log as before AND into this
// process-global ring, and an explicit user action posts the ring to the PAIRED host, where the
// web console shows it next to the host's own log.
//
// Bounded by lines AND bytes so a log-storm can't grow memory; the byte budget stays under the
// host's 1 MiB upload cap so a full ring always uploads whole. `.debug` deliberately skips the
// ring: it is per-key/per-event input chatter here, and a ring that a healthy keyboard can flush
// in thirty seconds is worse than no ring (the session client learned this from a Steam Deck
// bundle whose whole 27-minute session had been evicted by decoder DPB chatter).
import Foundation
import os
/// Process-global bounded ring of formatted log lines. `note` is cheap (one lock, one append);
/// `render` is the upload body.
public enum ClientLogRing {
/// Newest lines kept matches the host's own ring depth and the session client's.
public static let maxLines = 4096
/// Byte budget under the host's 1 MiB bundle cap with headroom for the header.
public static let maxBytes = 768 * 1024
private static let lock = OSAllocatedUnfairLock(initialState: State())
private struct State {
var lines: [String] = []
/// Index of the oldest live line in `lines` popped lazily, compacted when half is dead,
/// so eviction is O(1) amortised without a deque type.
var head = 0
var bytes = 0
var dropped = 0
}
/// Append one formatted log line (no trailing newline). Oversized lines are truncated to keep
/// a single event from evicting the whole ring.
public static func note(_ line: String) {
// `decoding:` rather than `String(_:)`: a cut mid-scalar yields U+FFFD, not nil.
let line = line.utf8.count > 2048
? String(decoding: line.utf8.prefix(2048), as: UTF8.self) + "" : line
let size = line.utf8.count
lock.withLock { s in
s.lines.append(line)
s.bytes += size
while s.lines.count - s.head > maxLines || s.bytes > maxBytes, s.head < s.lines.count {
s.bytes -= s.lines[s.head].utf8.count
s.head += 1
s.dropped += 1
}
if s.head > 0, s.head * 2 >= s.lines.count {
s.lines.removeFirst(s.head)
s.head = 0
}
}
}
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the app's own
/// identity line name, version, platform) and an eviction note when the ring wrapped.
public static func render(header: String) -> String {
lock.withLock { s in
var out = header + "\n"
if s.dropped > 0 {
out += "\(s.dropped) older lines evicted from the ring …\n"
}
for line in s.lines[s.head...] {
out += line
out += "\n"
}
return out
}
}
/// The bundle's first line: `punktfunk-apple 0.31.0 (42) (iOS 26.0.0 arm64; Apple TV) client
/// log bundle` the same shape as the session client's, so the host's log page reads them alike.
public static func header() -> String {
let info = Bundle.main.infoDictionary
let version = info?["CFBundleShortVersionString"] as? String ?? "dev"
let build = info?["CFBundleVersion"] as? String
let os = ProcessInfo.processInfo.operatingSystemVersion
#if os(macOS)
let platform = "macOS"
#elseif os(tvOS)
let platform = "tvOS"
#elseif os(iOS)
let platform = "iOS"
#else
let platform = "apple"
#endif
#if arch(arm64)
let arch = "arm64"
#else
let arch = "x86_64"
#endif
let v = build.map { "\(version) (\($0))" } ?? version
return "punktfunk-apple \(v) (\(platform) \(os.majorVersion).\(os.minorVersion).\(os.patchVersion) "
+ "\(arch); \(DeviceName.kind)) — client log bundle"
}
/// `2026-08-15T12:03:47.123Z` wall time, so a bundle correlates with the host log it lands
/// next to (the session client's `wallclock`).
static func stamp(_ date: Date = Date()) -> String {
Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)
}
}
/// Drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)`: the same call shape (string
/// interpolation with `privacy:`/`format:` options), forwarded to os_log AND noted in
/// `ClientLogRing`. Interpolated values are rendered in the clear in both places the unified log
/// was already being read with the app attached, and the ring only ever leaves the device by an
/// explicit "Send logs to host" to a host the user paired with.
public struct ClientLog: Sendable {
public let category: String
private let logger: Logger
public init(category: String) {
self.category = category
self.logger = Logger(subsystem: "io.unom.punktfunk", category: category)
}
/// os_log only see the file comment for why debug stays out of the ring.
public func debug(_ message: ClientLogMessage) {
logger.debug("\(message.text, privacy: .public)")
}
public func info(_ message: ClientLogMessage) {
logger.info("\(message.text, privacy: .public)")
note("INFO", message.text)
}
public func notice(_ message: ClientLogMessage) {
logger.notice("\(message.text, privacy: .public)")
note("INFO", message.text)
}
public func warning(_ message: ClientLogMessage) {
logger.warning("\(message.text, privacy: .public)")
note("WARN", message.text)
}
public func error(_ message: ClientLogMessage) {
logger.error("\(message.text, privacy: .public)")
note("ERROR", message.text)
}
public func fault(_ message: ClientLogMessage) {
logger.fault("\(message.text, privacy: .public)")
note("ERROR", message.text)
}
private func note(_ level: String, _ text: String) {
ClientLogRing.note("\(ClientLogRing.stamp()) \(level.padding(toLength: 5, withPad: " ", startingAt: 0)) \(category) \(text)")
}
}
/// The interpolated message: accepts the `OSLogMessage` options the call sites use (`privacy:`,
/// `format:`) so swapping `Logger` for `ClientLog` touches one declaration per file, not every
/// log line. Privacy is accepted and ignored (see `ClientLog`); `.fixed(precision:)` is honoured.
public struct ClientLogMessage: ExpressibleByStringInterpolation, ExpressibleByStringLiteral, Sendable {
public let text: String
public init(stringLiteral value: String) { text = value }
public init(stringInterpolation: StringInterpolation) { text = stringInterpolation.out }
public struct StringInterpolation: StringInterpolationProtocol, Sendable {
var out = ""
public init(literalCapacity: Int, interpolationCount: Int) {
out.reserveCapacity(literalCapacity + interpolationCount * 8)
}
public mutating func appendLiteral(_ literal: String) { out += literal }
public mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy = .auto) {
out += String(describing: value)
}
public mutating func appendInterpolation<T: BinaryFloatingPoint>(
_ value: T, format: ClientLogFloatFormat, privacy: OSLogPrivacy = .auto
) {
switch format {
case .fixed(let precision):
out += String(format: "%.\(precision)f", Double(value))
}
}
}
}
/// The one float format the call sites use. `OSLogFloatFormatting` cannot be pattern-matched, so
/// the message type names its own same spelling at the call site: `format: .fixed(precision: 2)`.
public enum ClientLogFloatFormat: Sendable {
case fixed(precision: Int)
}
@@ -0,0 +1,39 @@
// The Rust core's log lines, routed into `ClientLog` (os_log + the send-to-host ring).
//
// The core logs through `tracing`. The desktop and Android shells install a subscriber/logger and
// see those lines; this app never did, so every transport warning (socket-buffer clamp, QoS
// refusal), every quinn connection event and every rustls handshake note vanished, and a bundle
// sent to the host carried the Swift half of the story only. `punktfunk_set_log_callback`
// (ABI v25) hands them to the C callback below, which files each under a `core.<crate>` category.
import Foundation
import PunktfunkCore
public enum CoreLog {
/// Install once at launch. Levels above `maxLevel` (1 = error 5 = trace) are not even
/// formatted on the Rust side. Info is the ceiling on purpose: quinn's debug/trace is
/// per-packet and would churn the ring the same gate the session client's ring applies.
/// `PUNKTFUNK_CORE_LOG_LEVEL=4` raises it for a debugging session.
public static func install() {
let level = UInt8(ProcessInfo.processInfo.environment["PUNKTFUNK_CORE_LOG_LEVEL"] ?? "") ?? 3
let status = punktfunk_set_log_callback(level, { level, target, message, _ in
// Called from whichever Rust thread logged copy both C strings out before anything
// else, then hand off to ClientLog, which is cheap (one lock + os_log) and thread-safe.
let target = target.map { String(cString: $0) } ?? "core"
let message = message.map { String(cString: $0) } ?? ""
// The crate (first path segment) becomes the category; the full target stays in the
// line, so `quinn::connection` reads as `core.quinn quinn::connection `.
let crate_ = target.split(separator: ":", maxSplits: 1).first.map(String.init) ?? target
let log = ClientLog(category: "core.\(crate_)")
switch level {
case 1: log.error("\(target, privacy: .public) \(message, privacy: .public)")
case 2: log.warning("\(target, privacy: .public) \(message, privacy: .public)")
case 3: log.info("\(target, privacy: .public) \(message, privacy: .public)")
default: log.debug("\(target, privacy: .public) \(message, privacy: .public)")
}
}, nil)
if status != PUNKTFUNK_STATUS_OK.rawValue {
ClientLog(category: "core").warning("core log callback not installed: status \(status)")
}
}
}
@@ -21,7 +21,7 @@ import Metal
import QuartzCore
import os
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
private let presenterLog = ClientLog(category: "presenter")
#if os(macOS)
/// HOW a windowed (composited) macOS session pushes finished frames to glass the DCP
@@ -34,7 +34,7 @@ import Foundation
import Metal
import os
private let waveletLog = Logger(subsystem: "io.unom.punktfunk", category: "pyrowave")
private let waveletLog = ClientLog(category: "pyrowave")
/// The per-(component, level, band) 32x32-block table the exact Swift port of
/// `WaveletBuffers::init_block_meta` (pyrowave_common.cpp): the walk order (level 40,
@@ -56,9 +56,9 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
private let presentLog = ClientLog(category: "present")
/// Pump-side events (loss recovery, format seeding) the stage-2 sibling of StreamPump's log.
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
private let pumpLog = ClientLog(category: "pump")
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
/// user's presentation intent (design/apple-presentation-rebuild.md the 2026-07 rebuild that
@@ -8,7 +8,7 @@ import AVFoundation
import Foundation
import os
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
private let pumpLog = ClientLog(category: "video")
/// One pump per instance; create a fresh StreamPump per start (the stop is permanent
/// a restart hands the old pump its own token, so it can never be revived by a newer start()).
@@ -26,7 +26,7 @@ import os
/// Same diagnostic switch as InputCapture: PUNKTFUNK_INPUT_DEBUG=1 logs when the macOS
/// NSEvent mouse monitor (relative motion + buttons) is installed/removed, so the user can
/// confirm the new motion path is actually live for a session.
private let streamInputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
private let streamInputLog = ClientLog(category: "input")
private let streamInputDebug =
ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
@@ -47,7 +47,7 @@ import AVKit // AVDisplayManager — the per-session display-mode (HDR10/refresh
/// resolved pointer-lock state each time capture engages, so the user can see whether the
/// scene actually locked (GCMouse only delivers deltas while it did) or whether we're on
/// the touch fallback.
private let iosInputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
private let iosInputLog = ClientLog(category: "input")
private let iosInputDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
public struct StreamView: UIViewControllerRepresentable {
@@ -0,0 +1,69 @@
// The client log ring behind "Send logs to host", and the POST framing that carries it.
import XCTest
@testable import PunktfunkKit
final class ClientLogTests: XCTestCase {
/// The ring is process-global, so this single test owns the whole lifecycle (parallel tests
/// over one global would interleave) the same shape as `pf_client_core::logring`'s test.
func testRingBoundsAndRendersWithEvictionNote() {
let marker = "ringtest-\(ProcessInfo.processInfo.processIdentifier)"
for i in 0..<(ClientLogRing.maxLines + 10) {
ClientLogRing.note("\(marker) line \(i)")
}
let text = ClientLogRing.render(header: "punktfunk-apple test")
XCTAssertTrue(text.hasPrefix("punktfunk-apple test\n"))
XCTAssertTrue(text.contains("older lines evicted from the ring"))
XCTAssertFalse(text.contains("\n\(marker) line 0\n"), "oldest line survived eviction")
XCTAssertTrue(text.hasSuffix("\(marker) line \(ClientLogRing.maxLines + 9)\n"))
XCTAssertLessThanOrEqual(text.utf8.count, ClientLogRing.maxBytes + 256)
// A pathological line is truncated, not ring-flushing and cut safely mid-scalar.
ClientLogRing.note(String(repeating: "é", count: 10_000))
let after = ClientLogRing.render(header: "h")
XCTAssertTrue(after.contains(""))
XCTAssertTrue(after.hasSuffix("\n"))
// The drop-in logger formats `stamp LEVEL category message` and honours the OSLogMessage
// options the call sites use; debug stays out of the ring.
let log = ClientLog(category: "test")
log.info("\(marker) value \(1.23456, format: .fixed(precision: 2)) \(42, privacy: .public)")
log.debug("\(marker) debug-only")
let lines = ClientLogRing.render(header: "h").components(separatedBy: "\n")
let info = lines.last { $0.contains("\(marker) value") }
XCTAssertNotNil(info)
XCTAssertTrue(info!.contains(" INFO test \(marker) value 1.23 42"), info!)
// `2026-08-15T12:03:47.123Z ` leads wall time, so a bundle lines up with the host log.
XCTAssertNotNil(
info!.range(of: #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z INFO test "#, options: .regularExpression),
info!)
XCTAssertFalse(lines.contains { $0.contains("debug-only") })
}
func testHeaderNamesTheAppAndPlatform() {
let header = ClientLogRing.header()
XCTAssertTrue(header.hasPrefix("punktfunk-apple "))
XCTAssertTrue(header.hasSuffix(" — client log bundle"))
#if os(macOS)
XCTAssertTrue(header.contains("macOS"))
#endif
}
func testPostIsLengthFramedAndGetHasNoBody() {
let body = Data("hello ring\n".utf8)
let post = String(decoding: MgmtConnection.requestBytes(
host: "fd00::1", port: 47990, method: "POST", path: "/api/v1/client-logs",
body: body, contentType: "text/plain; charset=utf-8"), as: UTF8.self)
XCTAssertTrue(post.hasPrefix("POST /api/v1/client-logs HTTP/1.1\r\nHost: [fd00::1]:47990\r\n"))
XCTAssertTrue(post.contains("\r\nContent-Type: text/plain; charset=utf-8\r\n"))
XCTAssertTrue(post.contains("\r\nContent-Length: \(body.count)\r\n\r\nhello ring\n"))
XCTAssertTrue(post.hasSuffix("\r\n\r\nhello ring\n"))
let get = String(decoding: MgmtConnection.requestBytes(
host: "192.168.1.2", port: 47990, method: "GET", path: "/api/v1/library",
body: nil, contentType: nil), as: UTF8.self)
XCTAssertTrue(get.hasPrefix("GET /api/v1/library HTTP/1.1\r\nHost: 192.168.1.2:47990\r\n"))
XCTAssertFalse(get.contains("Content-Length"))
XCTAssertTrue(get.hasSuffix("\r\n\r\n"))
}
}
@@ -106,6 +106,25 @@ final class CommandChordTests: XCTestCase {
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
}
/// The system-shortcut tap (Space, Tab the keys macOS claims before the app sees them)
/// takes keys off the system ONLY while captured, under the capture mouse model, with the app
/// frontmost. Any other state must pass through: a tap that eats keys for the whole Mac is the
/// failure to pin here.
func testTheSystemShortcutTapOnlyClaimsWhileCapturedAndFrontmost() {
XCTAssertTrue(InputCapture.tapClaims(forwarding: true, desktopMouse: false, appActive: true))
XCTAssertFalse(InputCapture.tapClaims(forwarding: false, desktopMouse: false, appActive: true))
XCTAssertFalse(InputCapture.tapClaims(forwarding: true, desktopMouse: true, appActive: true))
XCTAssertFalse(InputCapture.tapClaims(forwarding: true, desktopMouse: false, appActive: false))
}
/// The keys the tap exists for must have host VKs it reposts them into the ordinary key path,
/// which drops unmapped keyCodes on the floor.
func testTheSystemShortcutKeysMapToHostVKs() {
XCTAssertEqual(InputCapture.keyCodeToVK[49], 0x20) // Space (Space)
XCTAssertEqual(InputCapture.keyCodeToVK[48], 0x09) // Tab (Tab)
XCTAssertEqual(InputCapture.keyCodeToVK[126], 0x26) // Up arrow ( Mission Control)
}
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
+9
View File
@@ -81,6 +81,15 @@ WHY THE APP ASKS FOR WHAT IT ASKS FOR
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
without this, no video, audio or rumble arrives.
- Accessibility (macOS, optional, never requested unprompted): "Capture system shortcuts" in
Settings > Input lets ⌘Space, ⌘Tab and Mission Control reach the remote desktop instead of the
Mac while the stream has captured the keyboard -- the same thing every remote-desktop/VM app
offers. macOS delivers those keys to Spotlight/the Dock before any app, so the only way to
receive them is a keyboard event tap, which needs Accessibility. The prompt appears only when
the user turns the toggle on or presses "Allow Accessibility access…"; the tap exists only while
a stream has the keyboard captured and the app is frontmost, and it reads nothing -- keys are
handed to the app's own stream window, never logged or stored. Without the grant, the toggle
still works for the app's own ⌘ shortcuts and simply says the system ones need Accessibility.
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
+43 -2
View File
@@ -82,6 +82,7 @@ pub fn run(target: Option<&str>) -> u8 {
.or_else(|| k.and_then(|h| h.mgmt_port))
.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: false,
clipboard_sync: k.is_some_and(|h| h.clipboard_sync),
last_used: k.and_then(|h| h.last_used),
os: k.map(|h| h.os.clone()).unwrap_or_default(),
pin: None,
@@ -336,6 +337,7 @@ fn fake_host_row() -> HostRow {
online: true,
mgmt_port: library::DEFAULT_MGMT_PORT,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: "linux/arch/steamos".into(),
pin: None,
@@ -667,9 +669,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,
@@ -695,6 +700,40 @@ impl ServiceState {
// `run` refreshes the rows right after this drain, so the carousel and
// the pin screen reflect the new card within the same service pass.
}
ConsoleCmd::BindProfile { key, profile_id } => {
// The BINDING half of the profile pair — `KnownHost::profile_id`, what a
// plain A-press on the primary tile connects with. `SetPin` above is the
// presentation half and never touches this field; this never touches the
// pins. Same store discipline, same refresh-after-drain.
let mut known = trust::KnownHosts::load();
let idx = index_for_key(&known, &key);
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
tracing::warn!(%key, "profile bind for an unknown host — ignoring");
return;
};
if h.profile_id != profile_id {
h.profile_id = profile_id;
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
}
}
}
ConsoleCmd::SetClipboard { key, on } => {
// Per-host clipboard trust (`KnownHost::clipboard_sync`) — the host
// menu's toggle. Same store discipline as the two arms above.
let mut known = trust::KnownHosts::load();
let idx = index_for_key(&known, &key);
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
tracing::warn!(%key, "clipboard toggle for an unknown host — ignoring");
return;
};
if h.clipboard_sync != on {
h.clipboard_sync = on;
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
}
}
}
}
}
@@ -784,6 +823,7 @@ impl ServiceState {
.or(h.mgmt_port)
.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: !online && !h.mac.is_empty(),
clipboard_sync: h.clipboard_sync,
last_used: h.last_used,
os: advert
.filter(|d| !d.os.is_empty())
@@ -842,6 +882,7 @@ impl ServiceState {
online: true,
mgmt_port: d.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: false,
clipboard_sync: false,
last_used: None,
os: d.os.clone(),
pin: None,
+23
View File
@@ -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) => {
+1 -29
View File
@@ -68,7 +68,7 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
event.record(&mut v);
pf_client_core::logring::note(format!(
"{} {:5} {} {}",
wallclock(),
pf_client_core::logring::wallclock(),
meta.level().as_str(),
meta.target(),
v.0
@@ -76,34 +76,6 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
}
}
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
fn wallclock() -> String {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let secs = (ms / 1000) as i64;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!(
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
ms % 1000
)
}
#[cfg(test)]
mod tests {
use super::*;
+6
View File
@@ -67,6 +67,12 @@ pub enum PointerInput {
x: f32,
y: f32,
button: PointerButton,
/// A finger (or stylus) on the glass, as opposed to a mouse button. The console
/// defers a touch press until the lift so a swipe can scroll instead of acting on
/// whatever the finger first lands on; a mouse press keeps acting immediately.
/// Only `Down` carries it — the console tracks the gesture it opened, so the
/// matching `Up`/`Move`/`Cancel` need no flag of their own.
touch: bool,
},
Up {
x: f32,
+8
View File
@@ -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,
})
}
+5 -1
View File
@@ -63,7 +63,11 @@ pub mod library;
// Per-host catalog cache, so a library screen has titles to show while a sleeping host boots.
#[cfg(any(target_os = "linux", windows))]
pub mod library_cache;
#[cfg(any(target_os = "linux", windows))]
// Android-enabled for the RING half (note/render — std only): the client's "Send logs to
// host" needs the ring on every platform. The `send_to_host` uploader inside stays
// desktop-gated with the rest of the ureq fetches; Android posts the rendered bundle
// through its own mTLS OkHttp client (`SkiaConsole.sendLogs`).
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
pub mod logring;
// The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the
// shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file.
+31
View File
@@ -55,6 +55,36 @@ pub fn note(mut line: String) {
}
}
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
/// Lives here (not in a shell) because every ring FEEDER wants the same stamp: the session's
/// `ring_layer` and the Android client's logcat tee both prefix their lines with it.
pub fn wallclock() -> String {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let secs = (ms / 1000) as i64;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!(
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
ms % 1000
)
}
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the shell's own
/// identity line — binary name, version, platform) and an eviction note when the ring wrapped.
pub fn render(header: &str) -> String {
@@ -79,6 +109,7 @@ pub fn render(header: &str) -> String {
/// trust as the library fetch: TLS client auth with the device identity, host pinned by
/// fingerprint. Errors reuse the library's classification (401/403 ⇒ `NotPaired`, a pin-verifier
/// rejection ⇒ `PinMismatch`), so the shell's existing error strings apply.
#[cfg(any(target_os = "linux", windows))]
pub fn send_to_host(
addr: &str,
mgmt_port: u16,
+14
View File
@@ -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.
+192 -3
View File
@@ -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,
+6
View File
@@ -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) => {
+30
View File
@@ -41,6 +41,11 @@ pub struct HostRow {
pub mgmt_port: u16,
/// Offline + a stored MAC → activating wakes first ("Wake & Connect").
pub can_wake: bool,
/// Share this device's clipboard with THIS host while streaming
/// (`KnownHost::clipboard_sync`) — surfaced so the host menu can show and flip it.
/// `serde(default)`: a producer predating the field still parses (as not-shared).
#[serde(default)]
pub clipboard_sync: bool,
/// Last successful connect (UNIX seconds) — the most-recent accent.
pub last_used: Option<u64>,
/// The host's OS-identity chain (live advert preferred, else the stored one), for a
@@ -220,12 +225,36 @@ pub enum ConsoleCmd {
profile_id: String,
pin: bool,
},
/// Bind (or clear) a saved host's DEFAULT profile — `KnownHost::profile_id`, the one a
/// plain A-press on the primary tile connects with (the port design's WP5 leftover;
/// [`ConsoleCmd::SetPin`] is presentation, this is the binding). `key` is the HOST
/// row's key; `None` clears the binding. Idempotent like `SetPin`: re-binding the
/// bound profile is a no-op.
BindProfile {
key: String,
profile_id: Option<String>,
},
/// Share (or stop sharing) this device's clipboard with a saved host while streaming —
/// `KnownHost::clipboard_sync`, the host menu's toggle. Per-host, never global:
/// handing a host your clipboard is a trust decision about that host.
SetClipboard { key: String, on: bool },
/// Open a screen the PLATFORM owns over the console (design android-skia-console-port.md
/// D7) — Android's connected-controllers view, the open-source licences. `id` is a
/// [`crate::platform::PlatformScreen::id`]. The host draws it, holds the console's input
/// 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
@@ -265,6 +294,7 @@ mod tests {
online: false,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
+7 -8
View File
@@ -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",
}
}
+20
View File
@@ -4,7 +4,9 @@
//! every screen animates and reads identically.
pub(crate) mod add_host;
pub(crate) mod bind_profile;
pub(crate) mod collections;
pub(crate) mod controllers;
pub(crate) mod home;
pub(crate) mod library;
pub(crate) mod options;
@@ -179,6 +181,14 @@ pub(crate) enum Screen {
AddHost(add_host::AddHostScreen),
Pair(pair::PairScreen),
PinHosts(pin_hosts::PinHostsScreen),
/// "Default for <host>": which profile the host's primary tile connects with — the
/// binding sibling of [`Screen::PinHosts`]'s presentation cards. Raised by the host
/// menu's "Default profile…" action.
BindProfile(bind_profile::BindProfileScreen),
/// "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 +210,8 @@ 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::BindProfile(s) => s.menu(ev, ctx, fx),
Screen::Controllers(s) => s.menu(ev, ctx, fx),
Screen::HostOptions(s) => s.menu(ev, ctx, fx),
}
}
@@ -218,6 +230,8 @@ 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::BindProfile(s) => s.pointer(p, ctx, fx),
Screen::Controllers(s) => s.pointer(p, ctx, fx),
Screen::HostOptions(s) => s.pointer(p, ctx, fx),
}
}
@@ -267,6 +281,8 @@ 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::BindProfile(s) => format!("Default for {}", s.host_name()),
Screen::Controllers(_) => "Connected controllers".into(),
Screen::HostOptions(s) => s.title(),
}
}
@@ -280,6 +296,8 @@ impl Screen {
Screen::AddHost(s) => s.hints(ctx),
Screen::Pair(s) => s.hints(ctx),
Screen::PinHosts(s) => s.hints(ctx),
Screen::BindProfile(s) => s.hints(ctx),
Screen::Controllers(s) => s.hints(ctx),
Screen::HostOptions(s) => s.hints(ctx),
}
}
@@ -304,6 +322,8 @@ 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::BindProfile(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,335 @@
//! "Default for “Desk”" — choose the profile a plain A-press on a saved host connects
//! with (`KnownHost::profile_id`), reached from the host tile's menu. One row per catalog
//! profile behind a leading "No default" row; choosing rides
//! [`ConsoleCmd::BindProfile`] to the binary, which persists the binding and refreshes
//! the rows — the checkmark follows the model, so what the list says is always what the
//! store holds (and what the tile's chip shows). Pinning is the sibling decision
//! (`pin_hosts.rs`): a pin adds a CARD, this changes what the primary tile itself does.
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
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};
use skia_safe::{Canvas, Rect};
pub(crate) struct BindProfileScreen {
/// The HOST row's primary key (fingerprint or `addr:port`, never a pinned card's
/// composite) — what every [`ConsoleCmd::BindProfile`] here addresses.
host_key: String,
host_name: String,
/// The catalog's `(id, name)` pairs, loaded once at construction — same stability
/// assumption the settings screen's Profiles tab makes (the console can't create
/// profiles, so the list can't change under this screen).
profiles: Vec<(String, String)>,
list: MenuList,
}
impl BindProfileScreen {
pub(crate) fn new(
host_key: String,
host_name: String,
profiles: Vec<(String, String)>,
) -> BindProfileScreen {
BindProfileScreen {
host_key,
host_name,
profiles,
list: MenuList::new(),
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
}
/// The host's current binding, read from the model — the primary row's chip IS the
/// state, so the checkmark can never disagree with what the carousel shows.
fn bound(&self, ctx: &Ctx) -> Option<String> {
ctx.hosts
.iter()
.find(|r| r.key == self.host_key)
.and_then(|r| r.bound_profile.as_ref())
.map(|p| p.id.clone())
}
/// Row `i`'s meaning: 0 is "No default", the rest the catalog in order.
fn choice(&self, i: usize) -> Option<Option<&str>> {
if i == 0 {
Some(None)
} else {
self.profiles.get(i - 1).map(|(id, _)| Some(id.as_str()))
}
}
fn len(&self) -> usize {
self.profiles.len() + 1
}
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 (msg, pulse) = self.list.menu(ev, self.len());
self.choose(msg, pulse, ctx, fx)
}
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
let (msg, pulse) = self.list.pointer(p, self.len());
if matches!(msg, ListMsg::None) && pulse.is_none() {
return false;
}
self.choose(msg, pulse, ctx, fx);
true
}
/// One list message against the focused row — shared by both input paths. A choice is
/// a radio press, not a toggle: A on the row that is already the binding is a boundary
/// thud, and ◀/▶ adjust nothing here.
fn choose(
&mut self,
msg: ListMsg,
pulse: Option<MenuPulse>,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
let Some(choice) = self.choice(self.list.cursor) else {
return pulse;
};
match msg {
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
ListMsg::Activate => {
let current = self.bound(ctx);
if current.as_deref() == choice {
return Some(MenuPulse::Boundary);
}
fx.cmds.push(ConsoleCmd::BindProfile {
key: self.host_key.clone(),
profile_id: choice.map(str::to_owned),
});
Some(MenuPulse::Confirm)
}
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
if self.profiles.is_empty() {
return vec![Hint::new(HintKey::Back, "Done")];
}
vec![
Hint::new(HintKey::Confirm, "Set default"),
Hint::new(HintKey::Back, "Done"),
]
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
if self.profiles.is_empty() {
fonts.centered(
canvas,
"No profiles yet \u{2014} create them in the desktop app, then choose one here.",
W::Regular,
14.0 * k,
fg(0.55),
cx,
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
f64::from(rect.width()) * 0.7,
);
return;
}
// The explainer band under the list, like the settings screen's detail text.
let detail_h = 34.0 * k;
let list_rect = Rect::from_ltrb(
rect.left,
rect.top,
rect.right,
rect.bottom - detail_h as f32,
);
let bound = self.bound(ctx);
let rows: Vec<RowSpec> = (0..self.len())
.map(|i| {
let (label, id) = if i == 0 {
("No default".to_string(), None)
} else {
let (id, name) = &self.profiles[i - 1];
(name.clone(), Some(id.as_str()))
};
let current = bound.as_deref() == id;
RowSpec {
header: None,
label,
value: Some(if current {
"Default".into()
} else {
String::new()
}),
value_dim: !current,
caret: false,
adjustable: false,
enabled: true,
}
})
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
fonts.centered(
canvas,
"What a plain press on this host's tile connects with. Pinned cards keep their own.",
W::Regular,
13.0 * k,
fg(0.55),
cx,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{HostRow, ProfileChip};
use pf_client_core::menu_nav::MenuDir;
use pf_client_core::trust::Settings;
fn host(bound: Option<&str>) -> HostRow {
HostRow {
key: "aa".into(),
name: "Desk".into(),
addr: "10.0.0.9".into(),
port: 9777,
fp_hex: "aa".into(),
paired: true,
saved: true,
online: true,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
bound_profile: bound.map(|id| ProfileChip {
id: id.into(),
name: "Work".into(),
accent: None,
}),
}
}
fn screen() -> BindProfileScreen {
BindProfileScreen::new(
"aa".into(),
"Desk".into(),
vec![("p1".into(), "Work".into()), ("p2".into(), "Game".into())],
)
}
#[test]
fn choosing_a_profile_binds_and_no_default_clears() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
let hosts = [host(Some("p1"))];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = screen();
// Row 2 = the second profile: binds it.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::BindProfile {
key: "aa".into(),
profile_id: Some("p2".into()),
}]
);
assert!(matches!(pulse, Some(MenuPulse::Confirm)));
// Row 0 clears the binding.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::BindProfile {
key: "aa".into(),
profile_id: None,
}]
);
}
#[test]
fn re_choosing_the_current_binding_is_a_boundary_not_a_command() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
let hosts = [host(Some("p1"))];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = screen();
// Row 1 = "Work", already bound.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(fx.cmds.is_empty());
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
// An unbound host: "No default" is already the state.
let hosts = [host(None)];
let mut settings = Settings::default();
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = screen();
let mut fx = Outbox::default();
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(fx.cmds.is_empty());
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
}
}
@@ -823,6 +823,7 @@ mod tests {
online: true,
mgmt_port: 9778,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -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)));
}
}
+1
View File
@@ -811,6 +811,7 @@ mod tests {
online,
mgmt_port: 47990,
can_wake,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
+74 -42
View File
@@ -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, 01. 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 `AZ` 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 `AZ` 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,
);
}
}
@@ -2131,6 +2162,7 @@ mod tests {
online: true,
mgmt_port: 9778,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
+116 -4
View File
@@ -36,6 +36,15 @@ enum Action {
SendLogs,
CopyLink,
Edit,
/// Choose the profile the host's primary tile connects with (opens the
/// [`Screen::BindProfile`] chooser). Offered on saved primary tiles only — a pinned
/// card's profile IS the card, and a title's menu addresses the title.
BindProfile,
/// Share this device's clipboard with THIS host while streaming
/// (`KnownHost::clipboard_sync`). Per-host because it is a trust decision about that
/// host — which is why it lives on the host and not in Settings. A toggle: the label
/// carries the current state, activating flips it.
Clipboard,
Forget,
Unpin,
Cancel,
@@ -115,7 +124,10 @@ impl OptionsScreen {
key.split('\0').next().unwrap_or(key)
}
fn actions(&self, platform: crate::platform::Platform) -> Vec<Action> {
// `_platform` is the seam platform-conditional rows plug into (Send logs used it until
// Android grew an uploader); unused today, kept so the next such row has its question
// already answered at every call site.
fn actions(&self, _platform: crate::platform::Platform) -> Vec<Action> {
let host = match &self.subject {
Subject::Host(h) => h,
// Deliberately not [Play, …]: the host menu does not repeat its tile's own A
@@ -137,14 +149,16 @@ impl OptionsScreen {
// error. This is the log-escape hatch for platforms whose own filesystem the user
// can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in
// its web console next to the host's own logs.
// Only where a service exists to upload them: the Android client has no log-ring
// uploader yet, and a row that can only toast "not available" is a promise broken.
if host.paired && host.online && platform == crate::platform::Platform::Desktop {
// Every platform has an uploader now (Android's rides `nativeSendLogs` over the
// same `logring` the desktop drains), so paired-and-reachable is the whole gate.
if host.paired && host.online {
a.push(Action::SendLogs);
}
a.extend([
Action::CopyLink,
Action::Edit,
Action::BindProfile,
Action::Clipboard,
Action::Forget,
Action::Cancel,
]);
@@ -159,6 +173,15 @@ impl OptionsScreen {
Action::SendLogs => "Send logs to host".into(),
Action::CopyLink => "Copy link".into(),
Action::Edit => "Edit\u{2026}".into(),
Action::BindProfile => "Default profile\u{2026}".into(),
Action::Clipboard => format!(
"Shared clipboard: {}",
if self.host().clipboard_sync {
"On"
} else {
"Off"
}
),
Action::Forget if self.armed => "Forget \u{2014} press again".into(),
Action::Forget => "Forget".into(),
Action::Unpin => "Unpin card".into(),
@@ -269,6 +292,24 @@ impl OptionsScreen {
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
self.host(),
))),
Action::BindProfile => fx.replace(Screen::BindProfile(
super::bind_profile::BindProfileScreen::new(
key,
self.host().name.clone(),
store.profiles(),
),
)),
Action::Clipboard => {
let host = self.host();
let on = !host.clipboard_sync;
fx.toast = Some(if on {
format!("Clipboard shared with {}", host.name)
} else {
format!("Clipboard no longer shared with {}", host.name)
});
fx.cmds.push(ConsoleCmd::SetClipboard { key, on });
fx.pop();
}
Action::Forget if !self.armed => self.armed = true,
Action::Forget => {
fx.cmds.push(ConsoleCmd::ForgetHost { key });
@@ -378,6 +419,7 @@ mod tests {
online: true,
mgmt_port: 9778,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -438,6 +480,25 @@ mod tests {
.contains(&Action::Wake));
}
/// "Send logs" is offered wherever a paired, reachable host can receive it — on BOTH
/// platforms since Android's uploader landed (`SkiaConsole.sendLogs` → `nativeSendLogs`
/// over the shared `logring`); before that the row was desktop-only, because a row that
/// can only toast "not available" is a promise broken.
#[test]
fn send_logs_is_offered_on_every_platform_with_an_uploader() {
let reachable = OptionsScreen::for_host(&HostRow {
paired: true,
online: true,
..host()
});
assert!(reachable
.actions(crate::platform::Platform::Desktop)
.contains(&Action::SendLogs));
assert!(reachable
.actions(crate::platform::Platform::Android)
.contains(&Action::SendLogs));
}
#[test]
fn a_pinned_card_cannot_forget_or_edit_the_host() {
let s = OptionsScreen::for_host(&pinned());
@@ -449,6 +510,57 @@ mod tests {
assert_eq!(s.host_key(), "aa");
}
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
/// the same reason — addressed to the HOST's plain key even from rows that carry a
/// composite one.
#[test]
fn default_profile_opens_the_chooser_on_the_hosts_plain_key() {
let mut s = OptionsScreen::for_host(&host());
assert!(s
.actions(crate::platform::Platform::Desktop)
.contains(&Action::BindProfile));
let mut fx = Outbox::default();
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
match fx.nav {
Some(crate::screens::Nav::Replace(screen)) => match *screen {
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
_ => panic!("expected the bind-profile chooser"),
},
_ => panic!("expected a replace"),
}
}
/// The clipboard toggle: the label says where the host stands, activating flips it —
/// and both address the HOST's plain key.
#[test]
fn the_clipboard_toggle_flips_the_stored_state() {
let mut s = OptionsScreen::for_host(&host());
assert!(s.label(Action::Clipboard).ends_with("Off"));
let mut fx = Outbox::default();
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetClipboard {
key: "aa".into(),
on: true,
}]
);
let mut s = OptionsScreen::for_host(&HostRow {
clipboard_sync: true,
..host()
});
assert!(s.label(Action::Clipboard).ends_with("On"));
let mut fx = Outbox::default();
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetClipboard {
key: "aa".into(),
on: false,
}]
);
}
#[test]
fn forget_needs_two_presses() {
let mut s = OptionsScreen::for_host(&host());
+1
View File
@@ -476,6 +476,7 @@ mod tests {
online: true,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -207,6 +207,7 @@ mod tests {
online: true,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: pin.map(|id| ProfileChip {
+155 -27
View File
@@ -56,6 +56,15 @@ enum RowId {
PadType,
SystemButtons,
GuideGesture,
/// The DualSense voice-coil haptics stream, rendered on the (wired) pad itself — see
/// `trust::Settings::pad_haptics`. Negotiated: it changes nothing without a capable
/// host and a wired DS5, which is why the row says what it is for, not what it does.
PadHaptics,
/// Where the pad's built-in-speaker stream renders — `trust::Settings::pad_speaker`.
/// Offered as On (`"pad"`) / Off, exactly like the GTK switch over the same key: the
/// third stored value (`"mix"`) is a declared TODO that renders as off, and a picker
/// offering it would be a control that changes nothing.
PadSpeaker,
Touch,
Mouse,
InvertScroll,
@@ -118,6 +127,14 @@ mod android_keys {
const GAMEPAD_UI_MODES: [(&str, &str); 2] =
[("connected", "With a controller"), ("always", "Always")];
/// `pad_audio::speaker_active`'s answer, restated: only `"pad"` renders today ("mix" is
/// the declared TODO that renders as off). Local because that module owns the actual
/// renderer and is `cfg(linux|windows)` — this row also ships on Android, where the
/// SETTING still travels with the stream request even though no local renderer exists.
fn pad_speaker_on(mode: &str) -> bool {
mode == "pad"
}
fn extra_bool(s: &pf_client_core::trust::Settings, key: &str, default: bool) -> bool {
s.extra
.get(key)
@@ -200,6 +217,8 @@ const TABS: [(&str, &[RowId]); 7] = [
RowId::PadType,
RowId::SystemButtons,
RowId::GuideGesture,
RowId::PadHaptics,
RowId::PadSpeaker,
RowId::PhoneRumble,
RowId::PhoneGyro,
RowId::Sc2Passthrough,
@@ -390,6 +409,12 @@ impl SettingsScreen {
self.tab
}
/// Row `i`'s rect as last drawn — the shell's touch tests press real coordinates.
#[cfg(test)]
pub(crate) fn row_rect_for_test(&self, i: usize) -> Option<Rect> {
self.list.row_rect(i)
}
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
/// value cycle), keeping each tab's own cursor.
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
@@ -488,17 +513,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
}
@@ -598,7 +633,10 @@ impl SettingsScreen {
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
let detail = ids.get(self.list.cursor).copied().map_or("", detail);
let detail = ids
.get(self.list.cursor)
.copied()
.map_or("", |id| detail(id, ctx.platform));
fonts.centered(
canvas,
detail,
@@ -722,9 +760,12 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// reading the same settings file on this same machine. Delete this arm when that
// client-side filter learns the frame ladder — not before.
RowId::AudioFormat => s.audio_channels == 2,
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
s.gamepad_forwarding
}
RowId::Pad
| RowId::PadType
| RowId::SystemButtons
| RowId::GuideGesture
| RowId::PadHaptics
| RowId::PadSpeaker => s.gamepad_forwarding,
_ => true,
};
let (header, label, value): (Option<&'static str>, &str, String) = match id {
@@ -851,6 +892,12 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
"Hold Select for guide",
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
),
RowId::PadHaptics => (None, "Controller haptics", on_off(s.pad_haptics).into()),
RowId::PadSpeaker => (
None,
"Controller speaker",
on_off(pad_speaker_on(&s.pad_speaker)).into(),
),
RowId::Touch => (None, "Touch mode", s.touch_mode().label().into()),
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()),
@@ -939,7 +986,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
}
}
fn detail(id: RowId) -> &'static str {
/// The focused row's one-line explainer. Takes the platform because two desktop rows
/// advertise desktop-only live chords (Ctrl+Alt+Shift+…) that no Android build has — a
/// shortcut the device cannot press must not be taught.
fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
use crate::platform::Platform;
match id {
RowId::Resolution => {
"The host creates a virtual display at exactly this size — no scaling. \
@@ -1013,15 +1064,29 @@ fn detail(id: RowId) -> &'static str {
the host's quick-access menu. Automatic arms it only where the real button \
can't reach the host. A Select tap still goes through, slightly delayed."
}
RowId::PadHaptics => {
"Play a DualSense's fine-grained haptics on the pad itself instead of plain \
rumble. Negotiated it changes nothing without a capable host and a wired pad."
}
RowId::PadSpeaker => {
"Play the audio a game sends to the controller's own speaker on the pad, \
not through this device's output."
}
RowId::Touch => {
"How the touchscreen drives the host: Trackpad (relative cursor), \
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
}
RowId::Mouse => {
"How a physical mouse drives the host: Capture locks the pointer (relative, \
for games), Desktop leaves it free and sends absolute positions. \
Ctrl+Alt+Shift+M switches live while streaming."
}
RowId::Mouse => match platform {
Platform::Desktop => {
"How a physical mouse drives the host: Capture locks the pointer (relative, \
for games), Desktop leaves it free and sends absolute positions. \
Ctrl+Alt+Shift+M switches live while streaming."
}
Platform::Android => {
"How a physical mouse drives the host: Capture locks the pointer (relative, \
for games), Desktop leaves it free and sends absolute positions."
}
},
RowId::InvertScroll => "Reverses the wheel and trackpad scroll direction sent to the host.",
RowId::Shortcuts => {
"Alt+Tab, Super and friends reach the host while input is captured. \
@@ -1046,10 +1111,15 @@ fn detail(id: RowId) -> &'static str {
stores as tiles instead of the whole shelf. A library with only one \
collection opens on the shelf as usual."
}
RowId::Stats => {
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
Ctrl+Alt+Shift+S cycles it live while streaming."
}
RowId::Stats => match platform {
Platform::Desktop => {
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
Ctrl+Alt+Shift+S cycles it live while streaming."
}
Platform::Android => {
"How much the overlay shows: Compact (one line) → Normal → Detailed."
}
},
RowId::Fullscreen => "Streams open fullscreen instead of windowed.",
RowId::AutoWake => {
"Send Wake-on-LAN to a sleeping host before connecting. Turn off for hosts \
@@ -1249,6 +1319,23 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
}
step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap)
}
RowId::PadHaptics => {
if !s.gamepad_forwarding {
return false;
}
toggle(&mut s.pad_haptics, delta, wrap)
}
RowId::PadSpeaker => {
if !s.gamepad_forwarding {
return false;
}
// On/Off over the stored string, the way the GTK switch edits the same key: a
// stored "mix" reads as Off (it renders as off today) and any step writes the
// two values that do something.
let mut on = pad_speaker_on(&s.pad_speaker);
toggle(&mut on, delta, wrap)
.map(|()| s.pad_speaker = if on { "pad" } else { "off" }.to_string())
}
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)
@@ -1549,6 +1636,44 @@ pub(super) mod tests {
});
}
/// The controller-audio rows: they follow the forwarding switch like every other pad
/// row, and the speaker row edits the stored STRING exactly the way the GTK switch
/// over the same key does — a stored "mix" (the declared TODO that renders as off)
/// reads as Off, and any step writes only the two values that do something.
#[test]
fn controller_audio_rows_follow_forwarding_and_speak_the_gtk_dialect() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
// Defaults: haptics on, speaker on the pad.
assert!(ctx.settings.pad_haptics);
assert_eq!(ctx.settings.pad_speaker, "pad");
assert!(adjust(RowId::PadHaptics, 1, true, &mut ctx));
assert!(!ctx.settings.pad_haptics);
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
assert_eq!(ctx.settings.pad_speaker, "off");
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
assert_eq!(ctx.settings.pad_speaker, "pad");
// A stored "mix" reads as Off and steps onto a value that works.
ctx.settings.pad_speaker = "mix".into();
assert!(adjust(RowId::PadSpeaker, 1, true, &mut ctx));
assert_eq!(ctx.settings.pad_speaker, "pad");
// Forwarding off parks both, like the sibling pad rows.
ctx.settings.gamepad_forwarding = false;
assert!(!adjust(RowId::PadHaptics, 1, true, &mut ctx));
assert!(!adjust(RowId::PadSpeaker, 1, true, &mut ctx));
}
#[test]
fn adjust_clamps_and_activate_wraps() {
let (mut settings, pads) = ctx_parts();
@@ -1861,6 +1986,7 @@ pub(super) mod tests {
online: true,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: Some(crate::model::ProfileChip {
@@ -2037,12 +2163,14 @@ pub(super) mod tests {
seen.push(*id);
}
}
// The pre-tab flat list, plus the palette row, the lossless-audio row and the
// reduce-motion row later passes added, minus the game-library toggle: this screen
// never read it, and the library is offered on any paired host now.
// 33 desktop rows + the eight Android-only ones (design android-skia-console-port.md
// The pre-tab flat list, plus the palette row, the lossless-audio row, the
// reduce-motion row and the two controller-audio rows (haptics + speaker — the
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
// game-library toggle: this screen never read it, and the library is offered on any
// paired host now.
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
// D3): six `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 41, "{seen:?}");
assert_eq!(seen.len(), 43, "{seen:?}");
assert!(seen.contains(&RowId::Palette));
assert!(seen.contains(&RowId::ReduceMotion));
assert!(seen.contains(&RowId::AudioFormat));
+157 -10
View File
@@ -58,6 +58,38 @@ const NAV_INPUT_OPENS: f64 = 0.85;
const TOP_BAND: f64 = 64.0;
const BOTTOM_BAND: f64 = 86.0;
/// How far a finger may wander (design units × the frame's `k`) and still be a tap. Past
/// this the gesture is a drag and the lift acts on nothing. ~12dp is the classic touch
/// slop; in device pixels it lands near Android's own ViewConfiguration figure.
const TOUCH_SLOP_DP: f64 = 12.0;
/// One drag step (design units × `k`): each `DRAG_TICK_DP` of dominant-axis travel emits
/// one synthetic scroll tick. 56 is the menu list's row pitch (`widgets::ROW_H` + gap), so
/// a list under the finger moves about as far as the finger does. The on-glass tuning knob.
const DRAG_TICK_DP: f64 = 56.0;
/// The active touch gesture, tracked by [`Shell::pointer_input`] (see the `touch` flag on
/// `PointerInput::Down`). A mouse never enters this machine — its press acts immediately,
/// which is what a mouse means. A second finger while one gesture is live is ignored
/// (single-tracked; multi-touch gestures are a non-goal).
#[derive(Clone, Copy, Debug)]
enum TouchGesture {
/// Finger down, still within slop of the anchor. A lift here is a tap: the Press is
/// delivered AT THE ANCHOR — the focused item scrolls toward the centre, so the down
/// point is where the user aimed and the lift point is where the content dragged
/// their eye; widgets hit-test last frame's rects and already tolerate exactly this
/// one-frame skew.
Armed { x: f64, y: f64 },
/// Slop exceeded: a drag, locked to the axis it left the slop on (diagonal jitter
/// must not alternate a carousel with a list). `last` is the dominant-axis position
/// the previous tick was emitted at.
Drag {
x: f64,
y: f64,
horizontal: bool,
last: f64,
},
}
/// Which way a transition is choreographed. The paint recipes differ (a push slides the
/// incoming screen up out of a fade; a pop grows the revealed one back while the leaving
/// one drops away), so the kind outlives the direction the spring happens to be heading.
@@ -139,7 +171,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".
@@ -260,6 +291,11 @@ pub(crate) struct Shell {
/// surface pixels and are brought into the same space `hint_rects` and every screen's
/// hit boxes were published in.
last_insets: (f32, f32),
/// The design-unit scale the last frame rendered at, for the touch tracker's slop and
/// tick distances — gesture geometry must grow with the UI it drags.
last_k: f64,
/// The touch gesture in flight, if any (see [`TouchGesture`]).
gesture: Option<TouchGesture>,
/// Skia's resource-cache budget for the host that renders this shell (see
/// [`ConsoleOptions::gpu_cache_bytes`]).
pub(crate) gpu_cache_bytes: usize,
@@ -332,6 +368,8 @@ impl Shell {
pads: Vec::new(),
hint_rects: Vec::new(),
last_insets: (0.0, 0.0),
last_k: 1.0,
gesture: None,
gpu_cache_bytes: opts.gpu_cache_bytes,
t0: Instant::now(),
last_frame: None,
@@ -362,25 +400,69 @@ impl Shell {
/// The host-facing pointer vocabulary onto the shell's own: primary press/release,
/// secondary-down = Back (its release is dropped, or a right-click would pop two
/// screens), wheel = discrete scroll steps, cancel.
///
/// A TOUCH primary down (`touch: true`) takes the gesture lane instead: the press is
/// deferred, and the lift decides whether it was a tap (Press at the anchor) or a drag
/// (scroll ticks were already emitted along the way, the lift acts on nothing). A press
/// that acted on contact made every swipe across the settings list flip a value — the
/// finger has to be allowed to mean "scroll" until it has said otherwise.
pub(crate) fn pointer_input(&mut self, input: pf_client_core::console::PointerInput) -> bool {
use pf_client_core::console::{PointerButton, PointerInput};
let (x, y, kind) = match input {
PointerInput::Move { x, y } => (x, y, PointerKind::Move),
PointerInput::Move { x, y } => {
if self.gesture.is_some() {
return self.gesture_move(f64::from(x), f64::from(y));
}
(x, y, PointerKind::Move)
}
PointerInput::Down {
x,
y,
button: PointerButton::Primary,
} => (x, y, PointerKind::Press),
touch,
} => {
if touch {
// A second finger while a gesture is live is ignored — single-tracked.
if self.gesture.is_none() {
self.gesture = Some(TouchGesture::Armed {
x: f64::from(x),
y: f64::from(y),
});
}
return true;
}
(x, y, PointerKind::Press)
}
PointerInput::Down {
x,
y,
button: PointerButton::Secondary,
..
} => (x, y, PointerKind::Back),
PointerInput::Up {
x,
y,
button: PointerButton::Primary,
} => (x, y, PointerKind::Release),
} => match self.gesture.take() {
Some(TouchGesture::Armed { x, y }) => {
// A tap: the deferred Press lands now, at the anchor, followed by the
// Release the widgets ignore today (and a fling closes on tomorrow).
let consumed = self.pointer(Pointer {
x,
y,
kind: PointerKind::Press,
});
self.pointer(Pointer {
x,
y,
kind: PointerKind::Release,
});
return consumed;
}
// A drag ends where its last tick left it; the lift itself does nothing.
Some(TouchGesture::Drag { .. }) => return true,
None => (x, y, PointerKind::Release),
},
PointerInput::Up { .. } => return true,
PointerInput::Wheel { x, y, dy } => {
if dy == 0.0 {
@@ -388,7 +470,10 @@ impl Shell {
}
(x, y, PointerKind::Scroll { up: dy > 0.0 })
}
PointerInput::Cancel => (0.0, 0.0, PointerKind::Cancel),
PointerInput::Cancel => {
self.gesture = None;
(0.0, 0.0, PointerKind::Cancel)
}
};
self.pointer(Pointer {
x: f64::from(x),
@@ -397,6 +482,60 @@ impl Shell {
})
}
/// Advance the touch gesture by a Move. Within slop nothing happens; past it the
/// gesture locks to its dominant axis and every [`DRAG_TICK_DP`]·k of travel becomes
/// one synthetic scroll tick at the anchor. Direction reads as "content follows the
/// finger": drag down/right = the previous item (a wheel-up), drag up/left = the next.
fn gesture_move(&mut self, x: f64, y: f64) -> bool {
let Some(gesture) = self.gesture else {
return false;
};
match gesture {
TouchGesture::Armed { x: ax, y: ay } => {
let (dx, dy) = (x - ax, y - ay);
if dx.hypot(dy) >= TOUCH_SLOP_DP * self.last_k {
let horizontal = dx.abs() > dy.abs();
self.gesture = Some(TouchGesture::Drag {
x: ax,
y: ay,
horizontal,
// Ticks count from where the slop was left, not from the anchor —
// the slop's travel was spent proving this is a drag.
last: if horizontal { x } else { y },
});
}
true
}
TouchGesture::Drag {
x: ax,
y: ay,
horizontal,
last,
} => {
let pos = if horizontal { x } else { y };
let tick = DRAG_TICK_DP * self.last_k;
let steps = ((pos - last) / tick).trunc();
if steps != 0.0 {
self.gesture = Some(TouchGesture::Drag {
x: ax,
y: ay,
horizontal,
last: last + steps * tick,
});
let up = steps > 0.0;
for _ in 0..steps.abs() as u32 {
self.pointer(Pointer {
x: ax,
y: ay,
kind: PointerKind::Scroll { up },
});
}
}
true
}
}
}
/// The host reports a session edge. `Connecting` is a no-op — the shell raised the
/// Launch itself and is already showing the takeover.
pub(crate) fn session_phase(&mut self, phase: pf_client_core::console::SessionPhase) {
@@ -436,7 +575,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 +642,6 @@ impl Shell {
.last_connect_title
.clone()
.unwrap_or_else(|| "the host".to_string()),
canceling: false,
appear: 1.0,
request_access: false,
});
@@ -683,9 +820,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);
}
+1 -9
View File
@@ -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,
+1
View File
@@ -101,6 +101,7 @@ impl Shell {
full_h - f64::from(ins.top) - f64::from(ins.bottom),
);
self.last_insets = (ins.left, ins.top);
self.last_k = k;
let t = self.t();
// Advance the transition. `None` means "settled" — which is also what makes the
+165 -4
View File
@@ -89,6 +89,7 @@ fn hosts() -> Vec<HostRow> {
online: false,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -178,15 +179,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());
}
@@ -493,6 +496,164 @@ fn every_settings_tab_rasters() {
s.render(surface.canvas(), 640, 400, &fonts, None, None, &pads);
}
/// The settings screen with one frame rendered, so its rows have real rects to press.
fn rendered_settings() -> (Shell, skia_safe::Rect) {
let fonts = crate::theme::build_fonts().unwrap();
let mut surface = skia_safe::surfaces::raster_n32_premul((1280, 800)).unwrap();
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.handle_menu(MenuEvent::Tertiary); // X → Settings
finish_motion(&mut s);
s.render(surface.canvas(), 1280, 800, &fonts, None, None, &[]);
let row = match s.stack.last() {
Some(Screen::Settings(scr)) => scr.row_rect_for_test(0).expect("the list drew its rows"),
_ => panic!("settings is not on top"),
};
(s, row)
}
/// The whole point of the touch tracker: a finger swiping across the settings list is
/// SCROLLING, and must not flip the value it happened to land on — which is exactly what
/// the press-acts-on-contact model did to every swipe before the `touch` flag existed.
/// The same contact lifted in place IS the tap, delivered on the lift at the anchor.
#[test]
fn a_touch_swipe_scrolls_settings_without_changing_a_value() {
use pf_client_core::console::{PointerButton, PointerInput};
let (mut s, row) = rendered_settings();
let (cx, cy) = (row.center_x(), row.center_y());
// The Resolution row's whole observable state: activating it steps the D1 tri-state
// Native -> Match window, which flips the FLAG while width/height stay (0, 0).
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
let before = state(&s);
// Finger lands on the Resolution row and swipes up, well past slop and several ticks.
s.pointer_input(PointerInput::Down {
x: cx,
y: cy,
button: PointerButton::Primary,
touch: true,
});
for i in 1..=6 {
s.pointer_input(PointerInput::Move {
x: cx,
y: cy - (i as f32) * 40.0,
});
}
s.pointer_input(PointerInput::Up {
x: cx,
y: cy - 240.0,
button: PointerButton::Primary,
});
assert_eq!(
state(&s),
before,
"a swipe across a row is a scroll, not a value change"
);
// The same contact, lifted where it landed: a tap. Deferred — nothing on contact,
// the step on the lift.
s.pointer_input(PointerInput::Down {
x: cx,
y: cy,
button: PointerButton::Primary,
touch: true,
});
assert_eq!(state(&s), before, "a touch press must not act on contact");
s.pointer_input(PointerInput::Up {
x: cx,
y: cy,
button: PointerButton::Primary,
});
assert_ne!(
state(&s),
before,
"the tap lands on the lift, at the anchor"
);
}
/// A mouse is not a finger: its press keeps acting on contact, exactly as before the
/// touch flag existed.
#[test]
fn a_mouse_press_still_acts_on_contact() {
use pf_client_core::console::{PointerButton, PointerInput};
let (mut s, row) = rendered_settings();
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
let before = state(&s);
s.pointer_input(PointerInput::Down {
x: row.center_x(),
y: row.center_y(),
button: PointerButton::Primary,
touch: false,
});
assert_ne!(state(&s), before, "a mouse click acts on the press");
}
/// A horizontal drag on Home steps the carousel — one tick per `DRAG_TICK_DP` of travel
/// past the slop — and the lift after a drag presses nothing. Needs no render: ticks act
/// on the cursor, not on drawn rects.
#[test]
fn a_horizontal_drag_steps_the_home_carousel() {
use pf_client_core::console::{PointerButton, PointerInput};
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
s.pointer_input(PointerInput::Down {
x: 640.0,
y: 400.0,
button: PointerButton::Primary,
touch: true,
});
// First move leaves the slop (locks the horizontal axis); the second travels one full
// tick leftward — content follows the finger, so the NEXT tile comes up.
s.pointer_input(PointerInput::Move { x: 620.0, y: 400.0 });
s.pointer_input(PointerInput::Move {
x: 620.0 - DRAG_TICK_DP as f32,
y: 400.0,
});
s.pointer_input(PointerInput::Up {
x: 620.0 - DRAG_TICK_DP as f32,
y: 400.0,
button: PointerButton::Primary,
});
// The fixture's second host (Office Tower) is offline with a stored MAC: Confirm on it
// raises the wake card. That proves the drag moved the cursor — and that the lift
// after a drag pressed nothing (a press would have acted before Confirm ran).
assert!(
s.wake.is_none(),
"the drag itself must not activate anything"
);
s.handle_menu(MenuEvent::Confirm);
assert!(
s.wake.is_some(),
"Confirm after a one-tick drag lands on the second host's wake"
);
}
/// A canceled touch (the finger left the window, the toolkit stole the gesture) is
/// dropped whole: no press ever lands.
#[test]
fn a_canceled_touch_never_acts() {
use pf_client_core::console::{PointerButton, PointerInput};
let (mut s, row) = rendered_settings();
let state = |s: &Shell| (s.settings.match_window, s.settings.width, s.settings.height);
let before = state(&s);
s.pointer_input(PointerInput::Down {
x: row.center_x(),
y: row.center_y(),
button: PointerButton::Primary,
touch: true,
});
s.pointer_input(PointerInput::Cancel);
s.pointer_input(PointerInput::Up {
x: row.center_x(),
y: row.center_y(),
button: PointerButton::Primary,
});
assert_eq!(
state(&s),
before,
"cancel dropped the gesture; the stray lift presses nothing"
);
}
/// The work package's whole reason for existing: Back pressed mid-push is HEARD, and it
/// turns the screen around rather than queuing a second animation behind the first.
///
+62
View File
@@ -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);
}
}
+20 -5
View File
@@ -2723,6 +2723,11 @@ fn apply_capture(
/// Only DIRECT touch devices are offered; an indirect trackpad already drives the mouse,
/// and forwarding both would double every tap.
fn overlay_pointer(event: &Event, window: &sdl3::video::Window) -> Option<PointerInput> {
// SDL's mouse id on mouse events it SYNTHESIZED from a touch (`SDL_TOUCH_MOUSEID`,
// not re-exported by the sdl3 crate). The finger arms below already forward the real
// touch stream; letting the synthesized twin through would land every tap twice —
// once deferred (touch), once immediate (mouse) — so those events are dropped here.
const TOUCH_MOUSEID: u32 = u32::MAX;
let (pw, ph) = window.size_in_pixels();
let (lw, lh) = window.size();
// Logical → physical. A zero-sized window (minimized) would divide by zero.
@@ -2734,20 +2739,29 @@ fn overlay_pointer(event: &Event, window: &sdl3::video::Window) -> Option<Pointe
_ => None,
};
Some(match event {
Event::MouseMotion { x, y, .. } => PointerInput::Move {
Event::MouseMotion { which, x, y, .. } if *which != TOUCH_MOUSEID => PointerInput::Move {
x: x * sx,
y: y * sy,
},
Event::MouseButtonDown {
mouse_btn, x, y, ..
} => PointerInput::Down {
which,
mouse_btn,
x,
y,
..
} if *which != TOUCH_MOUSEID => PointerInput::Down {
x: x * sx,
y: y * sy,
button: button(*mouse_btn)?,
touch: false,
},
Event::MouseButtonUp {
mouse_btn, x, y, ..
} => PointerInput::Up {
which,
mouse_btn,
x,
y,
..
} if *which != TOUCH_MOUSEID => PointerInput::Up {
x: x * sx,
y: y * sy,
button: button(*mouse_btn)?,
@@ -2767,6 +2781,7 @@ fn overlay_pointer(event: &Event, window: &sdl3::video::Window) -> Option<Pointe
x: x * pw as f32,
y: y * ph as f32,
button: PointerButton::Primary,
touch: true,
}
}
Event::FingerMotion { touch_id, x, y, .. } if is_direct_touch(*touch_id) => {
+378 -308
View File
@@ -128,6 +128,11 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
/// [`restore_takeover_on_startup`] sets it for a stranded takeover it adopts: unmasking a unit we
/// never masked is a no-op, while missing one that IS masked leaves the box unable to enter its
/// own Game Mode until reboot.
///
/// ⚠ The takeover itself no longer masks anything — it idles the autologin session instead
/// ([`install_idle_dropin`]), because a masked unit FAILS and a failing unit is what the display
/// manager relogin-loops against. So this is now only ever true for a takeover adopted from a
/// host old enough to have laid one, and the lift paths stay for exactly that box.
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
@@ -158,6 +163,10 @@ static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::
/// giving the DM's desktop session time to come up so re-detection follows it instead.
const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
/// Whether [`install_idle_dropin`] has one outstanding. Process memory only — the sweep in
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
static IDLE_DROPIN_ARMED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
@@ -373,6 +382,15 @@ pub fn restore_takeover_on_startup() {
);
systemctl_user(&["daemon-reload"]);
}
// Same shape, same reason: a host that died mid-stream leaves the box's Game Mode replaced by
// a session that does nothing at all, which looks exactly like broken hardware. Runtime-dir
// state, so a reboot clears it too — this covers the restart that does not.
if remove_idle_dropin() {
tracing::warn!(
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
own Game Mode session would have started and then done nothing"
);
}
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
return; // no takeover file — clean start
};
@@ -1107,8 +1125,9 @@ fn discover_session_display_env() -> Option<(Option<String>, Option<String>, Opt
/// ⚠ Only for callers whose timeout answer is the SAFE one. Both current callers time out into
/// "assume active"/"keep looping", so a miss costs a poll tick. A caller whose timeout would invert
/// the answer into the refusing direction must NOT use this bound — `loginctl show-user -p Linger`
/// was given it and became a hard connect failure on a correctly-configured box (see
/// [`linger_enabled`], now on [`UNIT_QUERY_BUDGET`]): 300 ms is an in-memory-read budget, and
/// was given it and became a hard connect failure on a correctly-configured box (the linger probe
/// that produced it is gone with the DM-stop path, but the lesson is not): 300 ms is an
/// in-memory-read budget, and
/// anything that spawns a process and makes a D-Bus round trip is not that.
const UNIT_STATE_BUDGET: Duration = Duration::from_millis(300);
@@ -1287,6 +1306,74 @@ fn legacy_session_plus_dropin_path() -> std::path::PathBuf {
.join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf")
}
/// Where the takeover's IDLE drop-in lives. Same runtime-dir argument as
/// [`session_plus_dropin_path`], and here it is the safety property the mechanism rests on rather
/// than a tidiness one: this drop-in replaces the box's game-mode `ExecStart`, so a copy that
/// outlived the host would leave the box unable to enter Game Mode at all. Under
/// `$XDG_RUNTIME_DIR` it dies with the login session, and a reboot restores game mode by itself —
/// on top of the unconditional sweep [`restore_takeover_on_startup`] does.
fn idle_dropin_path() -> std::path::PathBuf {
let base = crate::session::runtime_dir();
std::path::Path::new(&base)
.join("systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-idle.conf")
}
/// `sleep`'s path on this box. The idle `ExecStart` must not be a command that can fail to
/// EXECUTE: a unit that dies on start is precisely the relogin storm this drop-in exists to avoid
/// ([`mask_unit`] has that chain), so resolve it instead of hardcoding one distro's layout.
fn sleep_binary() -> &'static str {
["/usr/bin/sleep", "/bin/sleep"]
.into_iter()
.find(|p| std::path::Path::new(p).exists())
.unwrap_or("/usr/bin/sleep")
}
/// Idle the box's autologin game session for the stream's duration: a drop-in over the
/// `gamescope-session-plus@` TEMPLATE (so it reaches whichever instance this box autologs into)
/// that replaces `ExecStart` with a process which merely sleeps.
///
/// This is what the takeover uses INSTEAD of stopping the display manager, and it satisfies all
/// three things that path has to get right at once. Steam is freed (the session runs nothing).
/// The DM does not storm: its autologin still SUCCEEDS, so there is no failed session to relogin
/// against — unlike a masked unit, which fails in milliseconds and is the storm's engine. And the
/// box keeps a live display manager, so a session switch the user asks for can still be serviced;
/// that is the one a stopped DM could not, and it stranded `.41` on Steam's "Switch to Desktop"
/// modal until a reboot.
///
/// Measured on that box: with this installed, `steam` is down, `sddm` stays active, the unit sits
/// `active (running)` with `NRestarts=0`, and a subsequent `switch-to-desktop-mode` brings Plasma
/// up in ~10 s.
fn install_idle_dropin() -> Result<()> {
let path = idle_dropin_path();
let dir = path
.parent()
.context("the idle drop-in path has no parent directory")?;
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::write(
&path,
format!(
"[Service]\nExecStart=\nExecStart={} infinity\n",
sleep_binary()
),
)
.with_context(|| format!("write {}", path.display()))?;
systemctl_user(&["daemon-reload"]);
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
Ok(())
}
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
/// the drop-in outliving a host that died is exactly the case that has to be swept.
fn remove_idle_dropin() -> bool {
let removed = std::fs::remove_file(idle_dropin_path()).is_ok();
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = false;
if removed {
systemctl_user(&["daemon-reload"]);
}
removed
}
/// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and
/// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS`
/// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60).
@@ -2094,23 +2181,46 @@ 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 **45 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").
/// ⚠ Nothing in the takeover lays a mask any more — it idles the autologin session instead
/// ([`install_idle_dropin`]), precisely because a masked unit FAILS and a failing unit is what the
/// display manager relogin-loops against. This is kept for the test that builds the state
/// [`lift_autologin_mask`] exists to clean up: a takeover adopted from a host old enough to have
/// masked. That lift is still live code, so the state has to stay constructible.
#[cfg(test)]
fn mask_unit(unit: &str) {
let _ = crate::proc::status_within(
Command::new("systemctl").args(["--user", "mask", "--runtime", unit]),
@@ -2193,55 +2303,44 @@ 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_dm: bool,
/// A display manager drives this LIVE gaming session, so its autologin brings the session
/// straight back the moment we free Steam. That is what the idle drop-in answers
/// ([`install_idle_dropin`]) — not, any longer, stopping the DM.
///
/// Stopping it satisfied the same requirement and broke a different one: a box with no DM has
/// nothing that can start a desktop session, so the user's own "Switch to Desktop" hung on
/// Steam's modal until a reboot (field report 2026-08-18). It hung UNDETECTABLY, which is why
/// no amount of watching fixes it: on a `steamos-manager` box the switch is a D-Bus call whose
/// every trace — the sddm state file, the session units, the login mode — is written by the
/// display manager we had just stopped. Leave the DM up and there is nothing to detect.
dm_relogins: 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,
stop_dm: dm.is_some() && any_live,
skip: !any_live,
dm_relogins: dm.is_some() && any_live,
}
}
@@ -2311,6 +2410,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,87 +2684,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)
}
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login
/// session. logind then stops `user@<uid>.service` once `UserStopDelaySec` (10 s by default)
/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart
/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0**
/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0
/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was
/// enabled there for the sessionless tests.
///
/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is
/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure
/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then
/// degrades to attach, which mirrors the box's own session and never stops the DM.
///
/// `Err` carries **why** it could not be ensured, because the helper path is reached here first:
/// on a sessionless host the `linger` verb goes through the same [`dm_helper`] gate the `stop`
/// verb does, so a user outside the `punktfunk` group fails at THIS step and never reaches the
/// DM-stop one. Dropping the reason here would just move the misdiagnosis one message earlier.
fn ensure_host_survives_dm_stop() -> std::result::Result<(), String> {
if !host_is_under_user_manager() {
return Ok(()); // root / a system unit — the DM stop cannot reach us
}
if linger_enabled() {
return Ok(());
}
// `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the
// user's session can do this itself; a sessionless one (the packaged unit) goes through the
// helper, whose grant is scoped to the calling uid.
let uid = uid_string();
let _ = crate::proc::status_within(
Command::new("loginctl").args(["--no-ask-password", "enable-linger", &uid]),
UNIT_QUERY_BUDGET,
);
let helper = if linger_enabled() {
Ok(()) // the plain verb was enough — the helper was never needed
} else {
dm_helper("linger").map_err(|e| e.to_string())
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
};
match helper {
Ok(()) if linger_enabled() => {
tracing::info!(
uid,
"enabled lingering for this user — the managed takeover stops the display manager, \
which ends this login session, and without lingering logind would stop the host \
along with it (`loginctl disable-linger` reverts it)"
);
Ok(())
}
// The verb reported success and `loginctl` still says no: not a privilege problem, so say
// that instead of blaming the grant the operator would then go and re-check.
Ok(()) => Err(format!(
"`loginctl enable-linger {uid}` reported success but lingering is still off"
)),
Err(why) => Err(why),
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)"
);
}
}
/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager
/// stop take us down)? Read from our own cgroup path.
fn host_is_under_user_manager() -> bool {
std::fs::read_to_string("/proc/self/cgroup")
.as_deref()
.map(cgroup_under_user_manager)
.unwrap_or(false)
}
/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a
/// `user@<uid>.service` manager? Pure + unit-tested. A system unit
/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from
/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only
/// reaches units the user manager owns.
fn cgroup_under_user_manager(cgroup: &str) -> bool {
cgroup.contains("user@")
out.status.success()
}
/// Our uid as a string — what `loginctl` wants for a user argument.
@@ -2656,42 +2714,6 @@ fn uid_string() -> String {
crate::proc::current_uid().to_string()
}
/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)? An
/// unanswered one reads as "not lingering", which refuses the takeover rather than risking the DM
/// stop taking the host down with it.
///
/// [`UNIT_QUERY_BUDGET`], not [`UNIT_STATE_BUDGET`], and the failure DIRECTION is why. The 300 ms
/// bound is documented as "anything near it means the manager is wedged — the case each caller's
/// failure path already covers", and that holds for the other two callers, whose timeout answers
/// `true`/keep-looping (benign). Here a timeout INVERTS the answer to `false`, and `false` is the
/// refusing direction: `ensure_host_survives_dm_stop` then reports "`enable-linger` reported success
/// but lingering is still off" and the bare-spawn Steam path fails a connect that would have worked,
/// blaming a lingering configuration that is in fact correct. And this is not an in-memory read the
/// way `systemctl is-active` is: it is a process spawn plus libsystemd's dynamic link plus a logind
/// D-Bus round trip, sampled at the busiest moment on the box (a takeover, with Steam and a
/// compositor being torn down). Only a genuinely wedged logind exceeds 5 s.
fn linger_enabled() -> bool {
crate::proc::output_within(
Command::new("loginctl").args(["show-user", &uid_string(), "-p", "Linger", "--value"]),
UNIT_QUERY_BUDGET,
)
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
.unwrap_or(false)
}
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
/// packaged pkexec helper. The `Err` is the HELPER's reason (the plain verb's failure is expected
/// and carries no information: an unprivileged host is meant to fail it), and the caller puts it
/// in front of the operator instead of guessing.
fn try_stop_display_manager(dm: &str) -> std::result::Result<(), DmHelperError> {
if systemctl_system(&["stop", dm]) {
return Ok(());
}
dm_helper("stop")
}
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
@@ -2878,22 +2900,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 **45 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,110 +2951,133 @@ 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(());
}
if plan.stop_dm {
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.
//
// Both failure arms below 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"
);
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);
}
// Already idled by an earlier connect in this stream's life? Then the session listed as "live"
// above is our own idled one — it holds no Steam and there is nothing left to free. Without
// this, every reconnect and every in-place rebuild would kill and restart the box's session
// again to accomplish exactly nothing.
if *IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) {
return Ok(());
}
// The display manager STAYS UP. Freeing Steam means ending the session its autologin owns, and
// the two ways to stop that autologin fighting us are not equivalent: stopping the DM works
// until the user asks for a desktop session, at which point nothing on the box can give them
// one ([`DmPlan::dm_relogins`]). Idling the session instead keeps the autologin succeeding —
// no failed unit to relogin against, no storm — while leaving the DM able to service that
// switch.
if plan.dm_relogins {
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
}
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;
}
for unit in units {
if plan.mask {
mask_unit(&unit); // belt-and-braces under a stopped DM; the whole defense otherwise
}
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
if plan.dm_relogins {
// Bring it back ourselves rather than waiting for the DM to notice: deterministic, and
// it closes the window in which the DM sees a dead session and starts churning. The
// drop-in above is already loaded, so what comes back runs nothing.
systemctl_user(&["restart", &unit]);
}
tracing::info!(
%unit,
masked = plan.mask,
"freed Steam: stopped the autologin gaming session for this stream"
idled = plan.dm_relogins,
"freed Steam: the box's autologin gaming session is idled for this stream (its \
display manager stays up, so the box can still switch sessions)"
);
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 45/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 45/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);
@@ -3483,6 +3530,17 @@ fn do_restore_tv_session() {
// rests on. It used to sit after the desktop-active and DM returns, so those two paths leaked
// it.
disarm_session_plus_dropin();
// The idle drop-in belongs to the same rule and leaks the same way — worse, in fact: the bind
// one leaves the box's Game Mode running OUR gamescope, this one leaves it running NOTHING.
// The desktop-active return below is the live case (the user switched away, so we never
// restart the units), and a drop-in left there is a box whose Game Mode silently does nothing
// for the rest of the login.
if remove_idle_dropin() {
tracing::info!(
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
real again"
);
}
unset_forced_session_screen_env();
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
@@ -3515,6 +3573,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 +3583,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 \
@@ -3538,12 +3598,16 @@ fn do_restore_tv_session() {
clear_takeover();
return;
}
// (The idle drop-in is already gone — removed above every early return, so the restarts
// below bring the box's real session back rather than another idle one.)
for unit in units {
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
// that logged an unconditional success over a thrown-away exit status. A `--user start`
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
// and the DM branch thirty lines up already shows the shape — say what happened.
match issue_restore_verb(&["start", &unit]) {
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
// active unit is a no-op that would report success over a session still running nothing.
match issue_restore_verb(&["restart", &unit]) {
RestoreVerb::Done => tracing::info!(
unit,
"restored the TV's autologin gaming session (debounce elapsed, no client)"
@@ -5168,10 +5232,9 @@ impl Drop for GamescopeProc {
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,
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
game_hz, gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags,
mode_mismatch, nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
@@ -5351,26 +5414,6 @@ mod tests {
);
}
#[test]
fn user_manager_lifetime_detection() {
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
// login session the DM stop ends — this is the case that needs lingering.
assert!(cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n"
));
assert!(cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n"
));
// A system unit outlives every session — the DM stop cannot reach it.
assert!(!cgroup_under_user_manager(
"0::/system.slice/punktfunk-host.service\n"
));
// Started from a login shell: owned by the session scope, not the user manager.
assert!(!cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/session-2.scope\n"
));
}
#[test]
fn session_select_sentinel_needs_a_baseline() {
let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
@@ -5423,11 +5466,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();
}
@@ -5478,24 +5516,56 @@ 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).
fn dm_plan_idles_any_dm_that_drove_a_live_session() {
// A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two
// things that do NOT work is flavor-dependent — a mask fails the unit in milliseconds and
// makes the relogin loop fast (4-5 logins/s, pad at 1.4 Hz, 2026-08-18), and stopping the
// DM leaves nothing able to start a desktop session when the user asks for one.
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.dm_relogins);
// 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.dm_relogins == p.dm_relogins);
// 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: kill and leave it stopped. Nothing relogins, so
// there is no autologin to idle — and no reason to leave a drop-in on the box.
let p = dm_plan(None, true);
assert!(!p.skip && p.mask && !p.stop_dm);
assert!(!p.skip && !p.dm_relogins);
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]
+10 -1
View File
@@ -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
+6 -1
View File
@@ -49,7 +49,12 @@ socket2 = { version = "0.6", features = [
"all",
] } # SO_SNDBUF/SO_RCVBUF growth (default UDP buffers too small for 4K/5K bursts) + DSCP/SO_PRIORITY media QoS
thiserror = "2"
tracing = { version = "0.1", default-features = false, features = ["std"] }
# `log`: tracing events are mirrored as `log` records when no tracing subscriber is installed —
# what `abi::punktfunk_set_log_callback` (ABI v25) delivers to an embedder. On transitively via
# quinn's defaults already; declared here because the ABI promise must not hinge on that.
tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
# The backend `punktfunk_set_log_callback` installs (`log::set_logger` + `log::Log`).
log = "0.4"
rand = "0.9"
zeroize = "1"
# Interface enumeration for Wake-on-LAN: computes each NIC's subnet-directed broadcast so a
+199 -9
View File
@@ -59,9 +59,7 @@ use std::ptr;
/// for. The slots behind these mutexes are plain last-value caches (frame/audio/cursor/clip), so
/// whatever a poisoned writer left behind is still structurally valid data to overwrite or hand
/// out; recovering the guard is strictly better than aborting the embedding application.
/// (`quic`-gated with its only callers, the `punktfunk_connection_*` entry points — a
/// `default-features = false` consumer like the tray would otherwise see dead code.)
#[cfg(feature = "quic")]
/// (Ungated since v25: [`punktfunk_set_log_callback`]'s sink slot uses it on every build.)
fn lock_recover<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
@@ -258,6 +256,117 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
crate::ABI_VERSION
}
/// A log line from the core (ABI v25, [`punktfunk_set_log_callback`]). `level` is 1 = error,
/// 2 = warn, 3 = info, 4 = debug, 5 = trace. `target` is the Rust module path the line came from
/// (`punktfunk_core::transport::udp`, `quinn::connection`, …) and `message` the formatted text;
/// both are NUL-terminated UTF-8, borrowed for the duration of the call only — copy them out.
/// Called from whichever thread logged, so the callback must be thread-safe, must not block for
/// long (it sits on the transport and pump threads), and must not call back into the core's
/// logging (it would be re-entered).
pub type PunktfunkLogCb = Option<
unsafe extern "C" fn(
level: u8,
target: *const c_char,
message: *const c_char,
user: *mut c_void,
),
>;
#[derive(Clone, Copy)]
struct LogSink {
cb: unsafe extern "C" fn(u8, *const c_char, *const c_char, *mut c_void),
user: *mut c_void,
}
// SAFETY: the user pointer is an opaque token handed back to the caller's own callback, which the
// contract above requires to be thread-safe; the core never dereferences it.
unsafe impl Send for LogSink {}
static LOG_SINK: std::sync::Mutex<Option<LogSink>> = std::sync::Mutex::new(None);
static LOG_INSTALLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
/// The `log` backend behind [`punktfunk_set_log_callback`]: every record the core (and its
/// dependencies that log through `log`, plus its own `tracing` events via tracing's `log` bridge)
/// emits is handed to the registered sink. Installed once; the sink slot is swappable after.
struct CallbackLogger;
impl log::Log for CallbackLogger {
fn enabled(&self, _: &log::Metadata) -> bool {
// Level gating is `log::set_max_level`, applied by `punktfunk_set_log_callback`.
true
}
fn log(&self, record: &log::Record) {
// Copy the sink OUT of the lock before calling it: a callback that logs (it shouldn't, but
// an embedder's mistake must be a duplicate line, not a deadlock) re-enters `log` cleanly.
let Some(sink) = *lock_recover(&LOG_SINK) else {
return;
};
let cstr = |s: String| {
// An interior NUL can't cross as a C string; drop the byte rather than the line.
let mut bytes = s.into_bytes();
bytes.retain(|&b| b != 0);
std::ffi::CString::new(bytes).unwrap_or_default()
};
let target = cstr(record.target().to_string());
let message = cstr(record.args().to_string());
// SAFETY: the sink was registered through the ABI with exactly this signature; both
// strings outlive the call (they are locals dropped after it) and are NUL-terminated.
unsafe {
(sink.cb)(
record.level() as u8,
target.as_ptr(),
message.as_ptr(),
sink.user,
)
};
}
fn flush(&self) {}
}
/// Receive the core's log lines (ABI v25). The core logs through `tracing`; on the desktop and
/// Android shells a subscriber/logger installed by the shell picks those up, but an embedder that
/// installs none (Swift, any C host) saw NOTHING — every transport warning (socket-buffer clamp,
/// QoS refusal), every quinn connection event and every rustls handshake note vanished, and a
/// client log bundle carried the shell's half of the story only. This routes them to `cb`.
///
/// `max_level` is the most verbose level delivered (1 = error … 5 = trace; 0 = nothing) —
/// `log::set_max_level`, so anything above it costs no formatting. 3 (info) is the right default
/// for a field log ring; quinn's debug/trace is per-packet and would churn any bounded ring.
/// `cb == NULL` detaches the sink (lines are dropped again). `user` is handed back on every call.
///
/// Returns `Ok`, or `Unsupported` when another `log` backend is already installed in this
/// process (e.g. the Android shell's `android_logger`) — the core cannot replace it, and that
/// backend already receives everything this one would. Idempotent: call again to change the
/// level or the sink.
///
/// # Safety
/// `cb`, if non-null, must remain a valid function for as long as it is installed (until the next
/// call with NULL), and `user` must stay valid for every call the core may make meanwhile.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_set_log_callback(
max_level: u8,
cb: PunktfunkLogCb,
user: *mut c_void,
) -> PunktfunkStatus {
guard(|| {
let installed = *LOG_INSTALLED.get_or_init(|| log::set_logger(&CallbackLogger).is_ok());
if !installed {
return PunktfunkStatus::Unsupported;
}
*lock_recover(&LOG_SINK) = cb.map(|cb| LogSink { cb, user });
log::set_max_level(match (cb.is_some(), max_level) {
(false, _) | (_, 0) => log::LevelFilter::Off,
(_, 1) => log::LevelFilter::Error,
(_, 2) => log::LevelFilter::Warn,
(_, 3) => log::LevelFilter::Info,
(_, 4) => log::LevelFilter::Debug,
_ => log::LevelFilter::Trace,
});
PunktfunkStatus::Ok
})
}
/// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s).
///
/// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) —
@@ -2326,12 +2435,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.54.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.54.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 +2731,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() {
@@ -5850,6 +5966,80 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
})
}
#[cfg(test)]
mod log_sink_tests {
use super::*;
use std::sync::Mutex;
/// `(level, target, message, user token)` per delivered line. The collector asserts nothing
/// itself (an `extern "C"` fn must not panic — the hygiene gate enforces it); the test body
/// checks what landed.
static LINES: Mutex<Vec<(u8, String, String, usize)>> = Mutex::new(Vec::new());
unsafe extern "C" fn collect(
level: u8,
target: *const c_char,
message: *const c_char,
user: *mut c_void,
) {
// SAFETY: the core hands NUL-terminated strings valid for this call, per the callback contract.
let (t, m) = unsafe { (CStr::from_ptr(target), CStr::from_ptr(message)) };
lock_recover(&LINES).push((
level,
t.to_string_lossy().into_owned(),
m.to_string_lossy().into_owned(),
user as usize,
));
}
/// End to end through both doors: a `log` record and a `tracing` event (via tracing's `log`
/// feature) reach the C callback with level, real target, message and the user token; an
/// interior NUL is dropped rather than truncating the line; the level ceiling is honoured;
/// NULL detaches.
#[test]
fn callback_receives_log_and_tracing_lines() {
// SAFETY: `collect` is a valid fn for the life of the test binary, the user token is an
// opaque integer.
let st = unsafe { punktfunk_set_log_callback(3, Some(collect), 0x5151 as *mut c_void) };
assert_eq!(st, PunktfunkStatus::Ok);
log::warn!(target: "quinn::connection", "handshake \0 done");
tracing::info!(target: "punktfunk_core::transport", buf = 4096, "socket buffer clamped");
log::debug!(target: "quinn::connection", "must not arrive (above the ceiling)");
let lines = lock_recover(&LINES).clone();
let warn = lines
.iter()
.find(|l| l.1 == "quinn::connection")
.expect("log record delivered");
assert_eq!(warn.0, 2);
assert_eq!(warn.2, "handshake done", "interior NUL dropped, line kept");
assert_eq!(warn.3, 0x5151, "the user token must come back unchanged");
let info = lines
.iter()
.find(|l| l.1 == "punktfunk_core::transport")
.expect("tracing event delivered through the log bridge");
assert_eq!(info.0, 3);
assert!(
info.2.contains("socket buffer clamped") && info.2.contains("buf=4096"),
"{}",
info.2
);
assert!(!lines.iter().any(|l| l.2.contains("must not arrive")));
// SAFETY: NULL callback detaches; no pointer is retained.
let detached = unsafe { punktfunk_set_log_callback(3, None, ptr::null_mut()) };
assert_eq!(detached, PunktfunkStatus::Ok);
let before = lock_recover(&LINES).len();
log::error!(target: "quinn::connection", "after detach");
assert_eq!(
lock_recover(&LINES).len(),
before,
"a detached sink hears nothing"
);
}
}
#[cfg(all(test, feature = "quic"))]
mod tests {
use super::*;
+39 -12
View File
@@ -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;
+11 -1
View File
@@ -246,7 +246,17 @@ pub use stats::Stats;
/// this reads and writes landed with the plane itself, appended behind the existing trailing-field
/// discipline (old peers skip them in both directions, and a legacy request encodes byte-identical
/// to the pre-hi-res messages), so [`WIRE_VERSION`] is still unchanged.
pub const ABI_VERSION: u32 = 24;
/// **v25** adds [`abi::punktfunk_set_log_callback`] — a `log` backend behind a C callback, so an
/// embedder that installs no Rust subscriber (the Swift clients, any C host) can receive the
/// core's own log lines: transport warnings, quinn connection events, rustls handshake notes,
/// everything this crate and its dependencies say through `tracing`/`log`. Until now those went
/// nowhere on Apple, and a client log bundle sent to the host carried the shell's half only.
/// ADDED, not widened: one new function and one callback typedef; nothing existing moved, and an
/// embedder that never calls it behaves exactly as on v24. Client-local in every sense — the host
/// never sees it and [`WIRE_VERSION`] is unchanged. It relies on tracing's `log` feature, now
/// declared explicitly by this crate (it was on transitively through quinn's defaults, which is
/// not a thing an ABI promise should rest on).
pub const ABI_VERSION: u32 = 25;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -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 })
}
}
+30 -4
View File
@@ -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"));
+124 -4
View File
@@ -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).
+4 -1
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More