Compare commits

...
Author SHA1 Message Date
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
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
41 changed files with 1079 additions and 212 deletions
+1 -1
View File
@@ -417,7 +417,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
+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
+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 }
}
+1 -1
View File
@@ -45,7 +45,7 @@ three ABIs, which removes the Compose screenshot scenes.
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships |
| `@punktfunk/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,
+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 |
+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 |
+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
+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
+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
+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";