Compare commits

..
Author SHA1 Message Date
enricobuehler b03acc9153 Windows client: ship a per-user Inno Setup installer + portable zip as the default download; keep the MSIX for the Store
ci / web (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m32s
ci / bun-nix (pull_request) Successful in 21s
ci / rust-arm64 (pull_request) Successful in 2m4s
ci / docs-drift (pull_request) Successful in 25s
installer-smoke / smoke (arch) (pull_request) Successful in 43s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 1m0s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m33s
ci / rust (pull_request) Successful in 8m26s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m23s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m48s
A user report: launching via Big Picture doesn't work and the Steam overlay never appears.
Not because the app is UWP (it's full-trust Win32 under MSIX too) but because of the MSIX
install SHAPE: the exe lives under the ACL'd WindowsApps dir Steam's non-Steam-game picker
can't browse, and alias/shell:AppsFolder activation defeats the overlay's injection — Steam
must spawn the exe itself from a normal path.

- punktfunk-client.iss: per-user (no UAC) install to %LOCALAPPDATA%\Programs\Punktfunk.
  Re-creates the manifest's grants in HKCU (punktfunk:// scheme, Start entries, {app} on the
  user PATH for the punktfunk CLI) and fetches the Windows App Runtime when missing.
- pack-client-installer.ps1: consumes pack-msix.ps1's layout (one assembly, three artifacts),
  signs the four exes individually, emits setup.exe + a portable zip. Same signing backends
  and fail-closed-on-tags rule as the sibling scripts; no .cer (an exe runs untrusted).
- windows-client.yml: pack step after the MSIX, publish + release-attach the new artifacts
  (canary/latest aliases punktfunk-client-setup_<arch>.exe, ..._<arch>-portable.zip).
- deeplink.rs: write_shortcut targets the app-execution alias only under package identity;
  unpackaged installs have no alias but a stable path, so they target current_exe().
  has_package_identity() now shared with main.rs's AppUserModelID probe.
- docs: install-client (installer default + a 'Launching through Steam' section + MSIX/portable
  as alternates), channels, uninstall, clients, platforms.json (both copies, drift check green).
2026-08-20 00:52:46 +02:00
enricobuehler 6b5307618f Merge pull request 'KWin creates our virtual output disabled and refuses to stream it — enable it and retry' (#344) from worktree-kwin-vout-enable-repair into main
ci / bun-nix (push) Successful in 23s
ci / web (push) Successful in 1m23s
ci / docs-drift (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m44s
ci / rust-arm64 (push) Successful in 2m12s
deb / build-publish-gamescope (push) Successful in 59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 42s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m36s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
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 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
android / android (push) Failing after 7m23s
deb / build-publish-host (push) Successful in 5m43s
ci / rust (push) Successful in 7m28s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 34s
arch / build-publish (push) Successful in 8m37s
deb / build-publish (push) Successful in 4m5s
deb / smoke-install (push) Successful in 3m8s
windows-host / package (push) Successful in 12m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m12s
2026-08-19 20:48:43 +00:00
enricobuehler cdacd5636e Merge remote-tracking branch 'origin/main' into worktree-kwin-vout-enable-repair
ci / bun-nix (pull_request) Successful in 32s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m43s
ci / rust-arm64 (pull_request) Successful in 2m20s
android / android (pull_request) Successful in 5m39s
ci / rust (pull_request) Successful in 6m20s
2026-08-19 22:39:11 +02:00
enricobuehler 47f01149bf Merge pull request 'Guided Linux installer (preview): one command from nothing to a pairable host, running exactly what platforms.json states, smoke-tested in CI per package family' (#345) from worktree-wp4-install-script into main
apple / swift (push) Successful in 2m10s
ci / web (push) Successful in 2m2s
ci / rust-arm64 (push) Successful in 3m4s
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 25s
ci / docs-site (push) Successful in 2m12s
deb / build-publish-gamescope (push) Successful in 27s
decky / build-publish (push) Successful in 1m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
deb / build-publish (push) Successful in 4m5s
ci / rust (push) Successful in 8m17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
arch / build-publish (push) Successful in 10m11s
installer-smoke / smoke (arch) (push) Successful in 56s
installer-smoke / smoke (fedora-44) (push) Successful in 1m0s
installer-smoke / smoke (debian-13) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
docker / builders-arm64cross (push) Successful in 22s
android / android (push) Successful in 11m46s
docker / deploy-docs (push) Successful in 39s
deb / build-publish-host (push) Successful in 8m27s
apple / distribute (push) Successful in 12m59s
deb / smoke-install (push) Successful in 2m35s
apple / screenshots (push) Successful in 9m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m39s
2026-08-19 19:01:45 +00:00
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 20568d988f fix(kwin): KWin creates our virtual output disabled and refuses to stream it — enable it and retry
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m30s
ci / docs-site (pull_request) Successful in 1m14s
ci / docs-drift (pull_request) Failing after 19s
ci / bun-nix (pull_request) Successful in 1m42s
android / android (pull_request) Successful in 10m8s
ci / rust (pull_request) Failing after 10m48s
On KWin >= 6.6 `streamVirtualOutput` creates the output on the backend and then
passes `workspace()->findOutput(output)` to the stream, which is null for an
output the workspace does not manage (`wantsToManage` = `isEnabled() &&
!isNonDesktop()`). So an output KWin creates DISABLED is refused with
"Could not find output" — translated into the session's language, and logged
nowhere, because disabling an output is a perfectly valid configuration that
applies successfully. 6.4/6.5 passed the backend output straight through and
streamed it either way.

It repeats forever. The host asks for a STABLE per-client output name precisely
so KWin persists that client's scale and mode against it, so a stored setup
naming it `enabled: false` is reapplied to every future session for that client
— and the user cannot fix it in System Settings, because the output only exists
for the few milliseconds the request is alive.

Repair it instead. On a refusal, enable the head over kde_output_management_v2
and let the retry go again. Two properties of KWin make that possible, both read
off Plasma/6.7 rather than assumed:

  * `sendFailed` only sends the event — it does not emit `finished`, and
    `removeVirtualOutput` is wired to `finished`. The disabled output therefore
    stays alive for as long as we hold the failed stream open, which is the
    window the repair runs in (and why it must run inside the worker thread,
    before the connection drops).
  * `WaylandServer::handleOutputAdded` offers EVERY backend output to the
    output-device registry, gating only placeholders and non-desktop ones. Only
    `wl_output` is gated on being enabled, so a disabled output is invisible to
    `stream_output` but fully addressable over output management.

Enabling it is a user-applied configuration, so KWin persists it against that
output's identity: the retry's fresh request finds a stored setup that enables
it. The repair therefore fixes attempt N+1, never attempt N.

`REPAIRED_HINT` keeps that retry reachable. The opener wraps every worker error
with "KWin virtual output failed", which is the phrase the host's
`is_permanent_build_error` matches to short-circuit the retry loop — so a
repaired refusal carrying it would be classified permanent and the retry that
consumes the repair would never run, making the whole path dead code. A repaired
refusal is reported verbatim without that wrapper; an unrepairable one keeps it
and still fails fast, because nothing about the box changed. The host-side test
pins both halves.
2026-08-19 20:29:27 +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 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
149 changed files with 7238 additions and 4241 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
+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'
+13 -1
View File
@@ -173,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)
+38 -8
View File
@@ -56,8 +56,12 @@
#
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
#
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
# from one assembled layout:
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
# (stable path Steam can launch: overlay + Big Picture work)
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
@@ -283,6 +287,28 @@ jobs:
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
- name: Pack + sign installer + portable zip
if: github.event_name != 'pull_request'
shell: pwsh
env:
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& clients/windows/packaging/pack-client-installer.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
- name: Publish to Gitea generic registry
if: github.event_name != 'pull_request'
shell: pwsh
@@ -301,7 +327,10 @@ jobs:
# on that accident, so removing the quotes can't silently reintroduce it.
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
# The installer + portable zip (the default download; docs point at these alias URLs).
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
@@ -324,10 +353,11 @@ jobs:
Put $f "$base/$alias/$an"
}
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
# the race, and x64/arm64 filenames differ so the assets don't collide.
- name: Attach MSIX to the Gitea release (stable tags only)
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
# don't collide.
- name: Attach client artifacts to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
@@ -335,6 +365,6 @@ jobs:
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
+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
+1
View File
@@ -3582,6 +3582,7 @@ dependencies = [
"hmac 0.13.0",
"if-addrs",
"libc",
"log",
"opus",
"proptest",
"quinn",
+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": {
@@ -65,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)
@@ -106,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)
@@ -129,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)
@@ -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
@@ -213,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) }
@@ -536,12 +546,14 @@ 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 }
@@ -582,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")
@@ -591,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")
@@ -287,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
@@ -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 {
+11 -2
View File
@@ -323,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,
@@ -341,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,
@@ -351,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) {
+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>()
}
+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
+38
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,
@@ -698,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");
}
}
}
}
}
@@ -787,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())
@@ -845,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,
+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::*;
+7 -4
View File
@@ -33,11 +33,14 @@ the fast **`punktfunk/1`** protocol.
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
Builds and ships for both **x64** and **ARM64** as a signed **MSIX**.
Builds and ships for both **x64** and **ARM64**, three ways from one layout: a signed **installer**
(the default — a per-user setup.exe whose stable install path Steam can launch, so the Steam
overlay and Big Picture work), a **portable zip**, and a signed **MSIX** (kept for Microsoft Store
compatibility).
## Get it
Install the signed MSIX from the package registry — see
Install the signed installer from the package registry — see
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
A stock [Moonlight](https://moonlight-stream.org/) client also works over GameStream if you prefer.
@@ -58,7 +61,7 @@ punktfunk-client --headless --speed-test --connect host[:port] # probe burst
```
> `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header
> build. Packaging (MSIX manifest, signing) lives in [`packaging/`](packaging/).
> build. Packaging (MSIX manifest, the Inno Setup installer, signing) lives in [`packaging/`](packaging/).
## Layout
@@ -79,7 +82,7 @@ src/
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
probe.rs · wol.rs speed probe · Wake-on-LAN
logfile.rs log tee to %LOCALAPPDATA%
packaging/ MSIX manifest, signing, pack script
packaging/ MSIX manifest + Inno Setup installer, signing, pack scripts
```
## Manual smoke checklist
+24 -5
View File
@@ -1,11 +1,30 @@
# punktfunk Windows client — MSIX packaging
# punktfunk Windows client — packaging
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
The Windows client ships **three ways, packed from one assembled layout** by CI
([`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml)) to Gitea's
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
[Release Channels](https://punktfunk.unom.io/docs/channels).
[Release Channels](https://punktfunk.unom.io/docs/channels):
1. **Inno Setup installer** (`punktfunk-client-setup_<arch>.exe`) — the **default download**. A
per-user, no-UAC install to `%LOCALAPPDATA%\Programs\Punktfunk`. It exists because the MSIX
install shape breaks the top user-reported flows: the exe lands under the ACL'd
`C:\Program Files\WindowsApps`, which Steam's *Add a Non-Steam Game* picker can't browse, and
the alias/`shell:AppsFolder` activation defeats the Steam overlay's injection and Big Picture
launch — Steam must spawn the exe itself from a normal path. `punktfunk-client.iss` +
`pack-client-installer.ps1`; it re-creates the manifest's declarative grants per-user
(`punktfunk://` in HKCU Classes, Start shortcuts, `{app}` on the user PATH for the
`punktfunk` CLI) and fetches the Windows App Runtime when missing.
2. **Portable zip** (`punktfunk-client-windows_<arch>-portable.zip`) — the same signed file set,
nothing registered.
3. **Signed MSIX** (`punktfunk-client-windows_<arch>.msix`) — kept for **Microsoft Store**
compatibility. Everything below the fold documents this path.
`pack-msix.ps1` assembles the layout and packs the MSIX; `pack-client-installer.ps1` then consumes
that same `layout/` for the installer + zip (and signs the four exes individually — the MSIX only
signs its container).
# MSIX packaging
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
@@ -0,0 +1,246 @@
<#
.SYNOPSIS
Pack + sign the punktfunk Windows client as an Inno Setup setup.exe (the default download) and a
portable .zip, from the layout pack-msix.ps1 already assembled.
.DESCRIPTION
Runs AFTER pack-msix.ps1 in the same job and consumes its $OutDir\layout verbatim one assembly,
three artifacts (.msix, setup.exe, portable .zip). Why the installer exists at all: the MSIX
install shape (WindowsApps ACLs + alias-only activation) breaks Steam's non-Steam-game picker,
the Steam overlay's injection, and Big Picture launching — see punktfunk-client.iss's header.
Steps:
1. stage the runtime file set from -LayoutDir (drops AppxManifest.xml + the tile Assets),
2. sign the four exes individually (the MSIX only signs its container),
3. zip the stage -> the portable build,
4. ISCC punktfunk-client.iss over the same stage, sign the setup.exe,
5. emit CLIENT_SETUP_PATH / CLIENT_ZIP_PATH to GITHUB_ENV for the publish step.
Signing backend precedence is identical to pack-msix.ps1 / pack-host-installer.ps1 (Azure
Artifact Signing -> supplied .pfx -> ephemeral self-signed; fail closed on v* tags). No .cer is
exported here: unlike an MSIX, a plain exe RUNS regardless of signer trust an untrusted
signature only costs a SmartScreen warning, so canary self-signed builds need nothing imported.
.EXAMPLE
pwsh -File pack-client-installer.ps1 -Version 0.2.137.0 -Arch x64 `
-LayoutDir C:\t\msix\layout -OutDir C:\t\installer
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, same as the MSIX
[Parameter(Mandatory = $true)][string]$LayoutDir, # pack-msix.ps1's $OutDir\layout
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
[string]$OutDir = (Join-Path (Split-Path -Parent $LayoutDir) 'installer'),
# Subject for the EPHEMERAL self-signed fallback only; Azure/pfx carry their own subjects.
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT,
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT,
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE,
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB,
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto',
[switch]$NoSign # skip signing (local debug)
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Keep the "check $LASTEXITCODE myself" model (see pack-host-installer.ps1): pwsh 7.4 must not
# turn a non-zero native exit into a terminating error before Sign-File's timestamp retry runs.
$PSNativeCommandUseErrorActionPreference = $false
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
}
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$iss = Join-Path $here 'punktfunk-client.iss'
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) — same finders as the sibling scripts ---
function Find-Iscc {
foreach ($p in @(
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
'C:\Program Files\Inno Setup 6\ISCC.exe')) {
if (Test-Path $p) { return $p }
}
$c = Get-Command iscc -ErrorAction SilentlyContinue
if ($c) { return $c.Source }
throw "ISCC.exe (Inno Setup 6, any 6.x) not found - install it (choco install innosetup -y)."
}
function Find-SdkTool([string]$name) {
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
Select-Object -Last 1
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
$hit.FullName
}
function Find-AzureDlib([string]$Explicit) {
if ($Explicit) {
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
return (Resolve-Path $Explicit).Path
}
$roots = @(
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
'C:\trusted-signing\microsoft.trusted.signing.client'
) | Where-Object { $_ -and (Test-Path $_) }
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
Sort-Object LastWriteTime | Select-Object -Last 1
if (-not $hit) {
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
}
$hit.FullName
}
$iscc = Find-Iscc
Write-Host "ISCC: $iscc"
# --- stage the runtime file set (the portable layout = what the installer lays down) ----------
# Explicit list, not a wildcard copy: the MSIX layout also holds AppxManifest.xml and the tile
# Assets, which mean nothing outside a package (the exes embed their icons via build.rs).
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'punktfunk.exe',
'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
$stage = Join-Path $OutDir 'portable'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $stage | Out-Null
foreach ($f in $required) {
$src = Join-Path $LayoutDir $f
if (-not (Test-Path $src)) { throw "missing '$f' in $LayoutDir (did pack-msix.ps1 run first?)" }
Copy-Item $src (Join-Path $stage $f) -Force
}
$licSrc = Join-Path $LayoutDir 'licenses'
if (-not (Test-Path $licSrc)) { throw "missing licenses\ in $LayoutDir (did pack-msix.ps1 run first?)" }
Copy-Item $licSrc (Join-Path $stage 'licenses') -Recurse -Force
# --- signing backend, same precedence + fail-closed rule as pack-msix.ps1 ---------------------
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
else { [Convert]::ToBoolean($RequireSignedCert) }
if ($NoSign -and $requireCert) {
throw "release build ($env:GITHUB_REF) with -NoSign - refusing to publish an unsigned installer."
}
$pfxPath = Join-Path $OutDir 'signing.pfx'
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
$signMode = 'none'
$signtool = $null
if (-not $NoSign) {
$signtool = Find-SdkTool 'signtool.exe'
Write-Host "signtool: $signtool"
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
$signMode = 'azure'
$AzureDlib = Find-AzureDlib $AzureDlib
@{
Endpoint = $AzureEndpoint
CodeSigningAccountName = $AzureAccount
CertificateProfileName = $AzureProfile
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
if (-not [Environment]::GetEnvironmentVariable($v)) {
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
"DefaultAzureCredential; without the service-principal trio it falls through to " +
"an interactive login that cannot complete on a runner and hangs the build.")
}
}
}
elseif ($PfxBase64) {
$signMode = 'pfx'
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
}
elseif ($requireCert) {
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
}
else {
$signMode = 'selfsigned'
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
-KeyUsage DigitalSignature -FriendlyName 'punktfunk client installer (self-signed)' `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
}
}
# Timestamp policy matches the sibling scripts: best-effort for a long-lived .pfx, MANDATORY under
# Azure signing (those leaf certs expire in ~3 days; untimestamped signatures die with them).
function Sign-File([string]$Path) {
if ($NoSign) { return }
if ($signMode -eq 'azure') {
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
$ts = 'http://timestamp.acs.microsoft.com'
}
else {
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
$ts = 'http://timestamp.digicert.com'
}
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
if ($LASTEXITCODE -eq 0) { return }
if ($signMode -eq 'azure') {
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
"untrusted within days of release.")
}
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
& $signtool ($signArgs + @($Path))
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
}
# --- sign the inner exes, zip the stage (portable build), then build + sign the installer ------
foreach ($f in $required | Where-Object { $_ -like '*.exe' }) {
Sign-File (Join-Path $stage $f)
}
$zip = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}-portable.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
Write-Host "==> portable zip: $zip"
# Stage the .iss + branding next to each other under $OutDir: ISCC is a 32-bit process, and on the
# SYSTEM-profile runner WOW64 redirection breaks reads from the checkout path (see
# pack-host-installer.ps1's staging note) — everything ISCC touches must live under C:\t.
$issLocal = Join-Path $OutDir 'punktfunk-client.iss'
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
$brandSrc = (Resolve-Path (Join-Path $here '..\..\..\packaging\windows\branding')).Path
$brandStage = Join-Path $OutDir 'branding'
if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $brandStage | Out-Null
Copy-Item (Join-Path $brandSrc '*.bmp') $brandStage -Force
Copy-Item (Join-Path $brandSrc 'punktfunk.ico') $brandStage -Force
$defines = @(
"/DMyAppVersion=$Version",
"/DArch=$Arch",
"/DLayoutDir=$stage",
"/DBrandingDir=$brandStage",
"/DOutputDir=$OutDir"
)
Write-Host "==> ISCC $($defines -join ' ') $issLocal"
& $iscc @defines $issLocal
if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" }
$setup = Join-Path $OutDir "punktfunk-client-setup-${Version}_${Arch}.exe"
if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
Sign-File $setup
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "==> installer: $setup"
if ($signMode -eq 'azure') {
Write-Host "==> signed by a publicly trusted CA."
}
elseif ($signMode -ne 'none') {
Write-Host "==> $signMode-signed: the exe still runs everywhere; expect a SmartScreen prompt on canary builds."
}
if ($env:GITHUB_ENV) {
"CLIENT_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CLIENT_ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
@@ -0,0 +1,244 @@
; punktfunk Windows CLIENT installer (Inno Setup 6) — the default download.
;
; A classic per-user setup.exe, NOT because MSIX failed technically (the app is full-trust Win32
; either way) but because the MSIX install SHAPE breaks the most-reported use case: the exe lands
; under the ACL'd C:\Program Files\WindowsApps, which Steam's "Add a Non-Steam Game" picker cannot
; browse and whose activation path defeats the overlay's GameOverlayRenderer64.dll injection —
; Steam has to spawn the process itself from a normal path for the overlay (and a Big Picture
; launch) to work. This installs to {userpf}\Punktfunk: user-writable-visible, no UAC, and a
; stable path Steam can target. The MSIX is kept for Microsoft Store compatibility
; (clients/windows/packaging/pack-msix.ps1 — both are packed from the same layout every build).
;
; Built by pack-client-installer.ps1, e.g.:
; ISCC.exe /DMyAppVersion=0.2.137.0 /DArch=x64 /DLayoutDir=C:\t\installer\portable \
; /DBrandingDir=C:\t\installer\branding /DOutputDir=C:\t\installer punktfunk-client.iss
;
; What the MSIX manifest granted declaratively is re-created here per-user (all HKCU, so no
; elevation and uninstall leaves nothing behind):
; punktfunk:// protocol -> HKCU\Software\Classes\punktfunk (deeplink.rs positional parse)
; Start entries -> {userprograms} shortcuts (Punktfunk + Punktfunk Console)
; punktfunk.exe CLI alias -> {app} appended to the HKCU PATH (Playnite importer shells to it)
; punktfunk-client.exe alias -> unnecessary: deeplink.rs targets current_exe() when unpackaged
; Microsoft.WindowsAppRuntime.2 PackageDependency
; -> download + run the runtime installer when missing ([Code])
#ifndef MyAppVersion
#define MyAppVersion "0.0.0.0"
#endif
#ifndef Arch
#define Arch "x64"
#endif
#ifndef LayoutDir
#define LayoutDir "."
#endif
#ifndef BrandingDir
#define BrandingDir "..\..\..\packaging\windows\branding"
#endif
#ifndef OutputDir
#define OutputDir "."
#endif
; The unpackaged app resolves an INSTALLED Windows App SDK runtime via the bootstrap DLL
; (windows-reactor pins WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000; the MSIX manifest's
; PackageDependency floor is 2.2 — keep the two in sync with packaging/AppxManifest.xml).
#define AppRuntimeUrl "https://aka.ms/windowsappsdk/2.2/latest/windowsappruntimeinstall-" + Arch + ".exe"
[Setup]
AppId={{52464E61-68A1-4621-B6B3-5B8BBB823D1A}
AppName=Punktfunk
AppVersion={#MyAppVersion}
AppPublisher=unom
AppPublisherURL=https://git.unom.io/unom/punktfunk
; Per-user, no UAC: {userpf} = %LOCALAPPDATA%\Programs. A browsable, stable path is the point —
; see the header (Steam overlay / Big Picture).
DefaultDirName={userpf}\Punktfunk
PrivilegesRequired=lowest
DisableProgramGroupPage=yes
UsePreviousAppDir=yes
; Same floor as the MSIX manifest's TargetDeviceFamily MinVersion (10.0.17763).
MinVersion=10.0.17763
#if Arch == "arm64"
ArchitecturesAllowed=arm64
ArchitecturesInstallIn64BitMode=arm64
#else
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
#endif
OutputDir={#OutputDir}
OutputBaseFilename=punktfunk-client-setup-{#MyAppVersion}_{#Arch}
Compression=lzma2/max
SolidCompression=yes
; Modern branded wizard, same version gate as the host installer (punktfunk-host.iss).
#if VER >= EncodeVer(6,6,0)
WizardStyle=modern dynamic windows11
#else
WizardStyle=modern
#endif
SetupIconFile={#BrandingDir}\punktfunk.ico
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
UninstallDisplayName=Punktfunk {#MyAppVersion}
UninstallDisplayIcon={app}\punktfunk-client.exe
; {app} goes on the USER PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
; documented `punktfunk hosts list` / `punktfunk launch` one-liners work by name — same contract
; the MSIX's punktfunk.exe app-execution alias provided. Broadcasts WM_SETTINGCHANGE.
ChangesEnvironment=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a Desktop shortcut"; Flags: unchecked
[Files]
; The staged MSIX layout, minus the package-only bits (AppxManifest.xml, the tile Assets — the
; exes embed their own icons via build.rs winresource). pack-client-installer.ps1 signs the four
; exes individually before ISCC runs; the .msix signs only its container, so this cannot be
; skipped by "the MSIX build already signed them".
Source: "{#LayoutDir}\punktfunk-client.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk-session.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk-console.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\Microsoft.WindowsAppRuntime.Bootstrap.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\SDL3.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\resources.pri"; DestDir: "{app}"; Flags: ignoreversion
; MIT/Apache + the client-scoped THIRD-PARTY-NOTICES — same payload the MSIX carries.
Source: "{#LayoutDir}\licenses\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
[Icons]
; Flat Start-menu entries, mirroring the MSIX's two Application tiles.
Name: "{userprograms}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"
Name: "{userprograms}\Punktfunk Console"; Filename: "{app}\punktfunk-console.exe"; \
Comment: "Controller-driven couch interface for TVs and HTPCs"
Name: "{userdesktop}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"; Tasks: desktopicon
[Registry]
; The punktfunk:// scheme (design/client-deep-links.md §4.2) — the registry twin of the MSIX
; manifest's windows.protocol extension. Protocol activation delivers the URI as "%1" on the
; command line, so this lands in the same positional URL parse in main() that the packaged
; activation does. HKCU + uninsdeletekey: nothing survives uninstall.
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; \
ValueData: "URL:Punktfunk stream link"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
Root: HKCU; Subkey: "Software\Classes\punktfunk\DefaultIcon"; ValueType: string; \
ValueData: "{app}\punktfunk-client.exe,0"
Root: HKCU; Subkey: "Software\Classes\punktfunk\shell\open\command"; ValueType: string; \
ValueData: """{app}\punktfunk-client.exe"" ""%1"""
; Put {app} on the USER PATH so `punktfunk` (the headless CLI) is runnable by name. Appended to
; {olddata} and guarded by PathNeedsAdd so a repair/upgrade never appends a duplicate. NOT
; uninsdeletevalue — that would delete the whole Path value; the uninstaller surgically removes
; just our entry (RemoveAppFromPath). expandsz preserves %VAR%-style entries other software put here.
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
ValueData: "{olddata};{app}"; Check: PathNeedsAdd(ExpandConstant('{app}'))
[Code]
const
EnvKey = 'Environment'; { the HKCU per-user environment key }
{ Is the install dir missing from the user PATH? Guards the [Registry] append so a repair or
upgrade can't add a second copy. Semicolon-delimited, case-insensitive — a path that merely
CONTAINS ours as a substring doesn't count as a match. (Same helper as punktfunk-host.iss,
retargeted from the HKLM machine key to HKCU.) }
function PathNeedsAdd(Param: String): Boolean;
var
OrigPath: String;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
begin
Result := True; { no Path value at all - the append creates it }
exit;
end;
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
end;
{ Remove exactly our install-dir entry from the user PATH on uninstall, leaving every other entry
(and their order) intact. Entry-by-entry rebuild, never a substring delete. }
procedure RemoveAppFromPath;
var
OrigPath, NewPath, Entry: String;
Target: String;
P: Integer;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
exit;
Target := Uppercase(ExpandConstant('{app}'));
NewPath := '';
OrigPath := OrigPath + ';';
repeat
P := Pos(';', OrigPath);
Entry := Trim(Copy(OrigPath, 1, P - 1));
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
if (Entry <> '') and (Uppercase(Entry) <> Target) then
begin
if NewPath <> '' then NewPath := NewPath + ';';
NewPath := NewPath + Entry;
end;
until OrigPath = '';
RegWriteExpandStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', NewPath);
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usPostUninstall then
RemoveAppFromPath;
end;
{ The Windows App SDK runtime the bootstrap DLL resolves at launch (the unpackaged twin of the
MSIX's PackageDependency). Probe per-user via Get-AppxPackage; when missing, fetch Microsoft's
runtime installer and run it quietly — it registers Store-signed framework packages, which
needs no elevation. Every failure path is NON-FATAL and ends in the same message the docs
carry, because the app itself reports the missing runtime on first launch too. }
function AppRuntimeMissing(): Boolean;
var
ResultCode: Integer;
begin
{ exit 0 = found, 1 = missing; a powershell failure (rc <> 0/1) counts as missing - the
download below is idempotent and the runtime installer no-ops when it is present. }
if not Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "if (Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2*) { exit 0 } else { exit 1 }"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
Result := True;
exit;
end;
Result := ResultCode <> 0;
end;
procedure EnsureAppRuntime;
var
ResultCode: Integer;
Installer: String;
begin
if not AppRuntimeMissing() then
exit;
Installer := 'windowsappruntimeinstall.exe';
try
DownloadTemporaryFile('{#AppRuntimeUrl}', Installer, '', nil);
if not Exec(ExpandConstant('{tmp}\' + Installer), '--quiet', '',
SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
RaiseException('runtime installer exit code ' + IntToStr(ResultCode));
except
SuppressibleMsgBox(
'The Windows App Runtime 2.x could not be installed automatically.' + #13#10 + #13#10 +
'Punktfunk needs it to start. Install it from ' + #13#10 +
'https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads' + #13#10 +
'and then launch Punktfunk normally.',
mbInformation, MB_OK, IDOK);
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
begin
{ On upgrade a running shell/stream locks the exes; kill them best-effort so the copy succeeds.
taskkill matches the image NAME, so "punktfunk.exe" hits only the CLI, not the host service. }
if CurStep = ssInstall then
Exec(ExpandConstant('{sys}\taskkill.exe'),
'/F /IM punktfunk-client.exe /IM punktfunk-session.exe /IM punktfunk-console.exe /IM punktfunk.exe',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ ssPostInstall, NOT a wizard-page hook: silent installs (winget-style /VERYSILENT) show no
pages, and skipping the runtime there would ship an app that cannot start. This step runs on
every install mode, and SuppressibleMsgBox keeps the failure path unattended-safe. }
if CurStep = ssPostInstall then
EnsureAppRuntime;
end;
+32 -7
View File
@@ -203,14 +203,30 @@ pub(crate) fn queue(url: String) {
INBOX.lock().unwrap().push(url);
}
/// Whether this process runs with MSIX package identity. Decides how a shortcut must target us
/// (`write_shortcut` below) and whether the process may stamp its own AppUserModelID
/// (`set_app_user_model_id` in main.rs).
pub(crate) fn has_package_identity() -> bool {
use windows::Win32::appmodel::GetCurrentPackageFullName;
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
// SAFETY: `GetCurrentPackageFullName` with `len = 0` and no buffer is the documented identity
// PROBE — it writes nothing and only reports whether this process is packaged.
unsafe {
let mut len: u32 = 0;
GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE
}
}
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
///
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
/// still works if it isn't, because it invokes the client directly — which is the whole point
/// of a shortcut being a container for a URL rather than a second launch mechanism
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
/// it valid across updates, since the install path changes and the alias doesn't.
/// The shortcut targets the client exe with the URL as an ARGUMENT, rather than being a `.url`
/// internet shortcut. Both would work while the scheme is registered; only this one still works
/// if it isn't, because it invokes the client directly — which is the whole point of a shortcut
/// being a container for a URL rather than a second launch mechanism
/// (design/client-deep-links.md §5). Which exe reference is durable depends on how we were
/// installed: under MSIX the install path changes on every update but the app execution alias
/// doesn't, so packaged runs target the alias; the Inno Setup / portable installs have no alias
/// but a stable install dir, so unpackaged runs target the absolute exe path.
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
use windows::core::{Interface, HSTRING};
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
@@ -223,6 +239,15 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
.map_err(|_| "USERPROFILE isn't set".to_string())?;
let path = desktop.join(format!("{}.lnk", file_name(label)));
// Alias when packaged, absolute path when not — see the doc comment above.
let target = if has_package_identity() {
"punktfunk-client.exe".to_string()
} else {
std::env::current_exe()
.map_err(|e| format!("current exe: {e}"))?
.to_string_lossy()
.into_owned()
};
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
// synchronous call; nothing here dereferences a pointer the caller supplied.
@@ -233,7 +258,7 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
.map_err(|e| format!("shell link: {e}"))?;
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
link.SetPath(&HSTRING::from(target.as_str()))
.ok()
.map_err(|e| format!("shortcut target: {e}"))?;
link.SetArguments(&HSTRING::from(url))
+4 -10
View File
@@ -173,18 +173,12 @@ fn main() {
/// processes are left alone. Must run before any window exists.
#[cfg(windows)]
fn set_app_user_model_id() {
use windows::Win32::appmodel::GetCurrentPackageFullName;
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
// documented identity PROBE — it writes nothing and only reports whether this process is
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
if deeplink::has_package_identity() {
return; // packaged (or indeterminate) — leave the identity alone
}
// SAFETY: `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
unsafe {
let mut len: u32 = 0;
// No buffer: just probe whether the process has package identity.
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
return; // packaged (or indeterminate) — leave the identity alone
}
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
}
+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,
+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,
+19
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,6 +225,19 @@ 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
@@ -276,6 +294,7 @@ mod tests {
online: false,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
+10
View File
@@ -4,6 +4,7 @@
//! 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;
@@ -180,6 +181,10 @@ 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.
@@ -205,6 +210,7 @@ impl Screen {
Screen::AddHost(s) => s.menu(ev, ctx, fx),
Screen::Pair(s) => s.menu(ev, ctx, fx),
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
Screen::BindProfile(s) => s.menu(ev, ctx, fx),
Screen::Controllers(s) => s.menu(ev, ctx, fx),
Screen::HostOptions(s) => s.menu(ev, ctx, fx),
}
@@ -224,6 +230,7 @@ impl Screen {
Screen::AddHost(s) => s.pointer(p, ctx, fx),
Screen::Pair(s) => s.pointer(p, ctx, fx),
Screen::PinHosts(s) => s.pointer(p, ctx, fx),
Screen::BindProfile(s) => s.pointer(p, ctx, fx),
Screen::Controllers(s) => s.pointer(p, ctx, fx),
Screen::HostOptions(s) => s.pointer(p, ctx, fx),
}
@@ -274,6 +281,7 @@ impl Screen {
Screen::AddHost(s) => s.title(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
Screen::BindProfile(s) => format!("Default for {}", s.host_name()),
Screen::Controllers(_) => "Connected controllers".into(),
Screen::HostOptions(s) => s.title(),
}
@@ -288,6 +296,7 @@ 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),
}
@@ -313,6 +322,7 @@ impl Screen {
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::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,
+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,
@@ -2162,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 {
+137 -19
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> {
@@ -608,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,
@@ -732,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 {
@@ -861,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()),
@@ -949,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. \
@@ -1023,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. \
@@ -1056,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 \
@@ -1259,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)
@@ -1559,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();
@@ -1871,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 {
@@ -2047,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));
+144 -4
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.
@@ -259,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,
@@ -331,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,
@@ -361,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 {
@@ -387,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),
@@ -396,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) {
+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
+159
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,
@@ -495,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.
///
+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) => {
+165 -232
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).
@@ -2128,6 +2215,12 @@ fn kill_unit(unit: &str) {
/// 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]),
@@ -2230,16 +2323,24 @@ struct DmPlan {
/// 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,
/// Stop the DM for the stream's duration (only a live instance justifies it). Masking is not
/// a plan input: it is laid only once this stop has LANDED, so it can never substitute for it.
stop_dm: bool,
/// 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 {
DmPlan {
skip: !any_live,
stop_dm: dm.is_some() && any_live,
dm_relogins: dm.is_some() && any_live,
}
}
@@ -2608,122 +2709,11 @@ fn systemctl_system(args: &[&str]) -> bool {
out.status.success()
}
/// 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())
};
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),
}
}
/// 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@")
}
/// Our uid as a string — what `loginctl` wants for a user argument.
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
@@ -2974,88 +2964,37 @@ fn stop_autologin_sessions() -> Result<()> {
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.
//
// BOTH arms below now BAIL, on every DM flavor. They did not always: SDDM used to degrade
// to mask-only here, on the reasoning that the mask still protects Steam and the cost is
// just relogin churn. It is not just churn — the mask is IN sddm's relogin path, so a
// mask without the stop is a 45 logins/s fork storm that drops the pad from 250 Hz to
// 1.4 Hz ([`mask_unit`]). Degrading to attach costs the client's mode; degrading to
// mask-only costs the user their input plane. Attach wins.
//
// Both bails quote the REASON they were handed rather than describing one.
// 0.26.0/0.27.0 described one — "the packaged pf-dm-helper polkit action is missing or was
// denied (reinstall the punktfunk package, or install the display-manager polkit rule from
// the docs)" — and on the box that produced it the action was installed, permissive,
// correctly annotated, and pkexec had already RUN the helper; the helper's refusal ("user
// 'x' is not in the 'punktfunk' group") was thrown away with its stderr. Both suggested
// remedies were dead ends: neither a reinstall nor a polkit rule adds anyone to a group.
if let Err(why) = ensure_host_survives_dm_stop() {
// The reason goes LAST in both bails: the helper's own refusal ends in a command
// to paste, and burying that mid-sentence is how it stops being read.
bail!(
"stopping {dm} ends this user's last login session, and without lingering \
logind would stop the user manager and this host with it about 10s \
later, leaving the box with no display manager and nothing to restore it; \
lingering could not be enabled, so the managed takeover is unavailable. \
Either run `sudo loginctl enable-linger $USER` once, as the setup docs ask, \
and reconnect or fix the privileged path: {why}"
);
}
if let Err(why) = try_stop_display_manager(&dm) {
// ERROR, not WARN, and it names the SHAPE: this is the branch whose silence cost an
// evening on .41 — the takeover degraded, nothing failed loudly, and the storm that
// followed read as a pad bug. The `bail!` below reaches the caller's own warn line;
// this one exists so the shape survives into the journal even if the caller's does
// not, because the four shapes need four different fixes.
tracing::error!(
%dm,
shape = why.shape(),
reason = %why,
"the managed takeover planned to stop the display manager and could not — \
degrading to ATTACH rather than fighting its autologin: a killed session under \
a running DM relogin-loops at 4-5/s and starves the box's input plane"
);
bail!(
"the box's gaming session is driven by {dm}, and stopping it for the stream needs \
privilege this host does not have; taking over without stopping it would leave \
its autologin relogin-looping against us for the whole stream, so the managed \
takeover is unavailable {why}"
);
}
tracing::info!(
%dm,
"freed Steam: stopped the display manager for this stream (its autologin \
Relogin loop would otherwise churn against the takeover)"
);
// Baseline the switch sentinel HERE, not just at a successful launch: setting
// STOPPED_DM is what arms the honor gate, so from this instant an unbaselined
// sentinel would read as an in-stream "Switch to Desktop" — including the write from
// the switch that just brought the box INTO game mode. A successful launch
// re-baselines (tighter still).
record_session_select_baseline();
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
// 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")?;
}
// Reaching here means no display manager can relogin against us: either there is none
// (`!plan.stop_dm` with `dm == None`), or the stop above LANDED — both failure arms bail. That
// is the precondition the mask needs, and the only one under which it is a defense rather than
// the storm's accelerator ([`mask_unit`]).
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
let mut stopped = Vec::new();
// 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 {
mask_unit(&unit); // belt-and-braces: no DM is up to relogin through it
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
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,
dm_stopped = plan.stop_dm,
"freed Steam: masked and 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);
}
@@ -3591,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
@@ -3648,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)"
@@ -5278,15 +5232,14 @@ 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, game_hz, gamescope_output_size, hdr_args,
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script,
our_wsi_layer_dir, plan_bind, release_autologin_mask, script_hardcodes_gamescope,
sentinel_advanced, shape_dedicated_command, switch_ends_mask_window,
takeover_state_is_live, unmask_unit, xwayland_refusal_marker, BindOff, BindPlan,
BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan, AUTOLOGIN_MASKED,
DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV,
X11_SOCKET_DIR,
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,
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
};
use std::time::{Duration, Instant};
@@ -5461,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);
@@ -5583,27 +5516,27 @@ mod tests {
}
#[test]
fn dm_plan_stops_any_dm_that_drove_a_live_session() {
// A live gaming session behind a DM: stop the DM, whatever the flavor. The mask alone does
// NOT stop the relogin loop — on .41 it is what makes the loop fast, because the session
// script's last act is `systemctl --user --wait start gamescope-session-plus@…` and a
// masked unit fails that in milliseconds (4-5 logins/s, pad at 1.4 Hz, 2026-08-18).
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.stop_dm);
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.stop_dm == p.stop_dm);
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), live: mask+kill, nothing to stop — masking is sound
// here precisely because no relogin loop exists to run into it.
// 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.stop_dm);
assert!(!p.skip && !p.dm_relogins);
assert!(dm_plan(None, false).skip);
}
+43 -2
View File
@@ -66,6 +66,16 @@ use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
const POINTER_METADATA: u32 = 4;
const POINTER_EMBEDDED: u32 = 2;
/// Marks the one KWin refusal a retry can clear: the disabled-output repair ran and changed the
/// box between attempts ([`kwin_output_mgmt::enable_disabled_output`]).
///
/// It is load-bearing in TWO places and both are easy to break. The opener keys on it to skip the
/// `KWin virtual output failed` wrapper below — and that wrapper's prefix is exactly what the
/// host's `is_permanent_build_error` matches to short-circuit the retry loop, so a repaired
/// refusal carrying it would be classified permanent and the retry that consumes the repair would
/// never run. It is also the human-readable half of the message; keep it a phrase, not a code.
const REPAIRED_HINT: &str = "enabled it over output management";
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
const VOUT_NAME: &str = "punktfunk";
@@ -268,6 +278,10 @@ impl VirtualDisplay for KwinDisplay {
.context("spawn KWin virtual-output thread")?;
match setup_rx.recv_timeout(OPENER_BUDGET) {
Ok(Ok(v)) => Ok((v, stop)),
// Repaired: report it as-is. The wrapper below would prepend the phrase the host
// reads as "permanent, do not retry", and this is the one refusal whose retry is
// the entire point — the repair only fixes the NEXT request.
Ok(Err(e)) if e.contains(REPAIRED_HINT) => bail!("{e}"),
// KWin's reason is TRANSLATED into the session's language, so it is often
// unsearchable for the person reading the log. Say what it means once, here.
Ok(Err(e)) => bail!(
@@ -1793,14 +1807,41 @@ fn run(
);
// Pump events until KWin reports the node id (or an error, or the budget).
let node_id = await_created(
//
// A refusal here is where the KWin >= 6.6 disabled-output trap lands, and it is repairable
// FROM INSIDE THIS SCOPE and nowhere else: KWin destroys the output when our stream is
// destroyed, so the connection has to stay up while we enable it (see
// [`kwin_output_mgmt::enable_disabled_output`] for why the output is still alive at all, and
// why enabling it fixes the NEXT request rather than this one).
let node_id = match await_created(
&conn,
&mut queue,
&mut state,
stop,
"stream_virtual_output",
started,
)?;
) {
Ok(id) => id,
Err(e) => {
// `Virtual-<name>` is the address KWin exposes our output under (the same prefix the
// topology path resolves against).
match crate::kwin_output_mgmt::enable_disabled_output(&format!("Virtual-{name}")) {
// Deliberately does NOT carry the "KWin virtual output failed" prefix: that string
// is what marks a KWin refusal PERMANENT for the session's retry loop, and this is
// the one refusal where something DID change between attempts. Retrying is the
// whole point of repairing.
Some(repaired) => bail!(
"KWin created the virtual output disabled and refused to stream it ({e}); \
{REPAIRED_HINT} (head {repaired}) the retry picks up the configuration \
KWin just persisted"
),
// Nothing to repair (no such head, already enabled, or the apply was refused):
// the refusal stands, and its own prefix keeps it permanent so the session fails
// fast instead of burning the retry budget on an unchanged box.
None => return Err(e),
}
}
};
setup_tx
.send(Ok(node_id))
.map_err(|_| anyhow!("virtual-output opener went away"))?;
@@ -1274,6 +1274,76 @@ pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
complete
}
/// Enable a virtual output KWin created but left DISABLED, addressed by the `Virtual-<name>`
/// prefix it exposes ours under. Returns the head's name when one matched, was disabled, and the
/// enable applied.
///
/// This is the repair for the KWin ≥ 6.6 refusal (`"Could not find output"`, translated into the
/// session's language). `streamVirtualOutput` there creates the output on the backend and then
/// hands `workspace()->findOutput(output)` to the stream — and that returns null for an output the
/// workspace does not manage, which `wantsToManage` defines as `isEnabled() && !isNonDesktop()`.
/// KWin 6.4/6.5 passed the backend output straight through, so a disabled one streamed anyway;
/// from 6.6 it is a hard refusal, and one that repeats forever: the host asks for a STABLE
/// per-client name so KWin persists that client's scale and mode, and a stored setup naming it
/// `enabled: false` is therefore reapplied to every future session.
///
/// Two properties of KWin make the repair possible, both verified against Plasma/6.7:
///
/// * `sendFailed` only sends the event — it does not emit `finished`, and `removeVirtualOutput` is
/// wired to `finished`. So the disabled output stays alive for exactly as long as the caller
/// holds its (failed) stream open, which is the window this runs in.
/// * `WaylandServer::handleOutputAdded` offers EVERY backend output to the output-device registry,
/// gating only placeholders and non-desktop ones. A disabled output has no `wl_output` — that
/// side is gated on the workspace — but it is addressable over `kde_output_management_v2`.
///
/// Enabling it through output management is a user-applied configuration, so KWin persists it
/// against that output's identity: the caller's next `stream_virtual_output` under the same name
/// finds a stored setup that enables it. Which is why the caller must RETRY after this returns
/// `Some` — the request that failed cannot be salvaged, only the one after it.
pub(crate) fn enable_disabled_output(prefix: &str) -> Option<String> {
let mut sess = Session::open("enable_disabled").ok()?;
let deadline = Instant::now() + OP_BUDGET;
// Newest-wins, exactly as the supersede resolve elsewhere in this file: a reconnect can leave
// a predecessor of the same name briefly announced, and enabling THAT one repairs an output
// that is already going away.
let dev = sess
.state
.devices
.values()
.filter(|d| d.name.as_deref().is_some_and(|n| n.starts_with(prefix)) && d.proxy.is_some())
.max_by_key(|d| (d.global, d.seq))
.cloned()?;
let name = dev.name.clone()?;
if dev.enabled {
// Not the shape we repair. Say so rather than applying a no-op config that would `applied`
// successfully and read as a fix — the caller decides whether to retry on this.
tracing::debug!(
%name,
"KWin output management: our virtual output is already enabled — nothing to repair"
);
return None;
}
let proxy = dev.proxy.as_ref()?;
let config = sess.new_config();
config.enable(proxy, 1);
let ok = sess.apply(&config, deadline);
config.destroy();
if !ok {
tracing::warn!(
%name,
reason = ?sess.state.failure_reason,
"KWin output management: could not enable the virtual output KWin created disabled"
);
return None;
}
tracing::info!(
%name,
"KWin output management: KWin created our virtual output DISABLED and refused to stream \
it; enabled it KWin persists that, so the retry's request comes back enabled"
);
Some(name)
}
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
+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
+186 -3
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) —
@@ -5857,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::*;
+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**
@@ -5345,6 +5345,17 @@ mod tests {
"spawn gamescope (is it installed? `apt install gamescope`)"
));
assert!(is_permanent_build_error("virtual displays require Linux"));
// The ONE KWin refusal that must stay retryable: pf-vdisplay repaired the box (it enabled
// the output KWin created disabled, which KWin persists), so the next attempt is not the
// same attempt. That path deliberately reports WITHOUT the `KWin virtual output failed`
// prefix above — if it ever regains it, the retry that consumes the repair stops running
// and the repair is dead code.
assert!(!is_permanent_build_error(
"create virtual output: KWin created the virtual output disabled and refused to \
stream it (stream_virtual_output failed: Não foi possível encontrar saída); enabled \
it over output management (head Virtual-punktfunk-a1b2) the retry picks up the \
configuration KWin just persisted"
));
// Transient: negotiation/timeout races — exactly what backoff is for.
assert!(!is_permanent_build_error(
"first frame: no PipeWire frame within 10s (node 42): format negotiation never completed"
+220
View File
@@ -0,0 +1,220 @@
{
"$comment": "Single source for the install/port facts that used to drift across four surfaces (docs-and-onboarding-overhaul WP1). Consumers: docs-site install pages (<Install platform=…/> and <Ports/> in docs-site/src/components/platforms.tsx, reading the byte-identical snapshot docs-site/src/data/platforms.json that check-docs-drift.sh gates), the website download page (WP3), the guided install script (WP4). A port number, repo URL or install command lives HERE and nowhere else — pages quote it, they don't restate it. `install` is the shell snippet that puts the package on the box (lines, in order); `url` is a download/store link where there is no command. Validated by scripts/ci/check-docs-drift.sh (parse + snapshot sync; ponytail: cross-check against code literals when a consumer exists).",
"ports": {
"mgmt": {
"port": 47990,
"proto": "tcp",
"what": "management REST API (HTTPS + token; read-only status/library off loopback)",
"env": "PUNKTFUNK_MGMT_BIND",
"conflict": "Sunshine / Apollo / Vibeshine serve their web UI on 47990 too — the one port still shared with them once GameStream compat is off. Move it via PUNKTFUNK_MGMT_BIND; clients relearn it from discovery."
},
"web": {
"port": 47992,
"proto": "tcp",
"what": "web console (HTTPS, login-gated); plugin interfaces on 47993",
"also": [47993]
},
"native": {
"port": 9777,
"proto": "udp",
"what": "punktfunk/1 QUIC control port",
"env": "PUNKTFUNK_NATIVE_PORT"
},
"data": {
"port": null,
"proto": "udp",
"what": "per-session video data plane — ephemeral port the client hole-punches; nothing fixed to open",
"env": "PUNKTFUNK_DATA_PORT"
},
"mdns": {
"port": 5353,
"proto": "udp",
"what": "mDNS discovery"
},
"gamestream": {
"tcp": [47984, 47989, 48010],
"udp": [47998, 47999, 48000],
"what": "GameStream/Moonlight-compat planes (opt-in, PUNKTFUNK_GAMESTREAM=1)",
"conflict": "Sunshine / Apollo / Vibeshine bind these same fixed ports and advertise the same mDNS name — run only one GameStream host at a time, or keep punktfunk native-only."
}
},
"firewall": {
"$comment": "Service/profile names the Linux packages install for firewalld and ufw — the packages never open a port themselves.",
"native": "punktfunk-native",
"gamestream": "punktfunk-gamestream",
"web": "punktfunk-web"
},
"installer": {
"$comment": "The guided Linux installer (WP4, preview). Canonical URL is the website's /install.sh, a redirect to the raw script on main so it's versioned with the code it installs; the docs hub and the download page quote these lines.",
"status": "preview",
"url": "https://punktfunk.unom.io/install.sh",
"source": "https://git.unom.io/unom/punktfunk/raw/branch/main/scripts/install.sh",
"oneLiner": "curl -fsSL https://punktfunk.unom.io/install.sh | sh",
"inspectFirst": [
"curl -fsSLO https://punktfunk.unom.io/install.sh",
"less install.sh",
"sh install.sh"
],
"docs": "/docs/install#guided-install-preview"
},
"conflicts": {
"hosts": ["Sunshine", "Apollo", "Vibeshine"],
"detect": "punktfunk-host detect-conflicts",
"detectExit": "1 only when a conflicting host runs or will start on its own; dormant leftovers print but exit 0",
"docs": "/docs/switching-from-sunshine"
},
"platforms": [
{
"id": "debian",
"name": "Debian 13+ / Ubuntu 26.04+",
"installs": "host",
"packageManager": "apt",
"docs": "/docs/debian",
"repo": "https://git.unom.io/api/packages/unom/debian",
"install": [
"sudo install -d -m 0755 /etc/apt/keyrings",
"curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key | sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null",
"echo \"deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main\" | sudo tee /etc/apt/sources.list.d/punktfunk.list",
"sudo apt update",
"sudo apt install punktfunk-host"
]
},
{
"id": "arch",
"name": "Arch Linux / CachyOS",
"installs": "host",
"packageManager": "pacman",
"docs": "/docs/arch",
"repo": "https://git.unom.io/api/packages/unom/arch",
"install": [
"curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -",
"sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69",
"grep -q '^\\[punktfunk\\]' /etc/pacman.conf || printf '\\n[punktfunk]\\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\\n' | sudo tee -a /etc/pacman.conf >/dev/null",
"sudo pacman -Syu punktfunk-host"
]
},
{
"id": "fedora",
"name": "Fedora 43+",
"installs": "host",
"packageManager": "dnf",
"docs": "/docs/fedora",
"repo": "https://git.unom.io/api/packages/unom/rpm/fedora-44",
"install": [
"sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'",
"[punktfunk]",
"name=punktfunk",
"# fedora-44 on Fedora 44; bazzite on Fedora 43 (a plain Fedora 43 build of the same package)",
"baseurl=https://git.unom.io/api/packages/unom/rpm/fedora-44",
"enabled=1",
"gpgcheck=1",
"repo_gpgcheck=1",
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
"REPO",
"sudo dnf install punktfunk"
]
},
{
"id": "bazzite",
"name": "Bazzite / Fedora Atomic",
"installs": "host",
"packageManager": "sysext",
"docs": "/docs/bazzite",
"install": [
"curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh",
"sudo bash punktfunk-sysext.sh install"
]
},
{
"id": "nixos",
"name": "NixOS",
"installs": "host",
"packageManager": "nix",
"docs": "/docs/nixos",
"install": [
"# flake input: inputs.punktfunk.url = \"git+https://git.unom.io/unom/punktfunk\";",
"# then: imports = [ punktfunk.nixosModules.default ]; services.punktfunk.host.enable = true;"
]
},
{
"id": "steamos",
"name": "SteamOS (Steam Deck as host)",
"installs": "host",
"packageManager": "script",
"docs": "/docs/steamos-host",
"install": [
"git clone https://git.unom.io/unom/punktfunk ~/punktfunk",
"bash ~/punktfunk/scripts/steamdeck/install.sh"
]
},
{
"id": "windows",
"name": "Windows 11 (22H2+)",
"installs": "host",
"packageManager": "winget",
"docs": "/docs/windows-host",
"install": [
"winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest",
"winget install unom.PunktfunkHost"
]
},
{
"id": "linux-client",
"name": "Linux client (any distro)",
"installs": "client",
"packageManager": "flatpak",
"docs": "/docs/install-client#linux-desktop-flatpak",
"install": [
"flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref"
]
},
{
"id": "steam-deck-client",
"name": "Steam Deck (Gaming Mode)",
"installs": "client",
"packageManager": "decky",
"docs": "/docs/steam-deck"
},
{
"id": "windows-client",
"name": "Windows client",
"installs": "client",
"packageManager": "installer",
"docs": "/docs/install-client#windows",
"install": [
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
".\\punktfunk-client-setup_x64.exe"
]
},
{
"id": "macos-client",
"name": "macOS",
"installs": "client",
"packageManager": "dmg",
"docs": "/docs/install-client#macos",
"url": "https://git.unom.io/unom/punktfunk/releases"
},
{
"id": "apple-client",
"name": "iPhone, iPad, Apple TV",
"installs": "client",
"packageManager": "testflight",
"docs": "/docs/install-client#ios-ipados-apple-tv",
"url": "https://testflight.apple.com/join/Qr7uSemk"
},
{
"id": "android-client",
"name": "Android / Android TV",
"installs": "client",
"packageManager": "play",
"docs": "/docs/install-client#android",
"url": "https://play.google.com/store/apps/details?id=io.unom.punktfunk"
}
]
}
+21 -9
View File
@@ -4,7 +4,12 @@ The Punktfunk documentation site: [Fumadocs](https://fumadocs.dev) on
[TanStack Start](https://tanstack.com/start) (Vite + Nitro/bun preset).
Content lives in [`content/docs/`](content/docs) as `.md`/`.mdx`. This site is the source of truth
for the **user-facing** guides; design rationale lives in the internal punktfunk-planning repo.
for the **user-facing** guides; design rationale lives in the internal punktfunk-planning repo, and
READMEs and the marketing site link here instead of restating anything — see "Where facts live" in
[CONTRIBUTING.md](../CONTRIBUTING.md). Pages serve one of two audiences, not both at once: the
**get-started track** (quickstart, install, pairing) assumes no Linux expertise — short pages, one
task each, happy path only; the **reference track** (configuration, CLI, API, per-compositor
pages) is allowed to be dense.
## API reference
@@ -19,17 +24,24 @@ cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
```
Nothing in CI diffs the two, so the snapshot goes stale silently — that manual `cp` is the only
thing keeping them in sync. Before publishing docs, check that they match:
CI keeps the pair honest: the `docs-drift` job fails unless the snapshot is a byte-for-byte copy
of `api/openapi.json`, and the `rust` job regenerates the spec and diffs it against the committed
one — so a management-API change can't publish stale API docs any more, it fails CI until you run
the two commands above.
```bash
diff <(jq -S . api/openapi.json) <(jq -S . docs-site/public/openapi.json)
## Install commands and ports
`src/data/platforms.json` is a byte-identical snapshot of the repo-root
[`data/platforms.json`](../data/platforms.json) — the single source for install commands, repo
URLs, port facts and the Sunshine/Apollo/Vibeshine conflict facts. The `<Install platform="…" />`
and `<Ports />` MDX components (`src/components/platforms.tsx`) render from it, so no page restates
a command or a port. It's a snapshot for the same reason as `openapi.json` (the Docker build context
is this directory alone), and the same `docs-drift` job fails unless it matches:
```sh
cp data/platforms.json docs-site/src/data/platforms.json # from the repo root, after editing the canonical file
```
That should print nothing. Right now it doesn't: the committed snapshot predates the
`/api/v1/update/check`, `/api/v1/update/apply` and `/api/v1/update/status` endpoints, so the
published `/api` reference is missing the host self-update surface — re-copy it.
## Develop
```sh
+37 -39
View File
@@ -3,15 +3,14 @@ title: Access levels
description: What each paired device may do, and for how long — the three presets, the advanced toggles, temporary access that expires on its own, and what access control honestly does not cover.
---
Pairing used to be all-or-nothing: a paired device had full control of the host, forever. **Access**
changes that. Every paired device carries an **access level** — what it may send to the host — and
optionally an expiry — how long that lasts. A friend's phone can be a second controller for the
evening and nothing more; the living-room TV can watch and play but never type into your desktop;
a spectator can see and hear without sending anything.
Pairing used to be all-or-nothing: a paired device had full control of the host, forever. Now every
paired device carries an **access level** — what it may send to the host — and optionally an
expiry: a friend's phone as a second controller for the evening, a TV that can play but never type,
a spectator who only watches.
Access is **enforced by the host**. A client's UI reflects its access as a courtesy, but the host
drops anything a device isn't granted regardless of what the client sends — nothing a client can
send widens its own access.
drops anything a device isn't granted regardless of what the client sends — nothing a client sends
can widen its own access.
You manage access from the host's [web console](/docs/web-console): when you
[approve a device or arm pairing](/docs/pairing#choosing-access-when-you-admit-a-device), and any
@@ -22,16 +21,16 @@ countdown if it expires) and an edit sheet.
| Access level | What the device can do |
|---|---|
| **Full control** | Everything — keyboard, mouse, controllers, clipboard, microphone, launching games. This is what pairing has always meant, and it stays the default: every device paired before access levels existed keeps full control, and so does a plain **Approve**. |
| **Full control** | Everything — keyboard, mouse, controllers, clipboard, microphone, launching games. What pairing has always meant, and still the default: every device paired before access levels existed keeps full control, and so does a plain **Approve**. |
| **Controller only** | Gamepad input only — the guest and co-play preset. The device's pads show up as additional controllers (with rumble and pad audio), but it cannot type, move the mouse, read the clipboard, use the mic, or launch anything. |
| **View only** | See and hear the stream, send nothing. The spectator preset. |
The preset label is derived from the underlying toggles, so a hand-tuned combination simply shows
as **Custom** — there is no separate thing to keep in sync.
The preset label is derived from the underlying toggles, so a hand-tuned combination shows as
**Custom** — there is no separate thing to keep in sync.
## The advanced toggles
Each preset is a bundle of six independent grants, exposed under **Advanced** in the edit sheet:
Each preset is a bundle of six independent grants, under **Advanced** in the edit sheet:
| Toggle | Covers |
|---|---|
@@ -40,13 +39,13 @@ Each preset is a bundle of six independent grants, exposed under **Advanced** in
| **Keyboard** | Key presses. |
| **Clipboard** | The [shared clipboard](/docs/clipboard). Both switches still apply: the host operator's clipboard policy *and* this grant have to allow it — the grant can only narrow, never widen, what the operator permits. An ungranted device gets a clean "not permitted" instead of a toggle that silently does nothing. |
| **Microphone** | Sending the client's microphone to the host. Without it, the session never attaches to the host's mic service at all. |
| **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than being dropped onto the bare desktop. The library remains *visible* — this governs launching, not browsing. |
| **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than dropped onto the bare desktop. The library stays *visible* — this governs launching, not browsing. |
**Controller only deliberately does not include Launch**: in co-play the owner drives what runs. If
you want a guest picking games from the couch, that's one Advanced toggle away.
**Controller only deliberately does not include Launch**: in co-play the owner drives what runs.
Want a guest picking games? Turn on that one Advanced toggle.
A session's quality controls — resolution, bitrate, keyframe requests — are *not* governed. They
only shape that device's own stream, so restricting them would cost usability and buy no security.
only shape that device's own stream; restricting them would cost usability and buy no security.
## Temporary access
@@ -54,7 +53,7 @@ Any grant can carry an expiry, picked when you approve the device or set later i
**1 h / 4 h / 8 h / custom / forever**.
- Expiry is **wall-clock time on the host** — "4 hours" means four hours from now by the host's
clock, matching the mental model of "until tonight".
clock.
- A device streaming when its access runs out gets **warnings at 5 minutes and 1 minute** before
the deadline, then its session ends with an explicit reason: *"Your access to this host has
expired."* Only that device's sessions end — yours is untouched.
@@ -65,43 +64,42 @@ Any grant can carry an expiry, picked when you approve the device or set later i
extending re-arms the running session's deadline, and Expire now ends it with the same clean
"access expired" message — no lingering stream.
Edits other than expiry are just as immediate: changing a device's access level while it streams
takes effect within moments, and removing the device ends its sessions. Access is per *device*,
not per session — two sessions from the same device share one grant.
Other edits are just as immediate: changing a device's access level while it streams takes effect
within moments, and removing the device ends its sessions. Access is per *device*, not per session
— two sessions from the same device share one grant.
## What this does not cover
Be honest with yourself about three limits before relying on access levels:
Three limits before relying on access levels:
> **A view-only guest still sees your whole desktop.** On the shared-desktop backends every
> session shows the *same* desktop — access levels govern what a device can send *in*, not what it
> sees going *out*. A view-only or controller-only guest watches and hears everything you do,
> notifications included. Don't read email with a spectator attached.
> notifications included.
- **Moonlight / GameStream devices are not governed yet.** Access levels currently apply to the
native Punktfunk protocol. A device paired via [Moonlight](/docs/moonlight) has full control and
shows an honest **Full (ungoverned)** chip in the console — not a fake editor. When enforcement
reaches the GameStream plane, it will be *silent* from the client's side: the GameStream
protocol has no way to tell a Moonlight client about its access, so an ungranted keyboard will
simply be inert, with the explanation visible only in the console.
- **Moonlight / GameStream devices are not governed yet.** Access levels apply to the native
Punktfunk protocol. A device paired via [Moonlight](/docs/moonlight) has full control and shows
an honest **Full (ungoverned)** chip in the console — not a fake editor. When enforcement reaches
the GameStream plane it will be *silent* from the client's side: the protocol has no way to tell
a Moonlight client about its access, so an ungranted keyboard will simply be inert, with the
explanation visible only in the console.
- **Older Punktfunk clients are enforced, but can't explain it.** The host enforces access
identically for every client version. A client from before this feature just lacks the chrome:
identically for every client version; a client from before this feature just lacks the chrome:
no "Controller only · ends in 2 h" chip, no expiry warnings, a generic disconnect instead of
"access expired" — and an ungranted keyboard is silently inert rather than never captured in the
first place. If a guest reports "my keyboard does nothing", check their access level in the
console first, then whether their client is current.
"access expired" — and an ungranted keyboard is silently inert rather than never captured. If a
guest reports "my keyboard does nothing", check their access level in the console first, then
whether their client is current.
Up-to-date native clients do get the chrome: they stop capturing what can't land (no keyboard grab
Up-to-date native clients get the chrome: they stop capturing what can't land (no keyboard grab
without the Keyboard grant), hide the clipboard and mic controls when ungranted, show a small
overlay chip naming the session's access and time remaining, and surface the expiry warnings as
toasts.
## Where enforcement happens
For the security-minded: the host checks every input event against the device's grants before
injecting it, refuses ungranted planes at session setup (no Gamepad grant means the virtual pads
are never created; no Microphone grant means the mic plane never attaches), and re-pairing a
device **preserves** its existing access — the only way to widen a grant is the console's own
dialogs, behind the console login. Dropped traffic is logged once per session and category, not
per event, so a misbehaving client can't flood the log. See [Security & Safe
Use](/docs/security) for the wider picture.
The host checks every input event against the device's grants before injecting it, refuses
ungranted planes at session setup (no Gamepad grant means the virtual pads are never created; no
Microphone grant means the mic plane never attaches), and re-pairing a device **preserves** its
existing access — the only way to widen a grant is the console's own dialogs, behind the console
login. Dropped traffic is logged once per session and category, not per event, so a misbehaving
client can't flood the log. See [Security & Safe Use](/docs/security) for the wider picture.
-214
View File
@@ -1,214 +0,0 @@
---
title: Arch Linux
description: Install a Punktfunk host on Arch (and Arch-derived distros) from the signed pacman binary repo.
---
Set up a Punktfunk host on **Arch Linux** (or an Arch-derived distro like CachyOS/EndeavourOS). The
host installs from a **signed pacman binary repo**, so it updates with `pacman -Syu` like the rest
of your system — no building required. Host encode is **NVENC on NVIDIA**; on **AMD/Intel** HEVC
and AV1 go through **Vulkan Video**, with **VAAPI** for H.264 and as the fallback
(`PUNKTFUNK_ENCODER=auto` picks per GPU).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
> Prefer to build it yourself? A split `PKGBUILD` (host + client + optional web console) is in the
> repo at `packaging/arch/` — see the [appendix](#appendix--build-from-source-pkgbuild). The binary
> repo below is the supported path.
## 1. GPU prerequisites
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (provides NVENC + the EGL/CUDA zero-copy path).
Arch's stock `ffmpeg` already has NVENC built in — no RPM-Fusion-style swap like Fedora needs.
- **AMD / Intel:** the Mesa stack. HEVC/AV1 encode goes through **Vulkan Video** by default, so
install the Vulkan driver — `vulkan-radeon` (AMD) or `vulkan-intel` (Intel) — alongside the VAAPI
drivers (`libva-mesa-driver` for AMD, `intel-media-driver` for Intel), which carry H.264 and the
fallback path. Both are usually already installed on a desktop.
## 2. Add the signed repo
The registry **signs its database and every package**, so first trust its key once (after this,
packages install signature-verified):
```sh
# Trust the registry signing key.
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
| sudo pacman-key --add -
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
# verifies signed packages against the key you just trusted. (printf, not a heredoc, so this
# works in fish too — CachyOS's default shell has no `<<EOF` support.)
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
| sudo tee -a /etc/pacman.conf >/dev/null
```
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
> release is cut. For the latest `main` build, use `[punktfunk-canary]` instead (same `Server` line,
> just the repo name). Enable exactly one. See [Release Channels](/docs/channels).
## 3. Install the host
```sh
sudo pacman -Syu punktfunk-host # the streaming host
sudo pacman -Syu punktfunk-web # optional: the browser management console (pairing + status)
sudo pacman -Syu punktfunk-gamescope # optional: HDR (10-bit BT.2020 PQ) off gamescope sessions
sudo pacman -Syu punktfunk-scripting # optional: the plugin/script runner (see below)
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller**
(paddles, trackpads, gyro — it reaches games as a real USB pad, which is why Steam Input adopts
it), or this box autologins into Steam **Gaming Mode** and you want the host to take that session
over at the client's resolution:
```sh
sudo usermod -aG punktfunk "$USER" # usbip/vhci + display-manager takeover (re-login to apply)
```
That is a second group on purpose. It grants write access to the usbip `attach` file, which
materialises an arbitrary emulated USB device — so it stays off the `input` group everyone is
routinely told to join. Join it only on a machine you trust. On a plain desktop host, everything
else still works without it and the pad simply arrives as an ordinary Xbox 360 controller; on a
Gaming Mode box the takeover silently degrades to mirroring the box's own screen — see
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
Each install is a **full** `-Syu`, on purpose: our packages are built against current Arch
sonames, and `pacman -Sy <pkg>` would drop one onto a system whose other packages are still old —
the classic partial upgrade that breaks Arch boxes. To take several in one go, name them on a
single line: `sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-gamescope`.
`punktfunk-scripting` is the runner behind [Plugins](/docs/plugins); it isn't started for you —
`systemctl --user enable --now punktfunk-scripting` when you want it. `punktfunk-client` (the native
GTK4 Linux client) is in the same repo if this box is also a client. The host package ships the
systemd **user** units, the udev rule, the UDP socket-buffer sysctl tuning, and example configs.
Updates later are a normal `sudo pacman -Syu`, then `systemctl --user restart punktfunk-host` so the
running host picks up the new binary. A `-Syu` moves every Punktfunk package you installed, so
restart `punktfunk-web` the same way if you run the console. The web console can run the update for
you — see [Updating the Host](/docs/updating); on Arch that button additionally needs
`PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf`, because the only pacman update we will
run is a full one.
## 4. Configure and run
The host runs as a systemd **`--user`** service — it needs your session's PipeWire and D-Bus. Copy a
starting config:
```sh
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
```
How the host creates its virtual display and injects input depends on your desktop, not your distro —
edit `host.env` for the desktop you run, following its page for the exact settings and any quirks:
- [KDE Plasma (KWin)](/docs/kde)
- [GNOME (Mutter)](/docs/gnome)
- [Steam / gamescope](/docs/gamescope)
- [Hyprland](/docs/hyprland)
- [Sway / wlroots](/docs/sway)
Then enable the service and turn on linger so it starts at boot without a login:
```sh
systemctl --user daemon-reload
systemctl --user enable --now punktfunk-host
sudo loginctl enable-linger "$USER"
```
Check it came up:
```sh
systemctl --user status punktfunk-host # active
journalctl --user -u punktfunk-host -f # watch a client connect
```
Enable the browser console, find your login password, and arm PIN pairing from
[The Web Console](/docs/web-console). For a headless KWin appliance that streams at boot with no
graphical login, see [KDE → Headless session](/docs/kde#headless-session). Full reference:
[Configuration](/docs/configuration) · [Running as a Service](/docs/running-as-a-service).
## 5. Open the firewall (if you have one)
**Stock Arch ships no firewall** — every port is already open, so you can skip this. But **CachyOS
enables `ufw` by default** (firewalld is not installed), and some other spins (e.g. EndeavourOS)
enable **`firewalld`** — an Arch package never opens ports for you, so on those the host is
unreachable until you allow it.
The `punktfunk-host` package installs openers for **both**, so it's a one-liner whichever you run.
The unit you enabled in step 4 runs `serve --gamestream` — the package installs it as it ships and
only rewrites the binary path — so that host serves **both** the native `punktfunk/1` plane and
stock [Moonlight](/docs/moonlight) clients, and needs **both** openers:
```sh
# ufw — CachyOS (and Ubuntu, once you enable ufw):
sudo ufw allow punktfunk-native
# firewalld — Fedora-like spins (EndeavourOS, …):
sudo firewall-cmd --reload # load the installed definitions
sudo firewall-cmd --permanent --add-service=punktfunk-native
sudo firewall-cmd --reload
```
Enabled **GameStream/Moonlight compat** (`PUNKTFUNK_GAMESTREAM=1` in `host.env` — see
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts)), or you pass
`--gamestream` by hand? Then also open its service:
```sh
sudo ufw allow punktfunk-gamestream # ufw
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload
```
`punktfunk-native` opens the QUIC control port (UDP 9777), mDNS discovery and the mgmt/library API
(TCP 47990); `punktfunk-gamestream` opens the fixed Moonlight ports — TCP 47984, 47989 and 48010,
UDP 4799848000 — plus the same mDNS.
The media **data plane** uses an *ephemeral* UDP port that the client opens with a hole-punch — the
host streams back out through the path the client opened, so there's **nothing fixed to open** as
long as the firewall allows outbound UDP (the default for both ufw and firewalld).
Enabled the **web console** (`punktfunk-web`, above) and want to reach it from your phone or another
machine? It's not opened by the streaming rules — open its port too, the same one-liner way:
```sh
sudo ufw allow punktfunk-web # ufw
sudo firewall-cmd --permanent --add-service=punktfunk-web && sudo firewall-cmd --reload # firewalld
```
That opens **TCP 47992** (HTTPS, login-gated). The mgmt API (47990) is opened for paired clients by the
`punktfunk-native` profile (game-library browsing over mTLS); off-loopback it serves only read-only
status/library, and every admin action stays loopback-only. Full port lists (`nftables`, explicit ports) are in
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md#firewall).
## 6. Connect a client
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
the **PIN pairing**: arm it from [The Web Console](/docs/web-console#arm-pairing), which displays a
4-digit PIN to type into the client. (Pairing is required by default; pass `serve --open` only if
you deliberately want to disable it.) See [Clients](/docs/clients) for per-platform setup.
## Next steps
- **Keep it current** — [Updating the Host](/docs/updating).
- **Remove it again** — [Uninstalling](/docs/uninstall).
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
## Appendix — build from source (PKGBUILD)
To build instead of using the binary repo, use the split `PKGBUILD` in `packaging/arch/` (produces
`punktfunk-host` + `punktfunk-client`; set `PF_WITH_WEB=1` to also build `punktfunk-web` and
`PF_WITH_SCRIPTING=1` to also build `punktfunk-scripting` — both need `bun`):
```sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk/packaging/arch
# Build the working tree (no git fetch):
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
sudo pacman -U punktfunk-host-*.pkg.tar.zst
```
NVENC/EGL come from the NVIDIA driver (`nvidia-utils`); on a GPU-less builder, symlink the CUDA
stub into the link path first (the `PKGBUILD` header documents this). Full details, the
Fedora→Arch dependency map, and the systemd-sysext mechanism are in
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
(For a **SteamOS host**, use the [on-device installer](/docs/steamos-host) instead — it builds
the host and the HDR gamescope against the running OS.)
+80
View File
@@ -0,0 +1,80 @@
---
title: Arch Linux
description: Install the Punktfunk host on Arch, CachyOS or EndeavourOS from the signed pacman repo — four steps.
---
For **Arch Linux** and Arch-based distros (CachyOS, EndeavourOS, …). The host comes from a signed
binary repo and updates with `pacman -Syu` like everything else. SteamOS is different — it has
[its own page](/docs/steamos-host).
## 1. GPU driver
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (NVENC and the zero-copy path; Arch's `ffmpeg`
already has NVENC built in).
- **AMD / Intel:** the Mesa stack you already have — `vulkan-radeon` / `vulkan-intel` for Vulkan
Video, `libva-mesa-driver` / `intel-media-driver` for VAAPI. Usually all installed on a desktop.
## 2. Install the host
Trust the repo key once, add the repo, install. Every install and update here is a **full**
`-Syu` on purpose — our packages are built against current Arch sonames, and `pacman -Sy <pkg>` is
the partial upgrade that breaks Arch boxes:
<Install platform="arch" />
The browser console is **optional** on Arch, so name it yourself — same line, full upgrade:
`sudo pacman -Syu punktfunk-web`. (Also in the repo: `punktfunk-gamescope` for HDR off gamescope,
`punktfunk-scripting` for [plugins](/docs/plugins), `punktfunk-client` if this box is also a client.)
From then on a normal `sudo pacman -Syu` moves every Punktfunk package; restart the host afterwards
(`systemctl --user restart punktfunk-host`) — or let the [console do it](/docs/updating).
## 3. Let it use your controllers
Join the `input` group (virtual gamepads go through `/dev/uinput`), then **log out and back in**:
```sh
sudo usermod -aG input "$USER"
```
Want the **virtual Steam Deck controller** (paddles, trackpads, gyro)? Also join the `punktfunk`
group — [what it gates and why it's separate](/docs/gamescope#nobara-and-other-autologin-display-managers).
## 4. Start it
From a terminal inside your desktop session:
```sh
systemctl --user enable --now punktfunk-host punktfunk-web
systemctl --user enable --now punktfunk-scripting # if you installed the plugin runner — other distros start it for you, Arch doesn't
```
**Firewall:** stock Arch has none. **CachyOS enables `ufw`**, EndeavourOS enables **firewalld** —
on those the host is unreachable until you allow it (the package installed the profiles):
```sh
sudo ufw allow punktfunk-native && sudo ufw allow punktfunk-web # CachyOS (ufw)
sudo firewall-cmd --reload && sudo firewall-cmd --permanent --add-service=punktfunk-native --add-service=punktfunk-web && sudo firewall-cmd --reload # firewalld
```
[Ports & firewall](/docs/ports) has every port, and the GameStream profile if you turn Moonlight
compat on.
**That's the install.** Continue with the [Quick Start from step 3](/docs/quickstart#3-open-the-web-console)
— open the console, pair a client, stream.
## When you want more
- **pacman says `database already registered`?** The repo got added twice —
[the one-line fix](/docs/troubleshooting#pacman-error-could-not-register-punktfunk-database-database-already-registered).
**`unable to satisfy dependency 'libavcodec.so=…'`?** FFmpeg major mismatch —
[what to do](/docs/troubleshooting#pacman-unable-to-satisfy-dependency-libavcodecso).
- `punktfunk-host detect-conflicts` tells you if Sunshine or Apollo is also running;
[Troubleshooting](/docs/troubleshooting) starts from the symptom.
- Your desktop's particulars — [KDE](/docs/kde), [GNOME](/docs/gnome), [gamescope](/docs/gamescope),
[Hyprland](/docs/hyprland), [Sway](/docs/sway).
- Stream with nobody logged in — [Running as a service](/docs/running-as-a-service) (`sudo loginctl
enable-linger "$USER"` is the one extra line).
- Track `main` instead of releases (`[punktfunk-canary]`, same `Server` line — enable exactly one) —
[Release channels](/docs/channels). Build it yourself with the split `PKGBUILD` —
[Build from source](/docs/build-from-source#arch-pkgbuild).
+43 -49
View File
@@ -8,15 +8,15 @@ disconnects, a stream starts or stops, a pairing request arrives, a virtual disp
the library changes, the host starts or shuts down. Two ways to consume them:
- **Hooks** — zero-code: entries in `~/.config/punktfunk/hooks.json` run a **command** or POST a
**webhook** when a matching event fires. This covers the common automation: Do-Not-Disturb
during a stream, a phone notification on a pairing request, pausing downloads while playing.
**webhook** when a matching event fires. Covers the common automation: Do-Not-Disturb during a
stream, a phone notification on a pairing request, pausing downloads while playing.
- **The event stream** — code: `GET /api/v1/events` on the management API is a standard
[Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
stream of the same events, for scripts and integrations that want to *decide* things (e.g.
auto-approve pairing from a known subnet by calling the approve endpoint).
Hooks **observe** — they can never veto or delay a connection, a stream, or a pairing decision,
and nothing you configure here runs anywhere near the streaming path.
and nothing configured here runs anywhere near the streaming path.
## The events
@@ -38,7 +38,7 @@ and nothing you configure here runs anywhere near the streaming path.
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
version (additive-only — fields get added, never renamed), and the fields above. Example:
version (additive-only — fields get added, never renamed), and the fields above:
```json
{ "seq": 42, "ts_ms": 1784227449526, "schema": 1,
@@ -81,13 +81,12 @@ Each entry:
### What the host refuses
The document is validated as a whole, and **one bad entry disables every hook** — the host logs
`hooks.json invalid — hooks disabled until fixed` and runs none of them until you correct it. The
rules:
`hooks.json invalid — hooks disabled until fixed` and runs none until you correct it. The rules:
- An entry needs a non-empty `on`, plus `run` and/or `webhook`.
- `webhook` must be an `http(s)://` URL, and must **not** point at loopback, `localhost` or a
link-local address (which is also what blocks the cloud metadata endpoint). A receiver on this
same machine is what a `run` command is for. Ordinary LAN addresses — `192.168.x.x`, a ULA, a
link-local address (which also blocks the cloud metadata endpoint). A receiver on this same
machine is what a `run` command is for. Ordinary LAN addresses — `192.168.x.x`, a ULA, a
hostname — are fine, so Home Assistant on another box on your network works as written.
- `timeout_s` must be 1600.
- If `hmac_secret_file` is set but unreadable, the host **skips** that POST rather than sending it
@@ -107,10 +106,10 @@ A `run` command's shell one-liner vocabulary — the event flattened to env, val
[ "$PF_EVENT_KIND" = stream.started ] && makoctl mode -a do-not-disturb
```
Richer payloads (and the full document) are on stdin `jq` away. On a Windows host running as
the service, the command runs **in your interactive session** (never as SYSTEM); that path can't
carry per-process env or stdin, so the event JSON's path is appended as the command's last
argument instead.
Richer payloads (and the full document) are on stdin for `jq`. On a Windows host running as the
service, the command runs **in your interactive session** (never as SYSTEM); that path can't carry
per-process env or stdin, so the event JSON's path is appended as the command's last argument
instead.
Verify a signed webhook (Python):
@@ -120,15 +119,15 @@ expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(request.headers["X-Punktfunk-Signature"], expected)
```
**Rules of the road:** hooks are fire-and-forget and bounded — at most 8 in flight (extra
firings are dropped with a log line, never queued), and a command that outlives its timeout is
killed. Hook commands run as the host user, so `hooks.json` is operator-privileged config. On
Linux, when a command starts with an **absolute path** to a script, the host checks that file is
owned by you (or root) and not group/world-writable, and refuses to run it — loudly, in the log —
if it isn't. Write the full path (`/home/me/.config/punktfunk/scripts/on-stream.sh`, not `~/…`) if
you want that check: the shell expands `~` and looks up PATH names like `makoctl` only afterwards,
so those are never checked. On Windows there is no per-script check — the ACL on the config
directory is the boundary.
**Rules of the road:** hooks are fire-and-forget and bounded — at most 8 in flight (extra firings
are dropped with a log line, never queued), and a command that outlives its timeout is killed.
Hook commands run as the host user, so `hooks.json` is operator-privileged config. On Linux, when a
command starts with an **absolute path** to a script, the host checks that file is owned by you (or
root) and not group/world-writable, and refuses to run it — loudly, in the log — if it isn't. Write
the full path (`/home/me/.config/punktfunk/scripts/on-stream.sh`, not `~/…`) if you want that
check: the shell expands `~` and looks up PATH names like `makoctl` only afterwards, so those are
never checked. On Windows there is no per-script check — the ACL on the config directory is the
boundary.
The two simplest cases also exist as plain [host.env](/docs/configuration) settings, no
`hooks.json` needed: `PUNKTFUNK_ON_CONNECT_CMD` and `PUNKTFUNK_ON_DISCONNECT_CMD`.
@@ -137,9 +136,8 @@ The two simplest cases also exist as plain [host.env](/docs/configuration) setti
For per-title setup (HDR toggle, MangoHud, a VRR tweak), attach `prep` steps to a GameStream
`apps.json` entry or to a [custom library entry](/docs/game-library#adding-a-game-by-hand) — each
`do` runs **before** the title launches
(synchronously — the launch waits), each `undo` runs at session end in **reverse order**,
best-effort, even if the session crashed:
`do` runs **before** the title launches (synchronously — the launch waits), each `undo` runs at
session end in **reverse order**, best-effort, even if the session crashed:
```json
{ "id": 2, "title": "Steam", "compositor": "gamescope", "cmd": "steam -gamepadui",
@@ -153,11 +151,9 @@ A `do` that fails logs, keeps going, and its own `undo` is skipped (it never too
## Reacting to a game, not a stream
`stream.stopped` tells you the *stream* ended; `game.exited` tells you the *game* did. They are
often the same moment, but not always — a desktop stream has no game at all, and a stream can
outlive its game if you turned off "end the session when the game exits".
If you have been polling the host to work out when a game finished, you don't need to any more:
`stream.stopped` tells you the *stream* ended; `game.exited` tells you the *game* did. Often the
same moment, but not always — a desktop stream has no game at all, and a stream can outlive its
game if you turned off "end the session when the game exits". No polling needed:
```json
{ "hooks": [
@@ -170,8 +166,8 @@ Both carry the title in `PF_EVENT_GAME_TITLE` / `PF_EVENT_GAME_APP`, and `game.e
`PF_EVENT_REASON` so a script can tell "the player quit" (`exited`) from "the host closed it"
(`terminated`) — worth checking before you, say, power the TV off.
Ending the session yourself when a game exits needs no script at all: it is the default behavior,
on the console's **Virtual displays** page under
Ending the session when a game exits needs no script: it is the default, on the console's
**Virtual displays** page under
[When a game or a session ends](/docs/virtual-displays#when-a-game-ends-and-when-a-session-does).
## The event stream (`GET /api/v1/events`)
@@ -227,22 +223,21 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
## Recipe: full controller passthrough (VirtualHere)
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
triggers, USB rumble — or to use a device no emulation can stand in for, like a racing wheel or a
HOTAS, hand the physical device from the couch to the host over
triggers, USB rumble — or to use a device no emulation can stand in for (a racing wheel, a HOTAS),
hand the physical device from the couch to the host over
[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) while you play.
**Use the plugin.** [VirtualHere passthrough](/docs/plugins#virtualhere-usb-passthrough) does all of
this for you: it finds the device by name (so it survives the couch rebooting), brackets it around
the session, gives it back if anything crashes, and tells you which half of the setup is broken when
it isn't working. That is the supported route, and the rest of this section is only for people who
would rather not install a plugin.
**Use the plugin.** [VirtualHere passthrough](/docs/plugins#virtualhere-usb-passthrough) finds the
device by name (so it survives the couch rebooting), brackets it around the session, gives it back
if anything crashes, and tells you which half of the setup is broken. That is the supported route;
the rest of this section is for people who would rather not install a plugin.
**Turn off controller forwarding on the couch.** Whatever route you take below, the client that
hands the device over should stop *also* forwarding it: Settings → **Forward controllers**, off
([Client settings](/docs/client-settings#input)). Otherwise the host ends up with two controllers
for one pair of hands and games read both. On Linux and Windows it matters twice over — while the
client has the pad open it has *claimed* the device node, and VirtualHere cannot bind a device
somebody else is holding.
**Turn off controller forwarding on the couch.** Whatever route you take, the client that hands the
device over should stop *also* forwarding it: Settings → **Forward controllers**, off
([Client settings](/docs/client-settings#input)). Otherwise the host gets two controllers for one
pair of hands and games read both. On Linux and Windows it matters twice over — while the client
has the pad open it has *claimed* the device node, and VirtualHere cannot bind a device somebody
else is holding.
**The two sides.** VirtualHere is a server/client pair, and you run both: the **server on the couch**
(where the device is plugged in) shares it, and the **client on the host** mounts it. The client's
@@ -265,12 +260,11 @@ Bracket it on the stream with two [hooks](#hooks-hooksjson):
`couch-deck.11` is the device's address from `vhclientx86_64 -t LIST`.
Know what this trades away, because the plugin exists to fix exactly these: the address is
hard-coded, so it breaks when the couch reboots or the device moves port; and if the stream ends
abnormally the `stream.stopped` hook never fires, leaving the device stranded on the host until
somebody notices. There is also a
The trade-offs the plugin exists to fix: the address is hard-coded, so it breaks when the couch
reboots or the device moves port; and if the stream ends abnormally the `stream.stopped` hook never
fires, leaving the device stranded on the host until somebody notices. There is also a
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
SDK example if you want a worked script to build your own on.
SDK example to build your own script on.
> VirtualHere is a commercial product, sold separately by VirtualHere Pty. Ltd. — free for one
> shared device, licensed beyond that. Punktfunk is not affiliated with it.
-252
View File
@@ -1,252 +0,0 @@
---
title: Bazzite
description: Set up a Punktfunk host on Bazzite — it follows the box between Steam Gaming Mode (gamescope) and the KDE Plasma desktop automatically.
---
[Bazzite](https://bazzite.gg/) already ships everything a Punktfunk host needs — the NVIDIA driver,
NVENC, PipeWire, **gamescope**, and the **KDE Plasma desktop**. So a Bazzite host is the most
"appliance-like" setup, and it streams **both** of Bazzite's faces:
- **Steam Gaming Mode** (gamescope) — the couch/handheld game UI.
- **The KDE Plasma desktop** — the full desktop you get from "Switch to Desktop".
The host **auto-detects which one is live and follows the box across the switch** — including
mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam UI /
"Switch to Desktop"; the host just re-targets whatever's running and keeps streaming. Nothing in
`host.env` forces a mode.
> Ideal for a dedicated game-streaming box that you also occasionally want as a remote desktop. For a
> pure desktop machine, install on [Ubuntu](/docs/ubuntu) or [Fedora](/docs/fedora) and configure the
> [KDE](/docs/kde) or [GNOME](/docs/gnome) desktop directly — simpler.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## Install
The host installs as a **systemd system extension (sysext)** — no `rpm-ostree` layering. The
Bazzite docs treat layering as a last resort (layered packages slow every OS update and can block
upgrades until removed); a sysext never enters an rpm-ostree transaction: it overlays `/usr`
read-only from `/var/lib/extensions/`, survives OS updates, installs and updates **without a
reboot**, and is removable in one command. This is the same mechanism the Fedora Atomic
maintainers ship via the [fedora-sysexts](https://fedora-sysexts.github.io/) project.
```sh
# One-time bootstrap (afterwards the updater is on PATH as `punktfunk-sysext`):
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
sudo bash punktfunk-sysext.sh install # add `--channel canary` for rolling builds
```
That downloads the newest image — host + tray + web console + the plugin runner
(`punktfunk-scripting`), plus the HDR `punktfunk-gamescope` build — merges it, and applies the
udev/sysctl setup on the spot; the host is usable immediately, no reboot. The feed's checksum
manifest is OpenPGP-signed by packages@unom.io (key `AF245C506F4E4763`, the same one that signs our
RPMs), and `punktfunk-sysext` checks that signature against a key baked into the script before it
trusts a single checksum — so it needs `gpg` on the box, and it refuses a feed it can't verify.
The plugin runner rides along in the image and is **started for you** — the image bakes in its
`default.target.wants` symlink, because the game-library scanners ship as
[plugins](/docs/plugins). To turn it off: `systemctl --user mask punktfunk-scripting` (`mask`, not
`disable` — a plain disable cannot remove a symlink that lives in `/usr`).
From then on:
```sh
sudo punktfunk-sysext update # fetch + merge the newest build
sudo punktfunk-sysext status # channel, installed vs latest version
sudo punktfunk-sysext remove # unmerge and delete the image (~/.config/punktfunk is kept)
```
After an update, restart the host so it runs the new binary (the updater prints this reminder too).
The image carries the console as well, so restart that first if you enabled it:
```sh
systemctl --user restart punktfunk-web # only if you run the console
systemctl --user restart punktfunk-host
```
To **switch channel** later, re-run the install: `sudo punktfunk-sysext install --channel canary`
(or `--channel stable`). `update` takes no channel flag — it follows whatever the last install wrote
to `/etc/punktfunk-sysext.conf`. To be able to **go back** to a build that worked, keep a copy of the
image before you update, and re-install that file afterwards:
```sh
sudo cp /var/lib/extensions/punktfunk.raw ~/punktfunk-known-good.raw # before updating
sudo punktfunk-sysext install --from-file ~/punktfunk-known-good.raw # to go back to it
```
The web console can also run the update for you — see [Updating the Host](/docs/updating), which
needs the one-time `sudo usermod -aG punktfunk-update $USER`.
`remove` deletes the image and the `/etc` files it seeded (the tray autostart entry, and the
gamescope session drop-in unless you've edited it), but three things it created outside `/usr` stay
behind. To clear those too — services first, because once the image unmerges their binaries are
gone and the units just keep failing:
```sh
systemctl --user disable --now punktfunk-host punktfunk-web
sudo punktfunk-sysext remove
sudo rm -f /etc/modules-load.d/punktfunk.conf /etc/udev/rules.d/60-punktfunk.rules
sudo groupdel punktfunk-update # the (empty) group for web-console updates
```
[Uninstalling](/docs/uninstall) has the same walkthrough for the other install methods, and for the
clients.
Three things to know:
- **After a Bazzite major rebase** (Fedora 43 → 44) the old image **refuses to load** rather than
run against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches
the image built for the new base.
- **Already layering Punktfunk?** Install the sysext (it shadows the layered copy immediately),
then drop the layer so it stops slowing your updates:
`sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
- **If it refuses the feed.** `refusing to install from an unsigned feed` means that Fedora major's
feed predates signing; it gets sealed on the next publish. To install from it anyway, accepting
that the images are unauthenticated, run
`sudo env PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 bash punktfunk-sysext.sh install`. The other message,
`the feed's SHA256SUMS is NOT signed by packages@unom.io`, is not the same thing — don't install;
re-download the script and try again.
For a fully baked appliance image there's also a **bootc** Containerfile that installs the RPMs
from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree`
layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too: add the
repo exactly as on [Fedora](/docs/fedora), with the `baseurl` group matching your Fedora base, then
`sudo rpm-ostree install punktfunk punktfunk-web` and reboot. The sysext is still the supported
default. Building from source also works (Bazzite is Fedora Atomic underneath — same steps as
[Fedora](/docs/fedora)).
## Allow controller input
Gamepad and DualSense input needs your user in the `input` group. On Bazzite, don't use
`usermod` — the base is immutable and the group is managed by a recipe. Use:
```sh
ujust add-user-to-input-group
```
Then **log out and back in**. (A controller that's "detected but does nothing" is almost always this
permission, not a client problem.)
Then join `punktfunk``usermod` is fine here, because unlike `input` this group is ours and the
sysext creates it on merge:
```sh
sudo usermod -aG punktfunk "$USER" # then log out and back in
```
This box **is** a Gaming Mode box, so that group is not optional in practice: it authorizes the
helper the host uses to stop the display manager when it takes the Gaming Mode session over at your
client's resolution, and it gates the usbip `attach` file the **virtual Steam Deck controller**
(paddles, trackpads, gyro) attaches through. It is a separate group on purpose — writing that file
can materialise arbitrary emulated USB hardware, so it is not folded into the group everyone is
told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller, and the
takeover degrades to mirroring the box's own screen — see
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
## Configure
The RPM ships a Bazzite-tuned config you can copy as your starting point:
```sh
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
```
The template is deliberately minimal — it does **not** force a compositor, because the host
auto-detects Gaming Mode (gamescope) vs Desktop (KWin) on every connect and follows the switch
mid-stream. No session anchors are needed either (a user service inherits the right runtime dir).
The only settings that matter (GPU zero-copy is on by default):
```sh
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
```
### Gaming Mode: attach vs managed
For Gaming Mode there are two models. The template forces **neither** — the host picks per connect,
and on Bazzite (which ships `gamescope-session-plus`) that is **managed**:
- **Managed** (what you get by default here) — the host takes the box's gamescope over and
relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the
virtual screen — restoring the box on idle. This is the model that gives the client a display of
its **own**, and the only one under which a game launched from a client's library gets a
dedicated session. It needs the [`punktfunk` group](#allow-controller-input): the takeover stops
the display manager for the length of the stream, and without that grant it cannot.
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session on its own
display, and the host attaches to whatever's live without ever tearing it down (on a headless
box, a box-owned autologin session is restarted at the client's resolution on a mismatch; with a
display connected it streams at the box's own mode). Switching Desktop ↔ Game is rock-solid, and
the cost is that a box with a screen attached serves the client a **mirror** of that screen
rather than its own display. Setting it also outranks a dedicated game session.
`=0` turns the attach override off, the same as removing the line.
Full treatment: [Steam / gamescope → How the host gets a
gamescope](/docs/gamescope#how-the-host-gets-a-gamescope).
Mid-stream Gaming ↔ Desktop following (`PUNKTFUNK_SESSION_WATCH`) is **on by default** on
Bazzite/SteamOS. See [Configuration](/docs/configuration) for the full list of knobs.
### Streaming the KDE Plasma desktop
The **virtual output** (video) for the Desktop session needs no config — the host package ships an
`io.unom.Punktfunk.Host.desktop` file whose `X-KDE-Wayland-Interfaces` grants the host KWin's
restricted screencast protocol on a normal interactive Plasma session (background:
[KDE Plasma](/docs/kde)). After a **fresh host install, log out and back into the Desktop session
once** so KWin re-reads that grant.
The one thing a normal KDE login lacks is the RemoteDesktop grant for headless **input** injection.
Seed it once (as the streaming user, no root) so the host auto-approves instead of popping an
un-answerable dialog:
```sh
bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
```
Gaming Mode needs none of this — it auto-attaches.
## Run as an always-on host
Bazzite hosts are typically headless. Enable the host service and linger so it starts at boot — see
[Running as a Service](/docs/running-as-a-service). One host service covers both Gaming Mode and the
Desktop; it follows whichever the box is in.
```sh
systemctl --user enable --now punktfunk-host
systemctl --user enable --now punktfunk-web # web console: pairing + status
sudo loginctl enable-linger "$USER" # start at boot with nobody logged in
```
Without that last line the `--user` units don't start until someone logs in — which on a headless box
never happens.
Then open [The Web Console](/docs/web-console) for the login password and to
[arm pairing](/docs/web-console#arm-pairing).
## Good to know
These apply to the **Gaming Mode (gamescope)** path; the KDE Desktop path is unaffected:
- **gamescope 3.16.22 or newer is required; 3.16.23 or newer for the Steam overlay.** Below 3.16.22
headless capture can deadlock; between the two, capture works but the Steam overlay (Shift+Tab /
the Quick Access Menu) is never painted into the captured node. Bazzite's current gamescope is
past both; this only bites if you've pinned an old one.
- **Forcing attach costs you the cursor, HDR and your own display.** The sysext ships the
`punktfunk-gamescope` build, but it only reaches a session the host starts itself — under
`PUNKTFUNK_GAMESCOPE_ATTACH=1` the live session is Bazzite's own stock gamescope. The managed
default gets you the compositor-drawn pointer, real HDR and a display of the client's own. If you
deliberately stay on attach, also set `PUNKTFUNK_GAMESCOPE_HDR=0` and
`PUNKTFUNK_GAMESCOPE_BIN=/usr/bin/gamescope`. Why each half breaks:
[gamescope → Known limits](/docs/gamescope#known-limits) for the cursor,
[HDR → Linux + gamescope](/docs/hdr#linux--gamescope) for the failed connect.
⚠ Older templates set `PUNKTFUNK_GAMESCOPE_ATTACH=1` for you — if you copied one, delete that
line from `~/.config/punktfunk/host.env`, because an upgrade never rewrites a file you already
have.
Those are the two that bite on Bazzite. The full set — touch, mouse modes, the clipboard — is on
[gamescope → Known limits](/docs/gamescope#known-limits).
Then [connect a client](/docs/clients) — Moonlight works great for couch gaming, and the Apple app for
Apple TV / iPad. Trouble? See [Troubleshooting](/docs/troubleshooting).
+95
View File
@@ -0,0 +1,95 @@
---
title: Bazzite
description: Install the Punktfunk host on Bazzite (or any Fedora Atomic spin) as a sysext — no layering, no reboot — and stream both Gaming Mode and the desktop.
---
Bazzite already ships everything a host needs — the NVIDIA driver, NVENC, PipeWire, gamescope, KDE
Plasma — so this is the most appliance-like setup. One host streams **both** of Bazzite's faces,
Steam **Gaming Mode** and the **KDE desktop**, and follows the box when you switch, even mid-stream.
Nothing in the config picks a mode.
## 1. Install the host
The host installs as a **systemd system extension** — it overlays `/usr` from
`/var/lib/extensions/`, survives OS updates, and installs, updates and uninstalls **without a
reboot** (no `rpm-ostree` layering, which Bazzite's docs treat as a last resort). The feed is
signed; the installer refuses one it can't verify.
<Install platform="bazzite" />
That fetches the newest image — host, web console, tray, the plugin runner (started for you, because
the game-library scanners are [plugins](/docs/plugins)) and the HDR `punktfunk-gamescope` build — and
applies the udev/sysctl setup on the spot. From then on `sudo punktfunk-sysext update` (or the
[console's update button](/docs/updating)) moves you to the newest build; `status` shows where you
are. After an update restart the host: `systemctl --user restart punktfunk-web punktfunk-host`.
## 2. Let it use your controllers
On Bazzite the `input` group is managed by a recipe, so don't `usermod` it — use the helper. The
`punktfunk` group is ours (the sysext creates it) and gates the **virtual Steam Deck controller**
(paddles, trackpads, gyro) — without it that pad arrives as an ordinary Xbox 360 controller. Then
**log out and back in**:
```sh
ujust add-user-to-input-group
sudo usermod -aG punktfunk "$USER"
```
([Why it's a separate group](/docs/gamescope#nobara-and-other-autologin-display-managers).)
## 3. Start it
Bazzite hosts are usually headless, so enable the services **and** linger, from a terminal in the
desktop session — without linger the `--user` units wait for a login that never comes:
```sh
systemctl --user enable --now punktfunk-host punktfunk-web
sudo loginctl enable-linger "$USER"
```
Two one-time steps for streaming the **KDE desktop** (Gaming Mode needs neither): log out and back
into the Desktop session once so KWin re-reads the screencast grant the package installed, and seed
the input grant so the host auto-approves instead of popping a dialog nobody can answer:
```sh
bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
```
## 4. Open the firewall
Bazzite runs **firewalld**, and a package never opens ports for you — until you allow it, no client
can reach the host. The image installed the service definitions; enable them once:
```sh
sudo firewall-cmd --reload # load the definitions the package installed
sudo firewall-cmd --permanent --add-service=punktfunk-native --add-service=punktfunk-web
sudo firewall-cmd --reload
```
(`punktfunk-web` is only needed to reach the console from another device; Moonlight compat needs
`punktfunk-gamestream` too — [Ports & firewall](/docs/ports).)
**That's the install.** Continue with the [Quick Start from step 3](/docs/quickstart#3-open-the-web-console)
— open the console, pair a client, stream. Moonlight works great for couch gaming here, the Apple
app for Apple TV and iPad.
## When you want more
- **Gaming Mode: what the client gets.** By default the host relaunches Gaming Mode *headless* at the
client's exact resolution (a display of its own, real HDR, the compositor's cursor), idling the
box's own session with a user-level drop-in for the length of the stream — no privilege needed,
and Steam's "Switch to Desktop" keeps working — and restores the box on idle. `PUNKTFUNK_GAMESCOPE_ATTACH=1` makes it attach to the box's own session instead —
rock-solid switching, but a box with a screen serves a *mirror* of it, and loses HDR and the cursor.
[How the host gets a gamescope](/docs/gamescope#how-the-host-gets-a-gamescope) has the whole
model; the Bazzite template (`/usr/share/punktfunk/host.env.bazzite`) forces neither.
- **Stream lags, then freezes, with a DualSense-type client pad** — an SELinux storm, with a shipped
fix: [Troubleshooting](/docs/troubleshooting#stream-lags-then-freezes-with-a-dualsense-pad-bazzite-selinux).
- **Channels, rollback, after a Bazzite major rebase** —
[Updating → Bazzite sysext](/docs/updating#bazzite-sysext-channels-rollback-and-rebases).
Removing it — [Uninstall](/docs/uninstall#bazzite--fedora-atomic-systemd-sysext).
- **Other ways in.** Plain `rpm-ostree` layering from the [RPM repo](/docs/fedora#2-install-the-host)
(use the `bazzite` group) still works, and `packaging/bootc/` in the repo bakes an appliance image;
the sysext is the supported default. Already layering? Install the sysext (it shadows the layer at
once), then `sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
- Everything Gaming-Mode-specific — overlay, touch, HDR, the two gamescope version floors —
[Steam / gamescope](/docs/gamescope); the desktop — [KDE](/docs/kde).
@@ -0,0 +1,90 @@
---
title: Build from source
description: Compile the Linux host yourself — on Ubuntu/Debian, Fedora, or with the Arch PKGBUILD — when no package fits your release or you want to track main.
---
The package repos are the supported path ([Install the Host](/docs/install)). Build from source when
your release is older than a package supports (Ubuntu before 26.04, Debian 12, a Fedora without a
repo group), or to hack on it. A source build gets **no packaged units and no clean updates** — you
wire the service up by hand ([Running as a service](/docs/running-as-a-service) shows the unit).
Two build features matter on every distro: `punktfunk-host/nvenc` (direct NVENC on NVIDIA) and
`punktfunk-host/vulkan-encode` (Vulkan Video on AMD/Intel). They're what the packaged builds use;
without them the host falls back to the slower libav backends. Rust comes from
[rustup](https://rustup.rs) if you don't have it:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
## Ubuntu / Debian
The packaged host is built against **FFmpeg 8**. Ubuntu 26.04's and Debian 13's `libavcodec-dev`
are new enough; Ubuntu 24.04's is FFmpeg 6.1 — build FFmpeg 8 yourself first there (what
`ci/rust-ci-noble.Dockerfile` does), or stick with the packaged host.
```sh
sudo apt install build-essential pkg-config cmake clang libclang-dev nasm git curl \
pipewire pipewire-pulse wireplumber libpipewire-0.3-dev libspa-0.2-dev \
libwayland-dev wayland-protocols libxkbcommon-dev libopus-dev \
libdrm-dev libgbm-dev libgl-dev libegl-dev libgles-dev mesa-common-dev libva-dev \
ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
libnvidia-egl-wayland1 libnvidia-egl-gbm1 libei-dev
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
cargo build --release --locked \
--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host
```
## Fedora
```sh
sudo dnf install gcc gcc-c++ make cmake clang clang-devel nasm git pkgconf-pkg-config \
pipewire-devel wayland-devel wayland-protocols-devel libxkbcommon-devel opus-devel \
libdrm-devel mesa-libgbm-devel mesa-libGL-devel mesa-libEGL-devel mesa-libGLES-devel libva-devel \
ffmpeg-devel libei-devel
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
cargo build --release --locked \
--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host
```
`ffmpeg-devel` must be RPM Fusion's (with NVENC), not `ffmpeg-free-devel`. `mesa-libGL-devel` isn't
optional — the zero-copy GPU path links `libGL`, and without it the build fails at link time with
`cannot find -lGL`. To build an RPM instead, use the same toolchain CI does:
`docker build --build-arg FEDORA_VERSION=NN -f ci/fedora-rpm.Dockerfile -t pf-rpm ci`, then run
`packaging/rpm/build-rpm.sh` inside it.
## Arch (PKGBUILD)
The split `PKGBUILD` in `packaging/arch/` produces `punktfunk-host` and `punktfunk-client`; set
`PF_WITH_WEB=1` to also build `punktfunk-web` and `PF_WITH_SCRIPTING=1` for `punktfunk-scripting`
(both need `bun`):
```sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk/packaging/arch
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver # builds the working tree, no git fetch
sudo pacman -U punktfunk-host-*.pkg.tar.zst
```
NVENC/EGL come from `nvidia-utils`; on a GPU-less builder, symlink the CUDA stub into the link path
first (the `PKGBUILD` header documents this). Packager notes, the Fedora→Arch dependency map and the
sysext mechanism: [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
For a **SteamOS** host don't use the PKGBUILD — the [on-device installer](/docs/steamos-host) builds
ABI-matched to the running OS.
## Running what you built
The binary lands at `target/release/punktfunk-host`. Run it from inside your desktop session — it
auto-detects the compositor:
```sh
target/release/punktfunk-host serve # secure native-only host
target/release/punktfunk-host serve --gamestream # + Moonlight compat (trusted LAN only)
```
To run it as a user service, copy `scripts/punktfunk-host.service` to
`~/.config/systemd/user/` (it already points at `%h/punktfunk/target/release/punktfunk-host`), then
`systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host`. The other
workspace members (`punktfunk-web`, `punktfunk-scripting`, the client) build the same way — the
root [README](https://git.unom.io/unom/punktfunk#build--test-from-source) covers the dev loop.
+2 -1
View File
@@ -32,7 +32,8 @@ track per machine; switching is a one-line change.
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
| **Windows client** (installer) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-setup_x64.exe` | `…/latest/…` + the release page |
| **Windows client** (MSIX / portable zip) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` (or `…_x64-portable.zip`) | `…/latest/…` + the release page |
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
| **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` |
| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page |
+81 -92
View File
@@ -6,21 +6,21 @@ description: Every setting a Punktfunk client stores — what it does, what it d
The host has [its own settings reference](/docs/configuration). This page is the other half: the
settings each **client** keeps, which together decide what a session looks like.
Most of them are a *request*. The client asks, the host answers, and the answer comes back in the
handshake — so a setting the host can't honor is usually a quiet downgrade rather than an error.
Most of them are a *request*. The client asks, the host answers in the handshake — so a setting the
host can't honor is usually a quiet downgrade rather than an error.
## Where the settings live
The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same way — **General**,
**Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings*
elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings
screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the
client's **console home**, whose settings screen is one steppable list of sections — **Stream**,
**Video**, **Presentation**, **Audio**, **Controller**, **Touchscreen**, **Interface**,
**Profiles**. On a Steam Deck that list *is* the settings surface: the
[Decky plugin](/docs/steam-deck) is a launcher and keeps no settings of its own, and its **Open
Punktfunk** button puts the console home one tap from the Quick Access Menu. The console home is
part of the client — it is not the host's [web console](/docs/web-console).
elsewhere. The Apple TV app shows one scrolling list instead, as does any client's settings screen
reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the client's
**console home**, whose settings screen is one steppable list of sections — **Stream**, **Video**,
**Presentation**, **Audio**, **Controller**, **Touchscreen**, **Interface**, **Profiles**. On a
Steam Deck that list *is* the settings surface: the [Decky plugin](/docs/steam-deck) is a launcher
and keeps no settings of its own, and its **Open Punktfunk** button puts the console home one tap
from the Quick Access Menu. The console home is part of the client — it is not the host's
[web console](/docs/web-console).
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the console home
writes, so a change in either shows up in the other. Windows uses
@@ -30,8 +30,8 @@ Changes apply to the **next** session — a running stream keeps what it started
is the exception in effect, not in reading: it too is read at connect, but once a session is running
with it on, every window resize renegotiates the mode.)
Not every client offers every setting, and the wording on screen varies a little between them — the
names below are the ones the Linux app uses. The differences that matter are noted per setting.
Not every client offers every setting; the names below are the Linux app's, and differences that
matter are noted per setting.
## Video
@@ -73,8 +73,8 @@ link. The stops run 0.5× to 4×. The result is floored to an even size and capp
**Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can
also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264.
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or
an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or an
Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the
device has a hardware AV1 decoder; Android never offers PyroWave.
@@ -91,24 +91,23 @@ decode probe to pass). The console home offers the toggle; Android doesn't.
**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is
ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps and the console
home; the Apple and Android apps have carried the same setting for a while, and it is stored
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens those
hiccups out, at that buffer's worth of added delay. Linux and Windows apps and the console home; the
Apple and Android apps have it too, stored under the same name, so a
[profile](/docs/profiles-and-links) means the same thing on every device.
**Smoothness buffer** — *default: Automatic (two frames).* How many frames are held back before
showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of
delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of
jitter. If you never see stutter, you don't need this. The row appears wherever **Prioritize** is
offered, and only once you have picked **Smoothness** — under Lowest latency there are no held
frames for it to count, so it isn't shown at all.
offered, and only once you have picked **Smoothness**.
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every
driver or compositor offers a tearing mode, and where none is available the stream stays tear-free.
The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off"
from "off but unavailable". Linux and Windows apps and the console home.
**V-Sync** — *default: on.* Tear-free presentation. Off asks the GPU to show each frame the instant
it's ready instead of waiting for the screen's next refresh: the lowest delay a display can give
you, at the cost of visible tearing on fast motion. It is **best-effort** — not every driver or
compositor offers a tearing mode, and where none is available the stream stays tear-free. The
Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off" from
"off but unavailable". Linux and Windows apps and the console home.
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel
refresh in step with the stream rather than on a fixed cadence — which removes the wait between a
@@ -132,21 +131,20 @@ claims a sink advertising exactly that many channels, so applications produce re
**Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1
from a stereo endpoint is an upmix, not new channels. Offered everywhere.
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the
Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the
row is spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending
anything — see [Muting your microphone](/docs/input#muting-your-microphone).
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the Apple
app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the row is
spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending anything —
see [Muting your microphone](/docs/input#muting-your-microphone).
**Echo cancellation** — *default: on.* Stops the host's audio, playing out of this device's
speakers, from being picked up by the microphone and sent straight back. It hands the microphone
to the system's own canceller rather than doing the work itself: on **Linux** that means capturing
from an echo-cancelled PipeWire source when your desktop provides one, on **Windows** asking WASAPI
for the Communications stream category so the endpoint's processing engages, and on **Apple** and
**Echo cancellation** — *default: on.* Stops the host's audio, playing out of this device's speakers,
from being picked up by the microphone and sent straight back. It hands the microphone to the
system's own canceller rather than doing the work itself: on **Linux** that means capturing from an
echo-cancelled PipeWire source when your desktop provides one, on **Windows** asking WASAPI for the
Communications stream category so the endpoint's processing engages, and on **Apple** and
**Android** the platform's voice-processing mode. Turn it off if your microphone already runs its
own processing, or if the canceller makes your voice sound thin. The row sits under the microphone
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and
console-home clients. What it can and can't fix is in
[Why do I hear myself](/docs/echo).
console-home clients. What it can and can't fix is in [Why do I hear myself](/docs/echo).
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the
@@ -164,41 +162,39 @@ more settings are worth naming here.
**Forward controllers** — *default: on*, on every client. Off, the controllers connected to *this*
device are not sent to the host at all. That is what you want when your controller already reaches
the host by some other route — [USB passthrough](/docs/automation#recipe-full-controller-passthrough-virtualhere)
such as VirtualHere, or simply a pad plugged into the host itself. Leaving forwarding on in that
situation hands the host two controllers for one pair of hands, and games read both: a stick drifts
because the second pad is centred, or a menu takes every input twice.
the host by some other route —
[USB passthrough](/docs/automation#recipe-full-controller-passthrough-virtualhere) such as
VirtualHere, or a pad plugged into the host itself. Leaving forwarding on there hands the host two
controllers for one pair of hands, and games read both: a stick drifts because the second pad is
centred, or a menu takes every input twice.
On Linux and Windows it does more than stay quiet. Opening a controller is what *claims* it — the
client's SDL takes the device node — and a claimed device is one a passthrough tool cannot bind. So
with this off the session never opens the controller at all, which is precisely what leaves it free
for VirtualHere to hand over. The consequence to know: the
On Linux and Windows, opening a controller is what *claims* it — the client's SDL takes the device
node — and a passthrough tool cannot bind a claimed device; with this off the session never opens
the controller at all, leaving it free for VirtualHere to hand over. The consequence: the
[controller escape chord](/docs/input#leaving-with-a-controller) is read off forwarded pads, so it is
unavailable on those two while this is off — leave a stream with the keyboard chord or the client's
own UI. The Apple and Android apps claim nothing, so their chords keep working either way; the
Android app does stop its DualSense and Steam Controller 2 USB captures, which *do* claim the
device.
Android app does stop its DualSense and Steam Controller 2 USB captures, which *do* claim the device.
The rows below it — which pad, and what type — have nothing to act on while this is off, and every
client greys them out to say so.
**Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*,
which matches each physical controller. The pickers offer Xbox 360, Xbox One, DualSense and
DualShock 4 everywhere, plus Steam Deck on Linux, Android and the console home. Your client
declares a type per pad as it connects — Automatic declares what that controller really is, an
explicit choice declares your choice — and the host builds each virtual pad from that. A type the
host has no backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host,
for instance, or any Sony pad on a Linux host that can't open `/dev/uhid`.
DualShock 4 everywhere, plus Steam Deck on Linux, Android and the console home. Your client declares
a type per pad as it connects — Automatic declares what that controller really is, an explicit
choice declares your choice — and the host builds each virtual pad from that. A type the host has no
backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host, for
instance, or any Sony pad on a Linux host that can't open `/dev/uhid`.
That degrade is the one thing worth knowing about **motion**. An Xbox-class virtual pad has no
gyroscope in its HID contract, so a session that ends up on one throws every motion sample away —
your controller's gyro simply does nothing, which from the couch is indistinguishable from a broken
sensor. Automatic lands there for any controller punktfunk doesn't recognise as Sony or Valve (an
8BitDo with a gyro, say), and so does a Switch Pro streaming to a Windows host, which has no
Nintendo backend to build. **If you want motion, pick a DualSense-class type** — DualSense,
DualSense Edge, DualShock 4, Switch Pro or Steam Deck all carry a motion plane. The clients detect
this case and say so on-screen for a few seconds when it happens; the setting applies from the next
session, not the one you are in.
That degrade matters for **motion**. An Xbox-class virtual pad has no gyroscope in its HID
contract, so a session that ends up on one throws every motion sample away — your controller's gyro
does nothing. Automatic lands there for any controller punktfunk doesn't recognise as Sony or Valve (an 8BitDo
with a gyro, say), and so does a Switch Pro streaming to a Windows host, which has no Nintendo
backend to build. **If you want motion, pick a DualSense-class type** — DualSense, DualSense Edge,
DualShock 4, Switch Pro or Steam Deck all carry a motion plane. The clients detect this case and say
so on-screen for a few seconds when it happens; the setting applies from the next session, not the
one you are in.
On a **Steam Deck as the client**, motion also needs Steam Input switched off for punktfunk — with
it on, Steam hands the app its own virtual Xbox pad, which has no gyro to forward no matter which
@@ -229,9 +225,8 @@ Windows apps, *off* on Android. The two halves of [controller audio](/docs/contr
DualSense's voice-coil haptics, and the little speaker in the middle of the pad. Both need a
**wired** DualSense or DualSense Edge — over Bluetooth a controller exposes no audio device at all,
and both settings quietly do nothing. Neither costs anything without a host that sends them: the
plane is negotiated, and silence is never encoded or transmitted, so leaving haptics on is free even
on a pad that never gets any. Turn **Controller speaker** off if you would rather all game audio came
out of your speakers or headset.
plane is negotiated, and silence is never encoded or transmitted. Turn **Controller speaker** off if
you would rather all game audio came out of your speakers or headset.
Offered by the Linux, Windows and Android apps. On Linux, the client also switches the controller's
sound card to Pro Audio while it needs the voice coils, and puts it back afterwards — see
@@ -240,13 +235,13 @@ for why that is necessary and how to turn it off.
**Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the
console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key
(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine
instead — what you want when the stream shares a screen with local work. Either way the chords come
back the moment you release capture with **Ctrl+Alt+Shift+Q**, the window loses focus, or the stream
ends, and [Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving this on does
mean **Ctrl+Alt+Shift+Q is your way out** of a captured stream, since Alt+Tab no longer is.
matters only for a keyboard you attached yourself: Gaming Mode is gamescope, which has nothing to
hold back. On, Alt+Tab and the Windows key (Super on Linux)
reach the host while the stream has input captured. Off, they act on this machine instead — what you
want when the stream shares a screen with local work. Either way the chords come back the moment you
release capture with **Ctrl+Alt+Shift+Q**, the window loses focus, or the stream ends, and
[Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving this on does mean
**Ctrl+Alt+Shift+Q is your way out** of a captured stream, since Alt+Tab no longer is.
On macOS the chords in question are the **⌘** ones — ⌘Q above all, which reaches the host as Super+Q,
one of the most-bound chords on a Linux desktop. On, ⌘Q, ⌘W, ⌘H and the rest go to the host instead
@@ -270,7 +265,7 @@ learned. Turn it off for hosts you reach over a VPN, where "offline" usually mea
broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this
toggle, as does the console home — and on a Steam Deck it governs the
[Decky plugin's](/docs/steam-deck) launches too, because the plugin starts every stream through the
client, which reads this setting like any other connect. The console home also offers wake as an
client. The console home also offers wake as an
explicit action on an offline host, whatever the toggle says. See
[Wake-on-LAN](/docs/wake-on-lan).
@@ -282,14 +277,13 @@ offered on any paired host. See [Game library](/docs/game-library).
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
when you return to the host list. The console home carries the row for the desktop client that
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
and Android have no equivalent.
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV and
Android have no equivalent.
## Interface
These change how the client itself looks and behaves. None of them touches a stream, so none of them
can live in a [profile](/docs/profiles-and-links) — they are decisions about the device in front of
you.
These change how the client itself looks and behaves. None touches a stream, so none can live in a
[profile](/docs/profiles-and-links) — they are decisions about the device in front of you.
**Gamepad-optimized browsing** — *default: on.* Swaps the touch or desktop home for the
controller-optimized one: the host carousel, larger focus targets, a swipeable cover browser, and
@@ -299,22 +293,20 @@ controller-optimized home is a separate entry point rather than a switch, so the
turn off. An Android TV is always in this mode — its remote is the only input it has.
**Show it** — *default: With a controller.* Only shown while the switch above is on, and it decides
*when* that switch takes effect. **With a controller** is the long-standing behaviour: the
controller-optimized home appears as a pad connects and the touch interface returns when the last one
disconnects. **Always** keeps the controller-optimized home either way — for a phone or tablet that
lives docked to a TV, where the pad isn't always awake but the couch layout is always the one you
want. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
*when* that switch takes effect. **With a controller**: the controller-optimized home appears as a
pad connects and the touch interface returns when the last one disconnects. **Always** keeps the
controller-optimized home either way — for a phone or tablet docked to a TV, where the pad isn't
always awake. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
there.)
**Background** — *default: Violet.* The colour family the controller-optimized home's living backdrop
drifts through. Thirteen of them: seven dark fields — **Violet**, **OLED**, **Nebula**, **Abyss**,
**Ember**, **Moss**, **Graphite** — then six pale ones, **Holo**, **Sunset**, **Bloom**, **Dawn**,
**Mint** and **Opal**, which flip the whole interface to dark text on a light field. The backdrop
recolours as you step the row, so pick by looking. **OLED** is the one with a practical point rather
than a decorative one: it is true black — most of the frame is pixels switched off, which on an OLED
or AMOLED panel means no glow and no power drawn, with only a faint violet ember left in one corner.
Stored under the same name on every client, so a phone, a Deck and a desktop set to Mint all look
alike. Appearance only — nothing about a stream depends on it.
recolours as you step the row, so pick by looking. **OLED** is the one with a practical point: it is
true black — most of the frame is pixels switched off, which on an OLED or AMOLED panel means no
glow and no power drawn, with only a faint violet ember left in one corner. Stored under the same
name on every client, so a phone, a Deck and a desktop set to Mint all look alike.
The row lives in the controller-optimized settings themselves — the screen you reach with **X** from
the controller-optimized home — on every platform that has one, which includes the Steam Deck and the
@@ -329,8 +321,7 @@ superset of the one before. This setting only picks the tier a session *starts*
them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The
console home has the tier picker too, as **Statistics overlay** under **Interface**. The shortcuts,
and every number in the overlay, are in
[Understanding the stats overlay](/docs/stats).
and every number in the overlay, are in [Understanding the stats overlay](/docs/stats).
## Settings that are facts about your device
@@ -352,9 +343,7 @@ stay global and **cannot be put in a settings profile**:
- **Auto-wake on connect**, and **Show game library** where it still exists (the Apple and Android
apps) — decisions about this device and this network, not about how a given host is streamed.
- Everything under **Interface****Gamepad-optimized browsing**, **Show it** and **Background**.
How this client looks and which layout it wears has nothing to do with how a host streams to it,
so binding them to a host would only make the same device change appearance depending on what it
connected to.
How this client looks has nothing to do with how a host streams to it.
One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved
host's own edit sheet, because handing a machine your clipboard is a decision about that one host —
+58 -66
View File
@@ -5,15 +5,13 @@ description: The ways to connect to a Punktfunk host — the Apple app, Moonligh
A Punktfunk host accepts clients over its own `punktfunk/1` protocol (the macOS, Linux, Windows, and
Android apps) and over GameStream (Moonlight). Pick whichever fits the device you're streaming *to*.
Ready to install?
**[Install a Client](/docs/install-client)** has the step-by-step for every device — plus how to
**[Install a Client](/docs/install-client)** has the step-by-step for every device, plus how to
[update](/docs/install-client#keeping-a-client-up-to-date) and
[remove](/docs/install-client#removing-a-client) each one.
Two things apply to every app, whichever you pick:
[profiles and `punktfunk://` links](#profiles-and-links-every-app), and the keys and chords that
work [while you're streaming](#while-youre-streaming). What each one lets you change — resolution,
bitrate, codec, HDR, audio, controllers — is catalogued in
Two things apply to every app: [profiles and `punktfunk://` links](#profiles-and-links-every-app),
and the keys and chords that work [while you're streaming](#while-youre-streaming). What each app
lets you change — resolution, bitrate, codec, HDR, audio, controllers — is catalogued in
[Client settings](/docs/client-settings).
## Apple app (Mac, iPhone, iPad, Apple TV)
@@ -29,9 +27,9 @@ protocol — the lowest-latency, most resilient path, with the full feature set:
- A live **stats overlay** (resolution, fps, bitrate, latency) and a built-in **network speed test**
to pick a bitrate for your link.
- **Widgets, Live Activities and Shortcuts** — a hosts widget and a game-library widget for the
home screen (the library one opens a host you pick straight into its library), a Live Activity
while a session runs, and App Intents so Siri and the Shortcuts app can start a stream or jump
into a host's game library.
home screen (the library one opens a picked host's library), a Live Activity while a session
runs, and App Intents so Siri and the Shortcuts app can start a stream or jump into a host's game
library.
Open the app, pick your host, [pair](/docs/pairing) once, and stream. It builds from the
`clients/apple` directory in the repo (Swift / VideoToolbox / Metal).
@@ -40,11 +38,9 @@ Open the app, pick your host, [pair](/docs/pairing) once, and stream. It builds
Punktfunk also speaks the **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/)
client — a browser, a smart TV, an old phone, a games console — connects with no punktfunk-specific
software. (Most platforms also have a native Punktfunk app below — Moonlight is the catch-all.) See
[Connect with Moonlight](/docs/moonlight).
This is the broadest-compatibility option and great for couch gaming. It doesn't use the native
protocol's FEC/encryption extensions, but for a healthy LAN that rarely matters.
software; it's the catch-all where no native Punktfunk app exists. See
[Connect with Moonlight](/docs/moonlight). It doesn't use the native protocol's FEC/encryption
extensions, but on a healthy LAN that rarely matters.
## Linux desktop client (GTK4)
@@ -52,26 +48,26 @@ protocol's FEC/encryption extensions, but for a healthy LAN that rarely matters.
`punktfunk/1` directly, with vendor-ordered hardware decode (**Vulkan Video first on NVIDIA and
AMD**, **VAAPI dmabuf first on Intel**; whichever isn't first is the fallback, and software decode
is last), PipeWire audio, and SDL3 controllers (rumble, lightbar, DualSense touchpad/motion). The
decoders are Punktfunk's own — the client links no FFmpeg at all, and talks to your GPU's Vulkan
and VAAPI drivers directly. To force one, pick it in *Preferences → Display → Video decoder* or
set `PUNKTFUNK_DECODER=native-vulkan|native-vaapi|software`. Like the Apple app it discovers hosts
on your network automatically, does PIN pairing, pins reconnects, and browses the host's
**game library** (with cover art) so you can launch a title straight into the stream.
decoders are Punktfunk's own — the client links no FFmpeg and talks to your GPU's Vulkan and VAAPI
drivers directly. To force one, pick it in *Preferences → Display → Video decoder* or set
`PUNKTFUNK_DECODER=native-vulkan|native-vaapi|software`. Like the Apple app it discovers hosts
automatically, does PIN pairing, pins reconnects, and browses the host's **game library** (with
cover art) to launch a title straight into the stream.
It ships as a real package, not just a source build — full steps in
It ships as real packages — full steps in
[Install a Client](/docs/install-client#linux-desktop-flatpak):
- **Any Flatpak distro (recommended)** — one command from the hosted `flatpak.unom.io` repo; the
guide linked above has the exact command and how updates work. It's also the client the
- **Any Flatpak distro (recommended)** — one command from the hosted `flatpak.unom.io` repo (exact
command and update flow in the guide above). It's also the client the
[Decky plugin](/docs/steam-deck) uses by default, though the plugin drives a native
`punktfunk-client` just as well.
- **Ubuntu 26.04 or newer**`apt install punktfunk-client` from the Punktfunk apt registry. The
client package needs SDL3 and GTK4 ≥ 4.20, which Ubuntu 24.04 LTS doesn't ship — on 24.04 use the
Flatpak above.
Flatpak.
- **Fedora**`sudo dnf install punktfunk-client` from the Gitea RPM registry (add the repo as in
the [Fedora guide](/docs/fedora)).
- **Fedora Atomic / Bazzite** — use the Flatpak above. `rpm-ostree install punktfunk-client` works,
but layering slows every OS update, so it's a last resort on an image-based system (see
- **Fedora Atomic / Bazzite** — use the Flatpak. `rpm-ostree install punktfunk-client` works, but
layering slows every OS update, so it's a last resort on an image-based system (see
[Bazzite](/docs/bazzite)).
- **Arch**`sudo pacman -Syu punktfunk-client` from the signed binary repo (see [Arch Linux](/docs/arch)).
@@ -88,7 +84,7 @@ The client also updates itself (`punktfunk-client --check-update` / `--apply-upd
## Android app (phone + Android TV)
The native Android app speaks `punktfunk/1` directly, on both phones and Android TV. It does hardware
The native Android app speaks `punktfunk/1` directly, on phones and Android TV. It does hardware
HEVC decode (including [HDR10](/docs/hdr#per-client)), Opus audio with a mic uplink, game
controllers with rumble and DualSense feedback, automatic host discovery, PIN pairing with pinned
reconnects, the host's **game library** with cover art, and a live stats overlay — with D-pad and
@@ -96,29 +92,28 @@ game-controller focus navigation for the couch. It builds from the `clients/andr
(Kotlin + a shared Rust core).
**Controllers.** Plug a **DualSense**, **DualSense Edge** or **DualShock 4** into the phone or tablet
by USB and grant the USB permission Android asks for when it attaches — Punktfunk then drives the pad
itself instead of taking what Android's gamepad layer exposes, so the host gets rumble, adaptive
triggers, the lightbar and gyro. The app's **Controllers** screen lists attached pads and their
capture state, and the switch that turns this off is *DualSense / DualShock passthrough (USB)* in
Settings. Over **Bluetooth** the pad still works as an ordinary gamepad, but adaptive triggers and
the lightbar need the USB connection.
by USB and grant the USB permission Android asks for — Punktfunk then drives the pad itself instead
of taking what Android's gamepad layer exposes, so the host gets rumble, adaptive triggers, the
lightbar and gyro. The app's **Controllers** screen lists attached pads and their capture state; the
switch that turns this off is *DualSense / DualShock passthrough (USB)* in Settings. Over
**Bluetooth** the pad still works as an ordinary gamepad, but adaptive triggers and the lightbar
need USB.
The app is on **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** as a
public listing — no invite — or you can sideload the public APK instead (see
public listing — no invite — or sideload the public APK (see
[Install a Client](/docs/install-client#android)); canary builds ride a separate, invite-only Play
Internal testing track. Then open the app, pick your host, [pair](/docs/pairing) once, and stream.
Internal testing track. Open the app, pick your host, [pair](/docs/pairing) once, and stream.
## Windows desktop client
`punktfunk-client` for Windows (`clients/windows`) is the native graphical client for Windows — pure
Rust, the same `punktfunk/1` core as the Apple, Linux, and Android apps, with a **WinUI 3** UI (host
list, settings, PIN pairing); the stream itself runs in Punktfunk's Vulkan presenter. Its decoder
order is per-vendor: **Vulkan Video, then D3D11VA, then software** on NVIDIA and AMD, and
**D3D11VA first** on Intel and other GPUs (Intel's driver advertises Vulkan Video, but DXVA is the
proven path there), with [10-bit/HDR present](/docs/hdr#per-client), WASAPI audio + mic,
SDL3 controllers (rumble, lightbar, DualSense), network discovery, the host's **game library** with
cover art, and the full PIN-pairing trust surface. It builds for both `x86_64` and `aarch64` and
ships as a **signed MSIX**. Launch it and pick a host from the list, just like the other native apps.
`punktfunk-client` for Windows (`clients/windows`) is the native graphical client — pure Rust, the
same `punktfunk/1` core as the Apple, Linux, and Android apps, with a **WinUI 3** UI (host list,
settings, PIN pairing); the stream itself runs in Punktfunk's Vulkan presenter. Decoder order is
per-vendor: **Vulkan Video, then D3D11VA, then software** on NVIDIA and AMD, and **D3D11VA first**
on Intel and other GPUs (Intel's driver advertises Vulkan Video, but DXVA is the proven path there).
It has [10-bit/HDR present](/docs/hdr#per-client), WASAPI audio + mic, SDL3 controllers (rumble,
lightbar, DualSense), network discovery, the host's **game library** with cover art, and the full
PIN-pairing trust surface. It builds for `x86_64` and `aarch64` and ships as a **signed installer** (plus a portable zip, and an MSIX for Microsoft Store compatibility).
The package installs **two** Start-menu entries — **Punktfunk**, the desktop window, and
**Punktfunk Console**, a controller-driven fullscreen interface for a TV or HTPC (host list, pairing,
@@ -130,7 +125,7 @@ settings and game library, all navigable with a pad) — plus the headless
> is a proven alternative for Windows.
For scripting, prefer the [`punktfunk` CLI](#scripting-the-punktfunk-cli). The window binary's own
headless flags stay supported too:
headless flags stay supported:
```sh
punktfunk-client # open the WinUI 3 window (host list / settings)
@@ -138,12 +133,10 @@ punktfunk-client --discover # list hosts on the
punktfunk-client --headless --speed-test --connect <host>:9777 # no window: probe the link, print measured/recommended bitrate
```
Prefer the broadest compatibility, or no install? **Moonlight** also streams to Windows (see below).
## webOS (LG TV) — community
[`pf-webos`](https://github.com/dyptan-io/pf-webos) is a native client for LG webOS TVs, built and
maintained by the community ([dyptan-io](https://github.com/dyptan-io)) on top of Punktfunk's
maintained by the community ([dyptan-io](https://github.com/dyptan-io)) on Punktfunk's
`punktfunk/1` protocol and core. It's not an official Punktfunk app, but it speaks the real protocol
directly (not Moonlight/GameStream) — LAN discovery or add-by-IP, PIN pairing with pinned reconnects,
hardware video decode via webOS's NDL DirectMedia API, and a browsable game library with cover art,
@@ -155,7 +148,7 @@ It ships as a sideloadable `.ipk` (homebrew package) rather than through the LG
## Scripting: the `punktfunk` CLI
`punktfunk` is the headless client — the same core the graphical apps use, with no window. It ships
in **every Linux client package** (apt, dnf, pacman and the Flatpak) and in the **Windows MSIX**, so
in **every Linux client package** (apt, dnf, pacman and the Flatpak) and in the **Windows installer**, so
if you have a desktop client you already have it:
```sh
@@ -178,21 +171,21 @@ named, **6** it needs a person (pairing, or an unknown host).
Under the Flatpak, run it as `flatpak run --command=punktfunk io.unom.Punktfunk <args>`.
> The older headless flags stay supported for existing scripts — `punktfunk-client --connect`,
> `--discover` and `--headless --speed-test` on both Linux and Windows. `punktfunk` is the surface
> to build new things on: it wakes a sleeping host before connecting, which those never did.
> The older `punktfunk-client --connect`, `--discover` and `--headless --speed-test` flags stay
> supported on Linux and Windows for existing scripts, but build new things on `punktfunk`: it wakes
> a sleeping host before connecting, which those never did.
>
> `punktfunk-probe` is a different thing again — an in-repo protocol test and latency-measurement
> tool for development. It isn't shipped in any package; you build it from source.
> `punktfunk-probe` is different again — an in-repo protocol test and latency-measurement tool for
> development, not shipped in any package; you build it from source.
## Profiles and links (every app)
Two things work the same in the Apple, Linux, Windows and Android apps. **Settings profiles** are
named sets of stream overrides — bitrate, resolution, codec, HDR and the rest — that you bind to a
host or pick for a single connect, with every field you didn't touch still following your defaults.
And a **`punktfunk://` link** starts a stream from a browser, a desktop shortcut, a home-automation
rule or `punktfunk open`, carrying only *references* to things that already exist on your device —
never a setting, and never a trust decision.
host or pick for a single connect; every field you didn't touch still follows your defaults. A
**`punktfunk://` link** starts a stream from a browser, a desktop shortcut, a home-automation rule
or `punktfunk open`, carrying only *references* to things that already exist on your device — never
a setting, never a trust decision.
[Profiles and links](/docs/profiles-and-links) has both in full: the link grammar, where each app
puts **Copy link** and **Create shortcut…**, and what a link is refused for. From a script,
@@ -203,14 +196,14 @@ puts **Copy link** and **Create shortcut…**, and what a link is refused for. F
Click the stream and the desktop clients **capture** your keyboard and mouse — everything goes to
the host until you let go. **Ctrl+Alt+Shift+Q** (⌃⌥⇧Q or ⌘⎋ on a Mac) gives it back.
That chord, the three others a stream reserves, the controller chord that works with no keyboard in
reach, which app honours which of them, the two mouse modes, the three touch modes and stylus input
are all on [Mouse, touch and pen](/docs/input#getting-your-input-back).
That chord, the three others a stream reserves, the controller chord that needs no keyboard, which
app honours which, the two mouse modes, the three touch modes and stylus input are all on
[Mouse, touch and pen](/docs/input#getting-your-input-back).
Copying between the two machines is a separate opt-in: the host operator allows it in `host.env`
and you turn it on for that one host in your client. Content crosses today from the macOS, iOS,
iPadOS, Windows and Android apps — the Linux client has the switch but no bridge behind it yet, and
tvOS has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
and you turn it on per host in your client. Content crosses today from the macOS, iOS, iPadOS,
Windows and Android apps — the Linux client has the switch but no bridge behind it yet, and tvOS
has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
## Which should I use?
@@ -220,11 +213,10 @@ tvOS has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
| A Linux desktop or laptop | **[`punktfunk-client`](#linux-desktop-client-gtk4)** (GTK4) |
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the [GTK4 client](#linux-desktop-client-gtk4) in Desktop Mode |
| An Android phone or TV | The **[Android app](#android-app-phone--android-tv)** |
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed MSIX) or **[Moonlight](/docs/moonlight)** |
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed installer) or **[Moonlight](/docs/moonlight)** |
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **[Moonlight](/docs/moonlight)** |
| A browser, another smart TV, or any other device | **[Moonlight](/docs/moonlight)** |
| Scripts, plugins, home automation | The headless **[`punktfunk`](#scripting-the-punktfunk-cli)** CLI |
| Protocol development / latency measurement | **`punktfunk-probe`** (source build only) |
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing), and
[Install a Client](/docs/install-client) covers installing, updating and removing it.
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing).
+36 -40
View File
@@ -4,17 +4,16 @@ description: Copy on one machine and paste on the other — the two switches tha
---
Punktfunk can share the clipboard between the machine you are sitting at and the host you are
streaming. Copy a URL on your laptop, paste it into a browser on the host. Copy an error message on
the host, paste it into a chat app on your laptop.
streaming, in both directions — copy a URL on your laptop, paste it on the host, and back.
**Two separate switches have to be on:**
1. The **host** operator has to allow it, with a line in `host.env` and a host restart. This one is
off by default.
2. **You** have to turn it on for that one host, in that host's edit sheet on your client. This one
is off by default on the macOS, Windows and Linux clients — but **on by default on Android**.
1. The **host** operator has to allow it, with a line in `host.env` and a host restart. Off by
default.
2. **You** have to turn it on for that one host, in that host's edit sheet on your client. Off by
default on the macOS, Windows and Linux clients — **on by default on Android**.
Flipping one and not the other looks exactly like the feature not existing. So check both.
Flipping one and not the other looks exactly like the feature not existing. Check both.
## 1. Allow it on the host
@@ -52,14 +51,14 @@ punktfunk-host service restart
See [Configuration](/docs/configuration) for the rest of `host.env`.
> **About the file mode.** No client shipping today asks for file transfer, and no host clipboard
> backend offers file formats yet. `on` and `text-only` therefore behave the same in practice —
> `text-only` is how you make that explicit and keep it that way.
> backend offers file formats yet, so `on` and `text-only` behave the same in practice — `text-only`
> makes that explicit and keeps it that way.
## 2. Turn it on for that host, in your client
The client switch is **per saved host**, not global: handing a machine your clipboard is a decision
about *that* machine. You set it in the host's edit sheet, and it is deliberately not something a
[settings profile can carry](/docs/profiles-and-links#what-a-profile-cant-change).
The client switch is **per saved host**, not global handing a machine your clipboard is a decision
about *that* machine — so it lives in the host's edit sheet, deliberately not in a
[settings profile](/docs/profiles-and-links#what-a-profile-cant-change).
| Client | Where the switch is | Label | Default |
|---|---|---|---|
@@ -69,39 +68,38 @@ about *that* machine. You set it in the host's edit sheet, and it is deliberatel
| Linux (GTK) | Host card menu → **Edit…** | **Share clipboard** | Off |
| Android (touch) | Host card menu → **Edit…** | **Shared clipboard** | **On** |
On Android the switch is only in the touch edit dialog. The controller/TV interface — what you get
on Android TV, and on a phone when a controller is attached — has its own **Edit Host** screen with
no clipboard row, so there is nowhere to change it there. It stays on, which is the Android default.
On Android the switch is only in the touch edit dialog. The controller/TV interface — Android TV,
and a phone with a controller attached — has its own **Edit Host** screen with no clipboard row, so
it stays on, the Android default.
The setting is read when a session starts, so if you change it while streaming, reconnect.
macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop
Sharing Clipboard** once the host has acknowledged it. On an iPad with a hardware keyboard the same
combo works, though there is no menu bar to show it in — and only while the pointer is released, as
a captured session sends the keys to the host instead.
a captured session sends the keys to the host.
tvOS and a Steam Deck in Gaming Mode have no clipboard switch — the Apple TV has no pasteboard to
share at all, and neither the Decky panel nor the client's console home has a host edit sheet — see
share, and neither the Decky panel nor the client's console home has a host edit sheet — see
[what each client does](#which-hosts-and-clients-support-it) below.
## Nothing crosses until something pastes
A copy costs nothing. When you copy, your machine announces only the **list of formats** it now
holds — no bytes. The bytes are pulled across on a separate transfer, and only when an application
on the other end actually pastes. Copying a large image and never pasting it transfers nothing.
holds — no bytes. The bytes are pulled across on a separate transfer, only when an application on
the other end actually pastes. Copying a large image and never pasting it transfers nothing.
That holds for everything you copy on your own machine, and for both directions on the host. It
does **not** hold for a host copy arriving at a Windows or Android client: those two fetch the
content straight away and put it on your local clipboard, whether or not you ever paste. On Windows
that is because the lazy path needs Windows delayed rendering, which the client doesn't implement
yet; on Android there is no way to satisfy a paste from the network at all. The macOS and iOS
clients are lazy in both directions.
content straight away and put it on your local clipboard, whether or not you ever paste — on
Windows because the lazy path needs Windows delayed rendering, which the client doesn't implement
yet; on Android because there is no way to satisfy a paste from the network at all. The macOS and
iOS clients are lazy in both directions.
On iOS there is one deliberate exception. Backgrounding the app ends the session, and a promise
nobody can answer is worse than no promise at all — so if the host copied something and you have not
pasted it yet, those bytes are pulled across as the session ends, up to 8 MiB. That is what makes
"copy on the host, switch to Safari, paste" work on an iPad. Nothing is fetched if you never leave
the app, or if you already pasted.
iOS has one deliberate exception. Backgrounding the app ends the session, so if the host copied
something you have not pasted yet, those bytes are pulled across as the session ends, up to 8 MiB. That is what makes "copy on the host,
switch to Safari, paste" work on an iPad. Nothing is fetched if you never leave the app, or if you
already pasted.
A single transfer is capped at 64 MiB. Nothing else limits size, so a very large host-side copy can
cross to a Windows or Android client for a paste that never happens.
@@ -119,9 +117,7 @@ from a Punktfunk client. A Moonlight client has no clipboard.
## Which hosts and clients support it
**Hosts.** The host runs on Linux and Windows, and both have a clipboard backend — but on Linux it
depends on the desktop session.
On Linux the host needs one of two mechanisms in the session it is streaming:
depends on the desktop session, which needs one of two mechanisms:
- `ext-data-control-v1` — KWin, wlroots/Sway and Hyprland. Tried first.
- GNOME's own `org.gnome.Mutter.RemoteDesktop.Session` clipboard, used directly. Tried second.
@@ -154,8 +150,8 @@ registered `PNG` clipboard format. Many Windows apps publish only a bitmap, and
announced yet. The other direction is fine: an image copied on the host reaches the Windows client
either way.
The host side is richer than any client: it can offer and accept text, HTML, RTF, PNG, JPEG and
GIF. What you get is therefore whatever your client supports.
The host side is richer than any client: it can offer and accept text, HTML, RTF, PNG, JPEG and GIF.
What you get is whatever your client supports.
## Why the toggle does nothing (or is greyed out)
@@ -164,18 +160,18 @@ connected host did not advertise a clipboard. On the other clients there is noth
the per-host switch always looks available, and a host that can't do it simply does nothing. Work
through these in order:
- **The host has it off.** The default. Nothing was added to `host.env`, or the value is `off`,
`0`, `false` or empty. Fix it with step 1 above.
- **The host has it off.** The default. Nothing was added to `host.env`, or the value is `off`, `0`,
`false` or empty. Fix it with step 1 above.
- **`host.env` was edited but the host wasn't restarted.** The file is read once, at startup.
- **The switch is off for this host in your client.** It is per saved host, and off by default
everywhere except Android. Check the host's **Edit…** sheet — step 2 above.
- **The host's session has no supported backend.** The host allows the clipboard, so it still
advertises the capability, but it has nothing to read the desktop's clipboard with. This is a
gamescope session, a compositor with only the old `zwlr-data-control-unstable-v1`, or a GNOME
session whose Mutter doesn't expose the direct RemoteDesktop clipboard. Nothing on screen tells
you this apart — the host log does.
advertises the capability, but has nothing to read the desktop's clipboard with: a gamescope
session, a compositor with only the old `zwlr-data-control-unstable-v1`, or a GNOME session whose
Mutter doesn't expose the direct RemoteDesktop clipboard. Nothing on screen tells you this apart —
the host log does.
- **The host is older than the feature.** A host from before clipboard sync never advertises it.
- **Your client doesn't implement it** — Linux, Steam Deck, iOS, iPadOS or tvOS. Nothing crosses
- **Your client doesn't implement it** — Linux (GTK), Steam Deck or tvOS. Nothing crosses
regardless of what the host allows.
- **You changed the switch while connected.** Reconnect, or use ⌃⌥⇧C on macOS.
- **The copy was a secret, or a format nobody handles.** Concealed content is skipped on purpose on
+77 -85
View File
@@ -3,50 +3,46 @@ title: Controller speaker and haptics
description: DualSense voice-coil haptics and the pad's built-in speaker, streamed from the host to the controller in your hands — what to enable, and what "set it to Pro Audio" means on a Linux host.
---
A DualSense is partly an audio device. Its little speaker and its two voice-coil motors — the
actuators that make a PS5 pad feel like sand, rain or a bowstring instead of a buzzing phone —
are all driven by a four-channel audio stream, not by rumble commands. Games that support them
write PCM into "the controller's audio device".
A DualSense is partly an audio device. Its speaker and its two voice-coil motors are driven by a
four-channel audio stream, not by rumble commands. Games that support them write PCM into "the
controller's audio device".
Punktfunk gives that device to the game on the host, captures what the game writes, and streams
it to the controller physically in your hands, on its own low-latency plane. Channels 12 are the
pad's speaker, channels 34 are the voice coils.
Punktfunk gives that device to the game on the host, captures what the game writes, and streams it
to the controller in your hands on its own low-latency plane. Channels 12 are the pad's speaker,
channels 34 the voice coils.
## What you need
- **A DualSense or DualSense Edge plugged in over USB** on the client. Bluetooth pads expose no
audio interface at all, so they fall back to ordinary rumble — this is a limit of the
controller, not of Punktfunk.
audio interface, so they fall back to ordinary rumble — a limit of the controller, not of Punktfunk.
- On the client, **Controller haptics** is on by default. So is **Controller speaker** on the Linux
and Windows apps — turn it off in [client settings](/docs/client-settings#input) if you would
rather all game audio came out of your speakers. On Android the speaker is opt-in.
- On a **Linux host**, a game that speaks DualSense — which in practice means running it under
**GE-Proton 11-5 or newer**. Stock Proton does not route controller audio.
and Windows apps — turn it off in [client settings](/docs/client-settings#input) if you'd rather
all game audio came out of your speakers. On Android the speaker is opt-in.
- On a **Linux host**, a game that speaks DualSense — in practice, running under **GE-Proton 11-5 or
newer**. Stock Proton does not route controller audio.
- On the host, controller audio is on by default (`PUNKTFUNK_PAD_AUDIO`).
Nothing is sent while the pad is quiet, so leaving it on costs nothing.
## "Set the controller audio to Pro Audio" — you don't have to
If you have looked into DualSense haptics on Linux before, you have probably run into this
advice: plug the pad into the Linux box, open your sound settings, find *DualSense wireless
controller (PS5)*, and switch its **Profile** to **Pro Audio**. That advice is real and it is
correct — for a pad plugged directly into the host.
The usual advice for DualSense haptics on Linux — plug the pad into the Linux box, open your sound
settings, find *DualSense wireless controller (PS5)*, switch its **Profile** to **Pro Audio** — is
correct for a pad plugged directly into the host.
The reason is channel layout. A pad's other profiles present it as a mono speaker, a stereo
headphone jack, or a positioned four-channel "surround" device. Games write their haptics as four
*unpositioned* channels, so on any of those profiles the audio system helpfully re-mixes them into
the speaker pair and the voice-coil channels are folded away. You feel nothing. Pro Audio is the
one profile that hands the four channels through untouched, in order.
*unpositioned* channels, so on any of those profiles the audio system re-mixes them into the speaker
pair and the voice-coil channels are folded away. Pro Audio is the one profile that hands the four
channels through untouched, in order.
**Punktfunk's controller audio device is already in that shape.** It is created as four raw
channels with no re-mixing, which is exactly what Pro Audio produces — so there is nothing to
switch, and no switch to make.
**Punktfunk's controller audio device is already in that shape** — four raw channels with no
re-mixing, exactly what Pro Audio produces — so there is nothing to switch.
That is also why it looks different in your sound settings. A real pad is a USB sound card, so it
gets a **Profile** dropdown; Punktfunk's is a software device, so it has no card and no dropdown.
Seeing **Wireless Controller** with a volume slider and no profile selector is what a correctly
minted controller-audio device looks like. It is not a sign that something is missing.
gets a **Profile** dropdown; Punktfunk's is a software device with no card and no dropdown. Seeing
**Wireless Controller** with a volume slider and no profile selector is what a correctly minted
controller-audio device looks like, not a sign that something is missing.
## Checking it is working
@@ -68,9 +64,9 @@ When a game actually starts driving the actuators, the pad's own driver reports
DS5 title asserted haptics-select (audio haptics) pad=0
```
That last line is the one that matters: it means a title recognised the controller as an audio
device and switched the pad out of plain rumble. If you see it and still feel nothing, the problem
is downstream — on the client or the pad. If you never see it, the game never found the device.
That last line is the one that matters: a title recognised the controller as an audio device and
switched the pad out of plain rumble. If you see it and still feel nothing, the problem is
downstream — on the client or the pad. If you never see it, the game never found the device.
You can also look at the device directly:
@@ -78,22 +74,22 @@ You can also look at the device directly:
pactl list sinks | grep -A25 Speaker__sink
```
The line to check is `audio.position = "AUX0,AUX1,AUX2,AUX3"` — four unpositioned channels is the
layout that reaches the voice coils. Anything positioned (`FL,FR,RL,RR`) would not.
Check for `audio.position = "AUX0,AUX1,AUX2,AUX3"` — four unpositioned channels is the layout that
reaches the voice coils. Anything positioned (`FL,FR,RL,RR`) would not.
## If a game does not find it
Games identify the controller's audio device by name and by USB ids, and different titles check
different things. GE-Proton has several routes to the pad, and a couple of them are opt-in per
game. Add these as launch options if a title is not cooperating:
different things. GE-Proton has several routes to the pad, a couple of them opt-in per game. Add
these as launch options if a title is not cooperating:
```
PROTON_DUALSENSE_HAPTICS_PREFER_NON_EVENT=1 %command%
```
This forces GE onto its most direct route — it opens Punktfunk's controller-audio device by name
and writes the four channels straight into it, with no re-mixing anywhere in between. It is the
first thing to try.
This forces GE onto its most direct route — it opens Punktfunk's controller-audio device by name and
writes the four channels straight into it, with no re-mixing in between. It is the first thing to
try.
Some titles additionally want:
@@ -112,55 +108,52 @@ To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for
## On a Linux client, the pad's own profile matters too
Everything above is about the host, where the controller-audio device is one Punktfunk mints. On a
Linux **client** the pad is real, and the same channel-layout problem shows up from the other side:
the voice coils are physically channels 3 and 4 of the controller's USB sound card, and a
controller almost never presents as a four-channel device on its own. Depending on your distribution
it appears as a stereo output, or as a mono *Speaker* plus a stereo *Headphones* pair. Playing into
any of those puts the haptics in the headphone jack and folds the coil channels away — audio that
looks perfectly healthy, felt as nothing at all.
On a Linux **client** the pad is real, and the same channel-layout problem shows up from the other
side: the voice coils are physically channels 3 and 4 of the controller's USB sound card, and a
controller almost never presents as a four-channel device on its own. Depending on your
distribution it appears as a stereo output, or as a mono *Speaker* plus a stereo *Headphones* pair.
Playing into any of those puts the haptics in the headphone jack and folds the coil channels away —
audio that looks healthy, felt as nothing.
**Punktfunk handles this for you.** When it needs the coils and the pad is not already presenting
four channels, it switches the controller's card to **Pro Audio** for the length of the session and
puts your setting back afterwards. You will see the profile change in your sound settings while you
are streaming; that is expected. It is never saved as the card's remembered profile.
puts your setting back afterwards. You will see the profile change in your sound settings while
streaming; that is expected. It is never saved as the card's remembered profile.
If you would rather manage the card yourself, set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` on the client. Then
Punktfunk uses a four-channel profile if you have already selected one and logs what it needs if you
have not.
Many systems never reach the switch at all. On **SteamOS** a DualSense already exposes its four
channels behind a combined speaker-and-haptics output, and Punktfunk finds them there. That is a
Valve addition, though, not something every up-to-date system has: `alsa-ucm-conf` upstream — and
so Fedora, Bazzite and Arch — describes the pad as a *mono speaker plus stereo headphones* and
nothing else, which is precisely the shape that folds the coils away. The switch is the fallback
for those. **If you run the client as a Flatpak**, your audio manager may not let a sandboxed app
change a card's profile; if the log says so, switch the controller to Pro Audio yourself, which is
the same fix.
Many systems never reach the switch. On **SteamOS** a DualSense already exposes its four channels
behind a combined speaker-and-haptics output, and Punktfunk finds them there. That is a Valve
addition, not something every up-to-date system has: `alsa-ucm-conf` upstream — and so Fedora,
Bazzite and Arch — describes the pad as a *mono speaker plus stereo headphones* and nothing else,
precisely the shape that folds the coils away. The switch is the fallback for those. **If you run
the client as a Flatpak**, your audio manager may not let a sandboxed app change a card's profile; if
the log says so, switch the controller to Pro Audio yourself, which is the same fix.
Punktfunk's **host** packages (rpm, deb, Arch, and the Bazzite sysext) close that gap at the
source: they install a small ALSA profile for the DualSense that adds the combined
speaker-and-haptics output SteamOS has, and give it priority over the mono one. It adds files
rather than replacing any your distribution owns, so it upgrades cleanly and can be removed by
uninstalling Punktfunk. A pad plugged into the host then presents four channels on its own, with
no profile switching by anyone — and, because the lone mono output stops existing, games that
crashed when they opened it stop crashing. A card reads its profile once, when it appears, so
replug the pad after installing (or restart PipeWire) rather than expecting a pad that was
already plugged in to pick it up.
Punktfunk's **host** packages (rpm, deb, Arch, and the Bazzite sysext) close that gap at the source:
they install a small ALSA profile for the DualSense that adds the combined speaker-and-haptics
output SteamOS has, and give it priority over the mono one. It adds files rather than replacing any
your distribution owns, so it upgrades cleanly and can be removed by uninstalling Punktfunk. A pad
plugged into the host then presents four channels on its own, with no profile switching by anyone —
and, because the lone mono output stops existing, games that crashed when they opened it stop
crashing. A card reads its profile once, when it appears, so replug the pad after installing (or
restart PipeWire) rather than expecting an already-plugged pad to pick it up.
### Checking the client side without a host
The client can test the whole path on its own — no host, no game, no pairing. Plug in the
DualSense and run:
The client can test the whole path on its own — no host, no game, no pairing. Plug in the DualSense
and run:
```sh
punktfunk-session --pad-audio-test
```
It prints every DualSense object it can see in your audio graph, says which one it chose, and then
plays a tone into the voice coils for three seconds. **If the pad buzzes, the client side is
working** and any remaining silence is coming from the host or the game. Add `--speaker` to test
the pad's speaker instead, and `--seconds N` for a longer run.
It prints every DualSense object it can see in your audio graph, says which one it chose, and plays
a tone into the voice coils for three seconds. **If the pad buzzes, the client side is working** and
any remaining silence is coming from the host or the game. Add `--speaker` to test the pad's speaker
instead, and `--seconds N` for a longer run.
On the Steam Deck and other flatpak installs, run it inside the sandbox:
@@ -172,30 +165,29 @@ flatpak run --command=punktfunk-session io.unom.Punktfunk --pad-audio-test
The controller's speaker and its headphone jack **share a channel**. Channel 1 of the pad's audio
device is the headphone jack's right channel *and* the built-in speaker, and the controller decides
which one actually sounds. It powers up pointing at the jack — so with nothing plugged in, a
perfectly routed speaker stream is heard by nobody.
which one sounds. It powers up pointing at the jack — so with nothing plugged in, a perfectly routed
speaker stream is heard by nobody.
Punktfunk points the pad at its own speaker when **Controller speaker** is on. The voice coils are
different channels and are not affected by that choice, which is why haptics work as soon as the
audio is routed correctly and the speaker needs this extra step. A game that drives the pad's audio
settings itself still overrides it. If your pad's speaker stays quiet, `PUNKTFUNK_PAD_SPEAKER_PATH`
and `PUNKTFUNK_PAD_SPEAKER_VOLUME` let you bisect it without a rebuild.
different channels and unaffected by that choice. A game that drives the pad's audio settings
itself still overrides it. If your pad's speaker stays quiet, `PUNKTFUNK_PAD_SPEAKER_PATH` and
`PUNKTFUNK_PAD_SPEAKER_VOLUME` let you bisect it without a rebuild.
## Known limits
- **Bluetooth client pads get rumble, not haptics.** No audio interface exists over BT.
- **Titles that match the controller by container ID** — a Windows notion of "these devices are
the same physical thing" — will not recognise the pairing on a Linux host, because the virtual
pad has no USB device behind it to derive one from. Titles that match by name or by USB ids are
unaffected, which is most of them.
- **Titles that match the controller by container ID** — a Windows notion of "these devices are the
same physical thing" — will not recognise the pairing on a Linux host, because the virtual pad has
no USB device behind it to derive one from. Titles that match by name or by USB ids — most of them
— are unaffected.
- **A pad plugged into the host itself can steal the audio.** If a real DualSense is connected to
the host while you are streaming to a different one, some titles will find the local pad's sound
card first. Unplug it, or stream from a host that has no pad attached.
the host while you stream to a different one, some titles find the local pad's sound card first.
Unplug it, or stream from a host that has no pad attached.
- **The Pro Audio switch on a Linux client renames the pad's microphone too.** Switching a sound
card's profile re-creates all of its inputs and outputs, so if you had picked the DualSense's own
microphone as your [mic](/docs/client-settings#audio), that session falls back to your default
one. Pick a different microphone, or set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` and select a
four-channel profile on the card yourself.
- **A client killed mid-stream leaves the pad on Pro Audio.** The profile is restored when a
session ends normally and is never written to your saved settings, so anything that reloads the
card — unplugging it, logging out, a reboot — brings your own profile back.
one. Pick a different microphone, or set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` and select a four-channel
profile on the card yourself.
- **A client killed mid-stream leaves the pad on Pro Audio.** The profile is restored when a session
ends normally and is never written to your saved settings, so anything that reloads the card —
unplugging it, logging out, a reboot — brings your own profile back.
-227
View File
@@ -1,227 +0,0 @@
---
title: Debian
description: Install the Punktfunk host on Debian 13 with apt — including LMDE and Linux Mint.
---
Install a Punktfunk host on **Debian 13 ("trixie") or newer** from the apt registry. This page
covers the distro-level setup — GPU driver, package, gamepad access. How the host creates its
virtual display and injects input is desktop-specific, so pick your desktop on the
[configure pages](#configure-your-desktop) afterward rather than here.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
> **Which releases.** The host package needs **glibc 2.39 or newer**; Debian 13 has 2.41, so it
> installs and runs there. **Debian 12 (bookworm) has glibc 2.36 and cannot install it** — build
> from source ([Ubuntu appendix](/docs/ubuntu#appendix--build-from-source), which applies here too)
> or upgrade. Check yours with `ldd --version`.
> **The desktop client is not packaged for Debian yet.** `punktfunk-client` is built on Ubuntu 26.04
> and floors at `libc6 >= 2.43` (Debian 13 has 2.41), on top of needing GTK4 ≥ 4.20. On a Debian
> box, stream *to* it with a [different client](/docs/install-client) — the Flatpak, or a build from
> source. The **host**, the **web console** and the **plugin runner** all install normally.
## What works on Debian 13
| Package | Debian 13 | What it is |
|---|---|---|
| `punktfunk-host` | ✅ | The streaming host |
| `punktfunk-web` | ✅ | The browser management console |
| `punktfunk-scripting` | ✅ | The plugin/script runner |
| `punktfunk-gamescope` | ✅ | The patched gamescope (HDR + cursor + real refresh) |
| `punktfunk-client` | ❌ | Desktop client — `libc6 >= 2.43`, see above |
## 1. GPU driver
On **NVIDIA**, the driver lives in Debian's `contrib` / `non-free` / `non-free-firmware`
components, which a default install does not enable. Debian 13 keeps its sources in the deb822
format, so add them there and refresh:
```sh
sudo sed -i 's/^Components: .*/Components: main contrib non-free non-free-firmware/' \
/etc/apt/sources.list.d/debian.sources
sudo apt update
sudo apt install nvidia-driver firmware-misc-nonfree
```
Debian 13 ships driver 550, comfortably above the [535 floor](/docs/requirements).
Reboot, then confirm the driver and KMS modeset — Wayland on NVIDIA needs `modeset=1`:
```sh
nvidia-smi
cat /sys/module/nvidia_drm/parameters/modeset # should print Y
```
If modeset is not `Y`:
```sh
echo 'options nvidia-drm modeset=1' | sudo tee /etc/modprobe.d/nvidia-drm.conf
sudo update-initramfs -u && sudo reboot
```
> **Secure Boot:** with Secure Boot enabled, Debian's DKMS-built NVIDIA module must be signed and
> its key enrolled before it will load. If `nvidia-smi` can't talk to the driver, enrol the MOK
> (`sudo mokutil --import /var/lib/dkms/mok.pub`, reboot, choose **Enrol MOK**) or disable Secure
> Boot in firmware.
On **AMD/Intel** none of the NVIDIA steps apply. Encode runs on the Mesa stack: **Vulkan Video** for
HEVC and AV1 (`mesa-vulkan-drivers`), with **VAAPI** for H.264 and as the fallback —
`mesa-va-drivers` on AMD, `intel-media-va-driver` on Intel (the latter is in `non-free`).
## 2. Install the host (apt)
The registry is public — no auth needed, just trust its signing key:
```sh
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
| sudo tee /etc/apt/sources.list.d/punktfunk.list
sudo apt update
sudo apt install punktfunk-host
```
`punktfunk-host` `Recommends` the browser console (`punktfunk-web`), so apt pulls it in by default.
The NVIDIA driver is **not** a dependency — you installed it out of band in step 1. Later updates
are `sudo apt update && sudo apt upgrade`; restart the running host afterwards so it picks up the
new binary:
```sh
systemctl --user restart punktfunk-host
```
The `stable` component above is the stable channel. To track pre-release builds instead, see
[Release Channels](/docs/channels).
## 3. Grant gamepad access
Virtual gamepads inject through `/dev/uinput`, gated by the `input` group. Add yourself and re-login:
```sh
sudo usermod -aG input "$USER" # re-login to apply
```
Also join `punktfunk` if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro) —
it reaches games as a real USB device over usbip, which is what makes Steam Input adopt it. Join it
only on a machine you trust: writing the usbip `attach` file can materialise arbitrary emulated USB
hardware.
```sh
sudo usermod -aG punktfunk "$USER" # re-login to apply
```
## 4. Check it installed
```sh
punktfunk-host --version # the binary is on PATH
punktfunk-host detect-conflicts # exits 1 if Sunshine/Apollo is also installed
```
Two hosts on one machine is the most common reason a clean install never streams — see
[Troubleshooting](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
## 5. Open the firewall (if you have one)
**Debian ships no firewall enabled by default**, so out of the box there is nothing to open. If you
run one, the package installs the openers:
```sh
# ufw:
sudo ufw allow punktfunk-native
# firewalld:
sudo firewall-cmd --reload # load the installed definitions
sudo firewall-cmd --permanent --add-service=punktfunk-native
sudo firewall-cmd --reload
```
Add `punktfunk-gamestream` for Moonlight compat and `punktfunk-web` (TCP 47992) to reach the console
from another device. Full port lists are in
[`packaging/debian/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md#firewall).
## Cinnamon, Linux Mint and LMDE
**A Cinnamon desktop cannot host a virtual display, and no setting changes that.** Punktfunk gives
each client its own screen at that device's exact resolution by asking the compositor to create a
virtual output. Cinnamon's compositor, **Muffin**, has no such API: it forked from Mutter 3.36, and
its `org.cinnamon.Muffin.ScreenCast` interface offers only `RecordMonitor` and `RecordWindow`
never the `RecordVirtual` that Mutter gained in 42. Its portal backend
(`xdg-desktop-portal-xapp`) implements no ScreenCast either, so the route that serves Sway and
Hyprland is closed too. This is upstream's to fix, not a Punktfunk setting.
**Which Mint you run decides whether there is any route at all:**
| Edition | Base | Can it host? |
|---|---|---|
| **LMDE 7 "Gigi"** | Debian 13 | ✅ Yes — via gamescope (below) |
| **Linux Mint 22.x** ("Wilma"…"Zena") | Ubuntu 24.04 | ❌ No — see [below](#linux-mint-22x-cannot-host-yet) |
| **Linux Mint 23** | Ubuntu 26.04 | ✅ Expected — due December 2026 |
On **LMDE 7**, what works is **gamescope**: the host starts its own headless gamescope for each
connecting client and runs the game inside it, so it needs no desktop compositor at all. Your
Cinnamon session keeps running untouched; the stream is the game, not the desktop.
```sh
sudo apt install punktfunk-gamescope # LMDE 7 / Debian 13 — not available on Mint 22.x
echo 'PUNKTFUNK_COMPOSITOR=gamescope' >> ~/.config/punktfunk/host.env
systemctl --user restart punktfunk-host
```
The pin is required: auto-detection reads the live session, finds Cinnamon, and stops with an error
rather than guessing. Set a game to launch with
[`PUNKTFUNK_GAMESCOPE_APP`](/docs/gamescope) or per-session launch commands, then see
[Steam / gamescope](/docs/gamescope) for the rest.
> **Install `punktfunk-gamescope`, not Debian's.** Debian ships **no** `gamescope` package at all,
> and the patched build is what gives the stream HDR, a visible cursor, and the client's real
> refresh rate instead of a hardcoded 60 Hz.
If you want to stream the **desktop** from an LMDE box, the answer today is to log into a GNOME or
Sway session instead — Debian 13 ships GNOME 48.7 and sway 1.10, both above the
[floors](/docs/requirements). (Debian 13's KDE is KWin **6.3.6**, below the 6.5.6 floor, so Plasma
is not an option there yet.)
### Linux Mint 22.x cannot host yet
**On Linux Mint 22.x — the current mainstream release, and every version until Mint 23 in December
2026 — there is no working configuration.** `punktfunk-host` will install, which makes this easy to
miss, but nothing on the box can produce a stream:
- **Cinnamon** cannot host a virtual display (above).
- **gamescope is not available and cannot be made available.** Ubuntu 24.04 packages no gamescope,
and the patched `punktfunk-gamescope` cannot run there either: 24.04 is short of what the build
needs on *five* libraries — wayland 1.22.0 (needs ≥ 1.23.1), libinput 1.25 (≥ 1.26), libavif
1.0.4 (≥ 1.2.1), pixman 0.42 (≥ 0.44), and no `libdisplay-info2` or `libxcb-errors0` at all.
- **Switching desktop does not rescue it.** Ubuntu 24.04 ships KWin **5.27** (floor 6.5.6) and GNOME
Shell **46** (floor 48). Only `sway` 1.9 is even a candidate, and that means giving up Cinnamon.
If you want to run a host on Mint hardware today, use **LMDE 7** — it is the same desktop on a
Debian 13 base, where gamescope works. Otherwise wait for **Mint 23** (Ubuntu 26.04 base), where
both the patched gamescope and the newer compositors are available.
## Configure your desktop
How the host creates its virtual display and injects input depends on your desktop, not your distro:
- [KDE Plasma (KWin)](/docs/kde)
- [GNOME (Mutter)](/docs/gnome)
- [Steam / gamescope](/docs/gamescope)
- [Hyprland](/docs/hyprland)
- [Sway / wlroots](/docs/sway)
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect your first
[client](/docs/clients). To run the host at boot — including fully **headless** — see
[Running as a Service](/docs/running-as-a-service).
## Next steps
- **Keep it current** — [Updating the Host](/docs/updating).
- **Remove it again** — [Uninstalling](/docs/uninstall).
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
- **Build from source** (Debian 12, or tracking `main`) — the
[Ubuntu appendix](/docs/ubuntu#appendix--build-from-source) applies unchanged; Debian 13's
`libavcodec-dev` is new enough to build against.
+76
View File
@@ -0,0 +1,76 @@
---
title: Debian
description: Install the Punktfunk host on Debian 13 or newer with apt — including LMDE 7.
---
For **Debian 13 ("trixie") or newer**, and **LMDE 7**. Debian 12 is too old (glibc 2.36, the
package needs 2.39) — [build from source](/docs/build-from-source) there, or upgrade.
> **Desktop matters more than distro here.** Debian 13's GNOME (48) and Sway (1.10) can host; its
> KDE (KWin 6.3) is below the floor, and a Cinnamon desktop (Linux Mint, LMDE) can only host through
> gamescope. [What each desktop can do, and what Linux Mint 22 can't](/docs/requirements#cinnamon-linux-mint-and-lmde).
## 1. GPU driver
- **NVIDIA:** the driver lives in `non-free`, which a default install doesn't enable. Enable it,
install, reboot:
```sh
sudo sed -i 's/^Components: .*/Components: main contrib non-free non-free-firmware/' /etc/apt/sources.list.d/debian.sources
sudo apt update && sudo apt install nvidia-driver firmware-misc-nonfree
```
If `nvidia-smi` can't talk to the driver afterwards, Secure Boot is in the way — see
[Troubleshooting](/docs/troubleshooting#nvidia-smi-says-it-cant-communicate-with-the-driver).
- **AMD / Intel:** nothing to install — Mesa's Vulkan and VAAPI drivers are already there (Intel's
`intel-media-va-driver` is in `non-free`; the host package recommends it).
## 2. Install the host
The repo is public and signed — the `debian` in the URL is the package format, it's the same repo
Ubuntu uses. The browser console, `punktfunk-web`, comes along automatically:
<Install platform="debian" />
Updates ride along with `sudo apt upgrade`; restart the host afterwards
(`systemctl --user restart punktfunk-host`) — or let the [console do it](/docs/updating).
## 3. Let it use your controllers
Join the `input` group (virtual gamepads go through `/dev/uinput`), then **log out and back in**:
```sh
sudo usermod -aG input "$USER"
```
Want the **virtual Steam Deck controller** (paddles, trackpads, gyro)? Also join the `punktfunk`
group — [what it gates and why it's separate](/docs/gamescope#nobara-and-other-autologin-display-managers).
## 4. Start it
From a terminal inside your desktop session:
```sh
systemctl --user enable --now punktfunk-host punktfunk-web
```
Debian ships no firewall enabled, so there is nothing to open. If you run one, the package installed
profiles for ufw and firewalld — [Ports & firewall](/docs/ports).
**Cinnamon (LMDE 7):** the host can't stream the Cinnamon desktop itself; it streams games through
gamescope instead. Add `sudo apt install punktfunk-gamescope` and put
`PUNKTFUNK_COMPOSITOR=gamescope` in `~/.config/punktfunk/host.env`, then restart the host —
[Steam / gamescope](/docs/gamescope) takes it from there.
**That's the install.** Continue with the [Quick Start from step 3](/docs/quickstart#3-open-the-web-console)
— open the console, pair a client, stream.
## When you want more
- `punktfunk-host detect-conflicts` tells you if Sunshine or Apollo is also running;
[Troubleshooting](/docs/troubleshooting) starts from the symptom.
- Your desktop's particulars — [GNOME](/docs/gnome), [Sway](/docs/sway), [gamescope](/docs/gamescope).
- Stream with nobody logged in — [Running as a service](/docs/running-as-a-service).
- The Linux **client** isn't packaged for Debian (it needs a newer glibc) — use the
[Flatpak](/docs/install-client#linux-desktop-flatpak) to stream *to* a Debian box.
- Track `main` instead of releases — [Release channels](/docs/channels).
-240
View File
@@ -1,240 +0,0 @@
---
title: Fedora
description: Install the Punktfunk host on Fedora from the RPM registry.
---
Install a Punktfunk host on **Fedora** from the self-hosted RPM registry. The host installs as an
RPM-managed systemd **`--user`** service and updates with `dnf upgrade` like the rest of your
system — no building required. It works with either **KDE Plasma** or **GNOME**; the
desktop-specific setup (which compositor captures, headless sessions, quirks) lives on the
[desktop configure pages](#5-configure-your-desktop). Host encode is **NVENC on NVIDIA**; on
**AMD/Intel** HEVC and AV1 go through **Vulkan Video**, with **VAAPI** for H.264 and as the fallback
(`PUNKTFUNK_ENCODER=auto` picks per GPU).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
Install is two parts: **GPU driver****host RPM**. Then open the firewall and point the host at
your desktop from the [desktop configure pages](#5-configure-your-desktop).
## 1. NVIDIA driver (RPM Fusion akmod)
Enable RPM Fusion (free + nonfree), then install the akmod driver + CUDA. RPM Fusion's nonfree
NVIDIA repo is sometimes pre-enabled on some spins; the full free/nonfree repos below are still
needed (they carry the NVENC ffmpeg in the next step).
```sh
sudo dnf install \
https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \
https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm
sudo dnf install akmod-nvidia xorg-x11-drv-nvidia-cuda
```
**NVENC ffmpeg.** Fedora ships `ffmpeg-free`, which is built **without** NVENC — the host can't
encode with it. Swap to RPM Fusion's ffmpeg:
```sh
sudo dnf install --allowerasing ffmpeg ffmpeg-libs
ffmpeg -hide_banner -encoders | grep nvenc # expect hevc_nvenc / av1_nvenc / h264_nvenc
```
**Secure Boot.** If `mokutil --sb-state` says *enabled*, the akmod module is signed with a
locally-generated key that must be enrolled once:
```sh
sudo akmods --force # build + sign the module
sudo mokutil --import /etc/pki/akmods/certs/public_key.der # set a one-time password
sudo reboot
```
On the next boot a blue **MOK Manager** screen appears **on the machine's console** (not over
SSH): *Enroll MOK → Continue → Yes → (the password) → Reboot*. Then verify:
```sh
nvidia-smi # driver loads
ffmpeg -hide_banner -encoders | grep nvenc
```
(Or disable Secure Boot in firmware to skip the MOK step — fine for a dedicated test box.)
**AMD / Intel.** No akmod needed — the Mesa stack carries both encode paths. HEVC and AV1 go through
**Vulkan Video** by default (the Mesa Vulkan driver, present on any normal Fedora desktop), and
**VAAPI** is the H.264 path and the fallback. Install the freeworld VAAPI drivers for full codec
support (`mesa-va-drivers-freeworld` for AMD from RPM Fusion, `intel-media-driver` for Intel); on a
desktop these are usually already present.
## 2. Install the host (RPM)
The host is published to the self-hosted Gitea RPM registry, in a per-release group (an RPM is
soname-coupled to its base, so each Fedora release gets its own group). Pick the one matching your
release — `rpm -E %fedora` prints the number you're on:
- **Fedora 44**`fedora-44`
- **Fedora 43**`bazzite` — that group is a plain Fedora 43 build of the same `punktfunk` package,
so it's the right one for a regular Fedora 43 box too
Put your group in the `baseurl` below, then add the repo and install:
```sh
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
[punktfunk]
name=punktfunk
# The group for your release: fedora-44 on Fedora 44, bazzite on Fedora 43.
baseurl=https://git.unom.io/api/packages/unom/rpm/fedora-44
enabled=1
# Packages are GPG-signed (gpgcheck=1) AND the repo metadata is Gitea-signed (repo_gpgcheck=1).
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
REPO
sudo dnf install punktfunk
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller**
(paddles, trackpads, gyro — it reaches games as a real USB pad, which is why Steam Input adopts
it), or this box autologins into Steam **Gaming Mode** (Nobara and friends) and you want the host
to take that session over at the client's resolution:
```sh
sudo usermod -aG punktfunk "$USER" # usbip/vhci + display-manager takeover (re-login to apply)
```
That is a second group on purpose: it grants write access to the usbip `attach` file, which
materialises an arbitrary emulated USB device, so it stays off the `input` group everyone is
routinely told to join. Join it only on a machine you trust. Skip it on a plain desktop host and
the pad simply arrives as an ordinary Xbox 360 controller; skip it on a Gaming Mode box and the
takeover silently degrades to mirroring the box's own screen — see
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
Updates later are just `sudo dnf upgrade punktfunk`, followed by
`systemctl --user restart punktfunk-host` so the running host picks up the new binary. The package
ships the systemd user units, the udev rule, the UDP socket-buffer sysctl tuning, and example
configs.
The group you picked above is the **stable** channel. For the latest `main` build, point `baseurl` at
`fedora-44-canary` (or `bazzite-canary`) instead — see [Release Channels](/docs/channels). Updating
in general, including the opt-in one-click button in the web console, is covered in
[Updating the Host](/docs/updating).
> `fedora-44` and `bazzite` are the only stable groups published, so on Fedora 42 or older — or on a
> release newer than 44 — there's nothing matching yet. Build one with the same toolchain CI uses —
> `docker build --build-arg FEDORA_VERSION=NN -f ci/fedora-rpm.Dockerfile -t pf-rpm ci` then run
> `packaging/rpm/build-rpm.sh` inside it — or build from source (appendix below).
## 3. Check it installed
Before moving on, confirm the binary is there and nothing else is competing for the same job:
```sh
punktfunk-host --version # the binary is on PATH
punktfunk-host detect-conflicts # exits 1 if Sunshine/Apollo is also installed
```
If `detect-conflicts` reports another streaming host, remove it before going further — two hosts on
one machine is the most common reason a clean install never streams. See
[Troubleshooting → another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
Once you've enabled the service on your desktop page below, these are how you watch it:
```sh
systemctl --user status punktfunk-host # active
journalctl --user -u punktfunk-host -f # watch a client connect
```
## 4. Open the firewall
Fedora runs **firewalld** by default and the package never edits your firewall, so the host stays
unreachable until you allow it. The RPM installs the service definitions — enable them once.
The packaged unit runs `serve --gamestream` — the RPM installs it as it ships and only rewrites the
binary path — so a host you enabled with `systemctl --user enable --now punktfunk-host` serves
**both** the native `punktfunk/1` plane and stock [Moonlight](/docs/moonlight) clients, and needs
**both** services:
```sh
sudo firewall-cmd --reload # load the installed definitions
sudo firewall-cmd --permanent --add-service=punktfunk-native
sudo firewall-cmd --reload
```
Enabled **GameStream/Moonlight compat** (`PUNKTFUNK_GAMESTREAM=1` in `host.env` — see
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts))? Then also:
```sh
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload
```
`punktfunk-native` opens UDP 9777 (QUIC control), UDP 5353 (mDNS discovery) and TCP 47990 (the
mgmt/library API — HTTPS + mTLS, read-only off loopback). `punktfunk-gamestream` opens the fixed
Moonlight ports — TCP 47984, 47989 and 48010, UDP 4799848000 — plus the same mDNS. The media
**data plane** uses an ephemeral UDP port the client opens with a hole-punch, so there is nothing
fixed to open for video.
And if you want the web console reachable from another device, open **TCP 47992**:
```sh
sudo firewall-cmd --permanent --add-service=punktfunk-web && sudo firewall-cmd --reload
```
## 5. Configure your desktop
How the host creates its virtual display and injects input depends on your desktop, not your distro.
Continue on the page for the desktop you run — it covers your `host.env`, any compositor quirks, and
starting the host:
- [KDE Plasma (KWin)](/docs/kde)
- [GNOME (Mutter)](/docs/gnome)
- [Steam / gamescope](/docs/gamescope)
- [Hyprland](/docs/hyprland)
- [Sway / wlroots](/docs/sway)
Enable the browser management console (status, paired devices, arm pairing) — see
[Web Console](/docs/web-console).
For a headless KWin appliance that streams at boot with no graphical login, see
[KDE → Headless session](/docs/kde#headless-session).
Full config reference: [Configuration](/docs/configuration). Service model:
[Running as a Service](/docs/running-as-a-service).
## 6. Connect a client
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
the **PIN pairing** — arm it from the host's [web console](/docs/web-console#arm-pairing), which
displays a 4-digit PIN to type into the client. See [Clients](/docs/clients) and
[Pairing](/docs/pairing).
## Next steps
- **Keep it current** — [Updating the Host](/docs/updating).
- **Remove it again** — [Uninstalling](/docs/uninstall).
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
## Appendix — build from source
If there's no RPM for your Fedora release and you don't want to build one, compile the host directly
(no clean updates / no packaged units — you wire those up by hand):
```sh
sudo dnf install gcc gcc-c++ make cmake clang clang-devel nasm git pkgconf-pkg-config \
pipewire-devel wayland-devel wayland-protocols-devel libxkbcommon-devel opus-devel \
libdrm-devel mesa-libgbm-devel mesa-libGL-devel mesa-libEGL-devel mesa-libGLES-devel libva-devel \
ffmpeg-devel libei-devel
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk
cargo build --release --locked \
--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host
```
`mesa-libGL-devel` isn't optional — the zero-copy GPU path links `libGL`, and without it the build
fails at the link step with `cannot find -lGL`. The two `--features` are what the packaged builds
use: leave them off and the host has no direct NVENC (NVIDIA) and no Vulkan Video encode
(AMD/Intel), and quietly falls back to the slower libav backends.
Then write `~/.config/punktfunk/host.env` (as in `/usr/share/punktfunk/host.env.kde`, but the host
binary is `target/release/punktfunk-host`) and run it inside your desktop session — for a headless
KWin appliance see [KDE → Headless session](/docs/kde#headless-session).
+85
View File
@@ -0,0 +1,85 @@
---
title: Fedora
description: Install the Punktfunk host on Fedora 43 or newer from the RPM repo — four steps.
---
For **Fedora 43 or newer** (Workstation or KDE). Bazzite and other Fedora Atomic spins have
[their own page](/docs/bazzite).
## 1. GPU driver
- **NVIDIA:** the driver and an NVENC-capable FFmpeg both come from **RPM Fusion** — Fedora's own
`ffmpeg-free` has no NVENC and the host cannot encode with it. Enable RPM Fusion, install, reboot:
```sh
sudo dnf install \
https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \
https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm
sudo dnf install akmod-nvidia xorg-x11-drv-nvidia-cuda
sudo dnf install --allowerasing ffmpeg ffmpeg-libs
```
With **Secure Boot** on, the module won't load until you enrol its key — `nvidia-smi` will say it
can't talk to the driver; the fix is in
[Troubleshooting](/docs/troubleshooting#nvidia-smi-says-it-cant-communicate-with-the-driver).
- **AMD / Intel:** nothing to install — Mesa carries Vulkan Video and VAAPI. For full codec coverage
add RPM Fusion's `mesa-va-drivers-freeworld` (AMD) or `intel-media-driver` (Intel).
## 2. Install the host
The RPM repo has one group per Fedora release: **`fedora-44`** on Fedora 44, **`bazzite`** on
Fedora 43 (it's a plain Fedora 43 build of the same package). `rpm -E %fedora` prints your number —
set `baseurl` to match, then install. The browser console, `punktfunk-web`, comes along
automatically:
<Install platform="fedora" />
Updates ride along with `sudo dnf upgrade`; restart the host afterwards
(`systemctl --user restart punktfunk-host`) — or let the [console do it](/docs/updating).
## 3. Let it use your controllers
Join the `input` group (virtual gamepads go through `/dev/uinput`), then **log out and back in**:
```sh
sudo usermod -aG input "$USER"
```
Want the **virtual Steam Deck controller** (paddles, trackpads, gyro)? Also join the `punktfunk`
group — [what it gates and why it's separate](/docs/gamescope#nobara-and-other-autologin-display-managers).
## 4. Start it, open the firewall
From a terminal inside your desktop session:
```sh
systemctl --user enable --now punktfunk-host punktfunk-web
```
Fedora runs **firewalld**, and a package never opens ports for you — so until you allow it, no client
can reach the host. The RPM installed the service definitions; enable them once:
```sh
sudo firewall-cmd --reload # load the definitions the package installed
sudo firewall-cmd --permanent --add-service=punktfunk-native --add-service=punktfunk-web
sudo firewall-cmd --reload
```
(`punktfunk-web` is only needed to reach the console from another device. Turning on Moonlight
compat later? Add `punktfunk-gamestream` too — [Ports & firewall](/docs/ports).)
**That's the install.** Continue with the [Quick Start from step 3](/docs/quickstart#3-open-the-web-console)
— open the console, pair a client, stream.
## When you want more
- **No video on NVIDIA?** `ffmpeg-libs` is only a *recommended* dependency — without RPM Fusion's
build, NVENC fails at runtime. Re-run step 1.
[More in Troubleshooting](/docs/troubleshooting#no-video-on-fedora-nvenc-fails-ffmpeg-libs-is-missing).
- `punktfunk-host detect-conflicts` tells you if Sunshine or Apollo is also running;
[Troubleshooting](/docs/troubleshooting) starts from the symptom.
- Your desktop's particulars — [KDE](/docs/kde), [GNOME](/docs/gnome), [gamescope](/docs/gamescope).
- Stream with nobody logged in, or a headless KDE appliance —
[Running as a service](/docs/running-as-a-service), [KDE → Headless session](/docs/kde#headless-session).
- Track `main` instead of releases (`fedora-44-canary` / `bazzite-canary`) —
[Release channels](/docs/channels). No group for your release — [Build from source](/docs/build-from-source).
+53 -54
View File
@@ -3,23 +3,22 @@ title: Your game library
description: How Punktfunk finds your installed games, how to add one by hand, and how to launch a title from a client, from Moonlight, or from the command line.
---
Every Punktfunk host keeps one **game library** — a single list of titles that every surface reads
from. It has two sources: the [plugins](/docs/plugins) you install for the launchers you actually
use, and entries you add by hand in the [web console](/docs/web-console).
A Punktfunk host keeps one **game library** that every surface reads from. It has two sources: the
[plugins](/docs/plugins) you install for the launchers you use, and entries you add by hand in the
[web console](/docs/web-console).
Whichever source a title came from, it looks the same everywhere: a poster, a name, and a stable id
like `steam:570` or `custom:9f2a1c…`. Pick one on a client and the host launches it into the stream.
Whatever its source, a title looks the same everywhere: a poster, a name, and a stable id like
`steam:570` or `custom:9f2a1c…`. Pick one on a client and the host launches it into the stream.
## Where your games come from
**Install a plugin for each launcher you want in the library.** A fresh host holds no games until
you do — go to the console's **Library** page, open **Game sources**, and install the ones you use.
It takes a click each.
you do — on the console's **Library** page, open **Game sources** and install the ones you use, a
click each.
Each plugin reads that launcher's **own local files** on the host. There are no accounts to connect
and no API keys nothing leaves the machine to build the list. A launcher that isn't installed
contributes nothing, so installing a plugin you turn out not to need costs you an empty source and
nothing else.
Each plugin reads that launcher's **own local files** on the host no accounts to connect, no API
keys, nothing leaves the machine to build the list. A launcher that isn't installed contributes
nothing, so an unneeded plugin costs you an empty source and nothing else.
| Plugin | Linux host | Windows host | What it reads |
|---|---|---|---|
@@ -31,31 +30,30 @@ nothing else.
| **Playnite** | — | ✅ | Your Playnite library, whichever stores it aggregates |
| **ROM Manager** | ✅ | ✅ | Your ROM folders, matched against a metadata source |
> Through v0.27.x six of these scanners were built into the host itself and ran whether you wanted
> them or not. From **v0.28.0** they are plugins like any other. If you were already running the
> plugin for a launcher, nothing changes — the ids, art and app ids are identical by design. If you
> were relying on the built-in scanner, install that launcher's plugin once and your grid comes back
> exactly as it was, including anything you had switched off or hidden.
> Through v0.27.x six of these scanners were built into the host and always on; from **v0.28.0**
> they are plugins like any other. Already running the plugin for a launcher? Nothing changes — ids,
> art and app ids are identical by design. Relied on the built-in scanner? Install that launcher's
> plugin once and your grid comes back exactly as it was, including anything you had switched off or
> hidden.
A few things are deliberately left out. Steam's tooling — Proton, the Steam Linux Runtimes, Steamworks
Common Redistributables, SteamVR — is filtered out, so your grid holds games rather than plumbing. A
non-Steam shortcut you have hidden inside Steam stays hidden here too.
Deliberately left out: Steam's tooling — Proton, the Steam Linux Runtimes, Steamworks Common
Redistributables, SteamVR — so your grid holds games rather than plumbing, and any non-Steam
shortcut you have hidden inside Steam.
To see exactly what the host resolved, run [`punktfunk-host library`](/docs/host-cli) on the host: it
prints the whole library as JSON. That answers "does the host see my games?" without involving a
client.
[`punktfunk-host library`](/docs/host-cli), run on the host, prints the whole library as JSON —
"does the host see my games?" without involving a client.
## Turning a source off
The console's **Library** page has a **Game sources** card with one chip per source this host has.
A chip is highlighted when that source is contributing titles; click it to turn the source off.
On the console's **Library** page, the **Game sources** card shows one chip per source this host
has; a highlighted chip is contributing titles, and clicking it turns the source off.
Turning a source off hides its titles from **everywhere at once** — the console grid, every native
client, the Moonlight app list, and launching. Nothing is deleted and the change needs no restart:
the plugin keeps its titles, they simply stop being shown, and turning the source back on brings
them straight back on the next read. (To remove a source's titles for good, uninstall its plugin.)
That hides its titles **everywhere at once** — the console grid, every native client, the Moonlight
app list, and launching — with nothing deleted and no restart: the plugin keeps its titles, and
turning the source back on brings them straight back on the next read. (To remove a source's titles
for good, uninstall its plugin.)
Your hand-added entries are not a source and have no chip — they are always shown.
Hand-added entries are not a source and have no chip — they are always shown.
The choice is stored per host in `library-scanners.json`, next to the rest of the host config
(`~/.config/punktfunk/` on Linux, `%ProgramData%\punktfunk\` on Windows). Only the sources you turned
@@ -64,10 +62,10 @@ The choice is stored per host in `library-scanners.json`, next to the rest of th
## Adding a game by hand
Anything your launchers don't know about — an emulator, a ROM, a DRM-free build, a tool you want on
the couch — goes in by hand. On the console's **Library** page, click **Add custom game**.
the couch — goes in by hand: on the console's **Library** page, click **Add custom game**.
**Title** is the only required field. **Launch command** is the command the host runs for this title;
leave it empty and the entry is a poster the host has nothing to launch from.
**Title** is the only required field. **Launch command** is what the host runs for this title; leave
it empty and the entry is a poster with nothing to launch.
Under **Details (optional)** a title can carry:
@@ -90,8 +88,8 @@ runs, so it is locked down to the host user (0600 on Linux, a SYSTEM+Administrat
treat what you type there as operator-level configuration.
> **Editing replaces the whole entry.** The console form re-sends every field it knows about, so
> nothing you can see is lost. Fields the form has no input for prep/undo steps in particular — are
> **cleared** when you save an entry through the form.
> nothing you can see is lost — but fields the form has no input for (prep/undo steps in particular)
> are **cleared** when you save through the form.
### Cover art
@@ -105,9 +103,9 @@ host can see is fine. A plain Linux path like `/home/me/cover.jpg` is **not** re
Scanned titles need no art. Steam covers come from your local Steam cache, falling back to Steam's
public CDN. On a Windows host, GOG and Xbox covers are the one thing the library looks up over the
network: a background pass asks GOG's and Microsoft's public catalogs for them when the host starts,
and repeats every five minutes for any title it hasn't resolved yet. Neither needs an account or a
key, the answer is cached on the host, and a lookup that fails just leaves a title-only tile.
network: a background pass asks GOG's and Microsoft's public catalogs when the host starts and
repeats every five minutes for any title still unresolved. Neither needs an account or a key, the
answer is cached on the host, and a failed lookup just leaves a title-only tile.
## Games from a plugin
@@ -116,39 +114,40 @@ Manager and Playnite plugins get your collection into the grid, box art and all.
A library plugin can also publish a **launcher tile** — an entry that opens Steam Big Picture,
Heroic, Lutris or Playnite itself rather than a game, so you can install or fix something from the
couch. Clients group those into their own row above your titles, and each one draws its launcher's
logo. A launcher tile you don't want is a switch in that plugin's settings.
couch. Clients group those into their own row above your titles, each drawing its launcher's logo.
A launcher tile you don't want is a switch in that plugin's settings.
Entries a plugin owns are read-only to you. The host refuses a hand edit or a delete of one, because
Entries a plugin owns are read-only to you. The host refuses a hand edit or delete of one, because
the next sync would overwrite it anyway — change the title at its source and let the plugin sync
again. Only the plugin can remove its own entries, and it removes every one of them at once. Your
hand-added entries are never touched by a sync.
The console grid can't tell you which entries those are: a plugin's titles carry the same **Custom**
badge as your own and still show **Edit** and **Delete** on hover. The form and the delete
confirmation open as usual, but the host refuses the change and the entry stays exactly as it was.
badge as your own and still show **Edit** and **Delete** on hover — the form and the delete
confirmation open as usual, but the host refuses the change and the entry stays as it was.
## Launching a game
Whatever the surface, the client sends only an **id**. The host looks that id up in its own library
and runs what it already knows about the title, so a client can never hand the host a command to run.
Whatever the surface, the client sends only an **id**. The host looks it up in its own library and
runs what it already knows about the title, so a client can never hand the host a command to run.
- **Native clients** the browser needs a **paired** host, and that is the only condition: a paired
host's card offers **Browse library…** (**Browse Library…** on Apple) with nothing to switch on
first. Pick a title and the stream starts with the host launching it. The Apple and Android apps
- **Native clients** — a **paired** host's card offers **Browse library…** (**Browse Library…** on
Apple) with nothing to switch on first; pairing is the only condition. Pick a title and the stream
starts with the host launching it. The Apple and Android apps
keep a **Show game library** switch, on by default, for turning it off. See
[Client settings](/docs/client-settings).
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
options and choose **Library**.
- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open
Punktfunk**, which opens the client's console home, and a paired host's **Library** button is
right there — full-screen covers, gamepad-navigable, and a press starts the stream with the title
launching. See [Steam Deck](/docs/steam-deck).
- **Moonlight** — when the host runs with `--gamestream`, your library appears in Moonlight's app
list beside `Desktop`, with covers served by the host. A title keeps the same app id across host
restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are left out.
See [Moonlight](/docs/moonlight).
Punktfunk**, which opens the client's console home, where a paired host's **Library** button is
full-screen covers, gamepad-navigable, and a press starts the stream with the title launching. See
[Steam Deck](/docs/steam-deck).
- **Moonlight** — when the host runs GameStream compat (opt-in: `PUNKTFUNK_GAMESTREAM=1` in
`host.env`, or `serve --gamestream` — see [Moonlight](/docs/moonlight)), your library appears in
Moonlight's app list beside `Desktop`, with covers served by the host. A title keeps the same app id
across host restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are
left out.
- **A link** — a [`punktfunk://` link](/docs/profiles-and-links) carries the id in a `launch=`
parameter, so a desktop shortcut, a browser bookmark or a home-automation rule starts the stream
with the title already launching: `punktfunk://connect/couch-pc?launch=steam:570`. On the Apple
+21 -12
View File
@@ -40,12 +40,23 @@ set** — which is what every shipped template does — a box that has gamescope
### Nobara and other autologin display managers
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when that
session is a display-manager autologin, it has to stop the **display manager** too, for the length
of the stream. That is a privileged operation, and the privilege is granted to one group.
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when a display
manager autologs into that session, stopping it alone accomplishes nothing: the autologin puts it
straight back. So the host **idles** that session for the length of the stream instead, with a
systemd drop-in that replaces its `ExecStart` with a process that just sleeps. The autologin still
succeeds (nothing relogin-loops), the session it logs into does nothing (Steam is free), and the
**display manager keeps running** — which is what lets you still switch the box to Desktop Mode
from Steam while a stream is up.
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover's root helper
> runs for members of that group and for nobody else, so this one command is what authorizes it:
That needs no privilege at all: the drop-in is a user-level unit override, written under
`$XDG_RUNTIME_DIR` so it cannot outlive the login session, and a reboot clears it regardless.
Versions before this one stopped the display manager for the stream's duration — which needed a
root helper, the `punktfunk` group, and lingering, and left the box with nothing able to start a
desktop session, so Steam's own "Switch to Desktop" hung until a reboot.
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover itself no
> longer needs it — the group now gates the usbip nodes the virtual Steam Deck pad attaches
> through, so without it the pad arrives as an ordinary Xbox 360 controller:
>
> ```sh
> sudo usermod -aG punktfunk "$USER" # then log out and back in
@@ -60,14 +71,12 @@ of the stream. That is a privileged operation, and the privilege is granted to o
> symptom side is [Game Mode: black screen on
> connect](/docs/troubleshooting#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution).
How the takeover gets that privilege depends on the display manager driving the autologin:
The display-manager flavor is no longer an input — SDDM, plasmalogin and the rest all get the
idled session above, and none of them is stopped. The root helper described below is therefore no
longer part of a normal takeover; it is kept for the restore path, and for a box where an older
host left a display manager stopped:
- **SDDM** (Bazzite, SteamOS): SDDM survives having the session unit masked, so a box without the
grant still streams — at the cost of SDDM relogin-looping against the takeover for the whole
stream, which churns logind sessions and can starve the game.
- **plasmalogin** (Nobara) and other display managers: masking is fatal there (the autologin
start-limit-kills the display manager), so the host stops the display manager itself and
restarts it afterwards. The packages ship that privilege: a root helper
- The packages ship it: a root helper
(`/usr/libexec/punktfunk/pf-dm-helper`, or `/usr/lib/punktfunk/pf-dm-helper` from the Arch
package) behind its own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically
when the plain `systemctl` verbs are denied. The helper only stops/restores the unit the
+72 -76
View File
@@ -4,23 +4,23 @@ description: How an HDR10 stream is decided end to end — the four things that
---
An HDR session carries a **10-bit BT.2020 PQ (HDR10)** picture from the host's display to your
screen. It is on by default wherever it works, and a session that can't be HDR streams 8-bit BT.709
SDR instead. Which one you get is decided **before the first frame**: the host resolves every gate
below, then tells the client what it is really going to send. Nothing on this page takes effect
mid-stream, so reconnect after changing any of it.
screen. It is on by default wherever it works; otherwise the session streams 8-bit BT.709 SDR. The
host decides **before the first frame** — it resolves every gate below and tells the client what it
will really send — so nothing on this page takes effect mid-stream; reconnect after changing any of
it.
## The chain
Four things must all be true. If your stream is SDR when you expected HDR, one of these is why.
Four things must all be true. If you got SDR when you expected HDR, one of these is why.
1. **The source.** What the host captures must hand it 10-bit PQ pixels. This is the link that fails
most often, and it is entirely a host-side question — see [Per host](#per-host).
2. **The encoder.** The host GPU must encode 10-bit for the codec the session picked. The host
probes this by opening a tiny real encoder once per GPU and codec, and believes the answer.
(PyroWave skips the probe — it has its own rule, below.)
1. **The source.** What the host captures must hand it 10-bit PQ pixels. This link fails most often
and is entirely host-side — see [Per host](#per-host).
2. **The encoder.** The host GPU must encode 10-bit for the session's codec. The host probes by
opening a tiny real encoder once per GPU and codec, and believes the answer. (PyroWave skips the
probe — its own rule is below.)
3. **The codec.** Only HEVC, AV1 and PyroWave have a 10-bit path — see [Codec rules](#codec-rules).
4. **The client.** Your client must advertise 10-bit and HDR (its **HDR** setting), and be able to
present or tone-map PQ.
4. **The client.** It must advertise 10-bit and HDR (its **HDR** setting) and be able to present or
tone-map PQ.
The host allows 10-bit by default (`PUNKTFUNK_10BIT`). It only ever *allows* — the client's setting
is the real per-session switch.
@@ -29,78 +29,75 @@ is the real per-session switch.
### Windows
The Windows host creates a virtual display for your session and **turns HDR on for that display
itself** when the session negotiated 10-bit. You do not have to enable "Use HDR" in Windows Settings
first: Punktfunk enables advanced colour at capture open, waits for it to settle, then composes in
FP16 and encodes P010 BT.2020 PQ. The reverse is enforced too — a session that negotiated **SDR**
forces advanced colour **off** on that display, so a client that asked for 8-bit is never handed PQ.
If enabling it fails, the host logs a loud error and encodes 8-bit anyway: the client was already
told HDR, so that is the one place a Punktfunk label can outrun the picture. The log says so.
Two details worth knowing:
The Windows host **turns HDR on for the session's virtual display itself** when 10-bit was
negotiated — you don't have to enable "Use HDR" in Windows Settings. It enables advanced colour at
capture open, waits for it to settle, then composes in FP16 and encodes P010 BT.2020 PQ. The
reverse holds too: an **SDR** session forces advanced colour **off** on that display, so a client
that asked for 8-bit is never handed PQ. If enabling it fails, the host logs a loud error and encodes
8-bit anyway — the client was already told HDR, so that is the one place a Punktfunk label can
outrun the picture.
- **HDR and 4:4:4 compose on Windows, not on Linux.** A **Windows** host carries both: the capture
path writes full-resolution 10-bit chroma and NVENC encodes HEVC Main 4:4:4 10, so
[full chroma](/docs/client-settings) costs you nothing on an HDR desktop.
[PyroWave](/docs/pyrowave) does the same there, in 16-bit planes. On **Linux** the 4:4:4 route is
8-bit, so a session that negotiates both resolves back down to SDR — full chroma wins. AV1 never
carries 4:4:4 anywhere: Range Extensions are HEVC-only.
[full chroma](/docs/client-settings) costs nothing on an HDR desktop; [PyroWave](/docs/pyrowave)
does the same there, in 16-bit planes. On **Linux** the 4:4:4 route is 8-bit, so a session that
negotiates both resolves back down to SDR — full chroma wins. AV1 never carries 4:4:4 anywhere:
Range Extensions are HEVC-only.
- **Vulkan games need the bundled layer.** NVIDIA and AMD Vulkan drivers refuse to advertise any HDR
colour space for a surface on an indirect (virtual) display, so Vulkan games decide the device
"does not support HDR" — even though the driver happily presents an HDR swapchain there. The host
"does not support HDR" — though the driver happily presents an HDR swapchain there. The host
installer ships an implicit Vulkan layer, `VK_LAYER_PUNKTFUNK_hdr_inject`, that adds those formats
back (installer task **Install the HDR Vulkan layer**, ticked by default). It self-gates on the
monitor's live advanced-colour state, so it does nothing on an SDR session, and it already skips a
built-in list of kernel-anti-cheat titles. `DISABLE_PF_VKHDR=1` in a game's environment switches it
off for that process; `PF_VKHDR_EXCLUDE=foo.exe,bar.exe` skips further executables by name.
D3D11/D3D12 games need none of this.
monitor's live advanced-colour state, so it does nothing on an SDR session, and it skips a built-in
list of kernel-anti-cheat titles. `DISABLE_PF_VKHDR=1` in a game's environment switches it off for
that process; `PF_VKHDR_EXCLUDE=foo.exe,bar.exe` skips further executables by name. D3D11/D3D12
games need none of this.
### Linux + gamescope
A stock gamescope tone-maps its composite down to 8 bits before handing it over, so its capture
output is SDR no matter what the game rendered. Real HDR needs **`punktfunk-gamescope`**, a build
carrying a patch that adds the 10-bit PQ formats to its PipeWire node. It installs beside your system
gamescope rather than replacing it; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the
package for each distro.
Stock gamescope tone-maps its composite down to 8 bits before handing it over, so its capture output
is SDR whatever the game rendered. Real HDR needs **`punktfunk-gamescope`**, a build carrying a patch
that adds the 10-bit PQ formats to its PipeWire node. It installs beside your system gamescope rather
than replacing it; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the package for each
distro.
The host settles two facts before spawning anything: the gamescope binary it will run carries the
Before spawning anything the host settles two facts: the gamescope binary it will run carries the
patch (its `--version` banner contains `+pfhdr`), and this host is the one **starting** the session
rather than attaching to a node someone else started.
**Attach mode is the trap.** The patched build only reaches sessions the host spawns itself —
managed, `PUNKTFUNK_GAMESCOPE_SESSION`, or a bare spawn. A session started by your display manager
runs the distro's own gamescope, which offers neither the 10-bit formats nor the in-node cursor. The
host cannot tell that from the outside unless you pinned `PUNKTFUNK_GAMESCOPE_NODE`: with
runs the distro's own gamescope, which offers neither the 10-bit formats nor the in-node cursor, and
the host cannot tell that from the outside unless you pinned `PUNKTFUNK_GAMESCOPE_NODE`. With
`PUNKTFUNK_GAMESCOPE_ATTACH=1` and the patched build installed it reads the binary, believes HDR is
available, and offers it. The attached session can't answer that negotiation, so the connect fails
available, and offers it; the attached session can't answer that negotiation, so the connect fails
with no picture, the host latches an SDR downgrade for the rest of its life, and the next connect
streams — in SDR.
That combination bites on [Bazzite](/docs/bazzite), where the sysext installs `punktfunk-gamescope`
alongside a stock session gamescope. No template pins attach any more, so the managed default gets
you HDR and the compositor-drawn cursor — but an older template did, and an upgrade never rewrites a
`host.env` you already have, so check yours for `PUNKTFUNK_GAMESCOPE_ATTACH=1` and delete the line.
If you deliberately stay on attach, set `PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never
happens. Staying on attach also leaves the stream with no cursor;
[HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that half.
That bites on [Bazzite](/docs/bazzite), where the sysext installs `punktfunk-gamescope` alongside a
stock session gamescope. No template pins attach any more, so the managed default gets you HDR and
the compositor-drawn cursor — but an older template did, and an upgrade never rewrites a `host.env`
you already have: check yours for `PUNKTFUNK_GAMESCOPE_ATTACH=1` and delete the line. If you
deliberately stay on attach, set `PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never happens.
Attach also leaves the stream with no cursor; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope)
has the fix for that half.
SDR content rides the same PQ container — the desktop, the Steam overlay, an SDR game — mapped in at
`PUNKTFUNK_GAMESCOPE_SDR_NITS`, which defaults to **203 nits**. That is BT.2408 reference white, and
it is the level our clients decode against, so the two ends agree out of the box. gamescope's own
default is 400, nearly a stop brighter; hosts that let it float showed a glaring, over-saturated
Steam UI and washed-out HDR game content on the same stream. Move the knob if you want a brighter or
dimmer desktop, but be aware that moving it re-opens that gap.
SDR content — the desktop, the Steam overlay, an SDR game — rides the same PQ container, mapped in
at `PUNKTFUNK_GAMESCOPE_SDR_NITS`, default **203 nits**. That is BT.2408 reference white and the
level our clients decode against, so the two ends agree out of the box. gamescope's own default is
400, nearly a stop brighter; hosts that let it float showed a glaring, over-saturated Steam UI and
washed-out HDR game content on the same stream. Moving the knob re-opens that gap.
### Linux + GNOME
A Punktfunk host serves [two protocols](/docs/how-it-works#two-protocols): its own `punktfunk/1`,
which the Linux, Windows, Apple and Android apps speak, and GameStream, which
[Moonlight](/docs/moonlight) speaks. GNOME HDR is available on the GameStream side only.
A host serves [two protocols](/docs/how-it-works#two-protocols): its own `punktfunk/1` (the Linux,
Windows, Apple and Android apps) and GameStream ([Moonlight](/docs/moonlight)). GNOME HDR is
available on the GameStream side only.
GNOME 50 added HDR screencast for **real monitors** only, so this route mirrors a monitor instead of
creating a virtual display: set `PUNKTFUNK_VIDEO_SOURCE=portal`, put the monitor in HDR mode in
**Settings → Displays**, and connect an HDR-capable client. `PUNKTFUNK_CAPTURE_MONITOR=<connector>`
pins which head, and when it is set the host checks *that* monitor's colour mode rather than asking
pins which head; when it is set the host checks *that* monitor's colour mode rather than asking
whether any monitor is in HDR. If none is, the session degrades to 8-bit SDR and says so in the log.
A Punktfunk app connecting to a GNOME host over `punktfunk/1` gets SDR. On that protocol the only
@@ -109,10 +106,10 @@ Linux HDR source is the gamescope virtual output.
### Linux virtual displays on KWin, Mutter and wlroots
**These are SDR.** Mutter's `RecordVirtual` streams and the KWin and wlroots virtual outputs are
8-bit upstream, so there is nothing for the host to capture in 10 bits — no setting changes this.
Streaming a *physical* monitor with the [Streamed screen](/docs/virtual-displays) setting is SDR to
a Punktfunk app too, HDR panel or not; the GNOME/GameStream route above is the only Linux monitor
mirror that can be HDR.
8-bit upstream, so there is nothing to capture in 10 bits — no setting changes this. Streaming a
*physical* monitor with the [Streamed screen](/docs/virtual-displays) setting is SDR to a Punktfunk
app too, HDR panel or not; the GNOME/GameStream route above is the only Linux monitor mirror that can
be HDR.
## Per client
@@ -125,12 +122,12 @@ mirror that can be HDR.
| **Moonlight** | Its own HDR toggle, which appears only when the host advertises a 10-bit codec | — |
The Linux and Windows clients are deliberately looser: they advertise HDR whenever the setting is on
and let the presenter sort out the display side — HDR10 swapchain where the compositor offers one,
and let the presenter sort out the display — HDR10 swapchain where the compositor offers one,
tone-mapped to SDR where it doesn't. The stats overlay says which happened: `HDR` versus `HDR→SDR`.
One exception: frames from **software decode** never take the HDR10 swapchain, whatever the surface
offers. On a client with no hardware HEVC decode an HDR stream is therefore presented on the SDR
swapchain without a tone-map, which looks washed out. Turn the client's HDR setting off there. The
offers, so a client with no hardware HEVC decode presents an HDR stream on the SDR swapchain without
a tone-map — washed out. Turn the client's HDR setting off there. The
[Steam Deck plugin](/docs/steam-deck) streams through this same client.
## Codec rules
@@ -139,15 +136,15 @@ swapchain without a tone-map, which looks washed out. Turn the client's HDR sett
- **AV1** — 10-bit, where the GPU encodes it. Advertised separately from HEVC, so a box that does
one and not the other tells the truth about each.
- **H.264** — never. High10 is not an encode mode on the hardware Punktfunk targets, so negotiation
never even asks. Pinning H.264 in your client settings pins the session to SDR.
never asks. Pinning H.264 in your client settings pins the session to SDR.
- **[PyroWave](/docs/pyrowave)** — carries HDR in 16-bit planes, but **only from a Windows host**.
The Linux PyroWave capture path has no HDR colour conversion, so a Linux-hosted PyroWave session
is SDR. Use HEVC or AV1 for HDR from Linux.
One more rule if you also use full chroma: a **Linux** host encodes 4:4:4 at 8 bits, so a session
that negotiates both resolves back down to SDR before the stream starts — on Linux, 4:4:4 wins. A
**Windows** host has no such trade: it carries HDR and full chroma at once. Full chroma is off until
you turn it on, so this only bites if you did.
With full chroma: a **Linux** host encodes 4:4:4 at 8 bits, so a session that negotiates both
resolves back down to SDR before the stream starts — on Linux, 4:4:4 wins. A **Windows** host
carries HDR and full chroma at once. Full chroma is off until you turn it on, so this only bites if
you did.
## Check it
@@ -170,8 +167,8 @@ set -a; . ~/.config/punktfunk/host.env; set +a
punktfunk-host hdr-probe
```
There is no `hdr-probe` on Windows. What Windows has instead is a GPU colour self-test for the
capture conversion, which needs no display or session:
There is no `hdr-probe` on Windows. Windows has instead a GPU colour self-test for the capture
conversion, which needs no display or session:
```powershell
punktfunk-host hdr-p010-selftest 1920x1080 nvidia
@@ -193,13 +190,12 @@ Host, in [`host.env`](/docs/configuration):
|---|---|---|
| `PUNKTFUNK_10BIT` | **on** | Allow 10-bit (HEVC Main10 / AV1 10-bit) at all. `0`, `false`, `off` or `no` forces every session to 8-bit SDR. |
| `PUNKTFUNK_GAMESCOPE_HDR` | **on** | Allow HDR on the gamescope backend. It only decides whether HDR is *attempted* — a host without `punktfunk-gamescope` stays SDR either way. `0` is the escape hatch that puts the gamescope backend back on the old SDR path, spawn flags included. |
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | gamescope's own (400) | How bright SDR content is inside the PQ container of an HDR gamescope session. |
| `PUNKTFUNK_VIDEO_SOURCE=portal` | unset | Required for the GNOME 50+ monitor-mirror route. GameStream/Moonlight only — it has no effect on `punktfunk/1` sessions. |
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | **203** | How bright SDR content is inside the PQ container of an HDR gamescope session. |
| `PUNKTFUNK_VIDEO_SOURCE=portal` | unset | Required for the GNOME 50+ monitor-mirror route. GameStream/Moonlight only — no effect on `punktfunk/1` sessions. |
Client: one toggle, in Settings under **Quality** with
[the rest of the video settings](/docs/client-settings#video) — **10-bit HDR** on the Linux, macOS,
iOS, iPadOS and tvOS apps, **HDR (10-bit, BT.2020 PQ)** on Windows, **HDR** on Android. It is **on
by default** on all of them. Turning it off means "never send me 10-bit", and the host then never
upgrades the session. Like the other video settings it can be set per
[profile](/docs/profiles-and-links), so a Work profile can prefer 4:4:4 while a Couch profile
prefers HDR.
by default** on all of them. Off means "never send me 10-bit", and the host then never upgrades the
session. Like the other video settings it can be set per [profile](/docs/profiles-and-links), so a
Work profile can prefer 4:4:4 while a Couch profile prefers HDR.
+2 -3
View File
@@ -150,9 +150,8 @@ systemctl --user enable --now punktfunk-host
journalctl --user -u punktfunk-host -f
```
This unit runs `serve --gamestream`, so it serves stock [Moonlight](/docs/moonlight) clients as well
as the native ones. For a native-only host, see
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
This unit runs the secure native-only host; to serve stock [Moonlight](/docs/moonlight) clients as
well, see [What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
## Bring up the console and pair
+2 -1
View File
@@ -36,7 +36,8 @@ stream and links out to the detail as you need it. The rest of these are for whe
<Card title="Quick Start" href="/docs/quickstart" description="From nothing to streaming: set up a host and connect your first client." />
<Card title="How It Works" href="/docs/how-it-works" description="The ideas behind Punktfunk in a few minutes — virtual displays, the two protocols, pairing." />
<Card title="Support Matrix" href="/docs/support-matrix" description="What works where — every host desktop, GPU and client app, each cell read out of the code that decides it." />
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Debian, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
<Card title="Install the Host" href="/docs/install" description="One page per system — Ubuntu, Debian, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows — with the install command and nothing else." />
<Card title="Switching from Sunshine" href="/docs/switching-from-sunshine" description="Run Punktfunk next to Sunshine, Apollo or Vibeshine while you try it, then migrate — what maps to what." />
<Card title="Connect a Client" href="/docs/clients" description="Stream with the native app for your device — macOS, Linux, Windows, Android — or any Moonlight client." />
<Card title="Your Game Library" href="/docs/game-library" description="The host finds your installed games by itself — browse a paired host and launch a title straight into the stream." />
<Card title="API Reference" href="/api" description="Interactive OpenAPI reference for the host's management REST API — status, devices, pairing, library." />

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