Compare commits

...
Author SHA1 Message Date
enricobuehler 6d7e6f71c0 release: 0.31.0 — verification pass before the tag
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m8s
ci / docs-drift (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m47s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 5m4s
Re-measured every version-table row against the tree rather than
trusting the earlier cut: ABI 25, wire 2, driver 6/min 3, gamepad 3,
index schema 1, event schema 1, edition 2024, MSRV 1.85, 27 crate dirs,
39 workspace members, SDK 0.1.5, plugin-kit 0.4.3, openapi 0.31.0 with
both copies byte-identical, gamescope +pfhdr8 with 10 patch files and
no diff against the tag. All match what the table claims. The C header
diff against v0.30.0 removes exactly two things — the old ABI define and
the ex11 doc paragraph that stated the superseded hi-res rule — so
'addition only' holds.

Three fixes to the text itself:

  * The commit count is now taken at the tip being released (170/113 at
    d7fa5847) instead of at this branch, where it moved every time I
    committed to it.

  * The two NixOS items in Before-you-update are one item. They were
    separated by three unrelated bullets, so the audience that has to
    act on both — builds are failing AND there is now a cache — had to
    find them in two places.

  * A TL;DR line read 'it repaired itself on no retry', which says the
    opposite of what it means.

Checked and left alone: the Gaming Mode 'Switch to Desktop' entry is
presented as a bug users hit, and that is right — v0.30.0's takeover
does stop the display manager on mask-fragile flavors
(gamescope.rs:259 at the tag), so it shipped. Only the mask-vs-stop
detail moved inside this cycle.

Gates: fmt clean, punktfunk-core --lib 273 passed, cargo metadata ok,
Play notes 456/500 and unique, voice scan finds no internal names in the
notes body.
2026-08-20 09:42:02 +02:00
enricobuehler 9e3fba10c1 release: 0.31.0 — fold in the four commits that landed during the re-cut
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m43s
ci / docs-site (pull_request) Successful in 1m52s
ci / rust-arm64 (pull_request) Successful in 2m27s
ci / rust (pull_request) Canceled after 5m1s
170 -> 173 commits since v0.30.0 (115 non-merge, counted on this branch).

One is user-facing and is in the notes: the web console's Virtual
displays page rendered the Streamed-screen and session-lifetime cards
below the tab shell, so both tabs showed them and the Live tab read as
a duplicate of Configuration. They are policy surfaces and now sit
inside Configuration.

The other three are CHANGELOG-only: the guides trim's second round, the
fifth get-started screenshot (a client's host list), and the workflow
change that made it reachable — linux-client-screenshots now publishes
its PNGs to the generic registry as well, because the v3 artifact store
is browser-only and nothing could pull the shots out of it.

The CI bullet also records this branch's own retry.sh wrapping, since a
reader hitting 'Fail extracting tarball' deserves to find the diagnosis
rather than repeat it.

Gates re-run on this tip: fmt clean, punktfunk-core --lib 273 passed,
Play notes 456/500, both openapi copies identical at 0.31.0.
2026-08-20 09:36:58 +02:00
enricobuehler fd4f032d20 ci: retry bun install — a truncated tarball reads as a corrupt package
ci / bun-nix (pull_request) Successful in 44s
ci / docs-drift (pull_request) Successful in 43s
ci / web (pull_request) Successful in 1m14s
ci / rust (pull_request) Canceled after 2m3s
ci / rust-arm64 (pull_request) Canceled after 2m2s
ci / docs-site (pull_request) Canceled after 2m1s
docs-site died on `error: Fail extracting tarball for
"@rolldown/binding-linux-x64-gnu"` (run 19630, 2026-08-20). The message
points at the package; the package is fine.

MEASURED, because the message invites the wrong fix:
  * The tarball's sha512 matches docs-site/bun.lock exactly, and it is
    an ordinary 3-entry npm tgz — same gzip framing, same modes, no pax
    headers — as the 1.2.0 one that installs fine. Only the payload
    differs in size (20.6 MB vs 19.0 MB of .node).
  * bun 1.3.13 AND 1.3.14 both extract that exact tarball from disk in
    under 80 ms. So it is not the bun bump the floating oven/bun:1 tag
    brought in, and not a format bun stopped accepting.
  * In the SAME run, the web job installed the same registry over the
    same network and passed — it was 25 s ahead of docs-site.
  * Run 19632, seven minutes later, installed the identical lockfile
    and passed.

So: a transient truncation, not a bad package. bun streams
download-and-extract, so a tarball cut off mid-stream surfaces at the
extract step and names the package it was reading — which is why this
looks like `@rolldown/binding-linux-x64-gnu` is broken and why the
obvious fixes (bump rolldown, pin bun, refresh the lockfile) would all
have "worked" by changing which bytes were in flight, and none of them
would have fixed anything.

scripts/ci/retry.sh already exists for precisely this and its header
already diagnosed it: "the runner box executes many jobs in parallel and
its network drops packets under that load … Wrap every single-shot
network command in CI with this instead." `bun install` is a single-shot
network command and was the one class still unwrapped, so it is wrapped
now at all nine Linux sites — ci.yml (web, docs-site), arch, deb, rpm,
web-screenshots, sdk-publish and plugin-kit-publish (both installs).

3 attempts, not retry.sh's usual 5: a genuinely stale lockfile fails
deterministically under --frozen-lockfile, and 10s+20s of backoff is
enough to outlive a load burst without making that honest failure wait
a minute and a half.

The two windows-host.yml installs are left alone: pwsh, and a Windows
box that is not the contended runner.

Verified: all seven workflows still parse; the helper resolves from
web/, docs-site/ and sdk/ (the three working-directory shapes used);
the wrapper recovers a command that fails once and succeeds on the
retry; and `bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile
--ignore-scripts` in docs-site installs all 1138 packages, so the
lockfile is sound and the wrapper does not change the command.

Not done, deliberately: docs-site's lockfile still pins rolldown 1.1.2
where web has 1.2.0. That difference is real but it is not this bug,
and refreshing a lockfile to chase a network flake would have buried it.
2026-08-20 09:34:50 +02:00
enricobuehler 892e683f0e Merge remote-tracking branch 'origin/main' into worktree-release-next-prep 2026-08-20 09:24:01 +02:00
enricobuehler d7fa5847f1 Merge pull request 'Virtual displays: the Streamed-screen and session⇄game cards showed on both tabs — they join the Configuration tab' (#353) from worktree-displays-tab-dup into main
ci / bun-nix (push) Successful in 39s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m38s
ci / docs-drift (push) Successful in 1m4s
ci / rust-arm64 (push) Successful in 2m28s
deb / build-publish-gamescope (push) Successful in 41s
deb / build-publish-client-arm64 (push) Successful in 1m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m50s
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 18s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
deb / build-publish (push) Successful in 5m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m49s
arch / build-publish (push) Successful in 8m38s
deb / build-publish-host (push) Successful in 6m58s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 37s
deb / smoke-install (push) Successful in 3m7s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
ci / rust (push) Successful in 20m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
2026-08-20 06:38:08 +00:00
enricobuehler e473a4be7b fix(web): the Virtual displays page's trailing cards join the Configuration tab
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 1m1s
ci / web (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m3s
ci / docs-site (pull_request) Failing after 11m15s
ci / rust (pull_request) Successful in 20m0s
The Streamed screen and session-game lifetime cards sat BELOW the tab
shell, so they rendered on both tabs — from the Live displays tab the
page read as the Configuration tab's content duplicated (reported the
other way round, same sight). Both are policy surfaces, so they ride
inside the Configuration tab as siblings of the config card; the Live
tab is now only the live list + arrangement.
2026-08-20 08:34:05 +02:00
enricobuehler 21d9190324 Merge pull request 'The fifth get-started screenshot — a client's host list — and the workflow change that made it reachable' (#352) from docs-host-list-shot into main
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m17s
ci / docs-drift (push) Successful in 2m15s
ci / rust-arm64 (push) Successful in 3m17s
ci / rust (push) Successful in 13m27s
2026-08-20 06:19:35 +00:00
enricobuehler 7ae8866a5c Merge pull request 'Guides trim round 2: the five biggest pages each shed one prose category — −16%, no facts, anchors stable' (#350) from docs-guides-trim-round2 into main
ci / rust (push) Canceled after 24s
ci / docs-site (push) Canceled after 23s
ci / web (push) Canceled after 23s
ci / rust-arm64 (push) Canceled after 23s
ci / bun-nix (push) Canceled after 24s
ci / docs-drift (push) Canceled after 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
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 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m36s
docker / deploy-docs (push) Successful in 1m1s
2026-08-20 06:19:20 +00:00
enricobuehlerandClaude Fable 5 a8099e0f5b docs(quickstart): the client host list — the fifth get-started screenshot, pulled from the pipeline the previous commit teaches to publish
ci / web (pull_request) Successful in 3m38s
ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 5m53s
ci / docs-drift (pull_request) Successful in 50s
ci / rust (pull_request) Successful in 7m46s
Saved hosts with their pairing state + an unpaired host found on the network, from
linux-client-screenshots run 19593 via the new generic-package publish (fixed 'ci' version,
anonymous GET), cropped to the cards. Completes the handoff's screenshot list: console login,
Waiting for approval + PIN, the Approve dialog, live status, and a client's host list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:25:44 +02:00
enricobuehler b4b24f8b57 release: 0.31.0 — re-cut on d801cb72, folding in the 41 commits since #320
ci / rust-arm64 (pull_request) Successful in 1m59s
ci / web (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 54s
ci / docs-site (pull_request) Successful in 1m27s
ci / docs-drift (pull_request) Successful in 30s
ci / rust (pull_request) Successful in 16m35s
#320 merged but v0.31.0 was never tagged, and 69 more commits (41
non-merge) landed on top of it. The version stays 0.31.0 and Cargo.toml
does not move; the notes, Play notes and CHANGELOG section are
re-measured on the new tip.

THE NUMBER IS NOW FORCED, where the first cut's was a judgement call.
The C ABI moves 24 -> 25: punktfunk_set_log_callback plus the
PunktfunkLogCb typedef (#338's core half), so an embedder can hear the
core's tracing lines. Addition only — no declaration moved, no repr(C)
struct grew a field, and an embedder that never calls it is byte-
compatible with v24. include/punktfunk_core.h is therefore NO LONGER
byte-identical to the v0.30.0 tag, which the first cut's table and both
"nothing versioned moves" paragraphs claimed.

Table re-measured on d801cb72. Two rows changed since the first cut and
two were already corrected in-tree by their own commits:
  * C ABI 24 -> 25 (above).
  * api/openapi.json 0.29.0 -> 0.31.0 — the STAMP only. No management
    surface moved; the file had been left two versions stale and #337's
    regenerate-and-diff caught it. Both copies cmp identical.
  * SDK 0.1.5 and plugin-kit 0.4.3 were cut during the cycle (#348,
    #334), so the rows the first cut wrote as "owed" are now past tense.
    The registry still skips 0.4.2 on purpose.
Everything else holds: wire 2, driver protocol 6 / min 3, gamepad
channel 3, plugin index schema 1, host event schema 1, edition 2024,
MSRV 1.85, 27 crate dirs, gamescope +pfhdr8 with no new patches. The
PKGBUILD pfhdr7 discrepancy is unchanged and still pre-existing.

REWRITTEN RATHER THAN APPENDED TO, because the intervening work never
shipped and a user must not read a within-cycle correction as a second
bug:
  * The Gaming Mode takeover section. The first cut described "stops the
    login manager first, then steps in" — which #342 replaced before
    shipping, after a field report that a stopped display manager leaves
    the box unable to service Steam's own Switch to Desktop (it hung
    until reboot, and could not even be detected around: every trace of
    that switch is written by the component we had stopped). End state:
    idle the autologin over the gamescope-session-plus@ template, leave
    the DM running. #343 then deleted the whole 142-line stop/linger
    chain the old approach needed. Two shipped facts became false with
    it — the takeover no longer stops the DM and no longer needs the
    punktfunk group (that group still gates the usbip pad nodes) — and
    the notes' Before-you-update says so for Deck/Bazzite operators.
  * The Android ASC entries. HDR seeded from the codec's echo, untagged
    SDR raising blacks to grey, and the console's dispose re-showing the
    system bars over the stream are all regressions against THIS cycle's
    unreleased presenter, so they are folded into the Android story, not
    listed as fixes users hit.

NEW USER-FACING WORK, in rough order of how much it changes:
  * Windows client (#349): the default download becomes a signed
    per-user Inno Setup installer + portable zip; MSIX stays for the
    Store. This is the Steam fix — MSIX puts the exe under WindowsApps,
    which Steam's picker cannot browse, and alias activation defeats
    overlay injection. Before-you-update carries the one action in the
    release: an MSIX user who wants it must Remove-AppxPackage first and
    will re-pair once, because pairing identity lives with the package.
  * install.sh (#345/#346): one command from nothing to a pairable Linux
    host, running platforms.json's lines verbatim, with Sunshine
    coexistence, --dry-run, --uninstall and env twins. Shipped PREVIEW
    on purpose and the notes say so.
  * Send logs to host completes on Apple (#338) and Android (#339) — the
    two legs 0.30's notes named as follow-ups.
  * KWin 6.6 (#344/#331): it refuses to stream an output it created
    disabled, in the session's language, logged nowhere, and persists
    the disable against the stable per-client name so it repeats
    forever. Enable and retry; and a translated refusal no longer burns
    all 8 retries.
  * Deck Wake-on-LAN (#347, closes #322), portable Playnite covers
    (#330), plugin-kit republish on startup/manual (#334), console touch
    gestures and idle gates (#333), cancel-connect (#329), NixOS host
    builds failing outright (#328 + #332 + #336).
  * Docs overhaul (#337/#340/#343/#346): get-started track, Switching
    from Sunshine, screenshots, and data/platforms.json as the single
    source for install facts with seven CI drift gates over it.

Gates on this tree: cargo fmt --all --check clean (no drift this time —
the first cut needed a whitespace commit, which is already in main);
cargo metadata --offline ok; cargo test -p punktfunk-core --lib 273
passed; Play notes gate verbatim 456/500 and unique; voice scan clean
outside For developers; both openapi copies cmp identical at 0.31.0.

⚠ The C ABI harness still did not run here — no libopus on this MacBook
— and this time the header DID change, so ABI 25's C compile is owed to
CI. Named in Verification status rather than left to be found.

⚠ Owed outside this repo: platforms.json changed (the Windows download),
so punktfunk-website needs `bun run sync-platforms` committed — the step
#346 added to docs/releases/README.md, and this is its first exercise.
2026-08-20 01:24:15 +02:00
enricobuehlerandClaude Fable 5 c23fc84bef docs(guides): trim round 2 — each page sheds one prose category, no fact rows, headings or commands touched
ci / rust-arm64 (pull_request) Successful in 1m44s
ci / web (pull_request) Successful in 1m3s
ci / bun-nix (pull_request) Successful in 29s
ci / docs-site (pull_request) Successful in 1m24s
ci / docs-drift (pull_request) Successful in 23s
ci / rust (pull_request) Successful in 12m33s
The category sacrificed, per page (RFC 'screenshots over prose' / phase-2 handoff item 3):
- client-settings.md (4368→3144 words): the why-rationale narrative around each setting; every
  default, number, availability list, env var and link stays.
- virtual-displays.md (3724→3015): status/history notes (the 'What's live today' tracker
  paragraph, 'Windows has always lingered 10 s') and design rationale; compositor-specific
  operational facts stay.
- plugins.mdx (2835→2418): narrative duplication — restart instructions stated twice, story
  asides the linked plugin repos carry.
- input.md (2499→2403) and wake-on-lan.md (2286→2186): rationale asides and historical notes
  ('the plugin used to fire a packet itself…'); these two are per-platform fact tables end to
  end, so only a light pass was honest.

15712→13166 words over the five biggest Guides (−16%). Zero heading changes (anchors stable),
docs-drift + docs-links green, docs-site build + lint green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:17:16 +02:00
enricobuehlerandClaude Fable 5 674b16d8eb linux-client-screenshots: publish the PNGs to the generic package registry too — the v3 artifact is browser-only, which blocked reusing the shots for the docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:08:31 +02:00
enricobuehler d801cb72f2 Merge pull request 'Windows client: per-user installer + portable zip as the default download — a stable exe path Steam can launch (overlay, Big Picture); MSIX stays for the Store' (#349) from worktree-win-client-installer into main
ci / rust-arm64 (push) Successful in 2m26s
ci / web (push) Successful in 1m53s
ci / bun-nix (push) Successful in 35s
ci / docs-drift (push) Successful in 31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
ci / docs-site (push) Successful in 2m11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust (push) Successful in 7m57s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
installer-smoke / smoke (arch) (push) Successful in 45s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
installer-smoke / smoke (fedora-44) (push) Successful in 1m18s
installer-smoke / smoke (debian-13) (push) Successful in 2m26s
docker / builders-arm64cross (push) Successful in 31s
docker / deploy-docs (push) Successful in 32s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m48s
2026-08-19 23:05:32 +00:00
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 ace01f06a2 Merge pull request 'Cut SDK 0.1.5 — the mgmt-endpoint fix cannot reach a plugin until it ships' (#348) from release-sdk-0.1.5 into main
ci / bun-nix (push) Successful in 42s
ci / rust-arm64 (push) Successful in 1m42s
ci / docs-drift (push) Successful in 23s
ci / docs-site (push) Successful in 2m49s
ci / web (push) Successful in 2m51s
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 38s
deb / build-publish-client-arm64 (push) Successful in 1m41s
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 15s
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
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 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
sdk-publish / publish (push) Successful in 42s
deb / build-publish (push) Successful in 5m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
deb / build-publish-host (push) Successful in 4m57s
arch / build-publish (push) Successful in 11m7s
docker / builders-arm64cross (push) Successful in 11s
ci / rust (push) Canceled after 40s
docker / deploy-docs (push) Canceled after 0s
deb / smoke-install (push) Successful in 3m9s
windows-host / package (push) Successful in 17m26s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 36s
nix / flake (push) Failing after 24m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 27m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 29m36s
2026-08-19 22:51:11 +00:00
enricobuehler e4ec4cec31 chore(sdk): cut 0.1.5 — the mgmt-endpoint fix cannot reach a plugin until it ships
ci / rust-arm64 (pull_request) Successful in 1m33s
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 2m7s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 2m12s
ci / rust (pull_request) Successful in 7m25s
nix / flake (pull_request) Successful in 15m37s
v0.31.0's changelog recorded this cut as owed and said why: plugins resolve
`@punktfunk/host` from the registry, so a fix sitting in `sdk/` reaches nobody
until a version carries it. Four commits have touched the SDK since sdk-v0.1.4,
the headline one being the mgmt-port fix — a moved `PUNKTFUNK_MGMT_BIND` left
every plugin and the tray dialing 47990.

`SDK_VERSION` moves with it. It is a hand-maintained constant (the bundled
runner has no package.json to read at runtime), and the runner compares it
against the SDK actually installed in the plugins tree to decide whether to
reinstall — so shipping 0.1.5 with the constant still reading 0.1.4 would have
published the fix and then never delivered it. `version.test.ts` exists for
exactly this and caught it.

Gates: 83/83 SDK tests pass.
2026-08-20 00:50:46 +02:00
enricobuehler 230d253b06 Merge pull request 'The Deck learns a host's wake MAC, so Wake-on-LAN can fire there at all' (#347) from worktree-deck-wol-learn-mac into main
ci / web (push) Successful in 1m37s
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 35s
ci / rust-arm64 (push) Successful in 2m42s
ci / docs-site (push) Successful in 2m30s
deb / build-publish-gamescope (push) Successful in 1m40s
deb / build-publish-client-arm64 (push) Successful in 1m58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 20s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 19s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
ci / rust (push) Successful in 7m16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
deb / build-publish (push) Successful in 5m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
arch / build-publish (push) Canceled after 8m19s
deb / build-publish-host (push) Canceled after 5m10s
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 1s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 6m43s
android / android (push) Successful in 11m48s
flatpak / build-publish (push) Successful in 4m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m14s
Reviewed-on: #347
2026-08-19 22:41:32 +00:00
enricobuehler def215ae8e Merge pull request 'Installer bedding-in (--uninstall, NVIDIA silent-failure checks, detection matrix as gate 7) and screenshots for the get-started track' (#346) from worktree-docs-phase2 into main
android / android (push) Canceled after 44s
arch / build-publish (push) Canceled after 40s
ci / rust (push) Canceled after 38s
ci / rust-arm64 (push) Canceled after 37s
ci / web (push) Canceled after 36s
ci / docs-site (push) Canceled after 35s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 4s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
decky / build-publish (push) Successful in 47s
installer-smoke / smoke (arch) (push) Successful in 42s
apple / swift (push) Successful in 2m13s
installer-smoke / smoke (debian-13) (push) Successful in 1m2s
installer-smoke / smoke (fedora-44) (push) Successful in 2m15s
apple / distribute (push) Successful in 11m38s
apple / screenshots (push) Successful in 10m38s
2026-08-19 22:40:55 +00:00
enricobuehlerandClaude Fable 5 dfcc530ee7 installer bedding-in: --uninstall, NVIDIA silent-failure checks, the detection matrix as CI gate 7 — and the get-started track gets its screenshots
ci / docs-drift (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 2m8s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
installer-smoke / smoke (debian-13) (pull_request) Successful in 58s
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (fedora-44) (pull_request) Successful in 43s
ci / bun-nix (pull_request) Successful in 30s
ci / rust-arm64 (pull_request) Successful in 3m6s
installer-smoke / smoke (arch) (pull_request) Successful in 1m34s
ci / rust (pull_request) Successful in 7m54s
android / android (pull_request) Successful in 8m7s
Phase 2 of the docs-and-onboarding overhaul (items 1-partial, 2 and 4 of the handoff):

install.sh: --uninstall reverses step 1 + step 6 per family (user units off first, only the
punktfunk packages actually installed, then the repo; config/groups/firewall stay, as
/docs/uninstall states) — smoke-tested as a new installer-smoke step on all three families.
The end-of-run check now catches the two NVIDIA silent failures on every family: no driver at
all, and a module the kernel refused to load (Secure Boot) via an nvidia-smi probe pointing at
the troubleshooting anchor; the Fedora ffmpeg-libs/NVENC warning folds into the same block.

check-docs-drift.sh gate 7: the manual 16-file os-release matrix PR #345 was verified with,
committed — every family's detection, its install line, its removal line and the four
unsupported pointers run through the real script under --dry-run on every push (docs-drift's
container gains curl, the script's own prerequisite).

Screenshots (RFC: "screenshots over prose"): four console shots captured from the same
storybook-fixture pipeline web-screenshots.yml runs — login and the armed Pairing page into
quickstart.md, the Approve dialog (access level + expiry + guest fast-path) into pairing.md
replacing the prose that described it, live status into web-console.md. Files under
docs-site/public/img/, dark-theme, bundled+preloaded by the docs build (verified served).
Still missing: a client host-list shot — linux-client-screenshots run 19546 built it, but its
artifact isn't API-downloadable; add it when a browser session can fetch the zip.

WP5 rider: the release-flow docs-freshness step now includes the website content look-over.

NOT flipped: installer stays preview — the handoff gates the default flip on real-box mileage
(Bazzite above all), which a Mac can't provide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:56:19 +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
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 2b81bd286f fix(clients): the Deck learns a host's wake MAC, so Wake-on-LAN can fire there at all
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m33s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 1m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 5m19s
android / android (pull_request) Successful in 7m21s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m43s
Every wake gate in the codebase reads `!host.mac.is_empty()` against the saved
record — `ConnectPlan::wake`, the console's `can_wake`, `punktfunk wake`. That MAC
only ever reached the store through `trust::learn_mac`, and `learn_mac` had exactly
two callers: the GTK hosts page and the WinUI one.

Neither runs on a Steam Deck. Gaming Mode has only the Decky panel (which drives the
headless CLI) and the console home — and those learned the management port alone,
never the MAC. So a Deck's records stayed MAC-less forever, every wake gate stayed
false, and Wake-on-LAN was skipped silently: no packet, no error, nothing to see.
It worked on desktop purely because those two hosts pages learn on each discovery
tick. (#322)

Rather than add the missing call twice, collapse the three per-field learners
(`learn_mac`, `learn_os`, `learn_mgmt_port` — three `pub fn`s, three load/save
cycles) into one `learn_from_advert`, and call it at every site where an advert
meets a saved record: both desktop hosts pages, the console home, and the CLI's
`discover`. Remembering one call is not a thing a front-end can half-do; remembering
three is what produced this. It takes the three fields rather than a `DiscoveredHost`
because there are two of those — core's and the WinUI shell's verbatim port.

`discover` is where the panel-only flow is fixed: it is the one verb the Decky panel
runs that ever sees an advert. It keeps `KnownHosts::read()`, so it still mints no
ids and cannot join the race that comment warns about, and `learn_from_advert` writes
only when an advert genuinely taught the record something — a steady-state panel
refresh touches no disk.

Two things fall out of the same root cause: the console home now persists the OS
chain too, so a Deck host's icon stops vanishing the moment mDNS goes quiet; and
`punktfunk wake`'s "connect to it once while it's awake" is replaced, since a MAC
comes from an advert and never from a connect — that wording sent this diagnosis
looking in the wrong place.

The magic-packet sender itself was never at fault (`punktfunk-core::wol` passes its
7 tests) and neither was the flatpak sandbox (`--share=network`). Nothing reached
them.

Closes #322
2026-08-19 14:16:14 +02:00
56 changed files with 1948 additions and 895 deletions
+3 -1
View File
@@ -248,7 +248,9 @@ jobs:
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
- name: The console must exist (cache hit or fresh build)
+15 -3
View File
@@ -339,8 +339,19 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
# has the why: this box runs many jobs in parallel and drops packets under that load).
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
# 30s is enough to ride out a load burst without making that wait a minute and a half.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# Build first: it generates the orval API client + paraglide messages that
# typechecking imports.
- name: Build
@@ -368,8 +379,9 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# Retried — see the web job above; this is the job the flake was measured on.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
- name: Build
run: bun run build
@@ -417,7 +429,7 @@ jobs:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
- uses: actions/checkout@v4
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
+3 -1
View File
@@ -231,7 +231,9 @@ jobs:
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
# neither consumes nor commits, whose only effect here is to make the install depend on
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
+6
View File
@@ -61,3 +61,9 @@ jobs:
punktfunk-host detect-conflicts
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
- name: --uninstall takes the packages and the repo off again
run: |
sh scripts/install.sh --yes --uninstall
! command -v punktfunk-host
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
@@ -111,3 +111,20 @@ jobs:
name: punktfunk-linux-client-screenshots
path: clients/linux/screenshots
retention-days: 30
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
# blocked reusing these shots for the docs. Publish them to the generic package registry
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
# public repo:
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
- name: Publish screenshots to the package registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
for f in clients/linux/screenshots/*.png; do
name=$(basename "$f")
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
echo "published $BASE/$name"
done
+5 -2
View File
@@ -37,15 +37,18 @@ jobs:
- uses: actions/checkout@v4
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
# is the worst place to lose to a dropped packet — the tag is already pushed.
- name: Build the SDK (file:../sdk dependency source)
working-directory: sdk
run: |
bun install --frozen-lockfile --ignore-scripts
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
- name: Install dependencies
working-directory: plugin-kit
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
+3 -1
View File
@@ -176,7 +176,9 @@ jobs:
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
+4 -1
View File
@@ -39,8 +39,11 @@ jobs:
- uses: actions/checkout@v4
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
# worst place to lose to a dropped packet — the tag is already pushed.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
- name: Typecheck
run: bun run typecheck
+3 -1
View File
@@ -40,8 +40,10 @@ jobs:
- uses: actions/checkout@v4
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
# explicitly since build-storybook has no prebuild hook of its own.
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
- name: Generate API client + i18n messages
run: bun run codegen
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
+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 }
}
+322 -50
View File
@@ -14,16 +14,19 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.31.0
90 commits since v0.30.0 (65 non-merge).
170 commits since v0.30.0 (113 non-merge), counted at the tip this was cut from.
Nothing versioned moves. `WIRE_VERSION` stays **2**, the C ABI stays **24**`include/punktfunk_core.h`
is byte-identical to the v0.30.0 tag — the driver protocol, gamepad channel and plugin index schema
are all unchanged, and no `trust::Settings` field, capability bit or control-message type byte was
added. Every 0.30.x host, client, driver and plugin keeps interoperating in both directions, with no
re-pairing.
One versioned surface moves, additively: the **C ABI goes 24 → 25**, a single new symbol
(`punktfunk_set_log_callback`) that lets an embedder hear the core's own log lines. Nothing else
does — `WIRE_VERSION` stays **2**, the driver protocol, gamepad channel and plugin index schema are
unchanged, and no `trust::Settings` field, capability bit or control-message type byte was added.
No existing C function changed its signature or behaviour and no `#[repr(C)]` struct grew a field,
so an embedder that adopts nothing rebuilds against the new header and is done. Every 0.30.x host,
client, driver and plugin keeps interoperating in both directions, with no re-pairing.
What did move is beneath the versioned surfaces, and three parts of it are worth a packager's or
embedder's attention: the Linux host package installs **three new system files** (a udev rule, a
Beneath the versioned surfaces, four things are worth a packager's or embedder's attention: the
**Windows client's default download changes** to a per-user installer plus a portable zip, with the
MSIX kept for the Store; the Linux host package installs **three new system files** (a udev rule, a
WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux
desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a
host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the
@@ -35,7 +38,7 @@ three ABIs, which removes the Compose screenshot scenes.
| | v0.30.0 | v0.31.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 24 | **24** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.30.0 tag; the only new `pub` items in `punktfunk-core` are three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`), Rust-only, no `pub const` for cbindgen to pick up |
| C ABI | 24 | **25** | one additive step: v25 adds `punktfunk_set_log_callback` and the `PunktfunkLogCb` typedef (below). No existing declaration moved and no struct grew a field. Also new in `punktfunk-core`, Rust-only: three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`) |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
@@ -43,20 +46,39 @@ three ABIs, which removes the Compose screenshot scenes.
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag |
| `api/openapi.json` | 0.29.0 | **0.31.0** | **the stamp only** — no management-API surface moved this cycle. The file had been left at 0.29.0 while the crate was already 0.31.0; #337's regenerate-and-diff caught it and it was regenerated, which is a one-line change to both copies. `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.5** | cut — `sdk/src/config.ts` and `runner-cli.ts` carry the `mgmt-endpoint` fix below, and plugins resolve the SDK from the registry, so it could not reach them until it shipped |
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
`sdk-publish.yml` / `plugin-kit-publish.yml`); this release commit does not bump them. Both have
unpublished code changes, called out in the table so they are cut deliberately rather than
discovered.
`sdk-publish.yml` / `plugin-kit-publish.yml`), so their rows record what the registry holds, not
what this tag ships. Both were cut during this cycle rather than left owed — a plugin resolves them
from the registry, so a fix that never ships there never reaches one.
### ⚠ Breaking changes
**None on any versioned surface.** No wire change, no C ABI change, no driver-protocol change, no
plugin-contract change. Four things are worth attention anyway; none breaks a build:
**None that break a build.** No wire change, no driver-protocol change, no plugin-contract change.
The C ABI moves 24 → 25 by **addition only**:
- **v25 — `punktfunk_set_log_callback(max_level, cb, user)`.** The core logs through `tracing`; an
embedder that installs no Rust subscriber hears none of it — transport warnings, connection events,
handshake notes — and a client log bundle carries the shell's half alone, which is exactly what an
Apple TV field report turned out to be. The call registers a `log::Log` backend behind a C callback
(`PunktfunkLogCb`: level, target, message, user), gated by `log::set_max_level` so anything above
the ceiling costs no formatting; `NULL` detaches, and it answers `Unsupported` when another log
backend already owns the process (`android_logger`). Both strings are borrowed for the call only,
and an interior NUL drops the line rather than truncating it. `punktfunk-core` now declares
`tracing`'s `log` feature explicitly — it had been on transitively via quinn, which an ABI promise
must not rest on. An embedder that never calls it is byte-compatible with v24; see
`docs/embedding-the-c-abi.md` §2.6.
- **One header comment was wrong and is corrected, with no signature change:**
`punktfunk_connect_ex10`'s summary still stated the pre-2026-08-16 rule that only a format other
than 48000/16 requests the lossless plane. Any non-zero format at all does, 48000/16 included —
which is what its own warning already said and what the code always did. Embedders reading the
summary were reading the old rule.
Five more things are worth attention; none breaks a build:
- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the
desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a;
@@ -78,6 +100,11 @@ plugin-contract change. Four things are worth attention anyway; none breaks a bu
monitors disabled for the session now (closes #284).
- **Three new system files in the Linux host package** — the DualSense audio path does not work
without them. Downstream repackagers: see the packaging section.
- **The Windows client's default download is a per-user installer, not the MSIX.** The MSIX stays,
for the Store; the installer and a portable zip are what the download page now offers, and the
release carries `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip` alongside it.
Anyone scripting against the MSIX asset name is unaffected; anyone scripting against "the Windows
client download" gets a different artifact. See the Windows client section.
### DualSense audio and haptics on Linux: five faults, and the files they needed
@@ -295,6 +322,25 @@ gains two direct deps already in the graph.
`frameRatePowerSavingsBalanced`) raises the render-range floor — so the ineffective pins were
removed again and `pf.present` gained the cadence loop's late-permille / jitter / cushion /
re-anchors / qDepth.
- **Colour tagging, which the SurfaceView path never had to do.** MediaCodec tags its own window
buffers; with `AImageReader``ASurfaceControl` the transaction is the only carrier, and a
dataspace of 0 means `setBufferDataSpace` is never called. Two consequences, both fixed inside the
cycle: **HDR** was seeded from a hardcoded `BT2020_ITU_PQ` guess and then overwritten by whatever
the codec echoed on the first output-format change — a decoder that omits color-transfer (common)
echoes None, clobbering the dataspace to 0 before the first present, so P010 buffers composited as
sRGB, and an HLG stream was mis-seeded PQ. The initial dataspace now derives from `client.color`
(PQ vs HLG, range) and a format change only *refines* it when the codec actually reports an HDR
transfer, never resets it — the SurfaceView path's semantics. And **SDR** was untagged entirely:
a limited-range BT.709 buffer read as full range shows black (16) as grey, so SDR now maps to
`ADATASPACE_BT709` and every ASC buffer is tagged.
- **One owner for the system bars.** Console → stream rides an `AnimatedContent` cross-fade, so the
outgoing console shell stays composed until the fade ends and its
`onDispose { show(systemBars()) }` fired *after* `StreamScreen`'s hide — parking the status and
gesture bars over the video for the whole session. Hide/show now lives once in `App.kt`, keyed on
the resolved intent (streaming or console fronting = immersive, touch shell = bars back), and both
screens' per-screen bar management is deleted.
- **Idle gates** (from the console-ui sweep): the reachability sweep only probes while the console is
attached, and the render thread drops to half rate after 60 s without input.
### Hyprland / sway: `topology: exclusive` (closes #284)
@@ -314,20 +360,92 @@ with non-legacy parsers"). `primary` stays extend and warns distinctly. ⚠ **Th
exercised on a live sway** — no box in the fleet runs one; both argv shapes are pinned by tests and
the read-back turns a wrong guess into a warning naming the outputs. Six new unit tests.
### Gaming Mode takeover: the mask was the relogin storm
### Gaming Mode takeover: it no longer touches the display manager at all
On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin path, so every
autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind sessions in 3.7 min,
system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy crash-looping ~16
starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per event, read the pad at
~1.4 Hz. `dm_plan` loses its `mask` input and `dm_survives_masked_unit`; the mask is laid **only after
the stop has landed** and every restore path unmasks before restarting; a planned DM stop that does
not land now **fails the takeover** and the caller degrades to ATTACH. `skip` is `!any_live` on every
flavor; `any_live` now counts `deactivating` and `reloading`. New `DmHelperError::shape()`;
`watch_for_relogin_storm()` (two `read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s,
detect-only); `systemctl_system` captures stderr at DEBUG (the "requires interactive authentication"
line was going to the journal on the *successful* path). `cargo test -p pf-vdisplay --lib gamescope`
52 passed, 1 ignored.
This landed in two steps within the cycle, and the second retired the first — read the end state.
**The storm.** On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin
path, so every autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind
sessions in 3.7 min, system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy
crash-looping ~16 starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per
event, read the pad at ~1.4 Hz. Masking without stopping the display manager is not a weaker
defence; it is the storm's engine.
**Then stopping the DM proved wrong too.** With no display manager there is nothing on the box able
to start a desktop session, so Steam's own "Switch to Desktop" sat on its modal until a reboot
(field report 2026-08-18, `.41`). It could not even be detected and worked around: on a
steamos-manager box every trace of that switch is written by the component we had just stopped —
the `~/.config/steamos-session-select` sentinel is never written (that is the ChimeraOS/Nobara
layout), `/var/lib/sddm/state.conf` only advances when sddm actually *starts* a session,
`get-default-login-mode` stays `game` for a non-persistent switch, and `graphical-session.target`
going inactive fires at takeover time as well.
**End state: idle the autologin, leave the display manager alone.** The takeover drops a unit
override over the `gamescope-session-plus@` template replacing `ExecStart` with a process that
sleeps. The autologin still *succeeds*, so there is no failed unit to relogin against; the session
runs nothing, so Steam is free; and the DM is alive, so the box can service its own session switch.
No privilege, no DM-flavour matrix, no detection. Measured on `.41` in both directions: takeover
leaves `steam` down, `sddm` active, the unit `active (running)` with `NRestarts=0`; the switch that
used to hang brings Plasma up in ~10 s; the restore puts Steam back within 5 s. The drop-in lives
under `$XDG_RUNTIME_DIR` (a copy outliving the host would be a box whose Game Mode silently does
nothing), is swept unconditionally at startup, and its removal sits above every early return in the
restore — the desktop-active return is exactly the path that would leak it. The restore *restarts*
rather than starts, because `start` on an active-but-idle unit is a no-op that would log success
over it.
With nothing stopping a display manager any more, the whole chain built to survive doing so is
deleted: `try_stop_display_manager`, `ensure_host_survives_dm_stop`, `host_is_under_user_manager`,
`cgroup_under_user_manager`, `linger_enabled` and `dm_plan`'s mask input — 142 lines out, 17 in.
**Two shipped facts became false and are corrected:** the takeover no longer has to stop the display
manager, and it no longer needs the `punktfunk` group (the docs and the shipped Bazzite `host.env`
both said it did). That group still gates the usbip nodes the virtual Steam Deck pad attaches
through, which is what the advice now narrows to. Kept from the first step: `any_live` counts
`deactivating` and `reloading` (a unit mid-teardown used to read as a dead leftover, so a box that
*is* in gaming mode sampled as idle); `DmHelperError::shape()`; `watch_for_relogin_storm()` (two
`read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s, detect-only, and it states that
no audio, input or PipeWire measurement taken during a storm is valid); and `systemctl_system`
capturing stderr at DEBUG, since that verb is *expected* to fail on an unprivileged host and its
"requires interactive authentication" line was going to the journal on the successful path.
### KWin 6.6 creates our virtual output disabled, and refuses to stream it
On KWin ≥ 6.6 `streamVirtualOutput` creates the output on the backend and then hands
`workspace()->findOutput(output)` to the stream — null for an output the workspace does not manage
(`wantsToManage` = `isEnabled() && !isNonDesktop()`). An output KWin creates **disabled** is
therefore refused with "Could not find output", translated into the session's language and logged
nowhere, because disabling an output is a perfectly valid configuration that applies successfully.
6.4/6.5 passed the backend output straight through and streamed it either way. It repeats forever:
the host asks for a *stable* per-client output name precisely so KWin persists that client's scale
and mode against it, so a stored configuration naming it `enabled: false` is reapplied to every
future session for that client — and the user cannot fix it in System Settings, because the output
only exists for the few milliseconds the request is alive. The host now enables the output and
retries. Related, from the same investigation: a **translated** KWin refusal used to burn all 8
retries because the match was against KWin's message rather than our own prefix.
### Windows client: a per-user installer and a portable zip, because Steam must spawn the exe
A user report — launching through Big Picture does not work and the Steam overlay never appears —
turned out to be nothing to do with the app being UWP (it is full-trust Win32 under MSIX too) and
everything to do with the MSIX install **shape**: the exe lives under the ACL'd `WindowsApps`
directory that Steam's non-Steam-game picker cannot browse, and alias / `shell:AppsFolder`
activation defeats the overlay's injection. Steam has to spawn the exe itself, from a normal path.
- **`punktfunk-client.iss`** — a per-user Inno Setup install (no UAC) to
`%LOCALAPPDATA%\Programs\Punktfunk`, re-creating in `HKCU` what the MSIX manifest granted: the
`punktfunk://` scheme, the Start entries, and `{app}` on the user PATH for the `punktfunk` CLI. It
fetches the Windows App Runtime when missing.
- **`pack-client-installer.ps1`** consumes `pack-msix.ps1`'s layout (one assembly, three artifacts),
signs the four exes individually and emits `setup.exe` plus a portable zip — same signing backends
and fail-closed-on-tags rule as its siblings, and no `.cer`, because an exe runs untrusted.
- **`windows-client.yml`** packs after the MSIX and publishes/attaches the new artifacts;
canary/latest aliases are `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip`.
- **`deeplink.rs`**: `write_shortcut` targets the app-execution alias only under package identity —
an unpackaged install has no alias but does have a stable path, so it targets `current_exe()`.
`has_package_identity()` is now shared with `main.rs`'s AppUserModelID probe.
- Uninstall is `Settings → Apps → Installed apps` (per-user, no admin prompt) or
`unins000.exe /VERYSILENT`; a portable unzip registers nothing and is deleted by hand. Documented
in install-client (with a "Launching through Steam" section), channels, clients, uninstall, and
both copies of `platforms.json`.
### Windows host: two session-killers
@@ -351,6 +469,74 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
instead of `launching` forever. Fixture in `a_pid_only_launch_reports_its_exit` widened 4 → 8 s
(it passed only because of the bug); new ignored test drives the field report.
### `scripts/install.sh`: a guided Linux host install (preview)
Plain POSIX `sh`, dash-clean, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`. Detect the
distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows and unknown
distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora 45 hit the
documented floors with the right docs link) → install using the `data/platforms.json` lines
**verbatim** (channel and the Fedora group are edited into the string at run time) → run
`punktfunk-host detect-conflicts` (exit 1 = an active Sunshine-family host) → offer to keep both by
moving the management API port (`PUNKTFUNK_MGMT_BIND`, default 47991, which the firewall step then
opens) → input group (`ujust` on Bazzite) → optional `punktfunk` group, GameStream compat and shared
clipboard, all defaulting to no → firewalld/ufw profiles → enable host + console (+ the plugin
runner where it is not) → optional linger → verify (unit active, UDP 9777 bound) and print the
console URL, the password command and the pairing steps.
`--dry-run` prints every command and changes nothing; `--uninstall` reverses the install and the
service enable per family (user units off first, then only the punktfunk packages actually
installed, then the repo — config, groups and firewall stay, as `/docs/uninstall` states). Every
prompt has a `PUNKTFUNK_INSTALL_*` environment twin so `--yes` (or no terminal) runs unattended, and
stdin is never read, because under `curl | sh` stdin *is* the script. Re-running is safe. The
end-of-run check catches the two NVIDIA silent failures on every family — no driver at all, and a
module the kernel refused to load under Secure Boot — via an `nvidia-smi` probe pointing at the
troubleshooting anchor.
It is labelled **PREVIEW** on purpose: the per-distro docs pages remain the documented default until
it has mileage. CI runs it: a new `installer-smoke.yml` exercises install and `--uninstall` per
package family, and `check-docs-drift.sh` gate 7 runs the 16-file os-release detection matrix
through the real script under `--dry-run` on every push. One bug fixed by the first smoke run: the
`/dev/tty` probe used `-r`/`-w`, which answer yes in a container that has the node but no
controlling terminal, so the redirect failed — it opens the device instead now.
### One home per fact: `data/platforms.json`, and CI gates against drift
Install commands, repo URLs and port numbers had drifted across four surfaces. They now live in
`data/platforms.json` and nowhere else: the docs-site install pages quote it through an
`<Install platform="…"/>` MDX component reading a byte-identical snapshot at
`docs-site/src/data/platforms.json` (the Docker build context is `docs-site/` alone, the same
arrangement `openapi.json` uses), `<Ports/>` renders the port table from it, the website download
page vendors it, and `install.sh` runs it. `scripts/ci/check-docs-drift.sh` gates the parse, the
snapshot sync, undocumented `PUNKTFUNK_*` knobs (against a checked-in baseline) and the detection
matrix; `check-docs-links.sh` covers dead links.
**Two consequences for whoever cuts this release.** The website vendors `platforms.json` and only
refreshes when someone runs `bun run sync-platforms` in punktfunk-website and commits — the release
flow in `docs/releases/README.md` gained that step, and `platforms.json` **did** change this cycle
(the Windows client download). And the `.gitea/PULL_REQUEST_TEMPLATE.md` now asks the one question
CI cannot: did a user-facing fact change, and is the page that owns it updated in the same PR.
### Clients can send their logs to the host, on every platform that has a console
0.30 shipped "Send logs to host" on the Gaming Mode console alone and named the Apple and Android
legs as follow-ups. Both landed here.
- **Apple** — a `ClientLog` drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)` with the
same call shape, writing os_log *and* a process-global ring bounded at 4096 lines / 768 KiB (under
the host's 1 MiB cap), stamped wall-clock ISO-8601 so a bundle lines up with the host log;
`.debug` stays out of the ring, which is the Steam Deck DPB lesson applied in advance. 13 `Logger`
declarations swapped. `MgmtTransport`/`MgmtConnection` POST a length-framed body on the same
pooled, pinned mTLS connection; `SendLogs.toHost` requires identity and pinned fingerprint, the
same gates as the library. Reachable from the host card's context menu and the gamepad host
options. Paired with ABI v25 above, the Swift client finally hears the core's own lines too
(`core.<crate>`, info ceiling by default, `PUNKTFUNK_CORE_LOG_LEVEL` raises it).
- **Android**`pf-client-core`'s logring RING half (note/render/wallclock, std-only) is
Android-enabled, with `send_to_host` still desktop-gated alongside the ureq fetches; `wallclock`
moves in from the session's ring layer so every feeder stamps lines identically. `JNI_OnLoad`
installs a `RingTee`, so every `log` record goes to logcat **and** into the ring in the desktop
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
upload rides the client's own mTLS.
### Everything else an integrator might notice
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
@@ -402,44 +588,130 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
(screenshot harness only).
- **New environment variables:** `PUNKTFUNK_PAD_SINK_VOLUME` (`=0` skips both pad-sink pins),
`PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS` (pad-arrival grace), `PUNKTFUNK_USBIP_TRACE` (byte-level
USB/IP trace prefix, off by default), and the three Apple screenshot-harness hooks above.
USB/IP trace prefix, off by default), `PUNKTFUNK_CORE_LOG_LEVEL` (Apple: raises the ABI v25 log
sink's ceiling above its info default), the three Apple screenshot-harness hooks above, and nine
`PUNKTFUNK_INSTALL_*` twins for `install.sh`'s prompts (`_YES`, `_CHANNEL`, `_GAMESTREAM`,
`_CLIPBOARD`, `_PUNKTFUNK_GROUP`, `_LINGER`, `_MGMT_PORT`, `_DRY_RUN`, `_OS_RELEASE`).
`PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time.
- **A Steam Deck never learned a host's wake MAC, so Wake-on-LAN was skipped there in silence.**
Every wake gate reads `!host.mac.is_empty()`, and the MAC only ever reached the store through
`trust::learn_mac`, whose two callers were the GTK and WinUI hosts pages — neither of which runs
in Gaming Mode. Rather than add the missing call twice, the three per-field learners (`learn_mac`,
`learn_os`, `learn_mgmt_port`) collapse into one `learn_from_advert`, called wherever an advert
meets a saved record: both desktop hosts pages, the console home, and the CLI's `discover`.
Remembering one call is not something a front-end can half-do; remembering three is what produced
this (#322).
- **`HostRow` gains `clipboard_sync`** (`#[serde(default)]`) and `ConsoleCmd` two variants,
`BindProfile` and `SetClipboard` — additive and default-tolerant. From the 2026-08-19 console-ui
sweep, which also brought touch deferred-tap and drag-to-scroll to the console (a swipe across the
settings list used to cycle whatever value it landed on, because `MenuList` presses focus *and*
activate), Controller haptics/speaker rows, and two Android idle gates (the reachability sweep
only probes while the console is attached, and the render thread halves its rate after 60 s
without input).
- **Cancelling a connect returns the console immediately.** The takeover could only be dismissed by
a session phase coming back from the embedder and nothing guaranteed one would: Android's shell
sent no phase at all on the cancelled path, and the desktop shell waited on a pump parked inside
the blocking `NativeClient::connect*`, which had no abort — 15 s on a normal dial, **185 s** on a
request-access connect the host holds pending approval. The private `connect_*` inner fn takes a
trailing `cancel: Option<Arc<AtomicBool>>`; not exported through the C ABI.
- **A portable Playnite's covers survive the art confinement.** A Playnite unzipped outside the
users base keeps its library beside the exe, so every cover it exports sits outside every default
art root: the games synced and all **70** covers were dropped, with `PUNKTFUNK_LIBRARY_ART_ROOTS`
the only way out. The Playnite install dirs are art roots now, exactly as Steam's install root
already was, and `playnite_install_dirs` learned to find a portable copy at all — it registers no
uninstall entry and sits under no profile, but it does register the `playnite://` handler, which
is the very registration the launch path already follows. So a portable install also gets its
Fullscreen launcher tile, which it never had. The confinement is not loosened: roots come from the
host's own registry and filesystem probes, never from the plugin lane that supplies the art path.
Paired with the plugin-kit fix below, a fixed host no longer needs a cache file deleted.
- **`plugin-kit`: `startup` and `manual` sync reasons always publish.** The fingerprint says the
plugin would compute the same entries again; it does *not* say the host still holds them — and the
host may accept a payload and store less of it (an art path outside its roots is stripped and the
games kept, deliberately, because a cover must not cost a library). Once that happened the
fingerprint was a permanent "no changes", and the only way out was deleting the plugin's cache
file, which is exactly the advice a portable-Playnite library with 70 dropped covers was given.
The two triggers with a person behind them now always apply.
- **Nix:** nixpkgs bumped because its gamescope 3.16.24 no longer took our patch 0009 (the publish
tier was red on every build); `enableWsi` is a nixpkgs *function argument* defaulting to false, so
the plain derivation shipped a compositor with **no WSI layer at all** and nothing under it could
obtain an HDR10 swapchain — our own postInstall assertion caught it. Also: the prune makes `$out`
writable first (reshade installs read-only), the bun builds are serialised and the OOM is measured
against the real 7 GiB cgroup cap rather than guessed at, and a dispatch opt-in compared against
the string `"true"` silently skipped when the API delivered a real JSON boolean — the step was
skipped and the job still reported success.
- **New packaging payload (Linux host, rpm/deb/arch; nix where noted):** `scripts/60-punktfunk.rules`
(+2 sound rules), `scripts/60-punktfunk-dualsense.conf` (WirePlumber, also nix),
`scripts/alsa-ucm2/…` (UCM drop-in, **not** nix). Bazzite sysext inherits all three from the RPMs.
- **Docs:** `AGENTS.md` + `docs/agents/` (issue tracker is Gitea via the `gitea` MCP server; the
five triage labels; single-context domain docs). A host audio-source comment corrected
(`pw_impl_node_set_driver` marks props changed but leaves the flush to the next info emission).
- **CI:** Nix publish job records `df` after the build as well as before.
- **CI:** the Nix publish job records `df` after the build as well as before; the
`linux-client-screenshots` run publishes its PNGs to the generic package registry as well as the
v3 artifact store (which is browser-only, so nothing could reuse the shots for the docs — that is
how the get-started track got its fifth screenshot, a client's host list); and **every Linux
`bun install` is now wrapped in `scripts/ci/retry.sh`**. That last one is a real failure, not
tidying: `bun install` streams download-and-extract, so a tarball truncated by the runner's
packet loss under parallel load surfaces as `error: Fail extracting tarball for "<pkg>"` and
names a package that is perfectly intact — measured on run 19630, where docs-site died on
`@rolldown/binding-linux-x64-gnu` while the web job installed the same registry in the same run
and run 19632 installed the identical lockfile seven minutes later. The tarball's sha512 matches
the lockfile and bun 1.3.13 and 1.3.14 both extract it from disk, so neither the package nor the
floating `oven/bun:1` bump was ever at fault. `retry.sh`'s header had already diagnosed this
class and said to wrap every single-shot network command; `bun install` was the one still
unwrapped. Three attempts rather than the usual five, so a genuinely stale lockfile still fails
fast under `--frozen-lockfile`.
- **The web console's Virtual displays page** put the Streamed-screen and session-lifetime cards
below the tab shell, so both rendered on both tabs; they are policy surfaces and now sit inside
the Configuration tab, leaving the Live tab as the live list plus arrangement.
### Verification status
Gates run on the release tree (this MacBook, rustc/rustfmt 1.96.0 per `rust-toolchain.toml`):
`cargo fmt --all --check` clean**after** a whitespace-only commit on the release branch: two files
(`pf-console-ui/src/screens/controllers.rs`, `punktfunk-host/src/audio/linux/pad_card_volume.rs`)
had landed on main formatted differently from rustfmt 1.96.0, so `ci.yml`'s Format step was red on
the tip this is cut from; `cargo metadata --offline` ok with the `Cargo.lock` diff versions-only
(36/36 lines); `cargo test -p punktfunk-core` **272 passed** in the unit suite; the android.yml Play
notes gate run verbatim — 498/500 characters and not byte-identical to any prior release's; both
openapi copies `cmp` identical and unchanged since the tag; `include/punktfunk_core.h` regenerated
by the build and `git diff` clean against the tag.
`cargo fmt --all --check` clean; `cargo metadata --offline` ok with the `Cargo.lock` diff
versions-only (36/36 lines); `cargo test -p punktfunk-core --lib` **273 passed**; the android.yml
Play notes gate run verbatim — 456/500 characters and not byte-identical to any prior release's;
both openapi copies `cmp` identical, both stamped 0.31.0; notes voice scan clean outside the
For developers section.
**The C ABI harness (`tests/c_abi.rs`) did not run on this cut**: it links the staticlib with
`-lopus` and this machine has no libopus (`ld: library 'opus' not found`), which is an environment
gap, not a code fault. The header it exercises is byte-identical to v0.30.0's, where the harness
passed (261 + 1 + 8), and nothing in `punktfunk-core`'s C surface changed. The CI runner is its
first execution for this tag.
**This release was cut more than once.** The first cut (`601f040f`, merged as #320) was never
tagged, and 41 more non-merge commits landed on top of it — the Windows client installer, the
guided Linux installer, the docs overhaul, ABI v25, the KWin 6.6 repair and the takeover's final
shape among them; a handful more (the Virtual displays tab fix, the fifth get-started screenshot)
arrived while the second cut was being written. This section, the version table and the notes are
all re-measured on the latest tip; where the cuts disagreed, the earlier text was **rewritten
rather than appended to**, because none of
the intervening work ever shipped. Specifically: the "C ABI unchanged / header byte-identical"
claim is gone (it is 25 now), the openapi row moved off 0.29.0, the SDK and plugin-kit rows record
cuts that have happened rather than cuts that were owed, and the Gaming Mode takeover section
describes idling the autologin rather than stopping the display manager — a within-cycle correction
no user could have seen.
**Verified by reading only** — compiled nowhere available to the cutting host: the Windows runner
log redirect (`scripting-run.cmd`), the tray's `Option<u16>` port on Windows, and the sway half of
`topology: exclusive` (no live sway in the fleet, as with #283).
**The C ABI harness (`tests/c_abi.rs`) did not run on this cut**, and this time the header *did*
change: it links the staticlib with `-lopus` and this machine has no libopus (`ld: library 'opus'
not found`), which is an environment gap, not a code fault. `punktfunk_set_log_callback` is
therefore compiled by cbindgen and by the Rust unit tests here, but the generated header has not
been compiled by a C compiler on this cut — the CI runner is its first. Worth naming because ABI 25
is the one versioned surface that moved.
**Verified by reading only** — compiled nowhere available to the cutting host: the Windows client
installer and portable zip (`punktfunk-client.iss`, `pack-client-installer.ps1` — the pack step is a
Windows runner's), the Windows runner log redirect (`scripting-run.cmd`), the tray's `Option<u16>`
port on Windows, and the sway half of `topology: exclusive` (no live sway in the fleet, as with
#283).
**Not verified on hardware by this cut**, named rather than left to be discovered: the null-sink
capture topology's on-glass validation (pw-top showing our sink at the top of its own group, 5 min
of loud audio at `delivered_pct=100 gaps=0` on a box where a hardware sink also runs) was still owed
when it landed; the 96 kbps speaker lane was judged on glass by ear only; and the Android
`ASurfaceControl` path was verified on one device (Nothing Phone 3) the fallback presenter is
byte-for-byte the 0.30 one.
when it landed; the 96 kbps speaker lane was judged on glass by ear only; the Android
`ASurfaceControl` path was verified on one device (Nothing Phone 3), with the fallback presenter
byte-for-byte the 0.30 one; the Mac Accessibility intercept (the tap ahead of Spotlight, inside the
sandbox) needs a granted Accessibility switch the dev machine does not have; and `install.sh` is
smoke-tested per package family in CI containers but is shipped **preview** precisely because it has
no real-box mileage, Bazzite above all.
**Owed outside this repository:** `data/platforms.json` changed this cycle (the Windows client
download), and the website's download page vendors a copy that only refreshes when someone runs
`bun run sync-platforms` in punktfunk-website and commits — step 1 of `docs/releases/README.md`.
---
+18 -5
View File
@@ -370,15 +370,26 @@ from the config directory for a true factory reset."
.unwrap_or(DISCOVER_DEFAULT_SECS)
.min(DISCOVER_MAX_SECS);
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
// never hands their ids back. `load` would mint ids for a pre-mint store and save them
// a write from a read-only verb, and one that races the `hosts list` a caller is very
// likely running at the same moment (the Decky panel issues both together).
// `read`, not `load`: this verb never hands a record's id back, so it has no business
// MINTING one. `load` would mint ids for a pre-mint store and save them, racing the
// `hosts list` a caller is very likely running at the same moment (the Decky panel issues
// both together) — after which the ids one of them already handed out no longer resolve.
let known = KnownHosts::read();
let rows: Vec<(
&pf_client_core::discovery::DiscoveredHost,
Option<&KnownHost>,
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
// The one write this verb does make, and why it doesn't contradict the above: an advert
// is the only place a host's wake MAC is ever published, and this verb is the only one
// the Decky panel runs that ever sees one. Without it a Deck in Gaming Mode never learns
// a MAC at all and Wake-on-LAN cannot fire, with nothing to show for it (#322).
// `learn_from_advert` mints nothing either, and writes only when an advert genuinely
// taught the record something new — so a steady-state panel refresh touches no disk.
for (d, saved) in &rows {
if let Some(k) = saved {
trust::learn_from_advert(&k.fp_hex, &k.addr, k.port, &d.mac, &d.os, d.mgmt_port);
}
}
if has(args, "--json") {
let hosts: Vec<serde_json::Value> = rows
.iter()
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
};
let host = &known.hosts[i];
if host.mac.is_empty() {
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name);
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert", host.name);
return UNRESOLVED;
}
if !has(args, "--wait") {
+14 -27
View File
@@ -1087,33 +1087,20 @@ impl HostsPage {
// Online = advertising on mDNS OR proven reachable by the last probe sweep.
let online = self.adverts.values().any(|a| matches(k, a))
|| self.probed.get(&saved_key(k)).copied().unwrap_or(false);
// Learn this host's wake MAC(s) from its live advert while it's online.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && !a.mac.is_empty())
{
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
}
// Same for its OS chain — the icon then survives the host going offline.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && !a.os.is_empty())
{
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
// Same for its management port — and this one is not cosmetic: without it a host
// that moved off 47990 loses its library the moment mDNS is unavailable, because
// the advert was the only place the real port ever lived.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && a.mgmt_port.is_some())
{
if let Some(p) = a.mgmt_port {
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
}
// Learn what this host's live advert teaches while it's online: its wake MAC(s),
// its OS chain (so the icon survives it going offline), and its management port
// — the last one not cosmetic, since a host that moved off 47990 loses its
// library the moment mDNS is unavailable and the advert is the only place the
// real port ever lived.
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
crate::trust::learn_from_advert(
&k.fp_hex,
&k.addr,
k.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
saved.push_back(HostCard {
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
+14 -4
View File
@@ -800,11 +800,21 @@ impl ServiceState {
|| (d.addr == h.addr && d.port == h.port)
});
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
// Write the advertised mgmt port down while the host is visible, so this console
// keeps working against a moved port once it is not. No-op (and no disk write)
// Write down everything the advert teaches while the host is visible: the mgmt
// port (so this console keeps working against a moved one once it is not), the
// OS chain, and the wake MAC — which matters most here, because this console and
// the Decky panel are the only surfaces a Deck in Gaming Mode ever runs, and a
// record that never learned a MAC can never be woken. No-op (and no disk write)
// when unchanged, so this is safe on every refresh tick.
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
if let Some(a) = advert {
pf_client_core::trust::learn_from_advert(
&h.fp_hex,
&h.addr,
h.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
let row = HostRow {
key: key.clone(),
+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;
+15 -23
View File
@@ -700,31 +700,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
// it once it sleeps (no-op / no disk write when unchanged).
if let Some(a) = hosts.iter().find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
&& !h.mac.is_empty()
}) {
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
}
// Same for its OS chain — the tile's mark then survives the host going offline.
if let Some(a) = hosts.iter().find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
}) {
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
// Same for its management port — load-bearing, unlike the two above: a host moved off
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
if let Some(p) = hosts
// Learn what this host's live advert teaches while it's online: its wake MAC(s) (so we
// can wake it once it sleeps), its OS chain (so the tile's mark survives it going
// offline), and its management port — the last load-bearing rather than cosmetic, as
// a host moved off 47990 loses its library entirely once mDNS is gone unless we write
// the port down. No-op, and no disk write, when unchanged.
if let Some(a) = hosts
.iter()
.find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
&& h.mgmt_port.is_some()
})
.and_then(|h| h.mgmt_port)
.find(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
{
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
crate::trust::learn_from_advert(
&k.fp_hex,
&k.addr,
k.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
let can_wake = !online && !k.mac.is_empty();
let menu = {
+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))
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct DiscoveredHost {
/// persisted like `mac`. Empty if absent (older host).
pub os: String,
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
/// Persisted like `mac` (`trust::learn_from_advert`), and load-bearing rather than cosmetic:
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
pub mgmt_port: Option<u16>,
+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"));
}
+2 -2
View File
@@ -8,6 +8,6 @@
//! still load via a serde alias in core.
pub use pf_client_core::trust::{
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
parse_hex32, KnownHost, KnownHosts, Settings,
hex, learn_from_advert, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
KnownHosts, Settings,
};
+82 -54
View File
@@ -675,10 +675,10 @@ pub fn forget_placeholder(addr: &str, port: u16) {
}
}
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
/// data the live host advertised, purely because it came earlier in the file.
/// The record an advert's lesson should land on: the fingerprint match if there is one, else
/// whatever the address resolves to. Fingerprint FIRST — a single pass that took "either" would
/// hand a stale record at the same address the data the live host advertised, purely because it
/// came earlier in the file.
fn learn_target<'a>(
known: &'a mut KnownHosts,
fp_hex: &str,
@@ -692,61 +692,62 @@ fn learn_target<'a>(
known.hosts.get_mut(i)
}
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store.
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
if mac.is_empty() {
return;
}
let mut known = KnownHosts::load();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.mac == mac {
return;
}
h.mac = mac.to_vec();
let _ = known.save();
}
/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` TXT), matched
/// like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store.
pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
if os.is_empty() {
return;
}
let mut known = KnownHosts::load();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.os == os {
return;
}
h.os = os.to_string();
let _ = known.save();
}
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
/// Copy everything an advert can teach onto a saved record — wake MAC(s), OS-identity chain,
/// management port — and report whether anything actually moved, so the caller writes only when
/// there is something to write. Pure (no disk, no clock), which is what makes it testable.
///
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
if mgmt_port == 0 {
return;
/// A field the advert does not carry is left alone, never cleared: an older host simply omits the
/// TXT, and forgetting a MAC already learned would cost the user their wake.
fn apply_advert(h: &mut KnownHost, mac: &[String], os: &str, mgmt_port: Option<u16>) -> bool {
let mut changed = false;
if !mac.is_empty() && h.mac != mac {
h.mac = mac.to_vec();
changed = true;
}
let mut known = KnownHosts::load();
if !os.is_empty() && h.os != os {
h.os = os.to_string();
changed = true;
}
// 0 is how "not advertised" reaches us from a caller whose own type has no `Option`.
if mgmt_port.is_some_and(|p| p != 0 && h.mgmt_port != Some(p)) {
h.mgmt_port = mgmt_port;
changed = true;
}
changed
}
/// Write down everything a live advert teaches the saved record it matched — wake MAC(s), OS
/// chain, management port — matched by fingerprint or address. No-op, and no disk write, when
/// the record already says all three, so a surface can call this on every discovery tick.
///
/// ONE call rather than three. Each field used to be learned by its own function, which meant
/// every front-end had to remember all three, and only the two desktop hosts pages ever did:
/// the console home and the headless CLI learned the management port alone. On a Steam Deck,
/// whose Gaming Mode runs nothing but those two, that left every saved host with no MAC forever
/// — and every wake gate in the codebase reads `!mac.is_empty()` against this record, so
/// Wake-on-LAN there could not fire at all, with no error to show for it (#322).
///
/// [`KnownHosts::read`], not [`KnownHosts::load`]: `punktfunk discover` calls this, and that verb
/// is deliberately not an id-minter (see [`KnownHosts::read`] for the race that avoids). Learning
/// a MAC is no reason to become one.
///
/// Takes the three learned fields rather than a `DiscoveredHost` because there are two of those
/// — core's and the WinUI shell's verbatim port — and this has to serve both.
pub fn learn_from_advert(
fp_hex: &str,
addr: &str,
port: u16,
mac: &[String],
os: &str,
mgmt_port: Option<u16>,
) {
let mut known = KnownHosts::read();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.mgmt_port == Some(mgmt_port) {
return;
if apply_advert(h, mac, os, mgmt_port) {
let _ = known.save();
}
h.mgmt_port = Some(mgmt_port);
let _ = known.save();
}
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
@@ -785,7 +786,7 @@ pub fn touch_last_used(fp_hex: &str) {
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
/// fingerprint alone — the identity a just-connected client is certain of.
///
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
/// This is the mDNS-free path, and the one that matters most: [`learn_from_advert`] can only fire
/// where an advert is visible, whereas this fires on any successful connect, including a host
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
@@ -2293,6 +2294,33 @@ mod tests {
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
}
/// What an advert carries lands on the record; what it omits is left alone; and a repeat of
/// the same advert reports no change — which is what lets every surface call this on every
/// discovery tick without churning the store.
#[test]
fn apply_advert_learns_what_it_carries_and_keeps_what_it_omits() {
let mut h = KnownHost::default();
let mac = vec!["aa:bb:cc:dd:ee:ff".to_string()];
assert!(apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
assert_eq!(h.mac, mac);
assert_eq!(h.os, "linux/arch");
assert_eq!(h.mgmt_port, Some(47991));
// The same advert a tick later: nothing moved, so there is nothing to persist.
assert!(!apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
// An older host advertises none of the three. Clearing a learned MAC here is exactly what
// would cost the user their wake, so an absent field must never overwrite a known one.
assert!(!apply_advert(&mut h, &[], "", None));
assert_eq!(h.mac, mac);
assert_eq!(h.os, "linux/arch");
assert_eq!(h.mgmt_port, Some(47991));
// 0 is how "not advertised" reaches us from a consumer that has no Option — not a port.
assert!(!apply_advert(&mut h, &[], "", Some(0)));
assert_eq!(h.mgmt_port, Some(47991));
// A host that genuinely moved: the new value wins.
assert!(apply_advert(&mut h, &[], "", Some(47992)));
assert_eq!(h.mgmt_port, Some(47992));
}
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
/// presentation state, so a dangling one is never an error surface.
#[test]
+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 {
@@ -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"
+3 -3
View File
@@ -185,11 +185,11 @@
"id": "windows-client",
"name": "Windows client",
"installs": "client",
"packageManager": "msix",
"packageManager": "installer",
"docs": "/docs/install-client#windows",
"install": [
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix",
"Add-AppxPackage .\\punktfunk-client-windows_x64.msix"
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
".\\punktfunk-client-setup_x64.exe"
]
},
{
+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 |
+190 -263
View File
@@ -4,346 +4,273 @@ 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 in the handshake — so a setting the
host can't honor is usually a quiet downgrade rather than an error.
settings each **client** keeps. Most are a *request* — the client asks, the host answers in the
handshake, and a setting the host can't honor is 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, 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).
elsewhere. The Apple TV app and any settings screen reached with a controller show one steppable
list instead — **Stream**, **Video**, **Presentation**, **Audio**, **Controller**, **Touchscreen**,
**Interface**, **Profiles** — the client's **console home** (not the host's
[web console](/docs/web-console)). On a Steam Deck that list *is* the settings surface: the
[Decky plugin](/docs/steam-deck) is a launcher with no settings of its own, and its **Open
Punktfunk** button opens the console home from the Quick Access Menu.
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
`%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their own stores.
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json` (shared with the console home);
Windows in `%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their
own stores.
Changes apply to the **next** session — a running stream keeps what it started with. (*Match window*
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; the names below are the Linux app's, and differences that
matter are noted per setting.
Changes apply to the **next** session. (*Match window* is also read at connect, but once on, every
window resize renegotiates the mode.) Names below are the Linux app's; differences that matter are
noted per setting.
## Video
**Resolution** — *default: Native display.* The host builds a virtual display at exactly this size
and streams it; nothing is scaled. Native resolves at connect to the mode of the display your window
is on. The Apple app instead stores an explicit size (1920 × 1080 out of the box): on iPhone, iPad
and Mac a **Use this display's mode** button fills in what you're looking at, and the Apple TV app
picks a combined **Stream mode** preset instead ("This TV (native)", 720p, 1080p or 4K at 60 Hz). If
the host has been pinned to stream a *real* monitor rather than make one, your request is declined
and your client scales what it gets — see
[Virtual displays](/docs/virtual-displays#stream-a-real-monitor-instead).
**Resolution** — *default: Native display.* The host builds a virtual display at exactly this size;
nothing is scaled. Native resolves at connect to the mode of the display your window is on. The
Apple app stores an explicit size (1920 × 1080 out of the box) with a **Use this display's mode**
button on iPhone/iPad/Mac; Apple TV picks a combined **Stream mode** preset ("This TV (native)",
720p, 1080p or 4K at 60 Hz). A host pinned to stream a *real* monitor declines the request and your
client scales — see [Virtual displays](/docs/virtual-displays#stream-a-real-monitor-instead).
**Match window** — *default: off.* The stream mode follows your window instead, and each resize
renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and
console-home screens (in the console home it is an option inside the Resolution picker, and a
Gaming-Mode stream is always fullscreen, so there it lands on native); not by Android.
**Match window** — *default: off.* The stream mode follows your window; each resize renegotiates
the host's display and encoder. Fullscreen degenerates to the display's native mode. Linux,
Windows, Mac, iPhone/iPad and the console home (inside the Resolution picker; a Gaming-Mode stream
is always fullscreen, so there it lands on native); not Android.
**Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app
stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display,
on a Mac you type one in, and on Apple TV the rate rides along with the Stream mode preset above.
stores an explicit rate (60 Hz default): iPhone and iPad offer the device's displayable rates, on a
Mac you type one in, and Apple TV's rate rides with the Stream mode preset.
**Bitrate** — *default: Automatic.* For H.264, HEVC and AV1, Automatic means the host's own default,
**20 Mbps**, and it turns on two things an explicit rate switches off: adaptive bitrate, and a short
link-capacity probe about two seconds in that measures what your link really carries and lets the
rate climb past 20 Mbps. An explicit rate is fixed for the session, and clamped by the host to
**500 kbps 8 Gbps**. A host card's menu has a **Test network speed…** entry that measures your link
and suggests a value.
**Bitrate** — *default: Automatic.* For H.264, HEVC and AV1, Automatic means the host's default,
**20 Mbps**, plus two things an explicit rate switches off: adaptive bitrate, and a link-capacity
probe about two seconds in that lets the rate climb past 20 Mbps. An explicit rate is fixed for the
session, clamped to **500 kbps 8 Gbps**. A host card's menu has **Test network speed…** to suggest
a value.
PyroWave is the exception: it has no useful low-rate regime, so its Automatic rate is a fixed
per-pixel budget for the negotiated mode (hundreds of Mbps), and both adaptive bitrate and the
capacity probe stay off for the whole session.
PyroWave has no useful low-rate regime: its Automatic is a fixed per-pixel budget for the negotiated
mode (hundreds of Mbps), with adaptive bitrate and the probe off for the whole session.
**Render scale** — *default: Native (1×).* The host renders and encodes at your chosen mode
multiplied by this, and your device resamples the result to its window. Above 1× supersamples for
sharpness, at more bandwidth *and* more decode work; below 1× is lighter on both the host and the
link. The stops run 0.5× to 4×. The result is floored to an even size and capped per axis at
**Render scale** — *default: Native (1×).* The host renders and encodes at your mode times this;
your device resamples to its window. Above 1× supersamples at more bandwidth and decode work; below
1× is lighter on both. Stops 0.5×–4×; the result is floored to an even size and capped per axis at
4096 px for H.264, 8192 px otherwise. Offered everywhere.
**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
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.
**Video codec** — *default: Automatic.* A soft preference: your choice when the host can produce
it, else 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; elsewhere asking for it lands on that same order. See
[PyroWave](/docs/pyrowave). Android and Apple hide AV1 without a hardware AV1 decoder; Android never
offers PyroWave.
**10-bit HDR** — *default: on.* Off means "never send me 10-bit", and the host then never upgrades.
On, the stream goes 10-bit BT.2020 PQ only when the host has HDR content *and* the encoder can do
10-bit. Android disables the toggle, and never advertises HDR, on a panel that can't present HDR10.
Full detail: [HDR](/docs/hdr).
**10-bit HDR** — *default: on.* Off means "never send me 10-bit". On, the stream goes 10-bit
BT.2020 PQ only when the host has HDR content *and* the encoder can do 10-bit. Android disables the
toggle, and never advertises HDR, on a panel that can't present HDR10. Full detail: [HDR](/docs/hdr).
**Full chroma (4:4:4)** — *default: off.* Crisp small text and thin lines, at more bandwidth. It
needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full
chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is
built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware
decode probe to pass). The console home offers the toggle; Android doesn't.
**Full chroma (4:4:4)** — *default: off.* Crisp small text and thin lines, at more bandwidth. Needs
HEVC or PyroWave, the host's 4:4:4 policy on, a capture path that delivers full chroma, and a GPU
that can encode it; if any gate fails the host says 4:2:0 before your decoder is built. Apple
(hardware decode probe required), Linux, Windows and the console home; not Android.
**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 it too, stored under the same name, so a
**Prioritize** — *default: Lowest latency.* **Lowest latency** shows every frame the moment the
display can take it a network hiccup becomes an occasional repeated or skipped frame.
**Smoothness** holds a small buffer that evens hiccups out, at that buffer's worth of added delay.
Linux, Windows, the console home, Apple and Android — stored under the same name everywhere, 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**.
**Smoothness buffer** — *default: Automatic (two frames).* Frames held back before showing. Each
absorbs roughly one screen refresh of jitter and costs one refresh of delay — on a 120 Hz screen,
two frames ≈ 17 ms both ways. Appears wherever **Prioritize** is offered, once **Smoothness** is
picked.
**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.
**V-Sync** — *default: on.* Tear-free presentation. Off shows each frame the instant it's ready:
the lowest delay a display can give, with visible tearing on fast motion. Best-effort — where the
driver or compositor has no tearing mode the stream stays tear-free, and the Detailed
[stats overlay](/docs/stats) names the mode actually in use. Linux, Windows, 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
frame being ready and the screen being willing to show it. Applies to **fullscreen** sessions (a
windowed one is at the compositor's mercy) and is harmless on a fixed-refresh screen. It needs a
graphics driver that offers the modern queue-free display mode; on an older driver it does nothing
unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the
older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps
and the console home.
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, the panel
refreshes in step with the stream. Applies to **fullscreen** sessions; harmless on a fixed-refresh
screen. Needs a driver with the modern queue-free display mode; on an older driver it does nothing
unless `PUNKTFUNK_VRR_FIFO=1` is set (see [configuration](/docs/configuration)). The stats overlay
reports `vrr yes` once it has *measured* the panel following. Linux, Windows, console home.
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual
output. Advisory: a host without that backend quietly auto-detects instead.
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the
virtual output. Advisory: a host without that backend auto-detects instead.
## Audio
**Audio channels** — *default: Stereo.* You can ask for **5.1** or **7.1**; anything else is read as
stereo. The count the host will really send comes back in the handshake, and your client builds its
decoder from *that*, never from the request. What surround means differs by host: a **Linux** host
claims a sink advertising exactly that many channels, so applications produce real surround, while a
**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.
**Audio channels** — *default: Stereo.* **5.1** or **7.1** on request; anything else reads as
stereo. The count the host will really send comes back in the handshake and your decoder is built
from *that*. A **Linux** host claims a sink with exactly that many channels (real surround); a
**Windows** host loopback-captures the current output endpoint and lets Windows convert — 5.1 from
a stereo endpoint is an upmix. 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).
app.* Sends this device's microphone to the host's virtual mic. Spelled *Stream microphone* on
Linux and Windows; **Ctrl+Alt+Shift+V** mutes it mid-stream — 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
**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).
**Echo cancellation** — *default: on.* Stops the host's audio, playing from this device's speakers,
from re-entering the microphone. It uses the system's own canceller: an echo-cancelled PipeWire
source on **Linux**, the WASAPI Communications stream category on **Windows**, the platform
voice-processing mode on **Apple** and **Android**. Turn it off if your microphone runs its own
processing or the canceller thins your voice. Sits under the microphone toggle, greyed out while
the mic is off. Linux, Windows, Apple, Android, console home. See
[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
**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV,
Android and the console home have none, and the Windows app has none and ignores a stored speaker
choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather than
silently snapping back to the default; the Mac shows it as "Unavailable device". A Steam Deck in
Gaming Mode therefore has no endpoint picker at all: the session uses whatever the Desktop-Mode app
last stored, and the system default otherwise.
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint plays the
stream, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the **Mac** app
(plus a microphone *channel* picker) have these; the Windows app ignores a stored speaker choice. A
vanished device keeps a "(not detected)" entry on Linux, "Unavailable device" on the Mac. A Steam
Deck in Gaming Mode has no endpoint picker: the session uses what the Desktop-Mode app last stored.
## Input
Touch modes, mouse modes and the in-stream chords have their own page: [Input](/docs/input). Five
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 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.
**Forward controllers** — *default: on*, everywhere. Off, controllers connected to *this* device
are not sent to the host what you want when the controller already reaches the host another way
([USB passthrough](/docs/automation#recipe-full-controller-passthrough-virtualhere) such as
VirtualHere, or a pad plugged into the host), where forwarding would hand games two controllers for
one pair of hands.
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.
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.
On Linux and Windows, opening a controller *claims* it (SDL takes the device node), and a
passthrough tool can't bind a claimed device; off, the session never opens the pad. Consequence:
the [controller escape chord](/docs/input#leaving-with-a-controller) is read off forwarded pads, so
on those two it is unavailable while this is off — leave with the keyboard chord or the client's
UI. The Apple and Android apps claim nothing, so their chords keep working; Android does stop its
DualSense and Steam Controller 2 USB captures, which do claim the device. The rows below grey out
while this is off.
**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`.
which matches each physical controller. Pickers offer Xbox 360, Xbox One, DualSense and DualShock 4
everywhere, plus Steam Deck on Linux, Android and the console home. The host builds each virtual
pad from the declared type; a type the host has no backend for degrades to an Xbox 360 pad (Xbox
One on a Windows host, any Sony pad on a Linux host that can't open `/dev/uhid`).
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
type you pick.
That degrade matters for **motion**: an Xbox-class virtual pad has no gyroscope, so a session on
one throws every motion sample away. Automatic lands there for any pad not recognised as Sony or
Valve (an 8BitDo with a gyro, say), and for a Switch Pro streaming to a Windows host. **If you want
motion, pick a DualSense-class type** — DualSense, DualSense Edge, DualShock 4, Switch Pro or Steam
Deck all carry a motion plane. Clients say so on-screen when it happens; the setting applies from
the next session. On a **Steam Deck as the client**, motion also needs Steam Input off for
punktfunk — with it on, Steam hands the app its own virtual Xbox pad.
**Forwarded controller** (*Use controller* on Apple and the console home) — *default: Automatic*,
which forwards *every* connected controller, each as its own player, on Linux, Windows, Apple and the
console home. Pinning one restricts the session to that controller alone — single-player. The Android
app has no such picker.
which forwards *every* connected controller, each as its own player. Pinning one restricts the
session to that controller alone. Linux, Windows, Apple, console home; not Android.
**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, on every
client. Where the guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to
host** forwards them raw, **This device** keeps them local. Automatic forwards everywhere except
Gaming Mode, where SteamOS opens its own menus for those buttons no matter what — forwarding raw
there opens *both* menus at once, the local one covering the stream. The full story, including how
to reach the host's menus when the raw press stays local, is on the
[Input page](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access).
**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, everywhere.
Where guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to host** forwards
them raw, **This device** keeps them local. Automatic forwards everywhere except Gaming Mode, where
SteamOS opens its own menus for those buttons regardless — forwarding raw there opens both menus at
once. Full story:
[the guide button](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access).
**Hold Select for guide** — *default: Automatic*, on every client. The gesture that presses the
host's guide button from any controller: hold Select (Back/View) on its own for about a third of a
second, and keep holding for the host's long-press (a Gaming-Mode host's Quick Access Menu, on a
regular pad). Automatic arms it only where the raw guide press can't reach the host cleanly —
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
**Hold Select for guide** — *default: Automatic*, everywhere. The gesture that presses the host's
guide button from any controller: hold Select (Back/View) alone ~⅓ s; keep holding for the host's
long-press. Automatic arms it only where the raw press can't reach the host cleanly — Gaming Mode,
iPhone/iPad, Apple TV — because the gesture costs: a Select *tap* arrives a beat late, and a game
expecting a *held* Select would trigger it. **On**/**Off** overrule.
**Controller haptics** — *default: on*, and **Controller speaker***default: on* on the Linux and
Windows apps, *off* on Android. The two halves of [controller audio](/docs/controller-audio): a
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. Turn **Controller speaker** off if
you would rather all game audio came out of your speakers or headset.
**Controller haptics** — *default: on*, and **Controller speaker***default: on* on Linux and
Windows, *off* on Android. The two halves of [controller audio](/docs/controller-audio): a
DualSense's voice-coil haptics and the pad's speaker. Both need a **wired** DualSense or DualSense
Edge — Bluetooth exposes no audio device, and both settings quietly do nothing. The plane is
negotiated, so neither costs anything without a host that sends them. Linux, Windows and Android.
On Linux the client also switches the pad's sound card to Pro Audio while it needs the voice coils
and puts it back afterwards — see
[the controller-audio page](/docs/controller-audio#on-a-linux-client-the-pads-own-profile-matters-too).
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
[the controller-audio page](/docs/controller-audio#on-a-linux-client-the-pads-own-profile-matters-too)
for why that is necessary and how to turn it off.
**Capture system shortcuts** — *default: on.* Linux, Windows (spelled *Capture system shortcuts
(Alt+Tab, Win, …)*), macOS and the console home; on a Deck it matters only for an attached keyboard
(gamescope holds nothing back). On, Alt+Tab and the Windows/Super key reach the host while input is
captured; off, they act locally. Either way the chords return when you release capture with
**Ctrl+Alt+Shift+Q**, the window loses focus, or the stream ends —
[Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving it on means
**Ctrl+Alt+Shift+Q is your way out**, since Alt+Tab no longer is.
**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: 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 — on, ⌘Q, ⌘W, ⌘H and the rest go to the host
while input is captured (⌘Q arrives as Super+Q); off, they act on the Mac, which means ⌘Q quits
Punktfunk mid-stream. **⌘⎋ always stays local** — it releases capture, as does ⌃⌥⇧Q, and ⌃⌘F keeps
working on the window. ⌘Tab, ⌘Space and the Mission Control keys never reach the host either way —
macOS claims them first.
On macOS the chords in question are the **⌘** ones — ⌘Q above all, which reaches the host as Super+Q,
one of the most-bound chords on a Linux desktop. On, ⌘Q, ⌘W, ⌘H and the rest go to the host instead
of this app's menu bar while input is captured. Off, they act on the Mac as usual, which means ⌘Q
quits Punktfunk mid-stream. **⌘⎋ always stays local whichever way the toggle is set** — it is what
releases capture, as is ⌃⌥⇧Q, and ⌃⌘F keeps working on the window. A few chords never reach the host
either way, because macOS claims them before any app can see them: ⌘Tab, ⌘Space, and the Mission
Control keys.
On Linux this needs a compositor that supports keyboard-shortcuts-inhibit — KDE Plasma, GNOME and
the wlroots compositors all do, and X11 sessions grab the keyboard directly. Under
[gamescope](/docs/gamescope) there is nothing to inhibit: it hands the session everything already.
On Linux this needs a compositor with keyboard-shortcuts-inhibit — KDE Plasma, GNOME and wlroots
compositors have it, X11 sessions grab the keyboard directly. Under [gamescope](/docs/gamescope)
there is nothing to inhibit.
**Invert scroll direction** — *default: off*, i.e. the host scrolls the way this machine does.
## Behavior
**Auto-wake on connect** — *default: on.* Connecting to a saved host that looks offline sends
Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already
learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by
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. 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).
Wake-on-LAN and waits — only for a host whose MAC this client has learned. Turn it off for hosts
reached over a VPN, where the wake only adds delay. Linux, Windows, Apple, Android and the console
home; on a Steam Deck it also governs the [Decky plugin's](/docs/steam-deck) launches. The console
home additionally offers wake as an explicit action on an offline host, whatever the toggle says.
See [Wake-on-LAN](/docs/wake-on-lan).
**Show game library** — *Apple and Android only, default: on.* Browse a paired host's games and
launch one directly. The Linux and Windows apps have nothing to switch on — **Browse library…** sits
on every paired host's card — and neither does the console home, whose **Library** button was always
offered on any paired host. See [Game library](/docs/game-library).
launch one directly. Linux and Windows have **Browse library…** on every paired host's card, and
the console home a **Library** button — nothing to switch. 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.
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window returns
with the host list. The console home carries the row for the desktop client that shares the store —
a Gaming-Mode launch is fullscreen regardless. Not on iPhone, iPad, Apple TV or Android.
## Interface
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.
How the client itself looks. None touches a stream, so none can live in a
[profile](/docs/profiles-and-links).
**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
settings you can step with a thumbstick. The Apple and Android apps have this switch. Turn it off to
stay in the touch interface even with a pad in your hands. On Linux, Windows and the Steam Deck the
controller-optimized home is a separate entry point rather than a switch, so there is nothing to
turn off. An Android TV is always in this mode — its remote is the only input it has.
**Gamepad-optimized browsing** — *default: on.* Swaps the touch/desktop home for the
controller-optimized one: host carousel, larger focus targets, a swipeable cover browser, steppable
settings. Apple and Android have the switch; on Linux, Windows and the Steam Deck the
controller-optimized home is a separate entry point. An Android TV is always in this mode.
**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**: 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.)
**Show it** — *default: With a controller.* Shown while the switch above is on. **With a
controller**: the controller-optimized home appears as a pad connects, the touch interface returns
when the last one disconnects. **Always** keeps it either way — for a phone or tablet docked to a
TV. Apple and Android (an Android TV is in that mode regardless).
**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: 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
Linux and Windows console home. The Apple TV is the exception: it carries **Background** in its
ordinary Settings instead, next to **Show it**, because its controller-optimized home needs a real
controller to open and the palettes would otherwise be unreachable from the Siri Remote.
**Background** — *default: Violet.* The colour family of the controller-optimized home's backdrop.
Thirteen: seven dark — **Violet**, **OLED**, **Nebula**, **Abyss**, **Ember**, **Moss**,
**Graphite** — and six pale **Holo**, **Sunset**, **Bloom**, **Dawn**, **Mint**, **Opal** — which
flip the interface to dark text on a light field. The backdrop recolours as you step the row.
**OLED** is true black: most of the frame is pixels switched off — no glow, no power on an
OLED/AMOLED panel. Stored under the same name on every client. The row lives in the
controller-optimized settings (**X** from the controller-optimized home) everywhere that has one,
including the Steam Deck and the Linux/Windows console home; the Apple TV carries it in ordinary
Settings next to **Show it** instead, so it's reachable from the Siri Remote.
## Overlay
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
superset of the one before. This setting only picks the tier a session *starts* at — you can cycle
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).
superset of the last. This picks the tier a session *starts* at; cycle live in-stream with a
per-platform shortcut. The Apple app also picks the corner (Top/Bottom × Left/Right). The console
home has the tier picker under **Interface**. Shortcuts and every number:
[Understanding the stats overlay](/docs/stats).
## Settings that are facts about your device
A few of these describe the machine you're sitting at rather than how you want a host streamed. They
stay global and **cannot be put in a settings profile**:
These describe the machine you're sitting at, stay global, and **cannot be put in a settings
profile**:
- **Video decoder** and **GPU** — the decode path and adapter this device uses. Automatic is
vendor-ordered and falls back on its own; change it only when debugging, and note that
`PUNKTFUNK_DECODER` overrides it
([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux,
Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has
more than one adapter — the console home has none, and a Deck has a single adapter anyway. The
Apple and Android apps have neither.
vendor-ordered and falls back on its own; change only when debugging; `PUNKTFUNK_DECODER`
overrides it ([Configuration](/docs/configuration#client-side-native-clients)). Decoder picker:
Linux, Windows, console home. GPU picker: Windows, and Linux with more than one adapter. Apple
and Android have neither.
- **Speaker** and **Microphone** device pickers — this device's audio endpoints.
- **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a
preference and can live in a profile; which pad you hold cannot. **Forward controllers** is a
preference too, and does live in a profile — a work profile can decline to forward what a game
profile forwards.
- **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 has nothing to do with how a host streams to it.
- **Forwarded controller** — which physical pad is in your hands. (The *type* the host creates is a
preference and can live in a profile, as can **Forward controllers**.)
- **Auto-wake on connect**, and **Show game library** where it exists (Apple, Android).
- Everything under **Interface**.
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 —
+3 -3
View File
@@ -113,7 +113,7 @@ per-vendor: **Vulkan Video, then D3D11VA, then software** on NVIDIA and AMD, and
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 MSIX**.
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,
@@ -148,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
@@ -213,7 +213,7 @@ 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 |
+25 -34
View File
@@ -42,13 +42,11 @@ uplink keeps running underneath, so unmuting is instant.
While muted, a **Microphone muted** badge sits in the top-right corner of the stream — separate
from the [stats overlay](/docs/stats), so it shows even with stats off.
The mute lasts for that stream only — the next session starts unmuted, and nothing is written to
your settings. If the stream isn't sending a microphone at all (**Stream microphone** off in
[client settings](/docs/client-settings#audio)) the shortcut does nothing and no badge appears.
**Linux and Windows** clients only — a Steam Deck stream is the Linux client, so an attached
keyboard gets the chord. The Apple and Android clients have no mute shortcut yet; turn **Stream
microphone** off in their settings instead.
The mute lasts for that stream only — the next session starts unmuted; nothing is written to your
settings. With **Stream microphone** off in [client settings](/docs/client-settings#audio) the
shortcut does nothing and no badge appears. **Linux and Windows** clients only (a Steam Deck stream
is the Linux client, so an attached keyboard gets the chord); on Apple and Android turn **Stream
microphone** off in settings instead.
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
for with the chord stays released until you opt back in. Either way, keys and buttons you were
@@ -104,10 +102,9 @@ watching for the chord either way.
### Statistics with a controller
The **Apple** apps reserve a second chord: **Select + X**, which cycles the
[stats overlay](/docs/stats) one level each time you complete it. For when you have a pad but no
keyboard for **⌃⌥⇧S** and no free screen for the three-finger tap; on **Apple TV** it is the only
way there with a pad. X is deliberately none of the four leave-chord buttons, so reaching for one
chord never trips the other. Both buttons still reach the game; only the overlay changes locally.
[stats overlay](/docs/stats) one level each time you complete it — for a pad with no keyboard and
no free screen for the three-finger tap; on **Apple TV** it is the only way there with a pad. Both
buttons still reach the game; only the overlay changes locally.
On the **Siri Remote**, **hold Play/Pause** for about half a second instead. A quick tap is still a
right click, sent when you let go.
@@ -115,26 +112,23 @@ right click, sent when you let go.
### The guide button (Xbox / PS / Steam) and Quick Access
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
meant to open menus **on the host**: the Steam overlay, or a Gaming-Mode host's Steam menu. Some
devices want that button for themselves, so every client also carries a gesture that works
everywhere:
**Hold Select (Back / View) on its own for about a third of a second.** The host sees its guide
button held down for as long as you hold — a long press, which is how SteamOS opens the **Quick
Access Menu** for a regular pad. A quick tap of Select still reaches the game, delivered when you
let go (a beat late). Select pressed as part of a combo — including the leave chord above — passes
through untouched.
meant to open menus **on the host**. Some devices want that button for themselves, so every client
also carries a gesture that works everywhere: **hold Select (Back / View) on its own for about a
third of a second**. The host sees its guide button held for as long as you hold — a long press,
which is how SteamOS opens the **Quick Access Menu** for a regular pad. A quick tap of Select still
reaches the game, delivered when you let go (a beat late); Select in a combo — including the leave
chord above — passes through untouched.
What the raw button does, per client:
- **Linux & Windows desktop, macOS, Android** — the guide press is forwarded to the host. If Steam
Big Picture or the Xbox Game Bar is also watching for it *on the device in your hands*, both may
react — that's a local setting on that device, not something the stream can suppress.
- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default:
SteamOS always opens its own menus for them, so forwarding the raw press as well opened BOTH menus
at once, the Deck's on top of the stream. Reach the host's menus with **hold-Select**, or with the
Punktfunk panel's **Host menus** buttons ([Steam Deck page](/docs/steam-deck)). The old behavior
is one setting away: **Steam / guide button → Send to host**.
- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default
(SteamOS always opens its own menus for them; forwarding the raw press too opens both menus at
once). Reach the host's menus with **hold-Select**, or the Punktfunk panel's **Host menus**
buttons ([Steam Deck page](/docs/steam-deck)); **Steam / guide button → Send to host** restores
the old behavior.
- **iPhone / iPad** — iOS reserves the Home press for its own Game Overlay, so hold-Select is the
reliable route to the host's overlay. On iOS 27 or later you can also hand the button to the app
yourself, in the system's per-controller Home-button setting.
@@ -200,11 +194,9 @@ Touch passthrough depends on the host being able to inject touch, and that varie
| Sway, Hyprland and other wlroots compositors | Not injected — contacts are dropped |
| gamescope Gaming Mode | Degraded to a single absolute pointer — see [gamescope](/docs/gamescope) |
The gamescope row is a rule, not an exception: wherever the compositor offers no touchscreen device
to drive, only the first finger is used, as an absolute pointer. Tapping still clicks; pinches and
other multi-finger gestures do not survive.
The trackpad and pointer models are unaffected by all of this: they send ordinary mouse events.
Wherever the compositor offers no touchscreen device to drive, only the first finger is used, as
an absolute pointer — tapping still clicks; pinches and multi-finger gestures don't survive. The
trackpad and pointer models are unaffected: they send ordinary mouse events.
## Pen and stylus
@@ -237,10 +229,9 @@ The Linux, Windows, macOS and Apple TV clients do not send stylus input.
the virtual gamepads need, step 3 of your [install guide](/docs/install). Without it the host never
offers pen at all.
**If the host is too old, or pen is switched off**, nothing breaks: the client keeps folding the
stylus into its ordinary touch or pointer path. You can still draw — just without pressure and tilt.
Whether pen splits out is decided by the host, not by your touch mode: you can be in Trackpad mode
and still draw with full fidelity.
**If the host is too old, or pen is switched off**, the client folds the stylus into its ordinary
touch or pointer path — you can still draw, without pressure and tilt. Whether pen splits out is
decided by the host, not your touch mode.
**Operators** can turn the whole feature off by setting `PUNKTFUNK_PEN=0` in the host's `host.env`
(see [Configuration](/docs/configuration)). The host then stops advertising pen to Punktfunk and
+41 -22
View File
@@ -22,7 +22,7 @@ Already installed? Skip to [Keeping a client up to date](#keeping-a-client-up-to
|--------|---------|
| **Linux** desktop / laptop | [Flatpak](#linux-desktop-flatpak) (any distro) or native apt/rpm/Arch packages |
| **Steam Deck** | [Decky plugin](/docs/steam-deck) for Gaming Mode, or [Flatpak in Desktop Mode](#steam-deck) |
| **Windows** | [Signed MSIX](#windows) from the package registry |
| **Windows** | [Signed installer](#windows) from the package registry (portable zip and MSIX too) |
| **macOS** | [Notarized `.dmg`](#macos) from the releases page |
| **iPhone / iPad / Apple TV** | [TestFlight beta](#ios-ipados-apple-tv) |
| **Android / Android TV** | [Google Play](#android), or sideload the APK |
@@ -100,38 +100,51 @@ See [packaging/flatpak](https://git.unom.io/unom/punktfunk/src/branch/main/packa
## Windows
The Windows client ships as a **signed MSIX** in the package registry, signed with a publicly
trusted certificate — nothing to import or trust by hand.
The Windows client ships as a **signed installer** in the package registry, signed with a publicly
trusted certificate — nothing to import or trust by hand. It installs per-user (no admin prompt) to
`%LOCALAPPDATA%\Programs\Punktfunk`.
1. Download the package. Each channel keeps one fixed URL, so this line always fetches the current
build — in PowerShell:
1. Download the installer. Each channel keeps one fixed URL, so this line always fetches the
current build — in PowerShell:
```powershell
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix
curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe
```
Swap `_x64` for `_arm64` on an Arm device, and `latest` for `canary` to track `main`. The same
file is attached to every [release](https://git.unom.io/unom/punktfunk/releases), and every
build is kept under its own version on the
[packages page](https://git.unom.io/unom/-/packages) (generic group, `punktfunk-client-windows`).
2. Install it:
```powershell
# use the _arm64 file instead on an Arm device
Add-AppxPackage .\punktfunk-client-windows_x64.msix
```
If Windows reports a missing dependency, install the
2. Run it. The installer registers `punktfunk://` links, puts the headless `punktfunk` command on
your PATH, and fetches the
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
(the MSIX depends on `Microsoft.WindowsAppRuntime.2`), then re-run `Add-AppxPackage`.
automatically if this PC doesn't have it yet.
3. Launch **Punktfunk** from the Start menu and pick your host. A second entry, **Punktfunk
Console**, is the same client as a controller-driven fullscreen interface for a TV or HTPC.
Install from a signed-in desktop session. Over a remote, non-interactive session (SSH, an RMM
tool) `Add-AppxPackage` can fail with `0x80070005` when the Windows App Runtime is in use and
Windows can't restart the apps holding it.
### Launching through Steam (overlay, Big Picture)
3. Launch **Punktfunk** from the Start menu and pick your host. The package also adds a second
entry, **Punktfunk Console** — the same client as a controller-driven fullscreen interface for a
TV or HTPC — and the headless `punktfunk` command on your PATH.
Because the client is a normal exe at a stable path, you can hand it to Steam: **Add a Non-Steam
Game** → browse to `%LOCALAPPDATA%\Programs\Punktfunk\punktfunk-client.exe` (or
`punktfunk-console.exe` for the couch interface). Launched that way, the **Steam overlay** and
controller configs work in the stream, and it's launchable from **Big Picture**. This is exactly
what the older MSIX package couldn't do — Steam can neither browse nor inject into an app under
`WindowsApps` — so if you set that up before, reinstall with the installer above and re-add it.
### Portable zip and MSIX
Two alternates, same signed binaries, published next to the installer on every build:
- **Portable**`…/latest/punktfunk-client-windows_x64-portable.zip`: unzip anywhere and run
`punktfunk-client.exe`. Nothing is registered, so `punktfunk://` links and the `punktfunk`
command on PATH stay with the installer. Needs the
[Windows App Runtime 2.x](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads)
installed once.
- **MSIX**`…/latest/punktfunk-client-windows_x64.msix`, kept for Microsoft Store
compatibility: `Add-AppxPackage .\punktfunk-client-windows_x64.msix`. If Windows reports a
missing dependency, install the Windows App Runtime 2.x above and re-run it. Install from a
signed-in desktop session — over SSH/RMM, `Add-AppxPackage` can fail with `0x80070005`. Note the
Steam integration above does **not** work from the MSIX.
> The Windows client's hardware decode and HDR10 present are validated on glass on NVIDIA and Intel
> (including HDR pass-through on the Intel D3D11VA path). If anything misbehaves,
@@ -218,7 +231,8 @@ but keeping them close is the least surprising. (Updating the **host** is its ow
| **Linux Flatpak** | `flatpak update --user io.unom.Punktfunk`**without `sudo`** (see the [Flatpak section](#linux-desktop-flatpak)) |
| **Linux apt / dnf / pacman** | your normal `sudo apt upgrade` / `sudo dnf upgrade` / `sudo pacman -Syu`, or the app's own updater below |
| **Fedora Atomic (layered)** | `rpm-ostree upgrade` on its own is not enough — see the note below the table |
| **Windows MSIX** | no self-update — download the newer `.msix` as in [Windows](#windows) and re-run `Add-AppxPackage`. Coming from **0.28.1 or earlier**, see the note below the table |
| **Windows installer** | download the newer `punktfunk-client-setup_<arch>.exe` as in [Windows](#windows) and run it — it upgrades in place, keeping your saved hosts and pairing. Switching **from the MSIX**, see the note below the table |
| **Windows MSIX / portable** | no self-update — download the newer `.msix` and re-run `Add-AppxPackage` (from **0.28.1 or earlier**, see the note below the table), or unzip the newer portable build over the old one |
| **macOS `.dmg`** | download the newer `Punktfunk-<version>.dmg` and drag it over the copy in Applications |
| **iOS / iPadOS / tvOS** | TestFlight updates it |
| **Android** | Google Play updates it; if you sideloaded, download the APK again and install over it |
@@ -253,6 +267,11 @@ This is one-time; releases after that upgrade in place. A packaged app's setting
package, so removing the old one also removes this client's identity and its paired hosts — expect
to [pair](/docs/pairing) again once. Nothing on the host side is affected.
**Switching from the MSIX to the installer** (for the [Steam integration](#launching-through-steam-overlay-big-picture),
or just to follow the new default): remove the MSIX first — `Get-AppxPackage unom.Punktfunk |
Remove-AppxPackage` — then run the installer. Same caveat as above: the packaged app's saved hosts
and pairing identity go with the package, so expect to pair again once.
### The Linux client can update itself
The native Linux client checks its own channel and can apply the update in place, whichever package
+2 -2
View File
@@ -30,13 +30,13 @@ console, and tells you how to pair:
<Installer />
Prefer to read what runs first (it's plain `sh`, ~350 lines):
Prefer to read what runs first (it's plain `sh`, ~450 lines):
<Installer inspect />
It asks before anything optional (Moonlight compat, the shared clipboard, the `punktfunk` group,
starting at boot) and every answer has a default, so `sh install.sh --yes` — or piping it with no
terminal — runs unattended; `--channel canary`, `--mgmt-port`, `--no-start` and the environment
terminal — runs unattended; `--channel canary`, `--mgmt-port`, `--no-start`, `--uninstall` and the environment
twins (`PUNKTFUNK_INSTALL_YES`, `PUNKTFUNK_INSTALL_CHANNEL`, `PUNKTFUNK_INSTALL_GAMESTREAM`,
`PUNKTFUNK_INSTALL_CLIPBOARD`, `PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP`, `PUNKTFUNK_INSTALL_LINGER`,
`PUNKTFUNK_INSTALL_MGMT_PORT`) are listed by `--help`. It covers Ubuntu/Debian, Fedora, Arch-family
+7 -7
View File
@@ -36,15 +36,15 @@ stream on a host this device already trusts.
## Choosing access when you admit a device
Approving and deciding what the device may do are one dialog. Alongside the name:
Approving and deciding what the device may do are one dialog:
- **Access level****Full control**, **Controller only**, or **View only** (an **Advanced**
expander has the individual toggles — [Access levels](/docs/access-levels)).
- **Expires****Forever**, or 1 h / 4 h / 8 h / custom.
![Approve this device: name, access level, expiry, and the one-click Approve as guest](/img/console-approve-device.png)
Defaults are *Full control · Forever* — right for your own new laptop. For a friend's device there
is a one-click **Approve as guest**: Controller only, for 4 hours, then it expires on its own. The
same two controls sit on the **Pair a device** card, and apply to whichever device completes the PIN.
The levels are **Full control**, **Controller only** and **View only** (**Advanced** opens the
individual toggles — [Access levels](/docs/access-levels)); expiry is **Never** or 1 h / 4 h / 8 h /
custom. The defaults are right for your own new laptop; **Approve as guest** is for a friend's
device — Controller only, for 4 hours, then it expires on its own. The same two controls sit on the
**Pair a device** card, and apply to whichever device completes the PIN.
## Managing paired devices
+94 -118
View File
@@ -5,7 +5,8 @@ description: First-party plugins — bring each of your game launchers into the
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
plugin runs alongside the host, reconciles titles into your [game library](/docs/game-library) as a
provider — so they appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
provider — so they appear in the grid on every client — and can add its own page to the
[web console](/docs/web-console).
**Your game library is built from plugins.** From v0.28.0 the host scans no launcher on its own;
install the one for each launcher you use.
@@ -29,8 +30,7 @@ off succeeds and starts nothing, so check it first:
1. Open the [web console](/docs/web-console) → **Plugins** → **Installed** and look at the **Plugin
runner** card. *Not installed*: install the runner package first (see
[Troubleshooting](#troubleshooting)). *Disabled*: press **Enable runner** — once per host.
*Running* means you're set; *Stopped* means the runner is enabled but not up right now, and its
log says why (see [Troubleshooting](#troubleshooting)).
*Stopped*: the runner is enabled but not up, and its log says why.
2. Go to **Browse**, pick a plugin from the catalog and confirm. The host installs it and restarts
the runner, and the plugin's own page appears in the console's nav.
@@ -39,14 +39,13 @@ off succeeds and starts nothing, so check it first:
### What "Verified" means
Every catalogued plugin pins **one exact version** and its package hash. **Verified** means somebody
at unom reviewed *that exact package* — not the project in general, not whatever it publishes next;
when a plugin releases a new version, the store keeps offering the reviewed one until the new release
is reviewed too. Before anything is downloaded, the host re-checks the pinned hash against the
registry, so a package quietly republished under the same version number is refused.
at unom reviewed *that exact package* — not the project in general; a new release keeps offering the
reviewed version until the new one is reviewed too. Before anything is downloaded, the host
re-checks the pinned hash against the registry, so a package quietly republished under the same
version number is refused.
A catalog can also **revoke** a version. When an advisory covers an entry, the console shows the
reason and won't install that version — on **Browse** as a red-ringed panel with the install button
disabled, on **Installed** as a warning against the plugin you already have. It never removes
A catalog can also **revoke** a version: the console shows the advisory and won't install it — a
red-ringed panel on **Browse**, a warning on **Installed** if you already have it. It never removes
running code for you.
The three badges:
@@ -54,18 +53,15 @@ The three badges:
| Badge | Where it came from |
|---|---|
| **Verified** | The built-in catalog. unom reviewed this exact package. |
| **External source**, *from &lt;source&gt;* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. This badge is amber, not the red **Unverified** below. |
| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the plugin stays marked this way for as long as it is installed. |
| **External source**, *from &lt;source&gt;* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. Amber, not the red **Unverified** below. |
| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the mark stays for as long as it is installed. |
### Adding another catalog
**Sources** → *Add a catalog source*: a name and the URL of its index. Optionally paste the source's
`ed25519:…` public key — with a key set, the host refuses any index from that source that isn't
correctly signed, rather than falling back to an unsigned one.
Adding a source is a trust decision you make once: its plugins become installable on this host,
always attributed to it and never carrying the Verified badge, which belongs to the built-in
catalog alone.
correctly signed. Adding a source is a one-time trust decision: its plugins become installable,
always attributed to it, never carrying **Verified**.
To publish a plugin to the built-in catalog, open a pull request against
[`punktfunk-plugin-index`](https://git.unom.io/unom/punktfunk-plugin-index) — its README covers the
@@ -73,9 +69,6 @@ format and what review looks for.
## Installing from the CLI
Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating
the plugins directory, pointing it at the package registry, and starting the supervisor.
<Tabs items={['Linux', 'Windows']}>
<Tab value="Linux">
@@ -85,43 +78,43 @@ punktfunk-host plugins enable # turn the runner on (once)
```
On **SteamOS** the [host installer](/docs/steamos-host) ships the runner automatically (user-scoped
under `~/.local` — the read-only `/usr` can't take the package). If the console reports the runner
isn't installed on an older setup, re-run `scripts/steamdeck/update.sh` once.
under `~/.local`). If the console reports the runner isn't installed on an older setup, re-run
`scripts/steamdeck/update.sh` once.
</Tab>
<Tab value="Windows">
Run these from an **elevated** PowerShell — right-click **PowerShell** → **Run as administrator**.
The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned. The runner task
itself runs as the low-privilege `NT AUTHORITY\LocalService` account `plugins enable` sets that
up (including read access to the runner's scoped API token).
Run these from an **elevated** PowerShell — the plugins directory lives under
`%ProgramData%\punktfunk`, which is admin-owned. The runner task itself runs as the low-privilege
`NT AUTHORITY\LocalService` account; `plugins enable` sets that up (including read access to the
runner's scoped API token).
```powershell
punktfunk-host plugins add playnite # or: rom-manager
punktfunk-host plugins enable # turn the runner on (once)
```
If `punktfunk-host` isn't found, open a **new** terminal (the installer adds it to `PATH`), or use
the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add playnite`.
If `punktfunk-host` isn't found, open a **new** terminal, or use the full path:
`& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add playnite`.
</Tab>
</Tabs>
The plugin's page then appears in the [web console](/docs/web-console)'s nav automatically.
The runner is **on by default** on a new install — your game sources are plugins, so a host without
it would show an empty library. (A host that predates this stays however you left it; turn it on
with `punktfunk-host plugins enable`, once.) The runner discovers plugins when it starts, so one
installed later needs a restart (`systemctl --user restart punktfunk-scripting`, or `Restart` the
`PunktfunkScripting` task) — the console does that restart for you as part of installing.
The runner is **on by default** on a new install (a host that predates this stays however you left
it — `punktfunk-host plugins enable`, once). The runner discovers plugins when it starts, so one
installed later needs a runner restart — `systemctl --user restart punktfunk-scripting` on Linux,
`Restart` the `PunktfunkScripting` task on Windows; the console does that restart for you as part
of installing.
Don't want it? It is a normal service you can switch off: `systemctl --user mask punktfunk-scripting`
on Linux, or disable the `PunktfunkScripting` scheduled task on Windows. Your host keeps streaming;
you just lose plugin-provided game sources and any automation.
Don't want it? `systemctl --user mask punktfunk-scripting` on Linux, or disable the
`PunktfunkScripting` scheduled task on Windows. The host keeps streaming; you lose plugin-provided
game sources and automation.
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
and it carries its catalog badge instead.
A plugin installed from the CLI shows in the console as **Installed via CLI** the console knows
what is installed, not who vouched for it. Install the same plugin from **Browse** and it carries
its catalog badge instead.
### The rest of the commands
@@ -144,33 +137,31 @@ always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-
## Updating and removing a plugin
**Update.** The console's **Installed** tab grows an **Update to &lt;version&gt;** button whenever the
catalog pins a newer version than the one you have; it installs through the same confirmation as a
fresh install, and the console restarts the runner for you. There is no `plugins update` command —
from a terminal, re-run `punktfunk-host plugins add <name>`. That installs the newest version the
package registry has rather than the version the catalog pins, and does **not** restart the runner:
restart it yourself (`systemctl --user restart punktfunk-scripting`, or `Restart` the
`PunktfunkScripting` task) so the new code is picked up.
**Update.** The **Installed** tab grows an **Update to &lt;version&gt;** button whenever the catalog
pins a newer version; it installs through the same confirmation and restarts the runner for you.
There is no `plugins update` command — from a terminal, re-run `punktfunk-host plugins add <name>`,
which installs the newest version the *registry* has (not the catalog pin) and does **not** restart
the runner: restart it yourself so the new code is picked up.
**Remove.** The **Uninstall** (bin) button on the **Installed** tab removes the package *and*
restarts the runner, so the plugin stops straight away. `punktfunk-host plugins remove <name>`
removes the package only — restart the runner yourself, as above, to stop a plugin still running.
**Remove.** The **Uninstall** (bin) button removes the package *and* restarts the runner.
`punktfunk-host plugins remove <name>` removes the package only — restart the runner yourself to
stop a plugin still running.
Uninstalling removes the package only; a plugin's own config and cache stay where it wrote them
Uninstalling removes the package only; a plugin's own config and cache stay —
`~/.config/punktfunk/plugin-state/<plugin>/` on Linux,
`%ProgramData%\punktfunk\plugin-state\<plugin>\` on Windows — so re-installing later picks your
settings back up. Delete that directory yourself if you want it gone.
settings back up.
To stop *every* plugin without uninstalling anything, turn the runner off: **Disable runner** on the
Installed tab, or `punktfunk-host plugins disable`.
To stop *every* plugin without uninstalling anything: **Disable runner** on the Installed tab, or
`punktfunk-host plugins disable`.
## ROM Manager
`@punktfunk/plugin-rom-manager` — point it at your ROM directories and it scans them, matches each
platform to an installed emulator, fetches box art (SteamGridDB, or the keyless libretro thumbnails),
and reconciles the result into your library as the `rom-manager` provider. ~25 built-in platforms
(NES through Switch, PS1/2/PSP, Dreamcast, and more), per-game overrides, and a console page to
configure it all.
platform to an installed emulator, fetches box art (SteamGridDB, or the keyless libretro
thumbnails), and reconciles the result into your library as the `rom-manager` provider. ~25
built-in platforms (NES through Switch, PS1/2/PSP, Dreamcast, and more), per-game overrides, and a
console page to configure it all.
```sh
punktfunk-host plugins add rom-manager
@@ -212,21 +203,20 @@ Then add a root or two — from the console's **ROM Manager** page, or in the co
</Tabs>
`plugin-state` is where a plugin reads and writes its own files — on Windows it is the one
directory `plugins enable` grants the low-privilege runner write access to. A config file placed
directory `plugins enable` grants the low-privilege runner write access to; a config file placed
anywhere else under `%ProgramData%\punktfunk` is not read.
Full options and the platform/emulator list are in
Full options and the platform/emulator list:
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-rom-manager).
## Playnite
`@punktfunk/plugin-playnite` — mirrors your **[Playnite](https://playnite.link)** library (Steam,
GOG, Epic, Xbox, itch, emulators, manually-added games — everything Playnite manages) into your
library. Launching a title hands it back to Playnite, which performs the real launch, so there are no
per-store launch commands to maintain. Covers are served by the host, so it scales to large libraries.
`@punktfunk/plugin-playnite` — mirrors your **[Playnite](https://playnite.link)** library (every
store and emulator Playnite manages) into your library. Launching a title hands it back to
Playnite, so there are no per-store launch commands to maintain; covers are served by the host.
Playnite is Windows-only, so both halves live on the **Windows host**. Because Playnite keeps its
library locked while running, there are **two parts**:
Playnite keeps its library locked while running, so there are **two parts**, both on the **Windows
host**:
1. **The plugin** — from an elevated PowerShell:
@@ -236,20 +226,19 @@ library locked while running, there are **two parts**:
```
2. **The Punktfunk Sync extension** (in Playnite) — download `punktfunk-sync.pext` from the
[plugin's builds](https://git.unom.io/unom/punktfunk-plugin-playnite/actions) and **double-click
it** to install it in Playnite like any add-on, then restart Playnite once.
[plugin's builds](https://git.unom.io/unom/punktfunk-plugin-playnite/actions), **double-click**
to install it like any add-on, restart Playnite once.
Open the console's **Playnite** page — it shows "Exporter connected", and your games sync within
seconds of any library change. Filters (installed-only, per-store, hidden) live on that page or in
`%ProgramData%\punktfunk\plugin-state\playnite\config.json`. Details are in
The console's **Playnite** page shows "Exporter connected", and your games sync within seconds of
any library change. Filters (installed-only, per-store, hidden) live on that page or in
`%ProgramData%\punktfunk\plugin-state\playnite\config.json`. Details:
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-playnite).
## VirtualHere (USB passthrough)
`@punktfunk/plugin-virtualhere` — hands a **physical USB device** on your couch machine to the
host while you play, and gives it back afterwards. The game sees the real device — the answer for a
racing wheel, a HOTAS, pedals, an arcade stick, or any controller whose value is that it is not
emulated.
racing wheel, HOTAS, pedals or arcade stick.
<Callout type="warn">
This plugin drives [VirtualHere](https://www.virtualhere.com/), a commercial USB-over-IP product
@@ -258,31 +247,28 @@ Punktfunk — you install and license it yourself. The plugin is not affiliated
VirtualHere.
</Callout>
You need both halves of VirtualHere running before the plugin is any use:
You need both halves of VirtualHere running first:
- **The USB Server on the couch**, sharing the device. Free for one device; beyond that, and to run
the client as a service, VirtualHere requires a purchased licence.
- **The USB Client on the host**, ideally installed as a service so it survives logging out.
Servers exist for Windows, Linux, macOS and Android couches. **There is no VirtualHere server for
iOS or tvOS**, so iPhones, iPads and Apple TVs cannot pass devices through.
iOS or tvOS.**
```sh
punktfunk-host plugins add virtualhere
punktfunk-host plugins enable
```
Then open the console's **VirtualHere** page. The **Devices** tab lists whatever the couch is
sharing; pick one and it writes a rule matching the device *by name*, which keeps working after the
couch reboots or the device moves to another port. By default the device is handed over when video
starts and returned when it stops, so the couch keeps its own controller the rest of the time —
you can widen that to the whole session, or to the entire time a client is connected.
The console's **VirtualHere** page **Devices** lists whatever the couch is sharing; pick one and
it writes a rule matching the device *by name*, which survives reboots and port changes. By default
the device is handed over when video starts and returned when it stops; you can widen that to the
whole session, or to the entire time a client is connected.
If nothing happens, the **Diagnostics** tab walks the whole two-sided setup and tells you which
part to fix. The same checks run as `punktfunk-plugin-virtualhere doctor` the useful thing to
paste into a support thread.
Full configuration is in
If nothing happens, the **Diagnostics** tab walks the two-sided setup and names the part to fix —
the same checks as `punktfunk-plugin-virtualhere doctor`, the useful thing to paste into a support
thread. Full configuration:
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-virtualhere).
## Troubleshooting
@@ -291,38 +277,31 @@ Full configuration is in
installer's `PATH` change, or call the exe by full path. On Linux the host package installs it to
`/usr/bin/punktfunk-host`.
**"the plugin runner isn't installed"** — the runner ships as its own package. On Ubuntu:
`sudo apt install punktfunk-scripting`. On Fedora: `sudo dnf install punktfunk-scripting` from the
[same RPM repo you installed the host from](/docs/fedora). On Arch:
`sudo pacman -Syu punktfunk-scripting` (a full `-Syu`, like every other install from that repo).
On SteamOS, re-run `scripts/steamdeck/install.sh` (or
`scripts/steamdeck/update.sh`). On NixOS it comes with `services.punktfunk.scripting.enable` (on by
default whenever the host is). On Windows, re-run the installer and keep the scripting component.
**"the plugin runner isn't installed"** — the runner ships as its own package. Ubuntu:
`sudo apt install punktfunk-scripting`. Fedora: `sudo dnf install punktfunk-scripting` from the
[same RPM repo as the host](/docs/fedora). Arch: `sudo pacman -Syu punktfunk-scripting` (a full
`-Syu`). SteamOS: re-run `scripts/steamdeck/install.sh` (or `update.sh`). NixOS: comes with
`services.punktfunk.scripting.enable` (on by default whenever the host is). Windows: re-run the
installer and keep the scripting component.
If the runner *is* installed and the host still says it isn't, the host could not find the
`punktfunk-scripting` executable. It looks beside its own binary, then on `PATH`, then in the
packaged `/usr` and `~/.local` layouts — so a runner installed somewhere else needs
`PUNKTFUNK_SCRIPTING` pointed at it (see [Configuration](/docs/configuration)). The console installs
plugins from inside the host *service*, whose `PATH` is usually much shorter than your shell's: a
runner that `punktfunk-host plugins add` finds and the console does not is that difference, and the
env var is the fix.
`punktfunk-scripting` executable — it looks beside its own binary, then on `PATH`, then in the
packaged `/usr` and `~/.local` layouts. Point `PUNKTFUNK_SCRIPTING` at it (see
[Configuration](/docs/configuration)). The console installs plugins from inside the host *service*,
whose `PATH` is much shorter than your shell's — a runner that `plugins add` finds and the console
doesn't is that difference, and the env var is the fix.
**Where a plugin's log output goes** — the console's **Logs** page, under the **Plugins** filter.
The runner ships everything your plugins print to the host, so a plugin's own lines sit next to the
host's, on one timeline, with the same search and download. Each is tagged `plugin:<name>`the
plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a
plugin, restarting a crashed one, refusing an unsafe file).
An empty Plugins view almost always means the runner isn't running — it is a separate service. Check
with `punktfunk-host plugins status`.
Plugin lines sit next to the host's, on one timeline, tagged `plugin:<name>` (`plugin:runner` for
the supervisor). An empty Plugins view almost always means the runner isn't runningcheck with
`punktfunk-host plugins status`.
<Callout>
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
comes back, and says so in the log if the buffer overflowed.
</Callout>
**Reading the runner's log directly** — rarely needed, but the ground truth if the runner can't
reach the host at all:
**Reading the runner's log directly** — the ground truth if the runner can't reach the host at all:
<Tabs items={['Linux', 'Windows']}>
<Tab value="Linux">
@@ -334,19 +313,17 @@ journalctl --user -u punktfunk-scripting -f
</Tab>
<Tab value="Windows">
The runner task writes its output to `%ProgramData%\punktfunk\plugin-state\runner.log` (the
previous run is kept as `runner.log.1`). Read — or send — this file when the console's Plugins view
stays empty although the runner is running: everything the runner and its plugins printed lands
here even when they can't reach the host.
The runner task writes to `%ProgramData%\punktfunk\plugin-state\runner.log` (previous run:
`runner.log.1`) — everything the runner and its plugins printed lands here even when they can't
reach the host.
```powershell
Get-Content "$env:ProgramData\punktfunk\plugin-state\runner.log" -Tail 100
```
If the file doesn't exist, the task started before `punktfunk-host plugins enable` ever ran (which
is what makes `plugin-state` writable for the runner's `LocalService` account) — run it from an
elevated prompt, then read the file. To watch a start live instead, run the runner in the
foreground (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
is what makes `plugin-state` writable for `LocalService`) — run it from an elevated prompt. To
watch a start live, run the runner in the foreground (<kbd>Ctrl</kbd>+<kbd>C</kbd> stops it):
```powershell
& "$env:ProgramFiles\punktfunk\bun\bun.exe" "$env:ProgramFiles\punktfunk\scripting\runner-cli.js"
@@ -357,9 +334,9 @@ foreground (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
**A plugin can't reach a service running on the same box (Linux)** — plugins that drive a local
daemon usually talk to it over a socket or FIFO in `/tmp`. The runner's unit shipped with
`PrivateTmp=yes` in earlier releases, hiding the real `/tmp`: the plugin would launch the vendor's
binary and then time out reaching the daemon, while the same command worked in your own shell. On an
older host, or with a drop-in that reinstates it, put the real `/tmp` back:
`PrivateTmp=yes` in earlier releases, hiding the real `/tmp`: the plugin launches the vendor's
binary and times out reaching the daemon, while the same command works in your shell. On an older
host, or with a drop-in that reinstates it:
```sh
systemctl --user edit punktfunk-scripting
@@ -377,5 +354,4 @@ by the runner. The kit owns lifecycle, config and state, the library sync engine
plugin's console page; `@punktfunk/host` (`definePlugin`) is the lower-level host client underneath
it. Start from the
[plugin-kit README](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit) and
[ROM Manager](https://git.unom.io/unom/punktfunk-plugin-rom-manager), which is the reference
implementation — or any of the three plugins above.
[ROM Manager](https://git.unom.io/unom/punktfunk-plugin-rom-manager), the reference implementation.
+12 -5
View File
@@ -45,6 +45,8 @@ certificate is the host's own, so your browser warns once — continue) and log
(SteamOS: the install script printed it; it's in `~/.config/punktfunk/web.env`).
- **Windows:** the installer showed it on its last page.
![The console sign-in card: one password field](/img/console-login.png)
Lost it? [Forgot your password](/docs/forgot-password). Everything else about the console:
[The Web Console](/docs/web-console).
@@ -54,11 +56,16 @@ On the device you want to stream *to*, install the app — [Install a Client](/d
has the link for every device (Mac, iPhone/iPad/Apple TV, Linux, Windows, Android, Steam Deck), and
any Moonlight client works too once you [turn GameStream on](/docs/moonlight).
Open the app: your host is already in the list. Select it and **connect**. Back in the console, the
device appears under **Pairing → Waiting for approval** — click **Approve** and it's in, no PIN to
type. (Prefer a PIN? Click **Pair a device** in the console and type the 4-digit code into the
client.) Pairing happens once; the device reconnects on its own from then on.
Details: [Pairing & Trust](/docs/pairing).
Open the app: your host is already in the list.
![The client's host list: saved hosts with their pairing state, and unpaired hosts found on this network](/img/client-hosts.png)
Select it and **connect**, then click **Approve**
next to the device in the console's **Pairing** page — no PIN to type. (Prefer a PIN? **Pair a device**
shows a 4-digit code to type into the client.) Pairing happens once; the device reconnects on its
own from then on. Details: [Pairing & Trust](/docs/pairing).
![The console's Pairing page: two devices waiting for approval, and an armed 4-digit PIN](/img/console-pairing.png)
## 5. Stream
+16
View File
@@ -23,6 +23,10 @@ Jump to what you installed:
## Linux hosts
If you installed with the guided script, `sh install.sh --uninstall` runs this section and the
package removal for your family in one go (fetch it again with `curl -fsSLO https://punktfunk.unom.io/install.sh`);
what it leaves behind is the same list below.
### Stop the services first
The Linux packages ship systemd **user** units, and `systemctl --user enable` writes symlinks into
@@ -289,6 +293,18 @@ Then remove the repository as described under the host sections above, if this b
it. To clear the client's own state without uninstalling — saved hosts and stream settings, keeping
the paired identity — run `punktfunk-client --reset` instead.
### Windows client (installer)
Uninstall **Punktfunk** from **Settings → Apps → Installed apps** (it's a per-user install, so no
admin prompt), or silently:
```powershell
& "$env:LOCALAPPDATA\Programs\Punktfunk\unins000.exe" /VERYSILENT
```
The uninstaller removes the Start-menu entries, the `punktfunk://` registration, and its own PATH
entry. A **portable** unzip has nothing registered — just delete the folder.
### Windows client (MSIX)
```powershell
+127 -168
View File
@@ -11,8 +11,7 @@ settings like scaling.
Set it in the **web console** (the **Virtual displays** page), or edit
`~/.config/punktfunk/display-settings.json` (`%ProgramData%\punktfunk\display-settings.json` on
Windows). A change applies to the **next** connection — a running session keeps the display it
opened on.
Windows). A change applies to the **next** connection.
> **You rarely need to touch this.** The default matches how Punktfunk has always worked; reach for
> a preset when you want a specific experience.
@@ -23,43 +22,33 @@ opened on.
To stream a monitor the host **already has** instead, see
[Stream a real monitor instead](#stream-a-real-monitor-instead) — it turns most of this page off.
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
> *and* GNOME/Mutter) and **multi-monitor layout** (several clients as monitors of one desktop) are
> all enforced. A reconnect — even a fast one — always resumes the kept display instead of spawning
> a second. Gaps, noted inline: the Linux `primary` physical-keep *effect*, and multi-display for a
> *single* client (the next stage).
## Stream a real monitor instead
> **Linux only.** A Windows host enumerates its monitors but has no backend that can capture one, so
> the Streamed screen card is read-only there and every Windows session gets a virtual display.
> **Linux only.** A Windows host enumerates its monitors but has no backend that can capture one
> the Streamed screen card is read-only there.
For a wall-mounted shop-floor PC, a lab bench machine or a media box's TV output, you want *that*
screen, not a new one. Set **Virtual displays → Streamed screen** in the console to a listed
monitor and Punktfunk streams that physical monitor instead of creating a virtual display; every
client sees it at *its* resolution.
Set **Virtual displays → Streamed screen** in the console to a listed monitor and Punktfunk streams
that physical monitor instead of creating a virtual display; every client sees it at *its*
resolution.
- The monitor is **never touched** — not resized, moved, disabled or restored. Keep-alive, topology
and multi-monitor layout don't apply: there's no display of ours to apply them to.
and multi-monitor layout don't apply.
- **The resolution is the monitor's**, not yours. A client asking for a different one is told no and
scales its own picture; the mid-stream resize machinery is switched off.
scales its own picture; mid-stream resize is off.
- **Every client sees the same screen** — two clients are two viewers of one monitor.
- Naming a monitor this host **doesn't have, while it has others**, is a **hard error**, not a
fallback: the session fails with `no monitor named "DP-9" — this host has: HDMI-1`. The one
exception is a session with **no physical heads at all** — a nested or headless compositor: the
pin is set aside with a warning in the log, you get an ordinary virtual display, and the pin
applies again the next time a session with real heads runs.
- Naming a monitor this host **doesn't have, while it has others**, is a **hard error**: the session
fails with `no monitor named "DP-9" — this host has: HDMI-1`. The exception is a session with
**no physical heads at all** (nested or headless compositor): the pin is set aside with a log
warning, you get an ordinary virtual display, and the pin applies again on the next session with
real heads.
- **Virtual screen (default)** in the same card puts you back on the normal path.
Supported on **KDE/KWin**, **GNOME/Mutter**, **Sway/wlroots**, **Hyprland** and **gamescope Game
Mode** (a Steam Deck / Bazzite couch box, where gamescope drives the screen) — each through the
compositor's own screen-recording API, so there is **no chooser dialog**. That matters for a host
running unattended as a [service](/docs/running-as-a-service): a background `systemd --user` daemon
has nobody to answer a permission prompt. On gamescope only the head the session is driving is
listed — mirroring attaches to the session's own composited stream, so that screen keeps showing
what the person in front of it sees and nothing is relaunched. A *nested* or headless gamescope (the
per-client sessions the host spawns itself) has no head, so the picker is empty there.
Mode** — each through the compositor's own screen-recording API, so there is **no chooser dialog**
(a background [service](/docs/running-as-a-service) has nobody to answer one). On gamescope only the
head the session is driving is listed — mirroring attaches to the session's own composited stream,
so the screen keeps showing what the person in front of it sees. A *nested* or headless gamescope
has no head, so the picker is empty there.
### Naming the monitor from the host
@@ -83,8 +72,8 @@ To pin it **from the host's configuration** instead — the appliance route —
PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
```
The environment variable **wins over the console setting**, so an operator's declaration can't be
re-aimed by a click; the console shows the Streamed screen card as locked while it's set.
The environment variable **wins over the console setting**; the console shows the card as locked
while it's set.
Check the whole path — mirror, capture, frames — without a client:
@@ -98,10 +87,9 @@ the mouse on the host while it runs or a working mirror reads as a stall.
### Absolute input follows the pin
Pinning a monitor also re-aims **absolute** mouse and pen input to that head's origin, so a click
lands where you point on *that* screen. Heads are matched by position, not size — two monitors can
be the same size, and getting that wrong puts the pointer silently on the wrong screen. The host
resolves the pin at startup and whenever the console writes it, so no restart is needed; the log
line is `capture monitor: …`.
lands where you point on *that* screen. Heads are matched by position, not size. The host resolves
the pin at startup and whenever the console writes it — no restart; the log line is
`capture monitor: …`.
To check it with no client involved:
@@ -110,12 +98,10 @@ punktfunk-host anchor-test --monitor HDMI-A-1
```
It lists this host's heads, says whether the box has the same-size pair the matching exists for,
walks the pointer through the centre and corners so you can watch which screen it moves on, and
prints the region it mapped into. `--none` runs the same walk unanchored, as an A/B.
The anchor rides the **libei** injector — the backend a GNOME/Mutter host uses. On KWin, Sway and
Hyprland the host injects through a different protocol, and `anchor-test` stops and says so rather
than reporting a green run that proves nothing.
walks the pointer through the centre and corners, and prints the region it mapped into. `--none`
runs the same walk unanchored, as an A/B. The anchor rides the **libei** injector — the
GNOME/Mutter backend. On KWin, Sway and Hyprland the host injects through a different protocol, and
`anchor-test` stops and says so rather than reporting a green run that proves nothing.
## Pick a preset
@@ -132,20 +118,16 @@ further down.
## Save your own preset
Once you've dialed in a setup — by tweaking a preset or setting every option under **Custom**
**save it as your own named preset** and switch back to it in one click.
- **Save as preset** — names the settings currently in force (all the options below **plus**
*Dedicated game sessions*) and adds it to the picker alongside the built-ins.
- **Apply** — writes exactly those settings, like picking a built-in.
- **Edit / delete** — rename it, update it to your current settings, or remove it. Deleting never
changes what's running — it only takes the card out of the picker.
- **Edit / delete** — rename, update to your current settings, or remove. Deleting never changes
what's running.
The built-in presets deliberately leave *Dedicated game sessions* alone, so switching presets never
changes your game-launch routing; a **custom preset captures your full setup**, including that axis
— it's *your* saved configuration, not a curated behavior bundle. Custom presets live on the host in
`display-presets.json` (next to `display-settings.json`); the catalog and the active policy are
independent, so editing a preset never disturbs a running session.
The built-in presets leave *Dedicated game sessions* alone, so switching presets never changes your
game-launch routing; a **custom preset captures your full setup**, including that axis. Custom
presets live on the host in `display-presets.json` (next to `display-settings.json`); editing a
preset never disturbs a running session.
## Options reference
@@ -154,28 +136,25 @@ Choose **Custom** in the console to set these directly.
### Keep alive
How long the virtual display survives after your last session disconnects. On a gamescope game host
this also keeps the **game itself running**, so you can reconnect straight back into it.
this also keeps the **game itself running**.
- **Off** — tear the display down at session end.
- **A duration** (seconds) — keep it that long; a reconnect inside the window drops you straight
back in, with no re-negotiation and no desktop reshuffle.
- **A duration** (seconds) — a reconnect inside the window drops you straight back in, with no
re-negotiation and no desktop reshuffle.
- **Forever** — keep it until you stop the host or **release it** from the console (**Virtual
displays** → *Release*). The headless-box model.
Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down
immediately — a short linger makes reconnects smoother on both.
Default: **10 seconds**.
**A reconnect always resumes the kept display** — the host recognises your device and hands back the
same display, even a second or two after dropping (before it has noticed you left). **Deliberately
quitting** (closing the client, not a network drop) tears the display down at once, skipping the
linger. How quickly a *dropped* client is noticed is the QUIC idle
timeout — 8 s by default, tunable with `PUNKTFUNK_IDLE_TIMEOUT_MS` (see
[Legacy environment knobs](#legacy-environment-knobs)) to free kept displays sooner.
**A reconnect always resumes the kept display** — even a second or two after dropping.
**Deliberately quitting** (closing the client, not a network drop) tears the display down at once,
skipping the linger. How quickly a *dropped* client is noticed is the QUIC idle timeout — 8 s by
default, tunable with `PUNKTFUNK_IDLE_TIMEOUT_MS` (see
[Legacy environment knobs](#legacy-environment-knobs)).
> **Keep-alive + Exclusive keeps your physical monitors dark after you disconnect**, until the
> linger expires or you release the display. Intentional for a dedicated gaming box — but don't set
> a long/forever keep-alive with Exclusive on a machine whose monitors you also use in person; use
> **Shared desktop** there.
> linger expires or you release the display. Intentional for a dedicated gaming box — on a machine
> whose monitors you also use in person, use **Shared desktop**.
### Topology
@@ -184,11 +163,9 @@ What Punktfunk does with your monitor layout while it streams.
- **Extend** — add the virtual display alongside your real monitors; touch nothing else.
- **Primary** — make the virtual display your primary output; physical monitors stay on.
- **Exclusive** — the virtual display becomes your **only** enabled output (physical monitors are
disabled, then restored when streaming ends). This makes the streamed surface *be* the desktop,
so panels and windows land on it.
disabled, then restored when streaming ends), so panels and windows land on it.
- **Automatic** *(default)* — Exclusive on Windows and on an auto-detected KDE/GNOME desktop;
Extend when you've pinned a
specific compositor with `PUNKTFUNK_COMPOSITOR` (a test/CI posture).
Extend when you've pinned a specific compositor with `PUNKTFUNK_COMPOSITOR`.
Per-backend support:
@@ -198,62 +175,51 @@ Per-backend support:
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
| Exclusive | ✅ | ✅ | ✅ | ✅ |
**Primary** has no equivalent on **Sway/wlroots and Hyprland** — a Wayland fact, not a missing
feature: there is no primary-output concept. They have a *focused* output, and the host points that
at the streamed display — at session start, and again immediately before it launches anything from
your library. Both open new windows on the focused monitor, which is what puts the game on the
display you're streaming. Primary therefore behaves as Extend, and the host says so in the log.
**Sway/wlroots and Hyprland have no primary-output concept**; they have a *focused* output, and the
host points that at the streamed display — at session start, and again immediately before it
launches anything from your library, which is what puts the game on the streamed display. Primary
therefore behaves as Extend (the host says so in the log), and a window that opens *later* follows
whatever has focus then — clicking a physical monitor mid-launch can still pull a window over.
Because it's focus rather than promotion, a window that opens *later* (a launcher spawning a second
window, a game re-parenting itself) follows whatever has focus then — so if you're also sitting at
the machine, clicking a physical monitor mid-launch can still pull a window over to it.
**Exclusive** on both compositor families:
**Exclusive** does disable your physical monitors on both, and switches them back on when the last
streaming display is torn down. Two compositor-specific details:
- Punktfunk only disables monitors it did not create, so a second client streaming at the same time
never goes dark.
- On Hyprland the restore is a `hyprctl reload`, because nothing else re-enables a monitor that a
rule disabled — a re-applied monitor rule is accepted and ignored. The reload re-reads your
Hyprland config, which puts your monitors back; the side effect is that settings changed at
runtime with `hyprctl keyword` are dropped too, and a non-Lua config re-runs its `exec =` lines
(`exec-once` is not re-run). This only happens if a session actually disabled something.
- Punktfunk only disables monitors it did not create, so a second concurrent client never goes dark.
- On Hyprland the restore is a `hyprctl reload` — nothing else re-enables a monitor a rule disabled.
The reload re-reads your Hyprland config; settings changed at runtime with `hyprctl keyword` are
dropped and a non-Lua config re-runs its `exec =` lines (`exec-once` is not). Only happens if a
session actually disabled something.
### Conflict handling · identity · layout
- **Conflict handling** what happens when a *different* client connects while one is already
streaming and asks for a different resolution: give it its own display (**separate**), take the
box over (**steal**), share the existing display at its current mode (**join**), or refuse it
(**reject**). On Linux, `separate` gives each client its own display on the shared desktop. On
**Windows** a second client is **rejected** (a clean "host busy") even under `separate` — two
clients can't yet share one virtual display's capture there (a later stage), so the live session
is protected instead. A same-client *reconnect* never conflicts — it resumes.
- **Conflict handling** — a *different* client connects mid-stream asking for a different
resolution: give it its own display (**separate**), take the box over (**steal**), share the
existing display at its current mode (**join**), or refuse it (**reject**). On **Windows** a
second client is **rejected** ("host busy") even under `separate` — two clients can't yet share
one virtual display's capture there. A same-client *reconnect* never conflicts — it resumes.
- **Identity** — whether each client gets a **stable display identity** so your desktop environment
remembers its settings (see [Persistent scaling](#persistent-scaling)): one shared identity, one
**per client**, or one **per client + resolution**.
- **Layout / max displays** when several clients each become a monitor of one desktop, this places
them side by side (**auto**) or exactly where you arrange them in the console (**manual**, keyed to
each client), up to **max displays**. Arrange them on the **Virtual displays** page once two or more
are streaming.
- **Layout / max displays** — several clients as monitors of one desktop: side by side (**auto**) or
exactly where you arrange them in the console (**manual**, keyed to each client), up to **max
displays**. Arrange them on the **Virtual displays** page once two or more are streaming.
### Dedicated game sessions
**Dedicated game sessions** control how a session that *launches a game from
[your library](/docs/game-library)* is served (Linux hosts):
How a session that *launches a game from [your library](/docs/game-library)* is served (Linux
hosts):
- **Auto** (default) — the launch rides whatever session the box is in: the managed Steam session on a
Steam Deck / Bazzite couch box, a bare gamescope on a plain distro, or spawned into your live KDE /
GNOME / Sway desktop.
- **Dedicated** — every library launch gets its **own headless gamescope at your exact resolution and
refresh**, with just the game inside — no Steam Big Picture to navigate, no game-mode desktop.
Steam titles launch with the client hidden (`steam -silent`); non-Steam titles start almost
instantly (gamescope up in ~1 s, then the game's own boot). Combined with **keep alive**, the game
keeps running when you disconnect and you re-attach straight back into it.
- **Auto** (default) — the launch rides whatever session the box is in: the managed Steam session
on a Steam Deck / Bazzite couch box, a bare gamescope on a plain distro, or your live KDE / GNOME
/ Sway desktop.
- **Dedicated** — every library launch gets its **own headless gamescope at your exact resolution
and refresh**, with just the game inside. Steam titles launch with the client hidden
(`steam -silent`); non-Steam titles start almost instantly (gamescope up in ~1 s). Combined with
**keep alive**, the game keeps running when you disconnect.
Dedicated needs `gamescope` installed on the host; without it a launch falls back to **Auto**
routing. This axis is independent of the preset — pick it on the **Virtual displays** page. On a box
already in Steam game mode, a dedicated Steam launch frees game mode's Steam first and restores it
when the session ends. (GameStream / Moonlight launches follow the same routing.)
Dedicated needs `gamescope` installed; without it a launch falls back to **Auto**. This axis is
independent of the preset. On a box already in Steam game mode, a dedicated Steam launch frees game
mode's Steam first and restores it when the session ends. (GameStream / Moonlight launches follow
the same routing.)
## When a game ends, and when a session does
@@ -263,48 +229,44 @@ only to a game **this host launched for the session**: a game you started yourse
### When the game exits
**End the session** (default). Quit the game and your client goes back to its own library. A
dedicated game session has always done this; it now works on every path — your live KDE/GNOME/Sway
desktop, an attached gamescope, and Moonlight.
**Keep streaming** if you stream the desktop and treat the game as incidental.
**End the session** (default) — quit the game and your client goes back to its own library, on
every path (live desktop, attached gamescope, Moonlight). **Keep streaming** if you stream the
desktop and treat the game as incidental.
### When the session ends
Whether stopping — or losing — a session also closes the game.
- **Leave it running** (default). Nothing is ever closed. Disconnect, and the game plays on for when
you come back.
- **Close it on Stop** — closing the client, or pressing *Stop* in the console, closes the game.
A network drop does not: you get your game back when you reconnect.
- **Always close it** — a drop closes it too, but only after a **reconnect window** (5 minutes by
default). Reconnect inside the window and nothing happens; the console shows the countdown, with
an **End now** button if you'd rather not wait.
- **Leave it running** (default). Nothing is ever closed.
- **Close it on Stop** — closing the client, or *Stop* in the console, closes the game. A network
drop does not.
- **Always close it** — a drop closes it too, after a **reconnect window** (5 minutes by default).
Reconnect inside the window and nothing happens; the console shows the countdown, with an **End
now** button.
Closing a game costs whatever it hadn't saved, which is why nothing closes by default. The host asks
first — a polite close, the same as clicking the window's X, so the game runs its own shutdown — and
only forces the issue after ten seconds of being ignored.
Closing a game costs whatever it hadn't saved, which is why nothing closes by default. The host
asks first — a polite close, the same as clicking the window's X — and only forces the issue after
ten seconds of being ignored.
> **Keep alive and this setting are different clocks.** Keep-alive decides how long the *display*
> outlives a disconnect (10 s by default); the reconnect window decides how long the *game* does
> (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a
> pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it.
> outlives a disconnect (10 s default); the reconnect window decides how long the *game* does
> (5 min). A display set to **Forever** stays up regardless of what happens to the game.
### On a gamescope session, the display has the final say
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam Deck
or a Bazzite couch box — the game runs *inside* the streamed display, so it lives exactly as long as
that display does, and **Keep alive decides that, not the setting above**:
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam
Deck or Bazzite couch box — the game runs *inside* the streamed display and lives exactly as long
as it does, so **Keep alive decides, not the setting above**:
| you disconnect by | what happens to the game |
|---|---|
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is deliberately skipped for a real stop — and the game goes with it, even on *Leave it running* |
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is skipped for a real stop — and the game goes with it, even on *Leave it running* |
| dropping out (network, sleep) | the display lingers for your keep-alive window, then tears down; the game ends with it |
| dropping out, keep-alive **Forever** | the display is pinned, so the game genuinely survives — and *Always close it* still ends it when the reconnect window closes |
So on a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
window long enough to come back in), not just this setting. On a desktop session — KWin, GNOME, Sway —
the game is an ordinary process next to your desktop and the setting above is the whole story.
On a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
window long enough to come back in). On a desktop session — KWin, GNOME, Sway — the game is an
ordinary process and the setting above is the whole story.
### Automation
@@ -320,8 +282,8 @@ display identity*, so your desktop environment keys its per-monitor settings to
| Host | Supported | How |
|---|---|---|
| **Windows** | ✅ today | Set scaling in Settings while streaming — Windows remembers it per client. |
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. Validated live (150 %/125 % survive a full disconnect + reconnect). |
| **GNOME / Mutter** | ✅ today | GNOME's virtual-monitor API exposes no stable identity to key config on, so the **host persists the scale itself**: set scaling in Settings while streaming — the host captures the change, remembers it per client, and reapplies it on reconnect. |
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. |
| **GNOME / Mutter** | ✅ today | GNOME's virtual-monitor API exposes no stable identity, so the **host persists the scale itself**: set scaling in Settings while streaming — the host captures it per client and reapplies on reconnect. |
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
## Legacy environment knobs
@@ -335,51 +297,48 @@ them — when a settings file exists, it wins.
| `PUNKTFUNK_NO_ISOLATE` | **Topology** → Extend *(Windows)* |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | **Topology** → Exclusive (when set) / Extend (when `0`) |
One knob has no console equivalent — it's transport tuning, not display policy:
One knob has no console equivalent — transport tuning, not display policy:
- **`PUNKTFUNK_IDLE_TIMEOUT_MS`** (host, default `8000`) — how long the host waits before declaring a
*dropped* client gone, which is when a kept display starts its linger (or is freed). Lower it (e.g.
`3000`) to reclaim kept displays sooner after an ungraceful drop; it's clamped to ≥1 s and its
keep-alive ping scales with it, so a live session never false-disconnects. A deliberate quit is
instant regardless. Also `--idle-timeout-ms` on `punktfunk1-host`.
- **`PUNKTFUNK_IDLE_TIMEOUT_MS`** (host, default `8000`) — how long before a *dropped* client is
declared gone, which is when a kept display starts its linger (or is freed). Lower it (e.g.
`3000`) to reclaim kept displays sooner; it's clamped to ≥1 s and its keep-alive ping scales with
it, so a live session never false-disconnects. A deliberate quit is instant regardless. Also
`--idle-timeout-ms` on `punktfunk1-host`.
## Troubleshooting
**My physical monitors stayed off after I disconnected.** Keep-alive is set together with Exclusive
topology — the display (and your isolated desktop) is kept for the linger window. Release it from
the console (**Virtual displays**), or switch to the **Shared desktop** preset so streaming never
disables your real monitors.
topology — the display is kept for the linger window. Release it from the console (**Virtual
displays**), or switch to the **Shared desktop** preset.
**The virtual output shows only my wallpaper.** Your topology is Extend, so the streamed display is
an empty extension. Use **Primary** or **Exclusive** so your desktop lands on it.
**KWin can't create the virtual output.** On a normal Plasma session KWin runs its **DRM backend**,
which creates virtual outputs at any version. The 6.5.6 floor applies only to the **virtual backend**
(`kwin_wayland --virtual`, used for headless and test sessions) — below that the request fails with
"Could not find output". On **KWin 6.6+** that same message also covers an output KWin *did* create
and then left disabled; [KDE Plasma](/docs/kde#troubleshooting) walks that one. See
which creates virtual outputs at any version. The 6.5.6 floor applies only to the **virtual
backend** (`kwin_wayland --virtual`, headless and test sessions) — below that the request fails
with "Could not find output". On **KWin 6.6+** that same message also covers an output KWin *did*
create and then left disabled; [KDE Plasma](/docs/kde#troubleshooting) walks that one. See
[requirements](/docs/requirements).
**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box, disconnecting
and reconnecting within game mode reuses the still-warm session (or cleanly recreates it) instead of
landing on a dead stream — and switching between game mode and the KDE / GNOME desktop mid-stream
follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your
library; a game mode / desktop session keeps streaming.
**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box,
disconnect/reconnect within game mode reuses the still-warm session (or cleanly recreates it), and
switching between game mode and the desktop mid-stream follows the switch. If a launched game
**exits**, a dedicated session ends and returns you to your library; a game mode / desktop session
keeps streaming.
**My keep-alive / topology / layout settings do nothing.** Check whether **Streamed screen** is set
to a real monitor — those options are about a display Punktfunk created, and when it's mirroring one
of yours there is nothing to keep alive or rearrange. Switch the card back to *Virtual screen
(default)*.
to a real monitor — those options are about a display Punktfunk created. Switch the card back to
*Virtual screen (default)*.
**The console won't let me change Streamed screen.** `PUNKTFUNK_CAPTURE_MONITOR` is set in this
host's [`host.env`](/docs/configuration) and outranks the console. Unset it (and restart the host)
to choose from the console instead.
host's [`host.env`](/docs/configuration) and outranks the console. Unset it (and restart the host).
**My session fails with "no monitor named …".** The pinned connector isn't among this host's
monitors — renamed, unplugged, or the host is now in a different session. Run
`punktfunk-host list-monitors` on the host to see the real names. Punktfunk will not quietly stream
a different screen.
monitors — renamed, unplugged, or the host is in a different session. Run
`punktfunk-host list-monitors` to see the real names. Punktfunk will not quietly stream a different
screen.
**My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless box**
preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes
instantly — return to game mode on the box (or restart the host) to hand the TV back.
**My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless
box** preset (keep alive = *forever*), a managed Steam session is held indefinitely — return to
game mode on the box (or restart the host) to hand the TV back.
+18 -28
View File
@@ -14,15 +14,12 @@ are here for.
## How it works
While running, the host advertises itself over mDNS, including `mac` — the address of the card
carrying the IP clients reach it on first, then any other non-loopback cards as fallbacks, at most
four.
Each app stores those addresses on its **saved host** record. Linux, Windows and Android refresh
them whenever they see the host advertise; the Apple app when you save the host and on every
connect. A sleeping host stops advertising, but the client still has the addresses on disk.
That ordering is the whole prerequisite:
While running, the host advertises itself over mDNS, including `mac` — the card carrying the IP
clients reach it on first, then any other non-loopback cards as fallbacks, at most four. Each app
stores those addresses on its **saved host** record: Linux, Windows and Android refresh them
whenever they see the host advertise, the Apple app when you save the host and on every connect. A
sleeping host stops advertising, but the client still has the addresses on disk. That ordering is
the whole prerequisite:
> **The client must have seen the host awake at least once**, on a network where the host's mDNS
> advert reached it. Until then no address is known and there is nothing to wake with — the client
@@ -31,10 +28,9 @@ That ordering is the whole prerequisite:
The packet goes **out of every one of the client's network interfaces** — from a socket bound to
that interface's own address, aimed at both its subnet broadcast address and `255.255.255.255` — on
UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. The spread
is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it, and a
broadcast sent without binding an interface leaves by the default route only — on a machine running
a VPN or a mesh network, not the LAN the host sleeps on.
UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. (A
sleeping machine has no ARP entry, and an unbound broadcast leaves by the default route only — on a
VPN or mesh machine, not the LAN the host sleeps on.)
Neither the advert nor a magic packet is authenticated. A wrong address only makes the wake fail;
the host's certificate fingerprint still gates the connection. See [Security](/docs/security).
@@ -59,11 +55,9 @@ Two things can still stop it, neither visible from Punktfunk:
## Waking from a client
**Auto-wake on connect** is a client setting, **on by default**, in Settings under **Session**
([Client settings](/docs/client-settings#behavior) covers what sits beside it); the TV and
controller layouts list it among the other general settings. It is a property of the device and
the network, so it is *not* part of a
[settings profile](/docs/profiles-and-links#what-a-profile-cant-change) — "Game" and "Work" cannot
disagree about it.
([Client settings](/docs/client-settings#behavior)); the TV and controller layouts list it among
the general settings. A property of the device and the network, so *not* part of a
[settings profile](/docs/profiles-and-links#what-a-profile-cant-change).
With auto-wake on, opening a saved host that is not advertising:
@@ -108,12 +102,9 @@ shows an explanation with a link to system settings if you decline.
### On the Steam Deck
The [Decky plugin](/docs/steam-deck) has no wake button or wake setting of its own. It starts every
stream through the client, so the wake is the client's, on exactly the terms above — packet, 6-second
re-send, once-a-second watch, dial only when the host is really back. It follows **Wake hosts
automatically** in the client's own settings (**Open Punktfunk → Settings** from the same panel)
and is a no-op until the client has learned that host's MAC address. (The plugin used to fire a
packet itself and stretch the connect budget to 75 seconds to cover the resume; a wait that watches
for the host beats a fixed budget, so that is gone.)
stream through the client, so the wake is the client's, on exactly the terms above. It follows
**Wake hosts automatically** in the client's own settings (**Open Punktfunk → Settings** from the
same panel) and is a no-op until the client has learned that host's MAC address.
### From the command line
@@ -173,10 +164,9 @@ Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it fr
```
The warning line names the interface and the exact command to fix it. The host only reports; it
never changes the card's settings. It stays silent when it cannot tell `iw` or `ethtool` missing,
a driver that doesn't answer, or not enough privilege — rather than guessing, and says nothing at
all when mDNS adverts are off (`PUNKTFUNK_MDNS=0` or `--no-mdns`), because then no address is
published either.
never changes the card's settings, stays silent when it cannot tell (`iw`/`ethtool` missing, a
driver that doesn't answer, not enough privilege), and says nothing when mDNS adverts are off
(`PUNKTFUNK_MDNS=0` or `--no-mdns`) then no address is published either.
Read the line on the web console's **Logs** page, or with
`journalctl --user -u punktfunk-host`. See [Troubleshooting](/docs/troubleshooting#still-stuck).
+4 -3
View File
@@ -112,9 +112,10 @@ appears under **Waiting for approval** instead; approving it pairs it immediatel
Nine destinations in the sidebar (a **More** tab on a phone holds the last five):
- **Dashboard** — live status: whether video and audio are streaming, the active sessions with
their codec, resolution, frame rate and bitrate, which games are running, and how many clients
are paired. Buttons stop a session or ask the encoder for a fresh keyframe.
![Live status during a stream: video and audio streaming, the running game, the session's codec, resolution, frame rate and bitrate](/img/console-live-status.png)
- **Dashboard** — the live status above: what's streaming, which games run, how many clients are
paired. Buttons stop a session or ask the encoder for a fresh keyframe.
- **Host** — this host's identity (hostname, OS, local IP, version, unique id), the codecs it
advertises, its ports, the **Updates** card (see [Updating the Host](/docs/updating)), the
**GPUs** card — Automatic, or prefer one GPU for capture and encode, applied to the next session
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+3 -3
View File
@@ -185,11 +185,11 @@
"id": "windows-client",
"name": "Windows client",
"installs": "client",
"packageManager": "msix",
"packageManager": "installer",
"docs": "/docs/install-client#windows",
"install": [
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-windows_x64.msix",
"Add-AppxPackage .\\punktfunk-client-windows_x64.msix"
"curl.exe -LO https://git.unom.io/api/packages/unom/generic/punktfunk-client-windows/latest/punktfunk-client-setup_x64.exe",
".\\punktfunk-client-setup_x64.exe"
]
},
{
+2 -1
View File
@@ -19,7 +19,8 @@ release is born complete and the announcement always has something to say.
catches renamed knobs and dead links, not a stale sentence). If an install command, repo URL or
port changed, `data/platforms.json` changed with it — then run `bun run sync-platforms` in
punktfunk-website and commit, because its download page vendors that file and only refreshes
when someone does.
when someone does. Same pass for the website itself: does the landing page still describe what
this release ships (features, platforms, the blog post the CMS expects per release)?
2. **Tag & push.** `git tag -a vX.Y.Z … && git push origin vX.Y.Z` fans out to the build
workflows. Whichever one wins the create race seeds the release body from this file
(`scripts/ci/gitea-release.sh``ensure_release`, and its PowerShell twin). The release page
+30 -5
View File
@@ -2,25 +2,38 @@ Wire-compatible with 0.30.x — everything you have already paired keeps working
Most of this release is things that were wrong in ways nothing announced. A DualSense's rumble and speaker never actually worked in a game streamed from a Linux host, for five separate reasons stacked on top of each other, and every one of them is fixed. On Windows, ending a session was silently killing the whole host, and launching a game from the library on 0.30 could drop your stream a second later. Android phones and TVs now show the same controller console the desktop does — one interface on three platforms — and the picture on a phone arrives markedly earlier with no dropped frames. On Linux hosts, another device on the box can no longer make your desktop audio stutter, and a Gaming Mode takeover on some machines was starving your controller with a login storm of our own making.
Two things also change how you get Punktfunk in the first place. The Windows client is now an ordinary installer rather than a Store-style package, which is what finally lets Steam launch it — overlay, controller configs, Big Picture. And a Linux host can be set up with a single command, if you would rather not follow a page of them.
## TL;DR
- **Windows: the client is a normal installer now, and Steam can launch it.** Add it as a non-Steam game and the Steam overlay, your controller configuration and Big Picture all work — which the Store-style package could never do. Read *Before you update* if you already have it installed.
- **One command sets up a Linux host.** `curl -fsSL https://punktfunk.unom.io/install.sh | sh` takes a machine from nothing to a host you can pair with, including living alongside an existing Sunshine or Apollo. It is a preview; the per-system pages remain the documented way.
- **DualSense rumble and speaker work in-game from a Linux host.** The controller was dying 400 ms after it appeared, every message the game sent it was reported as failed, its sound card was invisible to the sound system, and what got through arrived 48 dB too quiet. All fixed; the details are below.
- **Windows: ending a session no longer kills the host, and launching a game no longer drops your stream.** The first looked like a mystery reconnect; the second arrived with 0.30.
- **Android has the desktop's console, and a much faster picture.** One controller interface across Windows, Linux and Android — including 32-bit TV boxes — and on the reference phone end-to-end latency went from 30 ms to about 18 ms with dropped frames going from 4050 a second to none.
- **Desktop audio on a Linux host no longer stutters because of somebody else's device.** In one 15-minute session, 15 % of what the listener heard was silence the host had papered over gaps with — because a controller's sound card, attached over the network and connected to nothing, was setting the pace for the whole box.
- **A Gaming Mode takeover on a machine that logs itself in was choking its own controller input** — the pad enumerated fine and then reported at about 1.4 times a second instead of 250. The cause was ours, and it is gone.
- **KDE Plasma 6.6 could refuse to start a session, in your own language, forever.** No number of retries helped and there was nothing to fix in System Settings. The host repairs it now.
- **Every client can send its logs to the host.** Mac, iPhone, iPad, Apple TV and Android join the Gaming Mode console — and the Apple app finally keeps a log worth sending.
- **Hyprland and Sway users: the default display mode now really turns your desk monitors off during a session.** It said it did before and did nothing. Read *Before you update*.
## Before you update
- **Windows client: the download you want has changed, and switching to it costs one re-pair.** The default is now `punktfunk-client-setup_x64.exe` — an ordinary per-user installer, no administrator prompt, which upgrades in place and keeps your saved hosts and pairing. A portable zip and the old Store-style package are still offered as alternates. **If you already have the Store-style package and you want the Steam integration, remove it first**`Get-AppxPackage unom.Punktfunk | Remove-AppxPackage` — and then run the installer: that package keeps its settings, saved hosts and pairing identity inside itself, so you will pair with your hosts once more. If you had added Punktfunk to Steam, add the new one; it lives at `%LOCALAPPDATA%\Programs\Punktfunk\punktfunk-client.exe`. Nothing on the host side is affected, and if you do not care about Steam, staying on what you have is fine.
- **Steam Deck and Bazzite hosts: two things we told you are no longer true.** Taking over Gaming Mode no longer stops your login manager, so Steam's own "Switch to Desktop" works during and after a session instead of hanging until you reboot — and for that reason the takeover no longer needs the `punktfunk` group. Keep that group only if you use the virtual Steam Deck controller, which still needs it. The comments in the Bazzite `host.env` we ship changed to match.
- **NixOS hosts: two things, and the first one is that builds were failing outright.** The version of our patched compositor that came with the pinned packages had drifted far enough that it no longer accepted our changes, and since HDR is on by default that failure sat on every `services.punktfunk.host.enable = true` build; the fix moves your `nixpkgs` input, so expect a rebuild. Second, there is now a binary cache, so that rebuild — and every future one — need not compile the workspace and the compositor from source, which took about an hour. Add the cache and its public key to your configuration; the install guide has the snippet, and the cache serves its own public key. Both of these come undone if your flake overrides Punktfunk's `nixpkgs` input: every store path changes, so nothing in the cache matches and everything rebuilds. Separately, one of the controller-audio fixes below is a sound-configuration file the other Linux packages install into a system location NixOS does not have; it needs a package override there.
- **Plugin authors: update your dependency.** `@punktfunk/host` 0.1.5 and `@punktfunk/plugin-kit` 0.4.3 are published, and plugins take them from the registry — so the fix that makes a plugin follow a moved management port, the cap on re-scanning, and the republish-on-restart behaviour below only reach your plugin once it depends on those versions.
- **Hyprland and Sway hosts:** the "exclusive" display setting — which is what the default resolves to on these desktops — now genuinely disables your own monitors for the length of a session and brings them back afterwards, exactly as it always has on KDE. Until now it was accepted, reported back as active, and quietly behaved as "extend", so your monitors stayed on. If you would rather keep them on, set the display topology to *extend*. Two things to know: on Hyprland, bringing the monitors back reloads your Hyprland configuration, which also drops any other runtime tweaks you have applied since login and re-runs its startup commands; and the Sway half is written to the same contract but has not been exercised on a live Sway machine, so if it misbehaves, please say so.
- **NixOS hosts:** there is now a binary cache, so a host no longer takes an hour to build. Add the cache and its public key to your configuration — the install guide has the snippet, and the cache serves its own public key. If your flake overrides Punktfunk's `nixpkgs` input, the cache cannot help you: every package changes and rebuilds. Separately, one of the controller-audio fixes below is a sound-configuration file the other Linux packages install into a system location NixOS does not have; it needs a package override there.
- **Other Linux hosts:** several controller-audio fixes arrive as system files inside the host package rather than as code — a device rule, a sound-system policy and a sound-card configuration. They take effect after the package is installed and the controller is reattached (or the machine rebooted).
- **Linux hosts, if you look in your sound settings mid-session:** you will now see a virtual output *and* a recording stream both named after Punktfunk. That is the host's own audio output, and it is not a leak — the troubleshooting guide has a new section on it and on what to do when it tells you another device is clocking your audio.
- **If you moved the host's management port and hand-edited the tray's start-up command to match, you can undo that.** The tray, every plugin and the plugin runner now follow the port the host actually bound, so nothing needs telling.
## New
- **The Windows client installs like an ordinary program, and Steam can launch it.** The download is now a signed per-user installer — no administrator prompt — that puts Punktfunk in your own account and registers what you would expect: the Start-menu entries for Punktfunk and Punktfunk Console, `punktfunk://` links, and the `punktfunk` command on your path. It fetches the Windows runtime it needs if the machine does not have it. **The reason this matters beyond tidiness:** someone reported that launching through Big Picture did nothing and the Steam overlay never appeared. That was never about the app — it was the shape of the old Store-style package, whose program file lives in a protected folder Steam's "add a non-Steam game" browser cannot open, and which Windows starts in a way that stops the overlay attaching. Steam has to start the program itself, from an ordinary path. Now it can: add it as a non-Steam game and the overlay, your per-game controller configuration and Big Picture launching all work. There is also a portable zip that registers nothing at all — unzip it anywhere, delete the folder to remove it — and the Store-style package remains for the Microsoft Store.
- **One command sets up a Linux host, as a preview.** `curl -fsSL https://punktfunk.unom.io/install.sh | sh` works out which system you are on, installs exactly the packages the documentation would have told you to, and then handles the parts people get stuck on: it notices an existing Sunshine, Apollo or Vibeshine and offers to keep both by moving Punktfunk's management port, puts you in the right group, writes a firewall rule, starts the host and web console, checks they are actually running, and prints your console address, the command for its password and the pairing steps. Everything optional defaults to no. `--dry-run` shows every command without running any, `--uninstall` reverses it, and answers can be given up front so it can run unattended. It also catches the two graphics-driver failures that otherwise produce a host that streams badly with no explanation — no driver at all, and a driver the machine refused to load because of Secure Boot. It is labelled **preview** deliberately: the per-system pages stay the documented route until it has more mileage, and NixOS, SteamOS and Windows are pointed at their own instructions rather than half-handled.
- **Every client can send its logs to the host.** In 0.30 this existed only on the Gaming Mode console. Mac, iPhone, iPad and Apple TV get it in the host card's menu and on the controller-driven host options, and Android phones and TVs get it from the console — where the button previously said it was unavailable. On Apple it is new twice over: the app had never kept a log of its own to send, so a report from an Apple TV arrived carrying only the host's half of the story. It now keeps a rolling log — bounded, so it cannot grow without limit — and for the first time that log includes what the streaming engine itself has to say about connections and network problems, which was previously invisible on those devices.
- **The console learns three things from the desktop.** Controller haptics and Controller speaker are now switches in the console's Controller tab rather than settings you could only reach elsewhere; each saved machine gets its own Shared clipboard toggle; and you can set a machine's default profile from its menu.
- **The documentation was rewritten, for two audiences.** There is a get-started track — one task per page, a couple of minutes each, with real screenshots of the console instead of prose describing it — and a Switching from Sunshine page covering what maps to what and how to run both at once. Every install command on those pages is now quoted from one file that the build checks, so the commands you are given cannot drift from the ones that are tested. Several things the pages had told you were simply wrong and are now corrected — among them that GameStream compatibility is opt-in on every route, a missing step in the Ubuntu and Debian instructions, a missing firewall step on Bazzite, and NixOS getting a page of its own.
- **NixOS users get prebuilt binaries.** Every other install channel shipped binaries; Nix compiled the whole workspace and our patched compositor from source — roughly an hour, on the critical path of enabling the host at all. A signed binary cache is now published on every change to the main branch, so a build that once took an hour takes the time it takes to download.
- **Hyprland and Sway hosts can run a session on the virtual display alone.** With the display topology on *exclusive*, your own monitors are switched off while the session runs and switched back on when its display goes away — never all at once, so the desktop is never left with nothing to show, and never a monitor belonging to a second session or a second host on the same machine. *Primary* is still treated as *extend* on these desktops, and now says so on its own rather than sharing a warning with *exclusive*: Wayland has no notion of a primary output, only a focused one, and the streamed display already holds that.
- **The Android app's controller interface is the desktop's.** Plug in or pair a controller and the phone or TV shows the same console Windows and Linux do — same screens, same navigation, same motion — instead of an Android-only recreation of it that had to be fixed three times over. It runs on every Android device, including the 32-bit TV boxes that were previously left out. On a phone the console now takes the whole panel, tucking the system bars away for the duration and bringing them back on a swipe, and the library gives the sort bar's height back to the covers unless you have actually pulled it down. Connected controllers is a page of the console itself now, with the permission prompts, the rumble test and the controller-audio self-test where they were before.
@@ -32,6 +45,10 @@ Most of this release is things that were wrong in ways nothing announced. A Dual
## Improved
- **Touching the console does what touching things should.** On a touchscreen, swiping across the settings list used to change whichever setting your finger happened to land on — a press was both "focus this" and "choose this". A tap is now a tap, delivered where you put your finger down, and a drag scrolls the list. Using a mouse is unchanged.
- **The Android console is easier on the battery.** It only looks for machines on the network while the console is actually in front of you, and it halves its drawing rate after a minute without input.
- **A portable Playnite gets its covers — and a tile of its own.** If your Playnite is unzipped somewhere of your choosing rather than installed, its whole library lives beside the program, which put every cover outside the folders the host is allowed to read art from: one reported library synced its games and dropped all 70 covers, with no way round it but setting an environment variable. Those folders are now included, exactly as Steam's are, and finding a portable Playnite at all also gives it the launcher tile it never had. If you set that environment variable as a workaround, you can drop it.
- **Fixing something on the host side no longer needs a cache file deleted.** A plugin remembers what it last sent so it does not resend it endlessly — but the host can accept a submission and keep less of it than was sent, which is deliberate, since a cover it cannot serve must not cost you the whole library. Once that happened, the plugin's memory said "nothing to do" forever: you would fix the host and nothing would republish. Restarting, or pressing Sync now, always republishes again.
- **Windows: the plugin runner writes a log file you can read.** A field report on a 0.30 host had plugins installed, the runner running, an empty library and "no logs at all" — and that was by design, since the runner's only way to speak was through the host it could not reach. It now writes a plain log file next to its plugin state, and the console's empty-library hint tells you where it is.
- **Apple gamepad screens move like the desktop's.** Screen transitions in the Mac, iPhone, iPad and Apple TV controller shell use the same spring the desktop console uses, and they can be interrupted — press B mid-flight and the same spring carries you back. Reduce Motion crossfades instead of snapping.
- **The Apple library fits a phone.** The grid fills the width instead of leaving a fifth of it empty on a phone; in a landscape phone's height it holds two rows instead of one; the shoulder-button hint hides on any phone and the sort bar has become a tray you pull down with ▲ and dismiss with ▼, A or B, so the field keeps every point of height it has. Navigating the grid no longer scrolls twice for one move, and a diagonal flick of the stick is one move, not two.
@@ -43,6 +60,12 @@ Most of this release is things that were wrong in ways nothing announced. A Dual
## Fixed
- **On KDE Plasma 6.6, a session could refuse to start — in your own language — and stay broken.** Plasma 6.6 changed the rules: it will not stream a display it created switched off, and the refusal it sends is a translated sentence with nothing written to any log, because switching a display off is a perfectly ordinary thing to have configured. It also repeated forever, because we deliberately ask for a display named consistently so Plasma remembers your resolution and scale against it — which meant it also remembered "off" and reapplied it to every future session. You could not even fix it in System Settings, since the display exists only for the fraction of a second the request is alive. The host now switches it on and tries again. Separately, that translated refusal used to be mistaken for a temporary problem on any non-English desktop and burned through eight retries over eleven seconds before giving up; it now fails immediately and says what it means.
- **Gaming Mode: "Switch to Desktop" hung until you rebooted.** Taking over Gaming Mode used to work by stopping the piece of the system that logs you in, which also happens to be the only thing on the machine able to start a desktop session — so Steam's own switch sat on its dialog forever. The takeover now leaves it running and simply parks the automatic gaming login for the duration. Measured on the machine that reported it: the switch that used to hang brings the desktop up in about ten seconds, and Steam is back within five when the session ends.
- **Wake-on-LAN never worked from a Steam Deck.** Waking a sleeping machine needs its network address, and the only two places that ever recorded one were the Windows and Linux desktop windows — neither of which exists in Gaming Mode. So a Deck's saved machines never had one, every wake was skipped in silence, and nothing said so: no packet, no error. All the places that see a machine announce itself now remember it. Two things fall out of the same fix: a Deck's saved machine keeps its icon instead of losing it the moment the network goes quiet, and the advice in `punktfunk wake` — "connect to it once while it's awake" — is corrected, because connecting never taught it anything.
- **The web console's Virtual displays page showed the same two cards on both tabs.** The Streamed-screen and session-lifetime cards sat below the tab strip rather than inside it, so whichever tab you were on you saw them — which read as the Configuration tab's contents leaking into the Live displays tab. They are settings, so they now live in Configuration, and the Live tab is just the live list and its arrangement.
- **Cancelling a connection left you looking at "Cancelling…".** The console waited for the machine you were dialling to confirm, which it had no obligation to do — on Android it never did, so the screen stayed until the app was killed, and on the desktop it waited out the dial: fifteen seconds normally, and just over three minutes when the host was holding your request for someone to approve. Pressing B is your decision and now takes effect at once, and the dial is actually abandoned.
- **NixOS hosts could not be built at all.** Our patched compositor no longer applied to the version of it that came with the pinned packages, and because HDR is on by default that failure was on the path of every host build. Two more faults were hiding behind it and are fixed in the same breath: the packages we build from turn off the piece that lets a game reach HDR unless explicitly asked, so the compositor would have installed under our name and quietly denied HDR to everything under it; and the cleanup step could not remove files that install into read-only folders.
- **Ending a session killed the entire host on Windows.** The service restarted it about six seconds later, so it read as a mystery reconnect rather than a crash — four times on one machine, every one of them a session teardown. The cause was a single log line written at a moment when the logging machinery could already be gone, and the crash handler then hid the evidence by trying to log the same way and failing the same way. Neither happens now, and if anything of that shape ever recurs, the message naming it will actually be written down.
- **Launching a game from a Windows host on 0.30 could drop your stream a second later**, with the console reporting nothing running. Almost every Windows launch is a hand-off to Steam, Playnite or the shell — a process that quits a second after the launcher takes over. 0.30 learned to track the process it started, and for a title without any way to recognise its window it took that hand-off's exit as the game's and closed the connection. It would also, in one shape, have asked your whole Steam client to close when the game ended. Both fixed; a title the host cannot track shows as untracked instead of "launching" forever.
- **DualSense rumble and speaker in a game streamed from a Linux host — five faults, in order:**
@@ -56,7 +79,7 @@ Most of this release is things that were wrong in ways nothing announced. A Dual
- **A Gaming Mode takeover on a machine that logs itself in flooded the box with logins.** On a host set to log its user in automatically, the way the takeover held the desktop's login manager back sat squarely in that manager's retry path — so every automatic login failed in milliseconds and it tried again, with no back-off: 962 logins in under four minutes, the system's buttons re-scanned 5,688 times, a load of 26 on 12 cores, and every program that listens for new devices drowning in the noise. To you it looked like "my DualSense is not detected, or only with an insane delay": the pad enumerated perfectly and then delivered input at about 1.4 Hz. That method of holding back is gone; the takeover now stops the login manager first and only then steps in. If it cannot stop it, it no longer tries anyway — it falls back to streaming the desktop's own session, which is a working stream, rather than fighting a login loop that costs you your controller.
- **Audio hiccups on the client that nothing counted.** On the Linux desktop client and the Steam Deck the audio callback was running on the wrong thread at ordinary priority, and when that thread was late the sound system played silence for us and moved on — an underrun no counter ever saw. On the host, the audio pacer measured its schedule against the wall clock while the audio it carried did not, so every small hole left it a little further behind until a bigger hole repaid the debt as a burst of silence frames — one field log showed 3372 % of departures late and the worst nearly 100 ms behind. Both fixed, and holes no longer open and close with a click.
- **A Steam Deck's support bundle covered three seconds instead of the whole session.** The video decoder writes a dozen bookkeeping lines per frame, and at 120 fps that flushed the entire log ring — 2,037,456 lines evicted from a 27-minute session, including the one audio line three rounds of investigation had been waiting for. The chatter is filtered before it reaches the ring.
- **Android: the picture arrived late and dropped frames on a phone that could easily keep up.** The reference phone decodes a frame in 45 ms and was still showing 30 ms end to end with 4050 skipped frames a second, because it was pacing against a display clock that Android quietly slows down for game processes. Frames are now composited straight onto the display on the panel's real timing, and each one reports back exactly when it landed. On glass: end-to-end 30 ms → about 18 ms, skipped frames 4050 a second → 0. Whether the panel *holds* 120 Hz turns out to be the phone maker's power policy, not the app's — nothing an app can ask lifts it — so if you want 120 on such a phone, set your phone's minimum refresh rate.
- **Android: the picture arrived late and dropped frames on a phone that could easily keep up.** The reference phone decodes a frame in 45 ms and was still showing 30 ms end to end with 4050 skipped frames a second, because it was pacing against a display clock that Android quietly slows down for game processes. Frames are now composited straight onto the display on the panel's real timing, and each one reports back exactly when it landed. On glass: end-to-end 30 ms → about 18 ms, skipped frames 4050 a second → 0. That new path carries the stream's colour properly too — high dynamic range is described by what the two ends agreed on rather than guessed at from the decoder, and ordinary content is labelled so the phone stops washing blacks out to grey — and the status and gesture bars no longer park themselves over the video for the whole session. Whether the panel *holds* 120 Hz turns out to be the phone maker's power policy, not the app's — nothing an app can ask lifts it — so if you want 120 on such a phone, set your phone's minimum refresh rate.
- **Android: the controller went dead after opening the Controllers or Licences page from the console** — every press was dropped until you force-stopped the app. Also, plugging in a controller could leave you on a grey screen for the rest of the session if the console could not draw; it now hands you back to the touch interface instead.
- **Mac, iPad and Apple TV: clicking a host connects to it again.** A change earlier in this cycle had made the host card open the game library instead, with "connect" pushed into the menu — the opposite of every other client. Reverted: tap to connect, "Browse Library…" back in the menu, everything else the library work landed kept.
- **The Apple grid's first day on real hardware.** A single stick flick was read as up-then-right on the way out of the dead zone; the grid drew over the pinned title; rows vanished while still in view; one step down scrolled the row above half away; and "Copy link" was a face button on a gamepad interface. All addressed — X now opens a small options menu for the title.
@@ -64,10 +87,12 @@ Most of this release is things that were wrong in ways nothing announced. A Dual
## Thanks
Several of these were found because someone sent a log detailed enough to disprove the obvious. The Steam Deck bundle that turned out to hold three seconds of decoder chatter is what made the log-ring bug visible; the audio-clock investigation went through four field logs and a purpose-built probe before a single column in a diagnostic tool named a controller's sound card; and the report of a Windows host with plugins installed, a runner running and nothing to show for it described exactly the failure the runner could not report on its own. Thank you.
Several of these were found because someone sent a log detailed enough to disprove the obvious. The Steam Deck bundle that turned out to hold three seconds of decoder chatter is what made the log-ring bug visible; the audio-clock investigation went through four field logs and a purpose-built probe before a single column in a diagnostic tool named a controller's sound card; and the report of a Windows host with plugins installed, a runner running and nothing to show for it described exactly the failure the runner could not report on its own.
Several more came from reports that were specific about the thing that did not happen, which is what made each of them findable: Big Picture not launching and the Steam overlay never appearing — a report about a program's install shape, not about streaming at all; ⌘Space doing nothing on a Mac with the setting switched on; a Playnite library that synced its games and dropped exactly 70 covers; a Portuguese-language KDE desktop where the same failure looked like a flaky one; a Steam Deck that would not wake a machine and produced no error to look at; and an Apple TV whose log bundle arrived carrying only the host's half — which is the report that got the client a log of its own. Thank you.
## For developers
Protocol, ABI, driver and embedder detail — including the version table and the notes on what moved — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.0/CHANGELOG.md).
The short version: nothing versioned moves — no wire, ABI, driver-protocol or plugin-contract change — and the C header is byte-identical to 0.30.0's. Three things are worth reading before you package or embed this release: the Linux host package now installs three new system files (a device rule, a sound-system policy and a sound-card configuration) that the controller-audio fixes depend on; the Linux desktop-audio capture changed topology by default, with a one-release escape hatch back to the 0.30 shape; and the Android app's Compose console is deleted outright, which removes its screenshot scenes.
The short version: the streaming protocol does not move, and neither does the driver protocol or the plugin contract, so nothing needs re-pairing in any direction. The embedding interface moves one step by pure addition — a single new call that hands an embedder the engine's own log lines, which is what gave the Apple client something worth sending in the first place; adopt it or ignore it, nothing else changed shape. Four things are worth reading before you package or embed this release: the Windows client's default download is now a per-user installer plus a portable zip, with the Store package kept as an alternate; the Linux host package installs three new system files (a device rule, a sound-system policy and a sound-card configuration) that the controller-audio fixes depend on; the Linux desktop-audio capture changed topology by default, with a one-release escape hatch back to the 0.30 shape; and the Android app's Compose console is deleted outright, which removes its screenshot scenes.
+4 -3
View File
@@ -1,3 +1,4 @@
• The controller interface is now the same console the desktop app shows — on every phone and TV, 32-bit boxes included — with a Controllers page of its own.
• A much faster picture: frames land on the panel's real timing, so on the reference phone latency fell from 30 to about 18 ms and dropped frames from 4050 a second to none.
Fixed: the gamepad going dead after opening Controllers or Licences, a grey screen when the console could not draw, and audio gaps when the buffer needed to grow.
• The controller interface is now the same console the desktop shows — on every phone and TV, 32-bit boxes included — with its own Controllers page.
• A much faster picture: frames land on the panel's real timing, so latency on the reference phone fell from 30 to about 18 ms and dropped frames to none.
New: send logs to the host, wake a sleeping PC, controller haptics and speaker switches.
• Fixed: swiping the settings list no longer changes values.
+34
View File
@@ -28,6 +28,9 @@
# states — every `install` line of an apt/pacman/dnf/sysext host platform must appear in
# the script verbatim (it edits channel/group into the string at run time, never the
# literal), and the script must parse under sh.
# 7. The installer under --dry-run against faked os-release files detects every family it claims
# to (and --uninstall prints each family's removal) — the committed half of the manual
# 16-file matrix PR #345 was verified with. Needs curl on PATH (the script's own prerequisite).
#
# Textual gates, so textual limits: gate 2/3 match token spelling, not env reads — a var name in
# a code comment counts as "exists", and a quoted constant that isn't an env var counts toward
@@ -123,4 +126,35 @@ elif command -v node >/dev/null 2>&1; then
node -e "$installer_check" || fail=1
fi
# ---------------------------------------------------------------- gate 7: installer detection matrix (--dry-run)
# Faked os-release files through the real script, nothing executed: each family must be detected
# and print its own package-manager line, both for the install and for --uninstall; the unsupported
# ones must stop with their pointer. A fix to the installer adds its case here.
osr=$(mktemp -d)
installer_case() { # name os-release-body expected-substring [extra args...]
name=$1; printf '%b' "$2" > "$osr/$name"; want=$3; shift 3
out=$(PUNKTFUNK_INSTALL_OS_RELEASE="$osr/$name" sh scripts/install.sh --dry-run --yes --no-start "$@" 2>&1)
case "$out" in *"$want"*) ;; *)
echo "::error::scripts/install.sh --dry-run $* on a fake $name os-release did not print '$want':"
printf '%s\n' "$out" | sed 's/^/ /'
fail=1 ;;
esac
}
installer_case debian 'ID=debian\nVERSION_ID=13\n' 'sudo apt install -y punktfunk-host'
installer_case ubuntu 'ID=ubuntu\nID_LIKE=debian\nVERSION_ID=26.04\n' 'sudo apt install -y punktfunk-host'
installer_case mint22 'ID=linuxmint\nID_LIKE="ubuntu debian"\nVERSION_ID=22.1\n' 'cannot host'
installer_case fedora 'ID=fedora\nVERSION_ID=44\n' 'sudo dnf install -y punktfunk'
installer_case fedora43 'ID=fedora\nVERSION_ID=43\n' '/rpm/bazzite'
installer_case arch 'ID=arch\n' 'sudo pacman -Syu --noconfirm punktfunk-host'
installer_case cachyos 'ID=cachyos\nID_LIKE="arch"\n' 'sudo pacman -Syu --noconfirm punktfunk-host'
installer_case bazzite 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext.sh install'
installer_case nixos 'ID=nixos\n' 'docs/nixos'
installer_case steamos 'ID=steamos\nID_LIKE=arch\n' 'docs/steamos-host'
installer_case gentoo 'ID=gentoo\n' 'build-from-source'
installer_case debian-rm 'ID=debian\nVERSION_ID=13\n' 'sources.list.d/punktfunk.list' --uninstall
installer_case fedora-rm 'ID=fedora\nVERSION_ID=44\n' 'yum.repos.d/punktfunk.repo' --uninstall
installer_case arch-rm 'ID=arch\n' '/etc/pacman.conf' --uninstall
installer_case bazzite-rm 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext remove' --uninstall
rm -rf "$osr"
exit "$fail"
+58 -6
View File
@@ -31,6 +31,7 @@ LINGER=${PUNKTFUNK_INSTALL_LINGER:-} # 1/0, empty = ask (default no)
MGMT_PORT=${PUNKTFUNK_INSTALL_MGMT_PORT:-47991} # where the management API moves to on a conflict
START=1
DRY=${PUNKTFUNK_INSTALL_DRY_RUN:-0}
UNINSTALL=0
usage() {
cat <<EOF
@@ -45,6 +46,7 @@ usage: sh install.sh [options]
--linger | --no-linger start the host at boot with nobody logged in (default no)
--mgmt-port N port to move the management API to if Sunshine/Apollo holds 47990 (default $MGMT_PORT)
--no-start install and configure, but don't enable the services
--uninstall stop the services and remove the packages + repo (config stays: $DOCS/uninstall)
--dry-run print every command it would run, change nothing
-h, --help this text
@@ -67,6 +69,7 @@ while [ $# -gt 0 ]; do
--mgmt-port) shift; MGMT_PORT=${1:-} ;;
--mgmt-port=*) MGMT_PORT=${1#*=} ;;
--no-start) START=0 ;;
--uninstall) UNINSTALL=1 ;;
--dry-run) DRY=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
@@ -106,7 +109,10 @@ run() {
cmd=$(printf '%s' "$cmd" | sed \
-e 's/^sudo apt install /sudo apt install -y /' \
-e 's/^sudo dnf install /sudo dnf install -y /' \
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /')
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /' \
-e 's/^sudo apt purge /sudo apt purge -y /' \
-e 's/^sudo dnf remove /sudo dnf remove -y /' \
-e 's/^sudo pacman -Rns /sudo pacman -Rns --noconfirm /')
fi
printf ' + %s\n' "$cmd"
[ "$DRY" = 1 ] && return 0
@@ -172,6 +178,43 @@ else
fi
say "Detected $PRETTY$FAMILY (guide: $DOCS_PAGE)"
# ---------------------------------------------------------------------------- --uninstall
# The reverse of step 1 + step 6, as $DOCS/uninstall spells it out per family: user units off first
# (package removal can't see the enable symlinks in $HOME), then only the punktfunk packages that
# are actually installed, then the repo. Config, groups and firewall rules stay — the page lists them.
if [ "$UNINSTALL" = 1 ]; then
say "Uninstalling the host ($DOCS/uninstall)"
run 'systemctl --user disable --now punktfunk-host punktfunk-web punktfunk-scripting 2>/dev/null || true'
case "$FAMILY" in
apt)
pkgs=$(dpkg-query -W -f='${Package} ${db:Status-Status}\n' 'punktfunk*' 2>/dev/null | awk '$2=="installed"{printf "%s ", $1}')
[ -n "$pkgs" ] && run "sudo apt purge $pkgs"
run 'sudo rm -f /etc/apt/sources.list.d/punktfunk.list /etc/apt/keyrings/punktfunk.asc'
run 'sudo apt update'
;;
dnf)
pkgs=$(rpm -qa --qf '%{NAME} ' 'punktfunk*' 2>/dev/null)
[ -n "$pkgs" ] && run "sudo dnf remove $pkgs"
run 'sudo rm -f /etc/yum.repos.d/punktfunk.repo'
;;
pacman)
pkgs=$(pacman -Qq 2>/dev/null | grep '^punktfunk' | tr '\n' ' ')
[ -n "$pkgs" ] && run "sudo pacman -Rns $pkgs"
run "sudo sed -i '/^\\[punktfunk\\(-canary\\)\\{0,1\\}\\]\$/,/^Server = /d' /etc/pacman.conf"
;;
sysext)
run 'sudo punktfunk-sysext remove'
;;
esac
cat <<EOF
Removed. Left on purpose: ~/.config/punktfunk (identity, pairings, host.env, plugins — a reinstall
picks them up), the punktfunk / punktfunk-update groups, and any firewall rules you opened.
The one-command cleanups for each are on $DOCS/uninstall#linux-hosts
EOF
exit 0
fi
# Version floors the package can't express: below these the install succeeds and nothing can stream.
major=${VERSION_ID%%.*}
case "$ID" in
@@ -361,11 +404,20 @@ if [ "$START" = 1 ] && [ "$DRY" != 1 ]; then
if command -v ss >/dev/null 2>&1 && ss -lun 2>/dev/null | grep -q ':9777 '; then ok "listening on UDP 9777 (punktfunk/1)"
else warn "nothing on UDP 9777 yet — give it a second, then: journalctl --user -u punktfunk-host -e"; fi
fi
# GPU drivers are the docs pages' job (one step, per distro) — but the one silent failure worth
# calling out: Fedora + NVIDIA with Fedora's own ffmpeg has no NVENC, and the RPM only Recommends
# RPM Fusion's build, so the install succeeded and encoding won't.
if [ "$FAMILY" = dnf ] && grep -qs 0x10de /sys/bus/pci/devices/*/vendor 2>/dev/null && ! rpm -q ffmpeg-libs >/dev/null 2>&1; then
warn "NVIDIA GPU, but RPM Fusion's ffmpeg-libs isn't installed — NVENC won't work until it is: step 1 of $DOCS_PAGE"
# GPU drivers are the docs pages' job (one step, per distro) — but the silent failures worth
# calling out, because the install succeeded and streaming won't: an NVIDIA card whose kernel
# module didn't load (Secure Boot blocks the unenrolled key — nvidia-smi can't talk to it), or no
# driver at all; and Fedora + NVIDIA with Fedora's own ffmpeg, which has no NVENC (the RPM only
# Recommends RPM Fusion's build).
if grep -qs 0x10de /sys/bus/pci/devices/*/vendor 2>/dev/null; then
if ! command -v nvidia-smi >/dev/null 2>&1; then
warn "NVIDIA GPU without the NVIDIA driver — nothing can encode until it's installed: step 1 of $DOCS_PAGE"
elif ! nvidia-smi >/dev/null 2>&1; then
warn "NVIDIA GPU, but nvidia-smi can't talk to the driver — the kernel module didn't load (Secure Boot? run: mokutil --sb-state): $DOCS/troubleshooting#nvidia-smi-says-it-cant-communicate-with-the-driver"
fi
if [ "$FAMILY" = dnf ] && ! rpm -q ffmpeg-libs >/dev/null 2>&1; then
warn "NVIDIA GPU, but RPM Fusion's ffmpeg-libs isn't installed — NVENC won't work until it is: step 1 of $DOCS_PAGE"
fi
fi
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -n "$ip" ] || ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}')
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/host",
"version": "0.1.4",
"version": "0.1.5",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module",
"license": "MIT OR Apache-2.0",
+1 -1
View File
@@ -8,4 +8,4 @@
*
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
*/
export const SDK_VERSION = "0.1.4";
export const SDK_VERSION = "0.1.5";
+13 -3
View File
@@ -56,7 +56,10 @@ import { m } from "@/paraglide/messages";
* (this is the host's *next-connect* behavior). The live-display list + multi-monitor arrangement
* table below act on whatever is currently streaming.
*/
export const DisplaySection: FC = () => {
export const DisplaySection: FC<{
/** See [`DisplayTabs`] — the page's other configuration cards, tabbed along with the form. */
configurationExtra?: ReactNode;
}> = ({ configurationExtra }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const q = useGetDisplaySettings();
@@ -181,6 +184,7 @@ export const DisplaySection: FC = () => {
return (
<DisplayTabs
dirty={dirty}
configurationExtra={configurationExtra}
live={<LiveDisplays />}
configuration={
<>
@@ -245,8 +249,13 @@ export const DisplaySection: FC = () => {
export const DisplayTabs: FC<{
dirty: boolean;
configuration: ReactNode;
/** Further self-contained cards that belong to the Configuration tab (streamed screen,
* sessiongame lifetime). Rendered as siblings of the config card, INSIDE the tab: parked
* below the tab shell they showed on both tabs, which read as the live tab's content being
* duplicated into Configuration. */
configurationExtra?: ReactNode;
live: ReactNode;
}> = ({ dirty, configuration, live }) => (
}> = ({ dirty, configuration, configurationExtra, live }) => (
<Tabs defaultValue="configuration" className="gap-card">
<TabsList>
<TabsTrigger value="configuration">
@@ -266,10 +275,11 @@ export const DisplayTabs: FC<{
<TabsTrigger value="live">{m.display_live()}</TabsTrigger>
</TabsList>
<TabsContent value="configuration">
<TabsContent value="configuration" className="flex flex-col gap-card">
<Card>
<CardContent className="space-y-4">{configuration}</CardContent>
</Card>
{configurationExtra}
</TabsContent>
<TabsContent value="live">
+12 -3
View File
@@ -14,6 +14,10 @@ import { SessionGameCard } from "./SessionGameCard";
* The sessiongame lifetime card sits here rather than on its own page because it is the same
* question one step further out: keep-alive decides how long a *display* outlives a disconnect, and
* this decides whether the *game* does.
*
* Both extra cards ride INSIDE the Configuration tab (they are policy surfaces): as plain siblings
* below the tab shell they were visible from either tab, which read as the Live tab's content
* bleeding into Configuration.
*/
export const SectionDisplays: FC = () => {
useLocale();
@@ -21,9 +25,14 @@ export const SectionDisplays: FC = () => {
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
<DisplaySection />
<MonitorCard />
<SessionGameCard />
<DisplaySection
configurationExtra={
<>
<MonitorCard />
<SessionGameCard />
</>
}
/>
</div>
</Section>
);