Commit Graph
2510 Commits
Author SHA1 Message Date
enricobuehler eb4b33ca49 Merge pull request 'An xbox launch kind a de-privileged plugin can publish' (#75) from worktree-xbox-launch-kind into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/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 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Failing after 11m41s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #75
2026-08-06 14:48:03 +00:00
enricobuehler 8e02f3a8e1 Merge pull request 'ci(nix): a stale bun.nix can no longer reach main unnoticed' (#73) from worktree-nix-bun2nix-drift-gate into main
apple / swift (push) Successful in 1m26s
ci / bun-nix (push) Successful in 1m20s
ci / rust-arm64 (push) Successful in 1m27s
android / android (push) Canceled after 1m44s
deb / build-publish-host (push) Failing after 3s
apple / screenshots (push) Canceled after 10s
arch / build-publish (push) Canceled after 1m47s
ci / rust (push) Canceled after 1m49s
ci / web (push) Canceled after 1m45s
ci / docs-site (push) Canceled after 1m45s
deb / build-publish (push) Canceled after 26s
deb / build-publish-client-arm64 (push) Canceled after 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/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 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
nix / flake (push) Failing after 18s
decky / build-publish (push) Successful in 50s
Reviewed-on: #73
2026-08-06 14:46:25 +00:00
enricobuehler 166e158afe feat(host/library): an xbox launch kind a de-privileged plugin can publish
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m9s
ci / docs-site (pull_request) Successful in 1m9s
ci / web (pull_request) Successful in 3m20s
ci / rust (pull_request) Failing after 9m9s
The xbox scanner resolves each package's PackageFamilyName by enumerating
%ProgramData%\Microsoft\Windows\AppRepository\Packages. Probed on .173 (design doc S1),
that directory is:

  NT AUTHORITY\LocalService   UnauthorizedAccessException   <- the plugin runner
  LocalSystem                 count=348                     <- the host service

So an extracted xbox plugin cannot build an AUMID, and `aumid` — which takes a complete
`<PFN>!<AppId>` — is unpublishable by one.

The design doc offered two fallbacks: entries without `aumid` (they would list but not
launch), or a one-shot elevated PFN resolve at install time. This is neither. The plugin
sends `<Identity>!<AppId>`, both read straight out of MicrosoftGame.config — which IS
readable de-privileged — and the host resolves the publisher hash at LAUNCH time, where it
already owns command construction.

That keeps D1 intact (the plugin supplies a validated value, the host builds the command),
it is the same shape as the `playnite` kind, and resolving late means a package update that
changes the publisher hash cannot leave a stale unlaunchable tile behind — which the
install-time cache would have.

`xbox_pfn` therefore moves from the scan path to the launch path rather than being deleted,
and its doc records the privilege asymmetry so the next reader doesn't re-derive it.

The charset guard is factored out (`aumid_part` / `valid_aumid`) and shared with the `aumid`
kind. On `aumid` it was belt-and-braces — every value was host-derived. On `xbox` it is
load-bearing: the Identity arrives over the wire from a plugin, and it is interpolated into
a `shell:AppsFolder\…` argument. Validated inbound at reconcile too, so a malformed value is
a 400 the plugin author can act on rather than a tile that fails on click.

Gates: punktfunk-host 438 passed / 0 failed on .21, and 55/0 for the library tests on .173
(where these arms actually compile). Both counts +1 for the new guard test.
2026-08-06 15:43:26 +02:00
enricobuehler fb707b4956 ci(nix): a stale bun.nix can no longer reach main unnoticed
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Failing after 2m19s
ci / docs-site (pull_request) Successful in 1m17s
android / android (pull_request) Successful in 3m10s
ci / bun-nix (pull_request) Successful in 1m6s
nix / flake (pull_request) Failing after 1m6s
ci / rust (pull_request) Failing after 6m2s
Moving the bun packages to bun2nix (1db8f763) removed the aggregate deps hash
that used to go stale, but not the second, quieter way a generated file rots.
bun2nix is a pure function of bun.lock, and `bun.nix` is committed alongside it
— but it regenerates ONLY from a local `bun install` that runs lifecycle
scripts. It does not regenerate under `bun install --ignore-scripts`, which is
what every bun install in CI uses, and it cannot regenerate on a merge or
rebase, where git carries someone else's lockfile change past a `bun.nix`
generated before it and reports no conflict.

That is not theoretical. web/bun.nix went onto main holding brace-expansion@5.0.7
(plus two nested entries the `^5.0.8` override had already collapsed) while
web/bun.lock said 5.0.8: the override landed in ec9aa415, the bun2nix branch had
generated bun.nix off the pre-override lock, and the merge kept both. The Nix
build fetches node_modules strictly from bun.nix, so the derivation's offline
`bun install` was asked for a tarball its store cache did not contain and
`nix build .#punktfunk-web` failed. It stayed broken for 553 commits
(2026-07-27 -> 2026-08-05) and was closed by accident, when an unrelated
advisory bump in b79d90b4 happened to rerun a real `bun install`.

Nothing caught it because nothing could: the string "nix" appeared in exactly
one workflow file, and only in a comment about bun2nix breaking a Windows step.

  * scripts/ci/check-bun-nix.sh regenerates each bun.nix from its committed
    bun.lock and diffs, with `--fix` to rewrite them. It reads which packages to
    check out of packages.nix's own `bunNix = src + ...` lines rather than a
    hardcoded list, so a third bun package is covered the day it is added, and
    an empty list is a hard error — a gate that checks nothing passes exactly
    like a clean tree. It also enforces the bun2nix version pin that flake.nix
    and the README only stated: bun.nix has no schema stability across releases,
    so the flake input ref and both package.json devDependencies must agree, and
    generation always uses the pinned version rather than a floating `bunx`.

  * ci.yml gains a `bun-nix` job running it. Deliberately unfiltered and not in
    nix.yml: it needs no Nix, takes under a minute, and the whole point is that
    this drift arrives through commits that look unrelated to Nix.

  * nix.yml is new and is the first CI that evaluates the flake at all —
    `nix flake check --no-build` over every output, then a real build of
    punktfunk-web + punktfunk-scripting, which are the two derivations whose
    inputs churn and the only ones that do not compile Rust. That leg is the
    end-to-end proof the drift gate cannot give: it catches a tarball the
    registry no longer serves, or the codegen going quietly message-less. It is
    path-filtered, has a workflow_dispatch opt-in for the hour-long Rust
    packages, and keeps `pull_request` — flatpak.yml shipped push-only and let
    manifest breakage reach main invisibly for weeks.

The script is POSIX sh (shellcheck-clean, exercised under dash, which is the
CI container's /bin/sh) and avoids process substitution, the parse-time failure
that silently disabled ci.yml's shader SPIR-V gate for 35 commits.
2026-08-06 15:28:14 +02:00
enricobuehler 65996621d8 Merge pull request 'fix(encode/pyrowave): stop stamping GPU scheduling priority over pf-frame's auto gate' (#72) from worktree-pyrowave-gpu-priority into main
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m26s
deb / build-publish-host (push) Failing after 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 1m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 10s
apple / swift (push) Successful in 1m27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
deb / build-publish-client-arm64 (push) Successful in 2m55s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish (push) Successful in 4m9s
docker / deploy-docs (push) Successful in 42s
arch / build-publish (push) Successful in 10m31s
ci / rust-arm64 (push) Failing after 8m55s
apple / screenshots (push) Successful in 5m51s
windows-host / package (push) Failing after 6m15s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m13s
ci / rust (push) Successful in 20m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m50s
Reviewed-on: #72
2026-08-06 13:15:06 +00:00
enricobuehler c48e60fbb7 Merge pull request 'Two July fixes that were never merged: rpm FFmpeg modules + the released-pointer double cursor' (#71) from worktree-july-rpm-and-cursor into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/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 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m48s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
release / apple (push) Successful in 9m31s
flatpak / build-publish (push) Successful in 10m3s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m17s
Reviewed-on: #71
2026-08-06 13:13:33 +00:00
enricobuehler 70684e5079 fix(encode/pyrowave): stop stamping GPU scheduling priority over pf-frame's auto gate
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m41s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / web (pull_request) Successful in 2m27s
ci / docs-site (pull_request) Successful in 3m35s
ci / rust (pull_request) Successful in 8m25s
`windows/pyrowave.rs` raised the process's WDDM scheduling class to HIGH itself, once per
process, at every session open. `pf-frame::dxgi::auto_priority_gate` already owns that policy
for the whole process and runs from `create_device` — the call the Windows capture path always
makes before any PyroWave texture exists. Two owners of one process-wide setting.

The audit filed this as "downgrades REALTIME to HIGH", which undersells it. pf-frame's default
`auto` mode starts at HIGH and then UPGRADES to REALTIME once it has established that is safe —
HAGS off, or HAGS on with VRAM headroom — and leaves a monitor running that drops back when VRAM
tightens, because REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang. Opening
a PyroWave session after that upgrade stamped HIGH back over the class AND orphaned the
monitor's decision, losing the ceiling-raise on exactly the GPU-saturated workload PyroWave
exists to survive: it encodes on the shader cores a game saturates, where the measured spike is
~2 ms to 15-18 ms.

Removed rather than reconciled. `PyroWaveEncoder::open` takes no device, so there was nothing
session-specific to preserve, and the surviving owner is strictly better informed — it knows the
adapter, HAGS state and VRAM headroom, none of which this call site had.

The duplicated knob goes with it: `PUNKTFUNK_GPU_PRIORITY` is retired in favour of
`PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default `auto`), which is a
superset — the removed knob could not express the auto gate at all. No other reference to it
exists in the tree.

Verified on .173: clippy -D warnings at nvenc,amf-qsv,qsv (host + pf-encode --all-targets),
amf-qsv without qsv, qsv alone, no-features, cargo test --features qsv (34 passed), rustfmt —
7 legs green. Windows-only file, so the Linux legs do not compile it.
2026-08-06 15:06:49 +02:00
enricobuehler 3f8a70dd45 Merge pull request 'Launcher tiles reach the clients, and Playnite can publish again' (#70) from worktree-library-clients into main
apple / swift (push) Successful in 1m34s
ci / web (push) Successful in 1m7s
deb / build-publish-host (push) Failing after 4s
ci / docs-site (push) Successful in 1m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m36s
ci / rust-arm64 (push) Successful in 5m47s
arch / build-publish (push) Successful in 7m36s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 25s
android / android (push) Canceled after 9m43s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 9m54s
deb / build-publish (push) Canceled after 8m14s
release / apple (push) Canceled after 8m21s
flatpak / build-publish (push) Canceled after 5m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / package (push) Failing after 6m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #70
2026-08-06 13:03:42 +00:00
enricobuehler 2a67c02f7e fix(clients/cursor): the host must not composite a pointer under a released client's own cursor
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 1m9s
android / android (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m36s
ci / web (pull_request) Successful in 4m31s
ci / docs-site (pull_request) Successful in 4m43s
ci / rust-arm64 (pull_request) Successful in 6m50s
ci / rust (pull_request) Failing after 12m8s
Streaming a KDE desktop showed two cursors: the one the user was moving, and a
second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3
in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer
and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()`
skips it and no pointer is ever painted into that stream. It was ours.

Both clients declared the render model as `captured && desktop`, so ANY released
pointer handed compositing back to the host. But releasing does not remove the
local cursor — it restores the ordinary window arrow over the video. The host then
blends its own pointer in underneath, and since a released client forwards no
motion, nothing drives it: it stays frozen wherever the host pointer was last left.
Caught live on the host with the render-model diag:

    cursor diag: client_draws=false blended=true live=Some((-1, 622, true))

x = -1 — parked on the streamed output's left edge, unchanged sample after sample,
while the user moved their own cursor around freely. Engaging capture flipped it to
`client_draws=true blended=false` and the duplicate vanished, which is why it only
looked "stuck when not dragging": dragging means engaged, and engaged was the one
state that behaved.

The host may composite ONLY while the client holds a grabbed, hidden pointer — the
capture model, engaged — which is the single state with no local cursor on screen.
Released now counts as "the client draws it": the host stops compositing and keeps
forwarding shape/state over the channel (the forwarder ticks on this side of the
flip), so re-engaging is seamless and the client's cached shape stays warm.
2026-08-06 15:03:11 +02:00
enricobuehler 79ee308a7a build(rpm): declare all seven FFmpeg pkg-config modules, not three
`ffmpeg-next` is pulled with default features, so `ffmpeg-sys-next`'s build script
pkg-config-probes codec/device/filter/format/util/resampling/scaling and panics on
the first one missing. The spec named three.

RPM Fusion's `ffmpeg-devel` ships all seven in one package, which hid it. On a host
where those three instead resolve to Fedora's split `libav*-free-devel` packages,
`dnf builddep` installs exactly three and the build dies in a build script:

    The system library `libavfilter` required by crate `ffmpeg-sys-next` was not found.
2026-08-06 15:03:05 +02:00
enricobuehler 58ee74cb58 fix(clients/windows): clippy's manual_is_multiple_of on the rescan tick
android / android (pull_request) Successful in 6m8s
ci / web (pull_request) Successful in 4m50s
ci / docs-site (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 5m50s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m37s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
`cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the
pinned 1.96.0 toolchain: `ticks % 5 == 0` trips `manual_is_multiple_of`. Pre-existing
and not from this branch — found while gating the launcher work on .173, because
neither macOS nor Linux ever compiles this crate.

Clippy's own suggestion, applied verbatim.
2026-08-06 14:50:18 +02:00
enricobuehler 6ae2ea6708 Merge remote-tracking branch 'origin/main' into worktree-library-clients 2026-08-06 14:45:36 +02:00
enricobuehler 00d4026054 Merge pull request 'Worktree field kleisty triage' (#69) from worktree-field-kleisty-triage into main
arch / build-publish (push) Failing after 40s
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 2m30s
deb / build-publish (push) Successful in 3m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 6m51s
docker / builders-arm64cross (push) Failing after 25s
docker / deploy-docs (push) Failing after 1m57s
release / apple (push) Successful in 9m17s
deb / build-publish-host (push) Successful in 7m58s
android / android (push) Successful in 12m19s
ci / rust (push) Successful in 12m1s
flatpak / build-publish (push) Successful in 9m40s
apple / screenshots (push) Successful in 5m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
windows-host / package (push) Canceled after 2m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 1s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #69
2026-08-06 12:41:28 +00:00
enricobuehler dd20a17edb test(host/library): the art tests build a file:// URL Windows can read
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.

The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.

Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
2026-08-06 14:36:04 +02:00
enricobuehler 8ff2c2e1c6 feat(host/library): Playnite can publish again, and gets a fullscreen tile
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.

Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:

  playnite     valued by the game's GUID; resolves to
               explorer.exe "playnite://playnite/start/<guid>", the same
               protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
               on the way in (so a bad value is a 400 the plugin author can act on)
               and again at launch.

  launcher_ui  now accepts "playnite" on Windows, resolving to
               Playnite.FullscreenApp.exe with Playnite's own install dir as the
               working directory.

Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.

`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.

Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
2026-08-06 14:35:48 +02:00
enricobuehler 883c317872 feat(clients/library): a launcher tile looks like one, on every client
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.

So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".

One contract, implemented in each client's own idiom:

  * launchers never interleave with titles — they lead, and each group keeps the
    host's title order
  * grid surfaces get a labelled section; a coverflow keeps its single carousel and
    names the group the cursor is in, changing as it crosses the boundary. A second
    focus rail would mean a new up/down nav model in three renderers for two or
    three tiles
  * an art-less launcher gets an accent face naming its launcher, not a title
    monogram on the neutral one — "opens Steam", not "a cover that didn't load"
  * anything that is not `"launcher"` is a game, and a host that omits the field
    renders exactly as before (design D4's intended degradation)
  * launching is unchanged: the client sends an id, the host resolves the recipe

The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.

Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.

The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.

Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.

Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
2026-08-06 14:35:24 +02:00
enricobuehler 72777119fd fix(client/android): stop reporting every disconnect as a lost connection
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m50s
android / android (pull_request) Successful in 3m43s
ci / rust-arm64 (pull_request) Successful in 4m26s
ci / rust (pull_request) Successful in 7m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 12m38s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m23s
The stream watchdog polled a bare "has the session ended" boolean, so it
had exactly one thing it could say and said it every time: "Connection
lost — the host may be asleep. Wake it to reconnect." That ran when the
player quit their game, when an operator ended the session from the
console, and when they pressed Back themselves — telling them to go wake
a host that was never asleep.

It now reads the end reason. Only a connection that actually died gets
that line, a host-side failure gets its own, and the three deliberate
endings say nothing at all: leaving the stream is already the feedback,
and a toast on top of it is just noise.

A game launched from a library also returns to that library instead of
host selection, which needs the intent hoisted out of the console shell:
the stream replaces that shell in the composition, discarding the
`remember`s holding its screen and host, so by the time the session ends
there is nothing left to navigate back with. The parent holds it across
the gap and the shell consumes it on the way in. The touch UI has no
library — only the console shell does — so there it is the toast fix
alone.
2026-08-06 14:30:59 +02:00
enricobuehler 81b4f76c4d fix(client): a session ending on purpose stops reading as a failure
The desktop clients turned every host-side close into "Host ended the
session", and a reason string means "abnormal" to everything downstream:
the GTK and Windows shells raised a banner, the console overlay drew a
status strip. Quitting a game you launched yourself produced all of
that. Now only a host error or a lost connection carries a message; the
deliberate endings return the silence those shells already give a clean
exit, which is also what puts the console back in its library with
nothing in the way.

The Apple client gains the same distinction. It had one line for every
ending — "Session ended by <host>." — which is fine for an operator
stopping the session and wrong for a link that died, so each now says
what happened. A game exiting stays silent and returns to the library it
was launched from.

Both read the reason while the connection is still up, because tearing
it down is what makes it unreadable, and both fall back to their previous
wording when there is no verdict — an older core, or a close that raced
the read — rather than inventing a new one for a case they cannot see.
2026-08-06 14:30:46 +02:00
enricobuehler ec44496285 feat(client): tell clients WHY a session ended, not just that it did
A session ending was a single bit. A player quitting their game, an
operator ending the session from the console, a stop the client itself
asked for, a host crashing and a Wi-Fi drop all arrived as the same
"closed" — so every client had to write one message covering all of
them, and every client picked an error. That is how quitting your own
game came to be reported as trouble on all three.

The information was already there and thrown away: the host closes with
APP_EXITED when a launched game exits, with 0 when it ends the session
cleanly and 1 when it fails, and a link that simply dies never closes at
all. The connection watcher now classifies that into a
PunktfunkEndReason — local, game exited, host ended, host error, lost —
and latches it before the shutdown flag, since the two are read by
different threads and the reason must never arrive second.

Exposed as punktfunk_connection_end_reason. This replaces the
game-exited flag added a moment ago rather than joining it: that
question is one row of this table, and it was never released. Still
additive to any embedder that ignores it, and the host sends the same
bytes either way, so the wire is untouched.

`is_normal()` is the question nearly every caller actually has, so both
the Rust and C surfaces answer it directly rather than making each
client re-derive which of five values are worth alarming a user about.
2026-08-06 14:30:33 +02:00
enricobuehler d74639de70 Merge pull request 'A safe-area resolution that keeps the picture out of the notch' (#68) from worktree-launchers-safearea-exclusions into main
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 1m53s
android / android (push) Successful in 5m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
ci / web (push) Successful in 1m4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 1m12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 34s
ci / rust (push) Failing after 9m39s
release / apple (push) Successful in 9m11s
apple / screenshots (push) Successful in 5m42s
Reviewed-on: #68
2026-08-06 11:59:31 +00:00
enricobuehler d4dd5f7a3d feat(client): a game exiting takes you back to its library
Quit a game you launched from a host's library and the stream ended with
"Session ended by <host>." on the host-selection screen — an error
report for something you had just done on purpose, and several taps away
from starting the next title.

The host has always said what happened: it closes the connection with
APP_EXITED when the game it launched for a session exits, and that
code's own documentation describes this feature. Nothing ever read it —
a search across every client found zero consumers. (It also could not
reach anyone until the previous commit, since the close only happens
once the lease declares the game gone.)

The core now records the reason as it observes the close, latched before
the shutdown flag because different threads watch the two, and exposes
it as punktfunk_connection_game_exited. Purely additive: a client that
never asks behaves exactly as before, the host sends identical bytes,
and the wire version is untouched — ABI 17.

The Apple client asks while the connection is still up, then treats a
game exit as the normal finish it is: no error banner, and if the
session began as a library launch it reopens that library so the next
title is one tap away. Any other ending — a stop, the host going away,
network loss — is unchanged. The other clients keep their existing
end-of-session behaviour; the call is there when they want it.
2026-08-06 13:58:57 +02:00
enricobuehler ea762b849d fix(client/ios): Escape stays in the game instead of freeing the pointer
Pressing Escape mid-stream on an iPad handed the mouse back to iPadOS:
the captured cursor was swapped for the system one and the game stopped
receiving relative motion, so aiming died until you clicked back in.
Two previous attempts treated that release as unavoidable and built
recovery around it — a re-lock burst, then a click that re-asks. Both
came back from the field unchanged, because both fought the release
after it had already happened, inside the cooldown the platform applies
straight after its own "let me out" gesture.

The release was never unavoidable. This app had no UIKit key handling at
all: every key arrives on the GameController path, which is a parallel
HID feed that does not consume the UIKit event, and the only thing that
ever became first responder was the video view, and only to summon the
soft keyboard. So every hardware Escape reached UIKit unclaimed — and an
unclaimed key press is precisely what lets the system apply its own
default for that key. Apps that read a hardware keyboard the ordinary
way consume the event as a side effect and never see this.

So claim it. The stream controller becomes first responder while capture
is engaged and takes Escape in pressesBegan/pressesEnded, passing every
other press to super untouched. Escape still reaches the host on the
GameController path, so in-game menus open exactly as before; only the
system's own interpretation is suppressed. Scoped to captured input, so
Escape keeps dismissing sheets and leaving full screen whenever the
stream doesn't own the keyboard, and the deliberate ways out are
untouched — Cmd-Escape and Ctrl-Opt-Shift-Q are read off the same
GameController path and clear capture themselves.

The recovery path stays as a backstop and is retimed to match what was
measured: the old burst spent its entire budget within ~0.6 s of the
drop, i.e. wholly inside the cooldown, where the answer can only be no.
Retries now continue at 1.2 s and 2.4 s, and quietly — they don't hide
the cursor or mute pointer motion the way the burst does, so a longer
recovery costs nothing when it fails.
2026-08-06 13:58:41 +02:00
enricobuehler 76e8bd1b98 fix(host/gamelease): a game that exited stops counting as running
When a launched game's processes are all gone, the watcher asks one last
out-of-band question before ending the session: does the launcher still
think the game is up? On Windows that reads Steam's per-app `Running`
registry flag. It was only ever meant to be a tie-breaker for a scan that
momentarily can't see the game — a launcher re-execing, an engine
relaunching itself into a new pid.

It had no bound. Honouring the flag reset the confirm window every pass,
so a flag Steam left set — it does that whenever it doesn't cleanly
observe the exit: it crashed, it was closed first, the game re-parented —
pinned the lease in `running` for the life of the host. The console kept
showing the game, `session_on_game_exit` never fired, and the only way to
get the stream back was a manual "End". Reported from the field on
Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that
says so, so a stale flag in any loaded profile was enough.

The absence timer now keeps running instead of being reset, and that is
what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the
box, the launcher's opinion is stale rather than early and the session
ends anyway, logged at WARN so it is visible. Ending a moment early is
the cheaper failure — the stream drops while the game lives, the user
reconnects, and nothing is ever killed. Ending never was the bug.

The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a
test. The watch loop polls a live process table and can't be unit-tested,
which is exactly how an unbounded veto shipped unnoticed.
2026-08-06 13:58:24 +02:00
enricobuehler fbdad8d917 Merge pull request 'fix(clients): host discovery heals itself, and every client can rescan' (#67) from worktree-host-discovery-refresh into main
ci / web (push) Successful in 1m14s
apple / swift (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m20s
deb / build-publish (push) Successful in 3m53s
deb / build-publish-host (push) Successful in 4m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
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 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
ci / rust-arm64 (push) Successful in 6m58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 17s
docker / builders-arm64cross (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 2m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Canceled after 8m10s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Successful in 8m22s
ci / rust (push) Canceled after 8m34s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m13s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 7m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m13s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m37s
flatpak / build-publish (push) Failing after 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 15m37s
Reviewed-on: #67
2026-08-06 11:51:30 +00:00
enricobuehler 78ad675507 feat(clients): a safe-area resolution that keeps the picture out of the notch
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 2m2s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m19s
Picking the device's native mode on a phone hands the host the panel's own
aspect ratio, so the aspect-fit presenter fills every pixel — including the ones
behind the sensor housing and under the four rounded corners. That is why the
corners look cut off at max resolution while 1080p has always been fine: a 16:9
mode on a 20:9 phone pillarboxes, and those black bars land exactly on the
unsafe regions.

So the fix is entirely a sizing one — no layout change, no input change. Ask the
host for a mode narrowed by the unsafe inset and the existing aspect-fit centres
it inside the safe region; pointer mapping follows for free, because both
clients derive the picture rect from the live host mode rather than assuming
full-bleed.

Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device
(safe area)" row beside the native one, using Moonlight's formula — full native
height, width less the left+right safe insets. The stream is always landscape
but the settings screen may be portrait, where the same housing is reported on
`top` and the horizontal insets read zero; the portrait top inset stands in,
gated so an iPad's status bar never fabricates an inset.

Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at
connect like the existing `0`=native one. The cutout insets get the same
portrait fallback, and the rounded corners are added on top — Android does not
count them as cutout, and a full-height picture needs exactly the corner radius
of horizontal clearance.

Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions
and an inset subtraction lands odd about half the time. Where a display has
neither cutout nor rounded corners the safe mode equals the native one, which on
Apple lets the existing dedup drop the duplicate row.
2026-08-06 13:44:01 +02:00
enricobuehler b25e6eda91 fix(clients): host discovery heals itself, and every client can rescan
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 4m16s
android / android (pull_request) Successful in 6m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 7m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 3m30s
ci / rust (pull_request) Successful in 15m13s
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.

Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:

- A failed resolve was never retried. `browseResultsChangedHandler`
  only fires when the result SET changes, and a host whose resolve
  failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
  throwaway UDP flow used to resolve an address could sit in
  `.preparing`/`.waiting` forever, and a service with a connection in
  flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
  re-armed). On iOS that is where the local-network privacy prompt
  lands on first launch after install: the browse starts, the system
  asks, and the browser waits. Granting does not revive that browser —
  only a new one sees the grant. That is the reported first-run bug.

HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).

Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.

The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.

Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.

Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
2026-08-06 13:30:10 +02:00
enricobuehler c79d9397fe Merge pull request 'fix(flatpak): the WSI layer module builds again — vkroots was declared twice' (#65) from worktree-flatpak-vkroots into main
ci / rust-arm64 (push) Successful in 1m22s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m44s
flatpak / build-publish (push) Successful in 6m22s
ci / rust (push) Successful in 7m50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 51s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
docker / deploy-docs (push) Failing after 10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders-arm64cross (push) Skipped
Reviewed-on: #65
2026-08-06 11:12:50 +00:00
enricobuehler 3edb01f1b8 Merge pull request 'Gamepad UI: section tabs, background palettes, and a backdrop that moves everywhere' (#66) from worktree-gamepad-ui-polish into main
ci / web (push) Successful in 1m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 20s
apple / swift (push) Successful in 1m37s
ci / docs-site (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 1m55s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m51s
android / android (push) Successful in 7m2s
docker / deploy-docs (push) Failing after 39s
ci / rust (push) Successful in 7m14s
deb / build-publish (push) Successful in 5m14s
deb / build-publish-host (push) Successful in 5m56s
flatpak / build-publish (push) Failing after 7m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
deb / build-publish-client-arm64 (push) Failing after 11m14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 11m56s
arch / build-publish (push) Failing after 13m25s
docker / builders-arm64cross (push) Skipped
release / apple (push) Successful in 12m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m25s
apple / screenshots (push) Successful in 6m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m52s
Reviewed-on: #66
2026-08-06 10:50:25 +00:00
enricobuehler 5a7f7f0fc5 feat(clients/gamepad-ui): section tabs, background palettes, and a backdrop that moves everywhere
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
The console settings were one 30-row scroll, which on a Deck meant thumbing past
Video and Audio to reach the pad settings. They are now split across sections —
Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the
desktop console, which alone carries the touch/mouse rows. L1/R1 walks them,
each section remembers where its cursor was, and the names are the same word on
every client so a setting is where you looked for it last.

Shoulders are not the only route, because a D-pad remote hasn't got any: on
Android, Up from the first row moves onto the strip (left/right walks sections
there, A drops back in), and on tvOS the pills are focusable, so the focus
engine handles it — a Siri Remote has no extended gamepad profile and never
reaches the input poll at all. The desktop console needs neither; PageUp and
PageDown already map to the same events.

New "Background" row, six palettes: Violet (the brand default), Tide, Forest,
Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over
the ONE colour field each client already draws, so every palette inherits its
structure and Violet is the identity transform — existing installs see exactly
what they see today. The maths is ported three times (Rust/Swift/Kotlin) under
one shared `ui_palette` key, with the same assertions pinned in each language.
It is presentation only, so it is a device preference and never part of a
profile.

The form screens no longer have a backdrop of their own. Settings, add-host and
pair used to sit on a still gradient; they now wear the same living field at a
calm mix — pools dimmed onto the palette's own corner colour, vignette halved so
rows that run to the edges don't get crushed. On the desktop console that
collapsed the old aurora-over-static crossfade into one shader pass with a
chased uniform. Motion speed is identical in both modes on purpose: changing it
would make the field jump mid-transition. Nothing in the gamepad UI is backed by
a static image now, and Reduce Motion (Apple) / "remove animations" (Android)
still freeze it.

Also: the settings screen had no raster coverage at all — the eyeball dump is
`#[ignore]`d — so a new test draws every tab, and the Android screenshot set
gains a console-settings scene. Both earned their keep immediately: the renders
showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now,
and the Section cell only appears where shoulders exist) and the form backdrop
crushing its own edges.
2026-08-06 12:39:30 +02:00
enricobuehler 25b08916b6 fix(flatpak): the WSI layer module builds again — vkroots was declared twice
ci / web (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 2m19s
ci / rust (pull_request) Successful in 6m22s
The flatpak has not built since 35ba64ca. Every push to main fails at "Build the
flatpak", before a single build command runs:

  cp: cannot overwrite non-directory
    '.../build/gamescope-wsi-layer-1/subprojects/vkroots/.git'
    with directory '.../git/https_github.com_Joshua-Ashton_vkroots.git'
  Error: module gamescope-wsi-layer: Child process exited with code 1

vkroots was declared twice. flatpak-builder clones git sources WITH SUBMODULES by
default, and `subprojects/vkroots` is a real gamescope submodule — `git ls-tree
8c676c39 subprojects/` shows it as mode 160000 at 5106d8a0, which is byte-for-byte
the commit the explicit source pinned. So the submodule checkout already produced
the right tree and left `subprojects/vkroots/.git` as a gitlink FILE; the second,
redundant source then tried to copy the bare mirror onto that path as a DIRECTORY,
and cp refused. Source extraction died there — `buildsystem: simple` and the
hand-applied glm/stb patch_directory copies were never reached, so neither is at
fault.

Removing the redundant source is therefore a no-op on the resulting tree: the
submodule supplies that exact rev. glm and stb are NOT submodules — `subprojects/
glm.wrap` and `stb.wrap` are plain blobs at that rev — so nothing else populates
them and their explicit sources have to stay. That asymmetry is the whole trap,
and it is now written down in the manifest next to the sources, along with the
disable-submodules escape hatch for anyone who later needs to pin a subproject
away from the gamescope rev.

Why this reached main: flatpak.yml has no `pull_request:` trigger — only `push` on
main with path filters, `tags: ['v*']`, and workflow_dispatch. PR #64's checks were
green because the flatpak was never built on the PR; run 15775 was the first time
this module had ever been built in CI. Adding a PR trigger (or a manifest lint) is
the durable follow-up, deliberately not bundled here.

This blocks the release, not just main. flatpak.yml runs on `tags: ['v*']`, and the
failing step gates the bundle export, the generic-registry publish, the OSTree push
to flatpak.unom.io and the release-asset attach — all of which stay skipped. A
v0.25.0 tag cut today would ship with NO Linux/Steam Deck flatpak at all, on the
release whose headline Linux change is Deck HDR working out of the box.

NOT VALIDATED LOCALLY: this cannot be built on macOS. The reasoning is confirmed
against the upstream tree (the ls-tree above) but the green run is still owed —
dispatch flatpak.yml on this branch before merging.
2026-08-06 00:49:25 +02:00
enricobuehler 35ba64ca0f Merge pull request 'fix(flatpak): Deck HDR works on a plain install' (#64) from worktree-deck-hdr-wsi-env into main
ci / rust-arm64 (push) Failing after 4s
ci / rust (push) Failing after 4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m8s
docker / deploy-docs (push) Successful in 28s
flatpak / build-publish (push) Failing after 3m9s
Reviewed-on: #64
2026-08-05 22:21:51 +00:00
enricobuehler 5f71aeb024 feat(flatpak): vendor the gamescope WSI layer so Deck HDR works on a plain install
ci / web (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 2m10s
ci / rust (pull_request) Successful in 7m59s
HDR on a Deck needed a manual second step nobody took:
  flatpak install --user flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08
documented only in a comment in this file. Build the layer ourselves
instead, so a plain `flatpak install` is all it takes.

The layer is genuinely required, not legacy. Measured on SteamOS 3.8.16
(gamescope 3.16.23.4): the gamescope-0 socket advertises
gamescope_swapchain_factory_v2 but NOT wp_color_manager_v1, with HDR both
off and on — so Mesa's Wayland WSI has no colour-management protocol to
negotiate HDR10 through, and this layer is the only thing that can append
the ST.2084 surface formats. Removing the extension gives zero
[Gamescope WSI] lines and hdr10_format=None.

Vendored rather than declared via add-extensions autodownload: the
extension is 94 MB of whole-gamescope for one 4 MB .so, its layer JSON
hardcodes a /usr library_path that an app-scoped extension mounted under
/app would not satisfy, and it would make flathub a hard install-time
dependency of an app we self-host on flatpak.unom.io.

enable_gamescope=false skips subdir('src') and every compositor
dependency, so only protocol/ and layer/ build. buildsystem is simple
rather than meson because glm and stb ship no meson.build of their own -
the wraps' patch_directory supplies it, and without that copy configure
dies with "Subproject exists but has no meson.build file".

meson generates the layer JSON from prefix+libdir, so it self-writes
library_path=/app/lib/... into /app/share/vulkan/implicit_layer.d, which
XDG_DATA_DIRS already covers. VK_ADD_IMPLICIT_LAYER_PATH is therefore
dropped - keeping it would also risk double-loading two same-named layers
for anyone who still has the flathub extension installed.

Pinned to the same gamescope rev as packaging/gamescope/PKGBUILD so the
client's layer and the host's punktfunk-gamescope come from one tree.

Verified on a Deck OLED: builds offline (--wrap-mode=nodownload) in
org.gnome.Sdk//50, and the resulting .so drives the Deck's system
gamescope to "hdr formats exposed to client: true" with
hdr10_format=Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT).

Still user-side, and not fixable in packaging: gamescope's hdr_enabled
convar (Steam's HDR display setting) must be on.
2026-08-06 00:15:16 +02:00
enricobuehler e1adc5d6d7 fix(flatpak): export GAMESCOPE_WAYLAND_DISPLAY so the Deck actually gets HDR
The gamescope WSI layer decides whether to engage from one signal:
isRunningUnderGamescope() reads $GAMESCOPE_WAYLAND_DISPLAY and nothing
else. flatpak does not forward host env into the sandbox, so it arrived
unset and the layer's CreateInstance early-returned before creating a
GamescopeInstance — no gamescope surface, so the HDR10/ST.2084 formats
were never appended and the surface stayed SDR.

The layer still loads and still logs its generic bits in that state, so
it reads as working. It is not: the three settings already here (layer
search path, ENABLE_GAMESCOPE_WSI, the socket bind) all sit downstream
of this gate and buy nothing without it.

Measured on a Deck OLED (Galileo, SteamOS 3.8.16), client --browse,
reading "swapchain config":
  unset              -> no [Gamescope WSI] Surface state block, None
  set, hdr_enabled=0 -> server hdr output enabled: false, None
  set, hdr_enabled=1 -> hdr formats exposed to client: true,
                        Some(A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT)

Matches the field report of "HDR->SDR" in the stats overlay on a
correct HDR host. DXVK_HDR was ruled out by measurement. The remaining
gate (gamescope's hdr_enabled convar = Steam's HDR display setting) is
a user-side step, not a packaging one.
2026-08-05 23:59:45 +02:00
enricobuehler 76a271b97a Merge pull request 'Worktree decky brand name' (#63) from worktree-decky-brand-name into main
ci / docs-site (push) Successful in 1m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 46s
ci / web (push) Successful in 1m26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
ci / rust-arm64 (push) Successful in 2m1s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 13s
docker / builders-arm64cross (push) Skipped
decky / build-publish (push) Successful in 39s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
ci / rust (push) Successful in 6m40s
docker / deploy-docs (push) Successful in 6m32s
Reviewed-on: #63
2026-08-05 21:41:17 +00:00
enricobuehler b53568c99f fix(decky): a host saved under its own IP now shows the name it advertises
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / rust (pull_request) Successful in 6m51s
The panel captioned most rows with an IP address. The saved records were
the source: `hosts add` falls back to the address when the pairing path
knew nothing better, so `name` is literally "192.168.1.21" — and
`mergeHosts` took `s.name || s.addr` unconditionally. The fallback only
ever fired for an EMPTY name, so a name that was already a copy of the
address sailed through as if it were meaningful, and the row printed the
address twice: once as its title, once as its subtitle.

The friendly name was in hand the whole time. The row is built by joining
the saved record to the live advert, and that advert carries the host's
actual hostname — the join was already trusted for address, port, online
and OS, and only the name was read from the saved side alone.

So treat a name equal to the record's own address as the placeholder it is
and yield to the advert. A real saved name still wins, even when stale: it
may be one the user chose, and an advert must never silently overwrite it.
The comparison is against the SAVED address, so a host that moved DHCP
lease still recognises its old address as a placeholder rather than
mistaking it for a chosen name.

Checked against the Deck that reported this, over its actual store and
browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and
steamdeck, the four offline ones keep their address (nothing is
advertising a better name for them yet), and a user-chosen name survives a
conflicting advert.
2026-08-05 23:37:18 +02:00
enricobuehler db0637928b fix(decky): the shortcut liveness guard answered "alive" for every appId
`shortcutStillExists()` extracted the store method before calling it:

    const get = appStore?.GetAppOverviewByAppID;
    return get(appId) != null;

`GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so
the unbound call throws on the lost `this` — and the function's own
`catch { return true }` swallowed it. The guard therefore returned "still
exists" for EVERY appId. Not a stale-data bug: it never once answered no.

Everything downstream of it was consequently inert. A dangling appId — the
documented hazard this guard exists to catch, since the id outlives the
shortcut in Steam's CEF localStorage across a plugin reinstall — was never
dropped, so `ensureGamepadUiShortcut` always took the reuse branch and
`SetShortcut*`'d a dead id (silent no-ops). The visible library entry never
came back, `recreateShortcuts` reported success having done nothing (its
toast only checks for a non-null appId, and the dead one is non-null), and
"Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with
"Game configuration unavailable".

Call it as a method so `this` survives, and guard the global with `typeof`
first: `appStore` is Steam-injected, and a bare reference to a missing one
is a ReferenceError that optional chaining does not prevent — which would
have landed in the same catch.

Verified against the live Deck that hit this: evaluated both versions over
its actual appIds, and where the old guard says alive/alive, the fixed one
says alive for the live stream shortcut and dead for the dangling UI id —
so the stale key now drops and the entry is recreated on the next mount.
2026-08-05 23:33:25 +02:00
enricobuehler 22bc81238d fix(decky): Decky's plugin list says "Punktfunk", not "punktfunk"
The label Decky shows for an installed plugin is plugin.json "name", which
we had set to the lowercase directory name — so the one place every user
sees the plugin listed was the one place it was off-brand, while the panel
header (titleView) already read "Punktfunk".

The two were conflated because the name looked load-bearing: the zip's
top-level dir becomes ~/homebrew/plugins/<dir>, and the scripts derived
that dir FROM plugin.json "name". They are in fact independent — Decky
extracts the zip as-is and locates an installed plugin by MATCHING
plugin.json "name", never by folder name (that is how a plugin can live in
DeckWebBrowser/ and list itself as "Web Browser").

So brand-case the label and pin the on-disk dir to the literal `punktfunk`
in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that
matters: had the dir followed the label, this rename would have installed a
second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin
would have shown up twice.

The self-update call passes the name Decky uninstalls before extracting, so
it moves to "Punktfunk" with it. The upgrade INTO this build still passes
"punktfunk" (the installed build's own value), which matches that build's
plugin.json — so the old folder is removed and the new zip lands in the
same lowercase dir either way. Decky's per-plugin settings dir is unused
(all state lives in ~/.config/punktfunk), so nothing is stranded.
2026-08-05 23:20:28 +02:00
enricobuehler de6b9e94ec Merge pull request 'fix(client/windows): settings persist when the app isn't installed on C:' (#62) from worktree-client-msix-persist into main
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m22s
apple / swift (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
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 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
flatpak / build-publish (push) Failing after 4s
deb / build-publish-host (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Failing after 9m30s
apple / screenshots (push) Successful in 10m16s
android / android (push) Successful in 13m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 13m28s
deb / build-publish (push) Successful in 14m47s
arch / build-publish (push) Successful in 15m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
Reviewed-on: #62
2026-08-05 20:53:38 +00:00
enricobuehler 5ebe840320 fix(client/windows): settings persist when the app isn't installed on C:
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 22s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 1m28s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 3m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m42s
ci / rust (pull_request) Successful in 7m46s
Reported from the field (2026-08-05): a fresh Windows 11 box with a data
partition, "New apps will save to: D:", and the client installed there. It
launches, finds hosts and streams — but no setting and no profile survives a
restart. Reinstalling to C: fixes it completely. The reporter's read was "it's
in read-only mode", and that is almost exactly right.

The one clue that localises it: the client creates its mTLS identity with a
plain `fs::write` on first run and hard-exits if that fails. Their app started,
so ordinary file creation in the config directory works. Only the config stores
were being lost — and those are the three files that go through `write_atomic`,
which writes a sibling temp and renames it over the target.

The rename is what breaks. The client ships as a full-trust MSIX package, so
its `%APPDATA%` writes are redirected into the package container. When the
package lives on a secondary drive, Windows keeps that redirected state on the
package's own volume: `C:\Users\<u>\AppData\Local\Packages\<pfn>\` stays a real
directory on C:, but its children (LocalCache, RoamingState, …) are junctions to
`D:\WpSystem\<SID>\…`. Both sides of our rename still spell `C:\Users\…`, so
nothing looks unusual, but they can resolve across that junction boundary — and
`std::fs::rename` is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` and *not*
`MOVEFILE_COPY_ALLOWED`, so a cross-volume move fails outright rather than
degrading to a copy. Creating files still works, which is why everything else
about the install looks healthy.

So the fix is not to make the rename work — it is to stop treating it as the
only way to persist. `write_atomic` now falls back to writing the target in
place when the atomic route fails. That is the same operation the identity files
already use, and those demonstrably round-trip on the affected installs, so the
fallback lands on a path we know resolves. It trades crash-atomicity for exactly
the writes that would otherwise be lost, and nowhere else: temp+rename stays the
normal route everywhere it works.

Writing into a redirected location cannot desync from reading it — Microsoft
documents one private-location-first resolution order for both, so whichever
layer a write lands in is the layer the next read finds. The fallback verifies
anyway, by reading the bytes straight back: a write that reports success and
disappears is precisely the bug being fixed, so this path does not get to claim
success on an `Ok(())` alone. It costs nothing normally — it only runs on an
install that has already shown it does something unusual.

Two things this uncovered on the way:

The temp file was a single shared `<name>.json.tmp`, but these stores have five
whole-file writers (WinUI shell, session, console UI, CLI, Decky). Two saving at
once collide on it — on Windows the second write hits a sharing violation, and
worse, one process can rename the other's half-written bytes over the target.
The scratch path now carries the pid.

And none of this was visible to anyone. Every save on this page is
fire-and-forget by design (a failed settings write must never take a stream
down), so ~15 call sites discard the error and the UI cheerfully shows the
toggle you just moved. The reporter had no log file to send either, because
"Open log folder" was handing out a phantom path — a separate bug, already fixed
in f3c0ee47 but not in the 0.24.0 they were running. `store_health` records the
last persistence failure centrally, and Settings shows an error bar naming the
path when the store is refusing writes, so a client that cannot save says so
instead of pretending.

`update.rs` had hand-rolled the same temp+rename inline, so it neither cleaned
up its temp on a failed rename nor picks up the fallback; it now goes through
the one writer. The update floor silently never rising is how a declined update
comes back forever.

Deliberately NOT done: disabling MSIX AppData virtualization in the manifest
(`desktop6:FileSystemWriteVirtualization`). It would stop the redirection at the
source, but every existing packaged install's settings, profiles and pairings
live inside the container today — turning it off points the client at an empty
real `%APPDATA%` and silently resets all of them. That needs a migration, not a
manifest flag.

Also considered and not taken: resolving the destination directory with
`GetFinalPathNameByHandleW` and creating the temp inside the resolved path, to
keep atomicity. It does not reliably close this hole — when the target file
exists only in the unvirtualized layer while its directory resolves to the
private one, the rename still straddles the boundary — and it would rest on
canonicalisation behaving through the redirection, which we have never verified
on a packaged run.

Verified on the RTX box (.173, Windows 11 26200), which is the platform that
actually has these rename semantics: `cargo fmt --all --check`, the full
`pf-client-core` lib suite (109 passed), and clippy `-D warnings --all-targets`
on both `pf-client-core` and `punktfunk-client-windows` — all clean. Also green
under linux/amd64 (116 passed). Three new tests: the pid-scoped scratch path,
the fallback actually persisting and reading back when the atomic route is
blocked, and a genuinely unwritable store surfacing its error instead of
swallowing it.

The mechanism above is established from documentation and third-party reports,
not from a reproduction on a second-drive install — that box does not exist
here. The fix does not depend on the diagnosis being exactly right: it repairs
any install where the rename fails but a direct write succeeds.
2026-08-05 22:33:36 +02:00
enricobuehler 4b1ce6b905 Merge pull request 'fix(android/hud): stop charging the compositor's wait to the stream' (#61) from worktree-android-hud-os-floor into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 3m14s
ci / docs-site (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 15s
docker / builders-arm64cross (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 1m10s
ci / rust (push) Successful in 8m28s
android / android (push) Successful in 9m24s
Reviewed-on: #61
2026-08-05 20:31:46 +00:00
enricobuehler a11c672bea fix(android/hud): stop charging the compositor's wait to the stream
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 3m21s
ci / rust-arm64 (pull_request) Successful in 3m30s
android / android (pull_request) Successful in 9m40s
ci / rust (pull_request) Successful in 16m25s
The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch
and scanout inside it — pipeline depth no client can pace under. The usual
Android streaming overlays stop measuring at decode-complete, so users
comparing overlays read our honesty as latency: on a 60 Hz panel that floor
alone clears 30 ms, more than everything those overlays display put together.

Exclude it, the way the Apple clients have since the presentation rebuild
(8a40e467): shave the measured floor off the shown display and end-to-end at
every tier, and name what came off in Detailed as `os present +N excluded
(display pipeline minimum)`. The equation still tiles the headline, because
the `display` term is shaved by the same amount.

The floor is the `latch` p50 we already measure (release→OnFrameRendered),
not a modelled 2/refresh: it moves with the panel rate, tunnelled playback
and the vendor's low-latency mode, and it exists on every render path (the
release stamp is parked on all three), so it does not depend on the timeline
presenter being active. Unmeasured reads 0.0 and nothing is shaved — we
exclude only what we actually measured. With the floor out, the `display`
term is already just `pace`, so the `(pace + latch)` split now renders only
on a window where no latch sample paired, and the hardcoded 2-refresh
Apple-equivalence twin is gone with it.

Raw numbers are untouched in the 1 Hz `pf.present` logcat line, so HUD-off
A/Bs and cross-session comparisons still read unshaved values.
2026-08-05 22:28:43 +02:00
enricobuehler cbd0e9664d Merge pull request 'fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move' (#60) from worktree-security-h6-registry-auth into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
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 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
ci / web (push) Successful in 2m24s
ci / docs-site (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 12s
docker / builders-arm64cross (push) Skipped
ci / rust-arm64 (push) Successful in 3m2s
ci / rust (push) Successful in 6m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6m49s
docker / deploy-docs (push) Successful in 35s
Reviewed-on: #60
2026-08-05 20:11:17 +00:00
enricobuehler 66df1624b6 Merge pull request 'Library scanners become plugins — the bridge half (host, wire, kit, console, packaging)' (#59) from worktree-library-plugins into main
apple / swift (push) Successful in 1m29s
ci / web (push) Successful in 1m56s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-site (push) Successful in 2m16s
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 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m4s
android / android (push) Successful in 6m34s
deb / build-publish (push) Successful in 6m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
apple / screenshots (push) Successful in 5m51s
docker / builders-arm64cross (push) Successful in 20s
deb / build-publish-host (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 1m13s
arch / build-publish (push) Successful in 9m1s
ci / rust (push) Successful in 9m42s
windows-host / package (push) Failing after 11m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m57s
flatpak / build-publish (push) Successful in 9m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m14s
Reviewed-on: #59
2026-08-05 19:58:44 +00:00
enricobuehler 6f07bd94d3 feat(library): launcher tiles a plugin can actually publish
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.

D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.

New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:

  heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
            minus --no-gui and minus the URI, so the window itself opens
  lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)

Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.

Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.

Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.

Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
2026-08-05 21:12:19 +02:00
enricobuehler d2085879da Merge main: plugin art rides THROUGH the H-2 confinement, not around it
ci / web (pull_request) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m0s
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / rust (pull_request) Successful in 9m22s
PR #58 hardened the art proxy in the same three files this branch rewrote, and
the two changes pull in opposite directions: #58 narrowed what the host will read
from disk, while WP1.2 widened what counts as a local art path so an extracted
scanner's covers can be served at all. Resolved so the widening goes through the
gate rather than beside it.

Kept from #58, unchanged: art_path_is_confined (UNC refusal, canonicalize-or-
refuse, config-dir exclusion, roots check), the image-extension whitelist,
sniff_image_type, validate_art_paths as write-time validation, the AuthLane
privileged-field check on every entry in a reconcile payload, and the launch
redaction in GET /library.

Three reconciliations:

  * `local_art_bytes` converts a `file://` value to a path BEFORE calling
    art_path_is_servable, so the confinement check and the read see the same
    path. Ordering is the point: percent-decoding happens before
    canonicalization, so a `%2e%2e` escape cannot hide from the traversal check.
    Pinned by a test.

  * `art_roots()` gains $HOME on POSIX. This is the one that would have bitten
    silently: the list was empty on non-Windows, which was correct while
    is_local_art_path was Windows-shaped (Playnite is Windows-only, so nothing on
    a POSIX host was ever classified as local art and the confinement had nothing
    to confine). Once WP1.2 classifies POSIX paths as local, an empty root list
    is not "secure by default" — it serves NO plugin art on Linux, which is every
    cover the lutris and steam plugins emit. $HOME is the exact analogue of the
    Windows users base #58 already ships, and covers Steam's librarycache and
    grid overrides, Lutris's coverart/banners (both copies), Heroic's caches and
    all the Flatpak variants. It is not the load-bearing control: a value still
    needs an image extension, must canonicalize to a real regular file inside a
    root and outside the config dir, and must CONTAIN image bytes.

  * The two tests that both wanted to mutate PUNKTFUNK_LIBRARY_ART_ROOTS became
    one. Cargo runs tests as parallel threads of a single process, so two tests
    setting the same env var race. The `file://` and confinement assertions moved
    into #58's existing confined test; what remains of the WP1.2 test is the
    pure classification/rewrite half, which touches neither env nor filesystem.

Also: `steam_ui` was missing from the list of host-resolved launch kinds in
privileged_field's doc comment and in the 403 a plugin sees. Prose only — the
check is a denylist (prep, launch.kind = "command"), so steam_ui was never
actually refused — but a plugin author reading that error would have concluded
otherwise.

Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and
the new file:// ones), full workspace tests clean, workspace clippy -D warnings
clean, cargo fmt --all --check clean, OpenAPI drift test green.
2026-08-05 19:59:11 +02:00
enricobuehler 19f637ea6e fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 6m8s
Second half of security-review-2026-08-05 H-6. The infra half (unom/infra,
runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and
refuses everything else with 405, :5011 demands basic auth on every request
including the /v2/ ping. Both fronts sit on one store, and a registry keys by
repository name rather than by the host:port the client used, so an image
pushed to :5011 is the identical image every consumer pulls from :5010.

So: builds tag the write port, a docker login precedes the push, and the
release-tag manifest PUTs authenticate. Consumers are untouched — every
`container:` in every other workflow still pulls anonymously from :5010, and
ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves.

Not doing the digest pinning the review asked for, deliberately, and the header
says why at length. Once pushes are authenticated, the people who can overwrite
a tag are exactly the people who can push to main and edit a pinned digest in
this file — a pin defends against nobody it did not already trust, and costs a
two-commit dance on every ci/ change (~3x a month) during which consumers run a
builder image predating the change they are testing.

What does close the residual gap is making :latest a checked function of the
tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the
same digest, re-points it when they are not, and warns loudly. An out-of-band
overwrite is caught on the next push to main with no churn, and it fixes a
pre-existing bug on the side: reverting ci/ used to leave :latest on the newer
build forever, because the older key is a cache hit and nothing re-pointed it.
Repair rather than fail, because a legitimate revert must not red-line main.

Verified against the live registry from a runner host with the real docker
client: unauthenticated push denied, push to :5010 refused 405, authenticated
push to :5011 accepted, that same image pulled back anonymously from :5010.
reconcile-latest.sh exercised over all three cases (diverged -> repaired,
already equal -> no-op, missing key -> exit 1). All seven builder images are
consistent with their content keys today, so the new step is a silent no-op on
its first real run.
2026-08-05 19:52:19 +02:00
enricobuehler 4a0d0ce587 Merge pull request 'The plugin lane stops being a way in — 37 of the 38 security-review findings' (#58) from worktree-security-review-0805-fixes into main
apple / swift (push) Successful in 1m24s
ci / web (push) Successful in 1m48s
ci / rust-arm64 (push) Successful in 2m2s
ci / docs-site (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish (push) Successful in 5m50s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Successful in 6m19s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 5m45s
deb / build-publish-host (push) Successful in 6m11s
arch / build-publish (push) Successful in 8m38s
ci / rust (push) Successful in 10m48s
windows-host / package (push) Failing after 11m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m44s
Reviewed-on: #58
2026-08-05 17:39:02 +00:00
enricobuehler 0d94ef0dbe fix(host/mgmt): the field gate returns the refusal, not an error carrying it
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 2m17s
ci / rust-arm64 (pull_request) Successful in 2m43s
android / android (pull_request) Successful in 3m3s
ci / rust (pull_request) Successful in 8m31s
`check_entry_fields` returned `Result<(), Response>`, which trips
`clippy::result_large_err` under CI's `-D warnings`: an axum `Response` is 128
bytes and it was riding in the `Err` variant.

`Option<Response>` is the shape this always wanted. There is no error value to
propagate here — the "error" IS the response the handler sends back — so `None`
means "the payload may proceed" and `Some(r)` is the refusal to return. The call
sites read the same, one word different.

Caught by CI, not by me: I ran `cargo check` and not `cargo clippy -D warnings`.
2026-08-05 19:14:19 +02:00
enricobuehler a1b8627e70 feat(plugin-kit): the lutris pilot as a worked example, and the export gap it found
plugin-kit-publish / publish (push) Successful in 29s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m28s
ci / rust-arm64 (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 4m28s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust (pull_request) Successful in 7m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 3m52s
Writing a real scanner against the kit before six repos get cut from it, rather
than after. It is the lutris pilot (M5/WP5.1) — the smallest of the six and the
one that exercises the POSIX local-art path end to end.

It earned its keep immediately: withReadOnlyDb / openReadOnly were never exported
from the parsers barrel, so the single most distinctive thing the lutris plugin
needs was unreachable from @punktfunk/plugin-kit/library. Nothing caught that,
because nothing had consumed the public surface yet.

It also caught a vacuous green in this package: tsconfig's include was
["src","test"], so anything under examples/ type-checked as a no-op. `examples`
is now in the check scope; tsconfig.build.json still narrows to src and
package.json still ships only dist + README, so nothing new is published (verified
against the built dist).

The example carries two deliberate departures from the Rust original, both
documented inline: art is emitted as file:// URLs instead of inlined data: URLs
(the host proxies the bytes, so the payload stays small — inlining covers is what
blew the 2 MB body limit at 49 titles during the playnite work, and is exactly
why the POSIX art path exists), and the untrusted-slug guard is carried over
verbatim, since the slug comes from Lutris's own database and is interpolated
into a path the host will later be asked to serve.

What it demonstrates, which is the reason one-repo-per-plugin is safe: everything
below `scan` is store-specific parsing, and everything else — store claim, sync
engine, launcher entries, __config, console registration, and the CLI verbs
including the parity gate — comes from defineLibraryPlugin.

plugin-kit: tsc clean (now including examples), 56 tests pass, build clean.
plugin-kit-v0.3.0
2026-08-05 18:54:47 +02:00
enricobuehler 91fa32fbb6 feat(plugin-kit): the parity gate moves into the kit, so plugins can be one repo each
One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.

Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.

The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.

What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.

  punktfunk-plugin-steam parity --snapshot before.json   # host on its built-in
  punktfunk-plugin-steam parity --compare  before.json   # offline; exits non-zero

--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.

Three judgement calls in the diff, each pinned by a test:
  * art is compared by PRESENCE, not value. The representation legitimately
    changes on extraction (a host-relative proxy path or inlined data: URL
    becomes a file:// path or a CDN URL), so comparing values would fail every
    run for no reason. Losing an art kind fails; gaining one does not.
  * launcher entries (role: "launcher") are reported separately instead of as
    unexpected extras — the built-in scanner had no concept of them, so they can
    never be in a baseline. An ORDINARY title the scanner never had still fails,
    which is what catches a bad tool filter.
  * absent and empty are the same thing in metadata: the host omits empty lists
    and nulls, so a plugin sending genres: [] has not changed anything.

plugin-kit: tsc clean, 56 tests pass (10 new).
2026-08-05 18:52:38 +02:00