Compare commits

..
Author SHA1 Message Date
enricobuehler 39a9f09f04 fix(mgmt/web): the running-game cover slot says "game" when there is no art
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m46s
ci / rust-arm64 (push) In progress
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 1m1s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
apple / screenshots (push) Blocked by required conditions
deb / build-publish-client-arm64 (push) Successful in 8m0s
windows-host / package (push) Failing after 35s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 31s
deb / build-publish-host (push) Successful in 10m43s
android / android (push) In progress
ci / rust (push) In progress
ci / bench (push) In progress
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) In progress
windows-host / winget-source (push) Skipped
apple / swift (push) In progress
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) In progress
docker / build-push-arm64cross (push) Blocked by required conditions
docker / deploy-docs (push) Blocked by required conditions
ci / docs-site (push) Successful in 1m57s
deb / build-publish (push) In progress
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) In progress
arch / build-publish (push) Successful in 13m21s
Rendered the Dashboard stories headlessly and looked at them, which is how this
turned up: with no cover the slot was an empty grey rectangle, reading as
something that failed to load rather than something absent.

Plenty of rows will never have art — an operator-typed command has no catalog
entry behind it, a custom entry may carry none, and nothing does until
`/library` has loaded. The slot keeps its fixed size so rows stay aligned when
only some titles have covers; it just holds a gamepad glyph instead of nothing.
2026-07-27 00:47:05 +02:00
enricobuehlerandClaude Opus 5 f57fc85d34 chore(release): bump workspace version to 0.20.0
audit / cargo-audit (push) Successful in 3m41s
ci / rust-arm64 (push) Successful in 13m12s
apple / screenshots (push) In progress
ci / bench (push) Successful in 6m53s
windows-host / package (push) Successful in 11m47s
ci / web (push) Successful in 1m2s
android-screenshots / screenshots (push) Successful in 4m20s
audit / bun-audit (push) Failing after 32s
ci / docs-site (push) Successful in 1m28s
deb / build-publish (push) Successful in 10m36s
arch / build-publish (push) Successful in 13m7s
linux-client-screenshots / screenshots (push) Successful in 7m29s
release / apple (push) Successful in 27m56s
deb / build-publish-client-arm64 (push) Failing after 14m30s
ci / rust (push) Successful in 33m52s
decky / build-publish (push) Successful in 44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 29s
deb / build-publish-host (push) In progress
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 20s
android / android (push) Successful in 18m0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
web-screenshots / screenshots (push) Successful in 3m26s
docker / build-push-arm64cross (push) Waiting to run
docker / deploy-docs (push) Waiting to run
apple / swift (push) Successful in 5m17s
flatpak / build-publish (push) In progress
windows / build (x86_64-pc-windows-msvc) (push) Failing after 29s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 15s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 12m20s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 23m59s
windows-host / winget-source (push) Failing after 29s
117 commits since v0.19.2. Minor rather than patch: the headline is the
session⇄game lifetime binding on both planes and both platforms — the
stream ends when the game does, and optionally the reverse — plus
library metadata, winget packaging for the Windows host, a 64-bit ARM
Linux client, headless `--pair` enrolment, DRM card selection for the
compositor-less presenter, and the ABR/latency de-escalation work, which
changes runtime behaviour on every session.

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6, so 0.18/0.19/0.20 hosts and
clients mix freely and no embedder rebuild is required. The new GameMeta
library fields are optional and flat on the wire.

Notes authored ahead of the tag per docs/releases/README.md, so the
release is born with a body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:33:45 +02:00
enricobuehlerandClaude Fable 5 0323158a3e fix(vdisplay/gamescope): a fresh dedicated launch streams from the first second
audit / bun-audit (push) Failing after 20s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
ci / bench (push) Successful in 6m35s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 7m50s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
arch / build-publish (push) Canceled after 7m36s
deb / build-publish (push) Canceled after 4m26s
ci / web (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Canceled after 6m54s
deb / build-publish-client-arm64 (push) Canceled after 6m48s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 40s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
apple / swift (push) Successful in 5m32s
docker / build-push-arm64cross (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 13s
audit / cargo-audit (push) Successful in 2m54s
ci / docs-site (push) Successful in 1m25s
release / apple (push) Canceled after 1m57s
decky / build-publish (push) Successful in 45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 39s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 6m34s
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
A freshly spawned headless gamescope never delivered a single PipeWire
buffer when a game was launched: gamescope only composites (and only on a
composite pushes a capture buffer) when a client paints, and a nested
Steam bootstrap paints nothing until its UI's first frame — far longer
than the 10 s first-frame budget. The native plane's retry ladder then
killed the half-booted Steam on every attempt and started from zero; the
GameStream plane died on its single wait. Reused live displays worked,
which pointed away from the real cause (root-caused on .41 with a raw
pw_stream probe: `sleep infinity` nested → 0 buffers ever, `vkcube`
nested → 60/s immediately).

Every bare spawn now backgrounds the host's own splash client
(`gamescope-splash`, hidden subcommand) beside the nested app: a dark
window with a subtle breathing bar painted at ~2.5 Hz — damage from the
first second, so capture gets its first frame within the budget on both
planes. In `--steam` mode gamescope composites only windows whose appid
is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a
painting vkcube gets zero composites without it), so the splash declares
itself as the Steam UI (STEAM_GAME=769) and seeds the baselayer iff
unset; Steam's own rewrite at game launch hands composite focus to the
game with no action on our side. PUNKTFUNK_GAMESCOPE_SPLASH=0 is the
escape hatch.

The dedicated Steam launch is also shaped with `-gamepadui` instead of
`-silent`: the nested Steam is Big Picture — the identity gamescope's
`--steam` integration is built around — so the boot shows the gamepad UI
instead of the desktop client window flashing through the stream, and
gamescope's focus rules (game outranks the Steam UI appid) cover what
`-silent` was working around.

On-glass on .41 (Bazzite, RTX 5070 Ti, gamescope c31743d): cold connect +
Balatro launch delivers frames on the first attempt on both a cold and a
torn-down-then-fresh spawn; Big Picture boot → game handover confirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 00:28:59 +02:00
enricobuehler 980b399ea8 test(session/gamelease): the live-process test waits out the shim window
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m8s
apple / swift (push) Successful in 5m53s
ci / bench (push) Successful in 5m59s
android / android (push) Successful in 13m13s
arch / build-publish (push) Successful in 13m16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 7m53s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 24s
ci / rust-arm64 (push) Successful in 15m10s
deb / build-publish (push) Successful in 14m12s
deb / build-publish-host (push) Successful in 15m13s
windows-host / package (push) Successful in 19m0s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 28m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m30s
apple / screenshots (push) Successful in 24m16s
Its child is `sh` running a script that `exec`s a binary outside the install
dir, so neither the image path nor the command line carries the directory — the
host's own child is the only signal there is. Which is exactly the case the shim
window now gates: a live child that might still turn out to be a launcher is not
called the game until that window passes.

So "running" legitimately arrives up to SHIM_WINDOW later than it used to, and
the assertion at 1.5 s was asserting the old behavior. Waiting it out is the
point now, not an accident of timing.

The delay is the deliberate cost of the fix: until the window passes there is no
way to tell "the child IS the game" from "the child is about to hand off". It
shows as a few seconds of `launching` in the console, and only for a title whose
store gave us signals.

.25: the ignored live test passes (Child -> Running -> terminate via the
SIGTERM->SIGKILL group ladder -> Exited, session-end action correctly NOT fired),
full suite 305 passed.
2026-07-26 22:33:42 +02:00
enricobuehler d5857abd91 fix(session/gamelease): a launcher handing off is not the game exiting
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m12s
ci / bench (push) Successful in 7m19s
apple / swift (push) Successful in 6m33s
ci / rust-arm64 (push) Successful in 10m0s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 9m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
arch / build-publish (push) Successful in 13m0s
deb / build-publish-host (push) Successful in 13m49s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m44s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 17m7s
deb / build-publish-client-arm64 (push) Successful in 10m24s
windows-host / package (push) Successful in 19m52s
windows-host / winget-source (push) Skipped
docker / build-push-arm64cross (push) Successful in 4m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m7s
ci / rust (push) Successful in 28m24s
apple / screenshots (push) Successful in 25m43s
On glass (.41), launching any Steam title over the compat plane ended the
session ~7 s later, before the game had started:

  launched app  command=steam steam://rungameid/2379780  pid=12699
  watching the launched game  kind="child"
  the launched game is running  kind="child"  procs=0      <- 3 ms in, zero game procs
  the launched game exited  ->  session ended

Linux launches are `Child` leases: the host spawns the launcher itself. The
watcher counts "my child is alive" as the game running, so the lease left the
waiting phase on its FIRST poll — and the shim reclassification lives in that
phase, so it never got to run. When `steam` handed off and exited, that read as
the game exiting.

The design always called for the shim window ("never fires B from a shim exit")
and the code implements it; the lease had simply moved past where it applies. So
when the store gave us signals to recognize the real game by, a bare live child
no longer counts as the game until that window has passed. With no signals the
child is all we have and still counts immediately — custom commands are
unchanged.

Windows could not reproduce this: it holds no child, so its leases are always
`Matched`, and the whole .173 pass was structurally blind to it. Both planes on
Linux were affected — which is most of the audience for this feature.

Also fixes the live-process fixture, which was CI-red for an unrelated reason:
it copied `/bin/sleep` to a file named `game`, and modern coreutils (uutils on
Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary dispatching on argv[0]
— under any other name it exits instantly, leaving nothing in /proc and looking
exactly like a broken matcher. Verified by A/B: the same test fails identically
on a tree predating these changes.

.25 (Ubuntu 26.04): 305 passed, 0 failed.
2026-07-26 21:24:12 +02:00
enricobuehler 0c261de636 ci(plugin-kit): quote the step name that contains a colon
decky / build-publish (push) Successful in 36s
arch / build-publish (push) Successful in 17m12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
apple / screenshots (push) Successful in 26m29s
docker / build-push-arm64cross (push) Successful in 10s
deb / build-publish (push) Successful in 8m55s
docker / deploy-docs (push) Successful in 13s
ci / rust (push) Failing after 17m55s
deb / build-publish-client-arm64 (push) Successful in 7m26s
deb / build-publish-host (push) Successful in 9m41s
apple / swift (push) Successful in 6m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m42s
ci / web (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m23s
android / android (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 9m33s
ci / bench (push) Successful in 7m23s
`- name: Repair the file: dependency (…)` is not valid YAML — the unquoted
`file: ` reads as a second mapping key — so the workflow has been unparseable
since it was added, which is why re-pushing the tag never triggered a run and a
manual dispatch 500'd. I had put that down to a Gitea tag quirk; it was this.

The failure is invisible from the repo side: nothing validates workflow YAML on
push, and an unparseable workflow simply never runs.
2026-07-26 20:45:58 +02:00
enricobuehler 6e07d063c3 fix(session/gamelease): Windows had no launch reference, so it adopted old games
ci / web (push) Successful in 1m15s
ci / docs-site (push) Successful in 1m2s
ci / bench (push) Successful in 6m37s
windows-host / package (push) Successful in 11m1s
windows-host / winget-source (push) Skipped
ci / rust-arm64 (push) Successful in 11m53s
apple / swift (push) Successful in 7m18s
deb / build-publish (push) Successful in 10m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m16s
ci / rust (push) Failing after 13m29s
docker / build-push-arm64cross (push) Successful in 10s
arch / build-publish (push) Successful in 13m42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / deploy-docs (push) Successful in 25s
decky / build-publish (push) Successful in 25s
apple / screenshots (push) Successful in 26m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
android / android (push) Successful in 15m6s
deb / build-publish-host (push) Successful in 11m27s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m45s
deb / build-publish-client-arm64 (push) Successful in 8m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m39s
On glass (.173), with a copy of Buckshot Roulette already running: a session
launched the same title, Steam focused the existing instance rather than
starting one, the lease adopted THAT process — and a deliberate stop closed the
player's pre-existing game.

That is the one thing the design says must never happen. The rule was written
and the matcher implements it; the reference instant it filters against just
never arrived. `procscan::launch_stamp` supports Windows, but the wrapper every
launch site calls was gated `#[cfg(target_os = "linux")]` and answered `None`
everywhere else — and `None` doesn't fail, it turns the filter OFF (`find` skips
it), so every process under the install dir became adoptable.

Windows is where this matters most: the host runs as SYSTEM and can see every
process on the box, which is why `procscan/windows.rs` calls rule 1
load-bearing rather than a nicety. Both planes were affected.

No unit test could have caught it — the Linux fixtures pass an explicit
`min_start`, and the Windows live test passes `None` deliberately. So the guard
added here asserts the reference EXISTS wherever processes can be matched, which
is the shape of the failure: silent, and invisible from downstream.

.133: 282 passed (+1), same 4 environmental native::tests failures.
2026-07-26 20:30:20 +02:00
enricobuehlerandClaude Fable 5 dff63b2a29 fix(encode/nvenc): a visible cursor no longer serializes submit — the blend goes stream-ordered
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 2m32s
android / android (push) Canceled after 39s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 34s
ci / rust (push) Canceled after 24s
ci / rust-arm64 (push) Canceled after 24s
ci / bench (push) Canceled after 5s
ci / web (push) Canceled after 24s
ci / docs-site (push) Canceled after 8s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 2s
deb / build-publish-host (push) Canceled after 1s
decky / build-publish (push) Canceled after 4s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1s
Report: iPad on a gamescope/NVIDIA 120 fps session capped at ~80 fps with
repeat_fps 0, zero loss, capture 0 µs, ASIC 15 µs — and submit p50 at
10.2 ms, ~81 % of the loop period. Under gamescope the host composites
the live pointer into EVERY frame, and a cursor-bearing frame forced the
CPU-synced submit path: a blocking CUDA copy plus a fence-waited Vulkan
blend, both exposed to the running game's GPU load. The "games hide the
cursor" assumption the gate relied on does not hold under gamescope.

The blend is now stream-ordered end to end. VkSlotBlend exports a
timeline semaphore (VK_KHR_timeline_semaphore + external_semaphore_fd)
into CUDA (cuImportExternalSemaphore, new dlopen entries): the enqueued
copy signals it on the encode thread's copy stream, the blend submission
waits for and advances it on the Vulkan queue, and a CUDA-side wait
orders the encode after the blend on the session's bound IO stream — no
CPU sync anywhere, so cursor frames keep the stream-ordered fast path.
Each ring slot gets its own command buffer + descriptor set (written
once) so several ordered blends can be in flight; cursor-bitmap uploads
and teardown quiesce through the timeline. Drivers without the timeline
export keep the previous CPU-synced blend, and any bring-up or per-frame
failure still degrades to "no cursor", never a dropped frame.

Also: the blocking multi-plane copies (the escalated/pipelined mode and
the non-stream-ordered fallback) now enqueue every plane and pay ONE
stream sync instead of one per plane (NV12 2→1, YUV444 3→1) — each
exposed wait costs scheduling latency under GPU contention, which is
what makes the escalation's blocking copies self-reinforcing.

Verified on the RTX 5070 Ti box (driver 610.43.03): all 12 nvenc_cuda
on-hardware smokes green, including the new
nvenc_cuda_cursor_blend_stream_ordered (6 cursor AUs, all ordered,
across a bitmap-serial flip); host suite 301/301; clippy --all-targets
-D warnings clean; struct layouts of the hand-flattened cuda.h params
asserted in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:05:27 +02:00
enricobuehler 02b94b828d fix(gamestream): the termination type comes from the version we advertise
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / package (push) Successful in 11m8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1s
windows-host / winget-source (push) Skipped
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 3s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
android / android (push) Canceled after 6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m3s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 8s
ci / rust-arm64 (push) Canceled after 10s
ci / rust (push) Canceled after 10s
ci / docs-site (push) Canceled after 6s
ci / bench (push) Canceled after 5s
ci / web (push) Canceled after 9s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish (push) Canceled after 3s
decky / build-publish (push) Canceled after 4s
Still -1 on glass. The message was going out; the client was ignoring it.

Which packet-type table a client reads is decided by exactly one thing —
moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1,
431)` — and we advertise 7.1.431, so every client reads the ENCRYPTED table,
where termination is 0x0109. We were sending 0x0100.

Deriving that from the nonce scheme was the error: `NonceKind` describes how the
GCM nonce is built and nothing else. The two look like the same "is this
encrypted" axis and are not. The HDR message could never have caught it either,
since 0x010e is identical in both tables — so the only symptom was the silence
this whole chain has been about.

Pinned with a test asserting APP_VERSION still clears 7.1.431, because if it
ever drops below, the correct type silently becomes 0x0100 again and the stream
goes back to ending as an error with nothing failing to explain why.

.133: 281 passed (+3), same 4 environmental native::tests failures. The Linux
box is off the network, so the full Linux suite is owed.
2026-07-26 20:04:54 +02:00
enricobuehler e44f2b1024 fix(gamestream): say "the app quit", not just goodbye
ci / web (push) Successful in 1m2s
android / android (push) Canceled after 7m52s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m49s
ci / docs-site (push) Canceled after 55s
ci / bench (push) Canceled after 5s
ci / rust (push) Canceled after 5m32s
ci / rust-arm64 (push) Canceled after 2m39s
deb / build-publish (push) Canceled after 7s
deb / build-publish-host (push) Canceled after 3s
deb / build-publish-client-arm64 (push) Canceled after 1s
decky / build-publish (push) Canceled after 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 3s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Successful in 19m26s
windows-host / winget-source (push) Skipped
Disconnecting the ENet peer stopped the freeze but the client then reported the
session as error -1: a bare disconnect is indistinguishable from the host
falling over, so Moonlight called it a connection failure. Which, from its side,
was a fair reading — we never said otherwise.

The protocol has a word for this. Send the TERMINATION control message with
`NVST_DISCONN_SERVER_TERMINATED_CLOSED` first, then disconnect; the client maps
that to a graceful termination (given it has seen a frame, true of any real
session) and lands the player back in the app list.

Constants verified against moonlight-common-c `ControlStream.c` rather than
recalled, because every way of getting this wrong is silent — the client ignores
an unparseable control message, which reads on glass as the same freeze:

* the type comes from the table the CLIENT chose, so it is derived from the
  negotiated nonce scheme (plain 0x0100 / encrypted 0x0109) instead of guessed,
* the reason is BIG-endian (the >=6-byte branch; the short branch is a
  little-endian u16, which is GFE's older shape).

`disconnect_later` rather than `disconnect`, so the queued message actually
reaches the wire instead of racing the teardown — which would land us back at -1.

The session's GCM key is cached per tick because ending a session clears the
launch state the key lives in, and this is precisely the message that has to be
sealed after that.
2026-07-26 19:58:25 +02:00
enricobuehler ddc28de32a fix(gamestream): tell the client when the HOST ends the session
ci / rust (push) Failing after 22m10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m15s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 10m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m13s
windows-host / package (push) Failing after 12s
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m2s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m26s
arch / build-publish (push) Successful in 12m29s
deb / build-publish (push) Successful in 8m57s
decky / build-publish (push) Successful in 32s
apple / swift (push) Successful in 5m57s
android / android (push) Successful in 19m51s
deb / build-publish-client-arm64 (push) Successful in 7m21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish-host (push) Successful in 9m34s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
On-glass on .173: a launched game exited, the host tore the session down
correctly — and the client froze on its last frame instead of returning to its
app list.

The host's half was right; it just never said anything. On the native plane
ending a session closes the QUIC connection with APP_EXITED, which every client
renders as "game ended". The compat plane has no such channel: `end_session`
stops the media threads, and stopping a UDP sender is silence, not a signal.
Moonlight holds the ENet control stream for the whole session, so with no word
from us it sat there until its own timeout eventually fired.

The control loop already reacted to the CLIENT disconnecting; it had no path for
the reverse. It now watches for its session being cleared out from under it and
disconnects the peer, which is what Moonlight reads as "the session is over" —
landing the player back in the app list, the same place quitting puts them.

This is the signal every host-side end needed, not just a game exiting: the
management stop and a `/cancel` racing another connection had the same silence.
2026-07-26 19:44:55 +02:00
enricobuehlerandClaude Opus 5 1839d7566b feat(packaging/winget): install the Windows host with winget, from our own source
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m56s
apple / screenshots (push) Canceled after 11m30s
windows-host / package (push) Failing after 12s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 6m2s
ci / web (push) Successful in 1m53s
ci / docs-site (push) Successful in 1m29s
ci / rust-arm64 (push) Successful in 9m56s
android / android (push) Successful in 12m33s
ci / bench (push) Successful in 7m17s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
arch / build-publish (push) Successful in 18m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish (push) Successful in 9m2s
ci / rust (push) Failing after 19m38s
deb / build-publish-client-arm64 (push) Successful in 7m21s
deb / build-publish-host (push) Successful in 9m43s
`winget install unom.PunktfunkHost` after adding the source, and `winget upgrade`
from then on. Windows had no update path at all before this — no self-update, no
package manager — so keeping a host current meant noticing a release and
re-running an installer by hand.

Ships the manifest trio in winget-pkgs' own format (so submitting upstream later
is a copy, not a rewrite) plus a release-time generator that substitutes only
version, URL, hash and release-notes link. Everything reviewable — the silent
switches, the agreements, the installation notes — stays in the checked-in
templates rather than buried in a generator.

Silent installs deliberately take the SAME task defaults the wizard shows: a
per-channel default is a support trap. The disclosures the wizard puts on screen
travel as manifest Agreements instead, shown before install and requiring
acceptance — VB-Audio's bundling grant wants the user to see VB-CABLE's origin
and donationware status, and a silent install shows them nothing otherwise. The
console password can only be pointed at, never printed: it is generated per
install and the notes are fixed at publish time.

Self-hosted rather than the community repo, which gates on Defender/SmartScreen
validation the self-signed installer cert would not clear today. The source is
three endpoints — /information, /manifestSearch, /packageManifests/{id}. The
reference implementation's other twenty are its admin API for mutating a
CosmosDB; a catalogue generated at release time has nothing to mutate.

It runs on unom-1 as a stock bun image with the two .mjs files bind-mounted, the
same shape as the flatpak server, so there is no image to build or pull. The
catalogue is derived from the RELEASES rather than local files: winget resolves
--version and upgrade against the version list, so a source that only knew the
newest release could neither pin an older one nor show an upgrade path from it.
That also leaves no state to drift — re-running the build reproduces it exactly.

NormalizedPackageNameAndPublisher is declared unsupported on purpose. winget
derives it client-side with its own normalization, and a near-miss silently
mis-correlates an installed host; ProductCode is exact and Inno gives us one.

28 checks drive the handler directly, and CI gates on them before anything
reaches the box — a wrong response shape does not fail loudly, it just makes
winget report "no package found".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:09:13 +02:00
enricobuehlerandClaude Opus 5 3d89301336 fix(installer/windows): a silent install can't hang on a prompt, and an upgrade keeps your choices
Three faults that only bite when nobody is watching the wizard — which is how a
package manager always runs us, and how a re-run by hand half-behaves today.

The conflicting-host warning used a plain MsgBox. That ignores /SUPPRESSMSGBOXES
and displays even under /VERYSILENT, so an unattended install onto a box already
running Sunshine/Apollo would sit on a modal dialog nobody can see or click.
SuppressibleMsgBox with an IDNO default aborts instead, which is the honest
answer for a combination the message itself calls unsupported.

The GameStream and Public-firewall choices were re-applied on every run. A silent
run has no wizard, so every task falls back to its script default and the
installer confidently re-asserted it: a user who enabled GameStream had it turned
off again, and one who opened the firewall on Public networks had it quietly
revoked (that task is default-unchecked, so the reset ran the other way). Both
params are now fresh-install-only, keyed off whether host.env already exists.
`service install` already read an absent --gamestream as "keep host.env as-is";
--allow-public-network becomes tri-state the same way, resolving an absent flag
from the marker the previous install recorded. It is strict on the value: a
typo'd =of must not fall through to a marker that may say true, because that
turns a mistyped opt-OUT into leaving Public open.

Also brands the installer "Punktfunk Host" in the strings a user actually sees —
Add/Remove Programs, the Start Menu group, the wizard task text. Paths are
untouched; renaming those would relocate installs and orphan existing config.

Verified on the windows-amd64 runner: ISCC compiles the script (with WithWeb
defined, so the password page parses too), and cargo check + clippy -D warnings
pass for punktfunk-host with nvenc,amf-qsv,qsv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:09:13 +02:00
enricobuehlerandClaude Opus 5 1ee06defa6 feat(library): descriptive metadata on every entry — platform, year, genres, and friends
apple / swift (push) Successful in 5m38s
windows-host / package (push) Successful in 19m0s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m32s
ci / rust (push) Failing after 12m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
android / android (push) Successful in 17m49s
decky / build-publish (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
arch / build-publish (push) Successful in 19m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m22s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m26s
deb / build-publish-host (push) Successful in 9m46s
flatpak / build-publish (push) Failing after 8m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m56s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
apple / screenshots (push) Successful in 25m51s
Emulation-and-beyond libraries need more than a title and a poster. Every
library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput)
now carries a shared, flattened GameMeta: platform, description,
developer, publisher, release_year, genres, tags, region, players. All
fields are optional and flat on the wire, so existing library.json files,
provider plugins, and clients keep working unchanged.

- Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp
  platform=PC; custom/provider entries carry whatever was authored.
- GET /library grows a ?platform= filter (case-insensitive) beside
  ?provider=.
- Console: the add/edit form gets a Details section (round-tripping every
  field, since update replaces the entry), the poster tile a platform
  badge (non-PC only — the store badge already implies PC) and the year.
- plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema,
  spread flat); SDK + OpenAPI spec regenerated.
- pf-client-core decodes platform for future client badges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:00:30 +02:00
59 changed files with 3731 additions and 542 deletions
+38
View File
@@ -99,3 +99,41 @@ jobs:
mkdir -p ~/unom-flatpak/site/repo
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
winget:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync winget source compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
target: "~/unom-winget"
strip_components: 3
overwrite: true
- name: Start winget REST source
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
# source exists so the container starts; it serves 503 until the first catalogue lands.
mkdir -p ~/unom-winget/data
cd ~/unom-winget
docker compose -f compose.production.yml up -d
# Surface a missing catalogue here rather than letting winget report "no package found".
sleep 3
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
# this step once bun links `file:` deps correctly again.
- name: Repair the file: dependency (bun 1.3 self-symlink)
- name: "Repair the file: dependency (bun 1.3 self-symlink)"
working-directory: plugin-kit
run: |
# -f follows the link, so this is true only when the manifest actually resolves.
+79
View File
@@ -359,3 +359,82 @@ jobs:
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
# winget manifests for the release just attached above. Runs AFTER the attach step so the
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
# alias has nothing it could point at.
- name: Emit + attach winget manifests (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
& scripts/ci/winget-manifest.ps1 `
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
}
# Republish the winget REST source on unom-1 once the release above carries its manifests.
#
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
# reads the manifests from the release, so it must not run before they are attached.
winget-source:
needs: package
if: startsWith(gitea.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
# so the result cannot drift and re-running any tag reproduces it byte for byte.
- name: Build + test the source catalogue
working-directory: packaging/winget/server
run: |
set -euo pipefail
npm install --no-audit --no-fund
node build-data.mjs --out data/data.json
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
# Gate on the suite before anything reaches the box.
node test.mjs
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
# flatpak repo's content and config deploy on separate paths.
- name: Ship the catalogue to unom-1
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "packaging/winget/server/data/data.json"
target: "~/unom-winget/data"
strip_components: 4
overwrite: true
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
# one actually being served, and fails the release if it is not.
- name: Verify the served catalogue
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
curl -fsS http://127.0.0.1:3240/healthz
echo
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
| grep -q "${GITHUB_REF_NAME#v}" \
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
echo "winget source serving ${GITHUB_REF_NAME#v}"
env:
GITHUB_REF_NAME: ${{ gitea.ref_name }}
Generated
+28 -27
View File
@@ -2194,7 +2194,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.19.2"
version = "0.20.0"
[[package]]
name = "lazy_static"
@@ -2299,7 +2299,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"bindgen",
"cmake",
@@ -2334,7 +2334,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"punktfunk-core",
]
@@ -2823,7 +2823,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2844,7 +2844,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ash",
@@ -2868,7 +2868,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2886,7 +2886,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ash",
@@ -2907,7 +2907,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ash",
@@ -2931,7 +2931,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"ash",
"bindgen",
@@ -2940,7 +2940,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"libc",
@@ -2952,7 +2952,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2966,11 +2966,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.19.2"
version = "0.20.0"
[[package]]
name = "pf-inject"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2999,14 +2999,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ash",
@@ -3021,7 +3021,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3048,11 +3048,12 @@ dependencies = [
"wayland-client",
"wayland-scanner",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"x11rb",
]
[[package]]
name = "pf-win-display"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3064,7 +3065,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ash",
@@ -3272,7 +3273,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"android_logger",
"jni",
@@ -3288,7 +3289,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3304,7 +3305,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3319,7 +3320,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3338,7 +3339,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3370,7 +3371,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3454,7 +3455,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3468,7 +3469,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"anyhow",
"ksni",
@@ -3491,7 +3492,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.19.2"
version = "0.20.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.19.2"
version = "0.20.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
+261 -147
View File
@@ -965,7 +965,7 @@
"library"
],
"summary": "List the game library",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns.",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
"operationId": "getLibrary",
"parameters": [
{
@@ -976,6 +976,15 @@
"schema": {
"type": "string"
}
},
{
"name": "platform",
"in": "query",
"description": "Only entries on this platform (case-insensitive, e.g. `PS2`)",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -4012,95 +4021,111 @@
}
},
"CustomEntry": {
"type": "object",
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.",
"required": [
"id",
"title"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
},
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns."
},
"external_id": {
"type": [
"string",
"null"
{
"type": "object",
"required": [
"id",
"title"
],
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
},
"id": {
"type": "string",
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
},
"launch": {
"oneOf": [
{
"type": "null"
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
},
{
"$ref": "#/components/schemas/LaunchSpec"
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns."
},
"external_id": {
"type": [
"string",
"null"
],
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
},
"id": {
"type": "string",
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
},
"launch": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/LaunchSpec"
}
]
},
"prep": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PrepCmd"
},
"description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])."
},
"provider": {
"type": [
"string",
"null"
],
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"title": {
"type": "string"
}
]
},
"prep": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PrepCmd"
},
"description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])."
},
"provider": {
"type": [
"string",
"null"
],
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"title": {
"type": "string"
}
}
}
],
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits."
},
"CustomInput": {
"type": "object",
"description": "Request body to create or replace a custom entry (no `id` — the host owns it).",
"required": [
"title"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept."
},
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
},
"launch": {
"oneOf": [
{
"type": "null"
{
"type": "object",
"required": [
"title"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
},
{
"$ref": "#/components/schemas/LaunchSpec"
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
},
"launch": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/LaunchSpec"
}
]
},
"prep": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PrepCmd"
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"title": {
"type": "string"
}
]
},
"prep": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PrepCmd"
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"title": {
"type": "string"
}
}
}
],
"description": "Request body to create or replace a custom entry (no `id` — the host owns it)."
},
"CustomPreset": {
"type": "object",
@@ -4750,48 +4775,129 @@
]
},
"GameEntry": {
"type": "object",
"description": "One title in the unified library, regardless of which store it came from.",
"required": [
"id",
"store",
"title",
"art"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata, flattened — see [`GameMeta`]."
},
"id": {
"type": "string",
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
"example": "steam:570"
},
"launch": {
"oneOf": [
{
"type": "null"
{
"type": "object",
"required": [
"id",
"store",
"title",
"art"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
},
{
"$ref": "#/components/schemas/LaunchSpec",
"description": "How the host would launch it, when known."
"id": {
"type": "string",
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
"example": "steam:570"
},
"launch": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/LaunchSpec",
"description": "How the host would launch it, when known."
}
]
},
"provider": {
"type": [
"string",
"null"
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"store": {
"type": "string",
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
"example": "steam"
},
"title": {
"type": "string"
}
]
},
"provider": {
}
}
],
"description": "One title in the unified library, regardless of which store it came from."
},
"GameMeta": {
"type": "object",
"description": "Descriptive metadata for a title — everything a richer library UI (details pane, platform\nfilter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to\nabsent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is\n`#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat\nwire shape everywhere.\n\nValues are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)\neach have their own vocabulary and the host has no business normalizing it.",
"properties": {
"description": {
"type": [
"string",
"null"
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
"description": "Short blurb for a details pane."
},
"store": {
"type": "string",
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
"example": "steam"
"developer": {
"type": [
"string",
"null"
]
},
"title": {
"type": "string"
"genres": {
"type": "array",
"items": {
"type": "string"
},
"description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)."
},
"platform": {
"type": [
"string",
"null"
],
"description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive).",
"example": "PS2"
},
"players": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Maximum simultaneous (local) players.",
"minimum": 0
},
"publisher": {
"type": [
"string",
"null"
]
},
"region": {
"type": [
"string",
"null"
],
"description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)."
},
"release_year": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Year of first release — the granularity metadata sources reliably agree on.",
"example": 2001,
"minimum": 0
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)."
}
}
},
@@ -6003,45 +6109,53 @@
}
},
"ProviderEntryInput": {
"type": "object",
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.",
"required": [
"external_id",
"title"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
},
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits."
},
"external_id": {
"type": "string",
"description": "The provider's stable id for this title (the reconcile diff key)."
},
"launch": {
"oneOf": [
{
"type": "null"
{
"type": "object",
"required": [
"external_id",
"title"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
},
{
"$ref": "#/components/schemas/LaunchSpec"
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits."
},
"external_id": {
"type": "string",
"description": "The provider's stable id for this title (the reconcile diff key)."
},
"launch": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/LaunchSpec"
}
]
},
"prep": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PrepCmd"
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"title": {
"type": "string"
}
]
},
"prep": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PrepCmd"
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"title": {
"type": "string"
}
}
}
],
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key."
},
"ProviderRemoved": {
"type": "object",
+1
View File
@@ -620,6 +620,7 @@ fn mock_library() -> (
store: store.to_string(),
title: title.to_string(),
art: crate::library::Artwork::default(),
platform: None,
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
+10 -1
View File
@@ -62,6 +62,10 @@ pub struct GameEntry {
pub title: String,
#[serde(default)]
pub art: Artwork,
/// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
#[serde(default)]
pub platform: Option<String>,
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
@@ -280,7 +284,7 @@ mod tests {
fn game_entry_decodes_the_wire_shape() {
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
let json = r#"[
{"id":"steam:570","store":"steam","title":"Dota 2",
{"id":"steam:570","store":"steam","title":"Dota 2","platform":"PC",
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
"launch":{"kind":"steam_appid","value":"570"}},
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
@@ -288,7 +292,12 @@ mod tests {
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
assert_eq!(games.len(), 2);
assert_eq!(games[0].id, "steam:570");
assert_eq!(games[0].platform.as_deref(), Some("PC"));
assert!(games[1].art.portrait.is_none());
assert!(
games[1].platform.is_none(),
"pre-metadata hosts still parse"
);
}
#[test]
+127 -22
View File
@@ -1613,14 +1613,22 @@ impl Encoder for NvencCudaEncoder {
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
// Cursor-bearing frames additionally force the CPU-synced path: the Vulkan blend sits
// between the CUDA copy and the encode, and its cross-API ordering is fence/CPU-
// established, not stream-ordered. Frames without a cursor (games hide it; client-draws
// sessions strip it) keep the stream-ordered fast path untouched.
let ordered = self.stream_ordered
&& self.async_rt.is_none()
&& self.pending.is_empty()
&& captured.cursor.is_none();
let base_ordered =
self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
// Cursor-bearing frames stay on the fast path when the blend itself can be stream-
// ordered: the Vulkan dispatch waits/advances a timeline semaphore CUDA also holds, so
// copy→blend→encode orders entirely on-device (`VkSlotBlend::blend_ref_ordered`). Where
// that isn't available (no timeline export, or the ring fell back to plain CUDA slots)
// a cursor forces the CPU-synced path: the blend's cross-API ordering is then fence/CPU-
// established, sitting between the copy and the encode. That slow path is why cursor
// frames USED to be gated out entirely — under gamescope the compositor re-attaches the
// live pointer to EVERY frame, and the per-frame CPU syncs (exposed to the running
// game's GPU load) capped a 120 fps session near 80 (submit p50 ~10 ms).
let cursor_ordered = base_ordered
&& captured.cursor.is_some()
&& matches!(self.ring[slot].surface, SlotSurface::Vk(_))
&& self.vk_blend.as_ref().is_some_and(|vk| vk.ordered_ready());
let ordered = base_ordered && (captured.cursor.is_none() || cursor_ordered);
let t0 = std::time::Instant::now();
// Copy the captured buffer into this slot's input surface before encoding it.
@@ -1629,29 +1637,45 @@ impl Encoder for NvencCudaEncoder {
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface via the
// SPIR-V compute pass (a dispatch over the cursor's rect — never the compositor's
// dmabuf). Cursor-bearing frames forced `ordered = false` above, so the CUDA copy has
// completed before the Vulkan dispatch and the fence-waited dispatch completes before
// the encode below — the cross-API ordering is CPU-established. Any failure degrades to
// no cursor, never a dropped frame.
// dmabuf). On the `cursor_ordered` path the enqueued copy, the dispatch, and the encode
// are ordered on-device through the timeline semaphore (no CPU sync — see the gate
// above). Otherwise `ordered` is false: the CUDA copy completed before the Vulkan
// dispatch and the fence-waited dispatch completes before the encode below — the
// cross-API ordering is CPU-established. Any failure degrades to no cursor, never a
// dropped frame (a failed ordered blend leaves the copy→encode stream ordering intact).
if let Some(ov) = &captured.cursor {
if let (Some(vk), SlotSurface::Vk(vref)) =
(self.vk_blend.as_mut(), &self.ring[slot].surface)
{
if self.cursor_serial != ov.serial {
// Quiesces any in-flight ordered blend internally before touching the
// staging buffer (bitmap changes are rare — shape flips).
vk.upload_cursor(ov.rgba.as_slice(), ov.w, ov.h);
self.cursor_serial = ov.serial;
}
// surfW = content width; the blend derives plane strides from the slot's luma
// height. Cursor pixels past the content land in cropped padding rows — harmless.
let r = vk.blend_ref(
vref,
slot_fmt_of(self.buffer_fmt),
self.width,
ov.w,
ov.h,
ov.x,
ov.y,
);
let r = if cursor_ordered {
vk.blend_ref_ordered(
vref,
slot_fmt_of(self.buffer_fmt),
self.width,
ov.w,
ov.h,
ov.x,
ov.y,
)
} else {
vk.blend_ref(
vref,
slot_fmt_of(self.buffer_fmt),
self.width,
ov.w,
ov.h,
ov.x,
ov.y,
)
};
if let Err(e) = r {
if !self.cursor_blend_warned {
self.cursor_blend_warned = true;
@@ -1686,7 +1710,9 @@ impl Encoder for NvencCudaEncoder {
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
// gate above; on the `cursor_ordered` path the blend's writes are likewise ordered before
// the encode, via the timeline-semaphore wait `blend_ref_ordered` enqueued on that same
// stream) — and is not overwritten until this slot is reused POOL submits later, by
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
// blocking lock additionally proves the enqueued copy completed).
unsafe {
@@ -2673,6 +2699,85 @@ mod tests {
);
}
/// ON-HARDWARE (RTX box `.21`): cursor-bearing frames must KEEP the stream-ordered fast
/// path — the gamescope 80-fps-on-a-120-session fix. With the timeline-semaphore blend
/// available, `submit` takes `blend_ref_ordered` (the ticket advances by 2 per frame)
/// instead of the CPU-synced fence-wait blend, and AUs keep flowing — including across a
/// cursor-bitmap change (exercises the upload quiesce) and per-frame position moves.
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_cursor_blend_stream_ordered() {
const W: u32 = 1280;
const H: u32 = 720;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
if !stream_ordered_requested() || async_retrieve_requested() {
println!("skipped: stream-ordered submit disabled by env");
return;
}
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
W,
H,
60,
8_000_000,
true,
8,
ChromaFormat::Yuv420,
true, // cursor_blend: bring up the Vulkan slot ring + blend
)
.expect("open NVENC CUDA session");
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
x,
y,
w: 32,
h: 32,
rgba: std::sync::Arc::new(vec![0xFF; 32 * 32 * 4]),
serial,
hot_x: 0,
hot_y: 0,
visible: true,
};
let mut aus = 0usize;
for i in 0..6u32 {
let mut frame = nv12_frame(W, H, i);
// Bitmap serial flips at frame 3 (upload quiesce over in-flight ordered blends);
// the position moves every frame (push-constant path).
frame.cursor = Some(cursor(
if i < 3 { 1 } else { 2 },
40 + i as i32 * 9,
60 + i as i32 * 5,
));
enc.submit_indexed(&frame, i).expect("submit cursor frame");
while enc.poll().expect("poll").is_some() {
aus += 1;
}
}
assert_eq!(aus, 6, "every cursor frame must deliver an AU");
assert!(
enc.stream_ordered,
"IO-stream binding must arm on a default-env session"
);
let vk = enc
.vk_blend
.as_ref()
.expect("Vulkan slot blend must come up on an RTX box");
assert!(
vk.ordered_ready(),
"timeline semaphore must export to CUDA on this driver"
);
assert_eq!(
vk.ordered_ticket(),
12,
"all 6 cursor blends must take the ordered path (2 timeline values each)"
);
println!(
"nvenc_cuda cursor stream-ordered: 6 cursor AUs, ticket={}",
vk.ordered_ticket()
);
}
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
+11
View File
@@ -115,6 +115,14 @@ pub struct HostConfig {
/// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles
/// and menus, so it's opt-in per host until validated on-glass.
pub gamescope_grab_cursor: bool,
/// `PUNKTFUNK_GAMESCOPE_SPLASH` — run the host's built-in splash client inside every bare
/// headless gamescope spawn. gamescope only composites (and only then pushes a PipeWire capture
/// buffer) when a client paints, and a dedicated Steam launch paints NOTHING
/// for the whole Steam bootstrap — so without the splash a fresh spawn's capture starves: format
/// negotiated, zero buffers, first-frame timeout, and every retry kills the booting Steam and
/// starts over (the "fresh gamescope output never delivers frames" field failure). Default ON;
/// explicit-off grammar (`=0` disables, the on-glass A/B + emergency escape hatch).
pub gamescope_splash: bool,
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
@@ -177,6 +185,9 @@ impl HostConfig {
"1" | "true" | "yes" | "on"
)
}),
// Default ON, explicit-off grammar: the splash is what makes a fresh bare spawn deliver
// its first frames at all; `=0` is the A/B + escape hatch.
gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
+3
View File
@@ -48,6 +48,9 @@ wayland-backend = "0.3"
# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
bitflags = "2"
# The gamescope bare-spawn splash client (gamescope/splash.rs): pure-Rust X11 core protocol (the
# same no-libxcb-link stance as pf-capture's XFixes cursor source), no extension features needed.
x11rb = { version = "0.13", default-features = false }
[target.'cfg(target_os = "windows")'.dependencies]
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
+10
View File
@@ -425,6 +425,16 @@ pub fn effective_topology() -> policy::Topology {
#[path = "vdisplay/linux/gamescope.rs"]
mod gamescope;
/// Entry point for the hidden `gamescope-splash` host subcommand: the tiny X11 client every bare
/// gamescope spawn backgrounds beside its nested app, so the fresh compositor composites — and its
/// PipeWire node delivers frames — from the first second instead of starving the first-frame wait
/// while the nested Steam bootstrap paints nothing (see `vdisplay/linux/gamescope/splash.rs`).
/// Blocks for the session's lifetime; gamescope's reaper tears it down with the session.
#[cfg(target_os = "linux")]
pub fn gamescope_splash_client() -> anyhow::Result<()> {
gamescope::splash_run()
}
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
@@ -21,6 +21,8 @@ use std::time::{Duration, Instant};
#[path = "gamescope/discovery.rs"]
mod discovery;
#[path = "gamescope/splash.rs"]
mod splash;
use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
gamescope_node_present, poll_managed_node, wait_for_node,
@@ -29,6 +31,7 @@ pub(crate) use discovery::{
game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit,
SteamGameWatch,
};
pub(crate) use splash::run as splash_run;
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
@@ -1963,11 +1966,6 @@ pub fn ei_socket_file() -> std::path::PathBuf {
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-silent` inserted so
/// the game is the gamescope focus with no Steam client window to navigate
/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam
/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested.
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
fn is_steam_launch(cmd: &str) -> bool {
@@ -1975,12 +1973,21 @@ fn is_steam_launch(cmd: &str) -> bool {
it.next() == Some("steam") && cmd.contains("steam://")
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-gamepadui` inserted
/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built
/// around (it's what SteamOS/Bazzite game mode runs): the boot shows the gamepad UI instead of the
/// desktop Steam client window (field report 2026-07-27: the desktop UI flashing through the
/// stream "looks bad"), and gamescope's focus rules already prefer the game window over the Steam
/// UI appid, which is what the previous `-silent` shaping was working around. Operator-typed
/// custom commands and non-Steam launches are returned unchanged. Idempotent (never
/// double-inserts). Pure + unit-tested.
fn shape_dedicated_command(app: &str) -> String {
let mut it = app.split_whitespace();
if it.next() == Some("steam") {
let rest: Vec<&str> = it.collect();
if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) {
return format!("steam -silent {}", rest.join(" "));
if !rest.contains(&"-gamepadui") && rest.iter().any(|t| t.starts_with("steam://")) {
return format!("steam -gamepadui {}", rest.join(" "));
}
}
app.to_string()
@@ -2016,7 +2023,11 @@ fn add_bare_gamescope_args(
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
/// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny
/// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
/// so the input injector can connect to gamescope's EIS server from outside.
/// so the input injector can connect to gamescope's EIS server from outside — and (unless
/// `PUNKTFUNK_GAMESCOPE_SPLASH=0`) backgrounds the host's splash client first, so the fresh
/// compositor has a painting window from the first second: gamescope pushes capture buffers only
/// when it composites, and a nested Steam bootstrap paints nothing until the gamepad UI's first
/// frame — far longer than any first-frame budget (see `gamescope/splash.rs`).
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
// A non-empty per-session command (set via `set_launch_command`) wins; else the
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
@@ -2033,8 +2044,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
// cursor-grab flag below.
let game_launch = app.is_some();
let app = app.unwrap_or_else(|| "sleep infinity".to_string());
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the
// gamescope focus with no Steam client window to navigate.
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-gamepadui` so the nested
// Steam is Big Picture — the identity gamescope's `--steam` mode is built around — instead of
// the desktop client window.
let app = shape_dedicated_command(&app);
let relay = ei_socket_file();
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
@@ -2047,29 +2059,37 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
// cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook.
// Default OFF (it forces relative mode, which would break absolute-pointer games/menus).
let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor;
// The splash client (see `gamescope/splash.rs`): without a painting client gamescope pushes NO
// capture buffers, and a nested Steam bootstrap paints nothing for far longer than any
// first-frame budget — so every bare spawn backgrounds the host's own splash beside the nested
// app. Skipped only via the PUNKTFUNK_GAMESCOPE_SPLASH=0 escape hatch (or if the host can't
// name its own executable, where the old starve-prone behaviour is still better than no spawn).
let splash_exe = pf_host_config::config()
.gamescope_splash
.then(std::env::current_exe)
.and_then(|r| {
r.map_err(|e| tracing::warn!(error = %e, "gamescope: current_exe failed — no splash"))
.ok()
});
let mut cmd = Command::new("gamescope");
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
cmd.args([
"sh",
"-c",
&format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
relay.display()
),
"sh",
])
.args(app.split_whitespace())
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
// makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before
// its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the
// bug because it predates any login's env import). gamescope exports its own DISPLAY /
// GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY");
let script = nested_wrapper_script(&relay, splash_exe.is_some());
cmd.args(["sh", "-c", &script, "sh"]);
if let Some(exe) = &splash_exe {
cmd.arg(exe);
}
cmd.args(app.split_whitespace())
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
// makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before
// its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the
// bug because it predates any login's env import). gamescope exports its own DISPLAY /
// GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY");
if let Ok(logf) = std::fs::File::create(log) {
if let Ok(log2) = logf.try_clone() {
cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2));
@@ -2077,11 +2097,34 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
} else {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
tracing::info!(w, h, hz, steam_mode, %app, log = %log.display(), "spawning gamescope (headless)");
tracing::info!(
w, h, hz, steam_mode,
splash = splash_exe.is_some(),
%app,
log = %log.display(),
"spawning gamescope (headless)"
);
cmd.spawn()
.context("spawn gamescope (is it installed? `apt install gamescope`)")
}
/// The nested-command wrapper script for a bare spawn: relay gamescope's `LIBEI_SOCKET` to the
/// injector's file, optionally background the splash client (`"$1"` is the host executable — passed
/// as an argv so its path never needs shell-escaping), then exec the real app. Pure + unit-tested.
fn nested_wrapper_script(relay: &std::path::Path, with_splash: bool) -> String {
if with_splash {
format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; \"$1\" gamescope-splash & shift; exec \"$@\"",
relay.display()
)
} else {
format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
relay.display()
)
}
}
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
/// output down.
struct GamescopeProc {
@@ -2105,9 +2148,24 @@ impl Drop for GamescopeProc {
mod tests {
use super::{
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
dm_survives_masked_unit, is_steam_launch, shape_dedicated_command,
dm_survives_masked_unit, is_steam_launch, nested_wrapper_script, shape_dedicated_command,
};
#[test]
fn nested_wrapper_script_shapes() {
let relay = std::path::Path::new("/run/user/1000/pf-ei");
// Plain: relay + exec, no splash machinery.
let plain = nested_wrapper_script(relay, false);
assert!(plain.contains("/run/user/1000/pf-ei"));
assert!(plain.ends_with("exec \"$@\""));
assert!(!plain.contains("gamescope-splash"));
// Splash: `"$1"` is the host exe (an argv, never shell-interpolated), backgrounded and
// shifted away so `exec "$@"` still runs the untouched app tokens.
let splash = nested_wrapper_script(relay, true);
assert!(splash.contains("\"$1\" gamescope-splash &"));
assert!(splash.contains("shift; exec \"$@\""));
}
#[test]
fn display_manager_flavor_detection() {
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
@@ -2169,15 +2227,15 @@ mod tests {
#[test]
fn dedicated_command_shaping() {
// Steam URI → -silent inserted so the game is the gamescope focus.
// Steam URI → -gamepadui inserted so the nested Steam is Big Picture (not the desktop UI).
assert_eq!(
shape_dedicated_command("steam steam://rungameid/570"),
"steam -silent steam://rungameid/570"
"steam -gamepadui steam://rungameid/570"
);
// Idempotent: an already-silent command is left alone.
// Idempotent: an already-gamepadui command is left alone.
assert_eq!(
shape_dedicated_command("steam -silent steam://rungameid/570"),
"steam -silent steam://rungameid/570"
shape_dedicated_command("steam -gamepadui steam://rungameid/570"),
"steam -gamepadui steam://rungameid/570"
);
// Non-Steam launches and operator custom commands are untouched.
assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
@@ -0,0 +1,159 @@
//! The bare-spawn **splash client** — the reason a fresh headless gamescope delivers frames at all.
//!
//! gamescope only composites (and only on a composite pushes a buffer to its PipeWire capture
//! node) when a client paints. A dedicated Steam launch paints NOTHING for the whole Steam
//! bootstrap (no window maps until the gamepad UI's first frame) — so a fresh
//! spawn's capture negotiated a format, reached `Streaming`, and then starved: zero buffers, the
//! 10 s first-frame timeout, teardown — and every native-plane retry killed the half-booted Steam
//! and started from zero, while the GameStream plane died on its single wait (the "fresh gamescope
//! output never delivers frames" field failure; root-caused on-box 2026-07-27 with a raw pw_stream
//! probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately).
//!
//! This client runs INSIDE the spawned gamescope (the nested-command wrapper backgrounds it before
//! exec'ing the real app) and guarantees damage from the first second: a dark window with a subtle
//! breathing bar painted at ~2.5 Hz. Two gamescope focus facts, both live-proven on c31743d:
//!
//! * In `--steam` mode gamescope composites ONLY windows whose Steam appid is listed in the root
//! window's `GAMESCOPECTRL_BASELAYER_APPID` property (a plain mapped+painting window — even
//! vkcube — gets zero composites). So the splash declares itself as the Steam client UI
//! (`STEAM_GAME=769`) and seeds the baselayer with that appid iff nobody has written it yet.
//! When Steam finishes booting it rewrites the baselayer as part of launching the game, which
//! hands composite focus to the real game window with no action on our side (verified: 2.5 Hz
//! splash cadence → 60 Hz game cadence on the same capture stream, no gap).
//! * In plain (non-`--steam`) mode any mapped painting window composites; the atoms are inert.
//!
//! The splash never exits on its own while the session lives: after the game takes focus it keeps
//! painting unfocused (damage on an unfocused window schedules no composite — harmless), so if the
//! game's window briefly goes away (Steam updater closed, level-load window swap) and gamescope
//! re-focuses the splash, liveness returns instead of a frozen last frame. It dies with the session:
//! gamescope's reaper kills it at teardown, and any X error (the connection dropping) exits cleanly.
use anyhow::{Context, Result};
use std::time::Duration;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
AtomEnum, ChangeGCAux, ConnectionExt, CreateGCAux, CreateWindowAux, PropMode, Rectangle,
WindowClass,
};
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as _;
/// The Steam client UI's appid — the identity the splash borrows so `--steam`-mode gamescope
/// treats it as focusable before (and beneath) any real game.
const STEAM_UI_APPID: u32 = 769;
/// Window/bar palette: near-black background, greys the bar breathes through.
const BG: u32 = 0x0d0d0d;
/// How often the splash repaints (each paint is the damage that makes gamescope push a capture
/// buffer). 400 ms ≈ 2.5 fps — far inside every first-frame budget, invisible in CPU terms.
const TICK: Duration = Duration::from_millis(400);
/// Entry point for the hidden `gamescope-splash` host subcommand. Blocks for the session's
/// lifetime; returns (or errors) only when the X connection is gone or never came up.
pub(crate) fn run() -> Result<()> {
// gamescope execs its nested command only once Xwayland is ready and DISPLAY is set, so the
// first attempt normally lands — the retry covers a slow Xwayland under driver-init load.
let (conn, screen_num) = connect_with_retry()?;
let screen = &conn.setup().roots[screen_num];
let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
let win = conn.generate_id()?;
conn.create_window(
x11rb::COPY_DEPTH_FROM_PARENT,
win,
screen.root,
0,
0,
w,
h,
0,
WindowClass::INPUT_OUTPUT,
screen.root_visual,
&CreateWindowAux::new().background_pixel(BG),
)?;
conn.change_property8(
PropMode::REPLACE,
win,
AtomEnum::WM_NAME,
AtomEnum::STRING,
b"Punktfunk",
)?;
// STEAM_GAME on the window + GAMESCOPECTRL_BASELAYER_APPID on the root — the pair that makes a
// `--steam` gamescope composite us. Seed the baselayer only while unset: once Steam is up it
// owns that property (its rewrite at game launch is exactly the handover we want).
let steam_game = conn.intern_atom(false, b"STEAM_GAME")?.reply()?.atom;
conn.change_property32(
PropMode::REPLACE,
win,
steam_game,
AtomEnum::CARDINAL,
&[STEAM_UI_APPID],
)?;
let baselayer = conn
.intern_atom(false, b"GAMESCOPECTRL_BASELAYER_APPID")?
.reply()?
.atom;
let existing = conn
.get_property(false, screen.root, baselayer, AtomEnum::CARDINAL, 0, 4)?
.reply()?;
if existing.value_len == 0 {
conn.change_property32(
PropMode::REPLACE,
screen.root,
baselayer,
AtomEnum::CARDINAL,
&[STEAM_UI_APPID],
)?;
}
conn.map_window(win)?;
let gc = conn.generate_id()?;
conn.create_gc(gc, win, &CreateGCAux::new().foreground(BG))?;
conn.flush()?;
tracing::info!(
w,
h,
seeded_baselayer = existing.value_len == 0,
"gamescope splash: mapped"
);
// The breathing bar. Server-side fills generate the damage; the background pixel handles
// exposures, so no event loop is needed. Any request failing = the session (or its Xwayland)
// is gone = a clean exit.
let bar_w = 120i16;
let bar_h = 8i16;
let bar = Rectangle {
x: (w as i16) / 2 - bar_w / 2,
y: (h as i16) / 2 - bar_h / 2,
width: bar_w as u16,
height: bar_h as u16,
};
let mut t: u32 = 0;
loop {
// 8-step triangle pulse through dark greys (0x1a…0x40 per channel).
let phase = (t % 8) as i32;
let tri = if phase < 4 { phase } else { 8 - phase };
let grey = 0x1a + 0x0c * tri as u32;
let colour = (grey << 16) | (grey << 8) | grey;
conn.change_gc(gc, &ChangeGCAux::new().foreground(colour))?;
conn.poly_fill_rectangle(win, gc, &[bar])?;
conn.flush()
.context("gamescope splash: X connection lost")?;
t = t.wrapping_add(1);
std::thread::sleep(TICK);
}
}
/// Connect to the session's `DISPLAY`, retrying briefly — gamescope sets the variable before
/// exec'ing the nested command, but a slow Xwayland under cold driver init gets a grace window.
fn connect_with_retry() -> Result<(RustConnection, usize)> {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
match x11rb::connect(None) {
Ok(ok) => return Ok(ok),
Err(e) if std::time::Instant::now() >= deadline => {
return Err(e).context("gamescope splash: could not connect to the session DISPLAY")
}
Err(_) => std::thread::sleep(Duration::from_millis(200)),
}
}
}
+158 -13
View File
@@ -262,6 +262,15 @@ unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
}
/// Block until everything enqueued on THIS THREAD's copy stream completed — the shared tail of
/// the multi-plane blocking copies (stream FIFO: one sync after the last enqueue covers every
/// plane, where the per-plane `copy_blocking` paid one exposed CPU wait EACH — and under a
/// game's GPU load each exposed wait eats scheduling latency). The shared context must be
/// current.
unsafe fn sync_copy_stream() -> Result<()> {
ck(cuStreamSynchronize(copy_stream()), "cuStreamSynchronize")
}
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
@@ -985,17 +994,24 @@ pub fn copy_nv12_to_device(
Height: h / 2,
..Default::default()
};
// SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
// enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
// `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation
// to the caller (documented above). Wrappers → live table.
// SAFETY: two unsafe `copy_async` device→device enqueues + an optional stream sync; the
// caller must have the shared context current (documented). `&y`/`&uv` are live local
// `CUDA_MEMCPY2D`s outliving each enqueue. All four device pointers are valid:
// `src.ptr`/`src_uv_ptr` come from a live NV12 `DeviceBuffer` (its `.uv` presence was
// checked via `ok_or_else`), `y_dst`/`uv_dst` are the caller's live NVENC surface planes;
// the luma copy is `w`×`h`, the chroma copy `(w/2)*2`×`h/2`, each within its planes. With
// `sync` the single trailing stream sync covers both enqueues (FIFO) before we return — the
// same completion guarantee as the old per-plane blocking copies at half the exposed waits;
// `sync: false` shifts the source-lifetime obligation to the caller (documented above).
// Wrappers → live table.
unsafe {
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?;
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync)
copy_async(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
copy_async(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")?;
if sync {
sync_copy_stream()?;
}
}
Ok(())
}
/// Copy our imported stacked-YUV444 [`DeviceBuffer`] into NVENC's three-plane CUDA surface
@@ -1024,13 +1040,19 @@ pub fn copy_yuv444_to_device(
Height: h,
..Default::default()
};
// SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared
// SAFETY: unsafe `copy_async` device→device enqueue; the caller must have the shared
// context current (documented). `&copy` is a live local outliving the enqueue;
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
// both; `sync: false` shifts the source-lifetime obligation to the caller (documented
// above). Wrapper → live table.
unsafe { copy_issue(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
// both. Completion is the trailing stream sync below (`sync`) or the caller's
// stream-ordering obligation (`sync: false`, documented above). Wrapper → live table.
unsafe { copy_async(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? };
}
if sync {
// SAFETY: one stream sync after the last enqueue covers all three planes (FIFO) — the
// same completion guarantee as the old per-plane blocking copies at a third of the
// exposed waits. Context current per the caller's contract. Wrapper → live table.
unsafe { sync_copy_stream()? };
}
Ok(())
}
@@ -1169,6 +1191,101 @@ impl Drop for ExternalDmabuf {
}
}
/// A Vulkan **timeline** semaphore imported as a CUDA external semaphore — the cross-API ordering
/// primitive for the stream-ordered cursor blend (`vkslot.rs`): CUDA [`signal`](Self::signal)s a
/// value on this thread's copy stream once the input copy is enqueued, the Vulkan blend waits for
/// and then advances the timeline on its queue, and CUDA [`wait`](Self::wait)s that advanced
/// value before the encode — all ordering on-device, no CPU sync anywhere. One imported handle
/// per [`VkSlotBlend`](super::vkslot::VkSlotBlend); values are monotonic for its lifetime.
pub struct ExternalSemaphore {
sem: CUexternalSemaphore,
}
// SAFETY: `CUexternalSemaphore` is an opaque driver handle with no thread affinity (the driver
// API allows use from any thread with the context current). It is uniquely owned here, used from
// the encode thread but moved with its `VkSlotBlend`, and destroyed exactly once in `Drop` —
// `Send` (not `Sync`) matches that single-thread-at-a-time use, like `ExternalDmabuf`.
unsafe impl Send for ExternalSemaphore {}
impl ExternalSemaphore {
/// Import a Vulkan timeline semaphore exported as an OPAQUE_FD (`vkGetSemaphoreFdKHR`). The
/// fd is handed over: the driver owns it on success, we close it on failure. The shared
/// context must be current.
pub fn import_owned_timeline_fd(fd: i32) -> Result<ExternalSemaphore> {
let mut desc = CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC {
type_: CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD,
..Default::default()
};
desc.handle[0] = fd as u32 as u64; // union member `int fd` (little-endian low bytes)
let mut sem: CUexternalSemaphore = std::ptr::null_mut();
// SAFETY: `cuImportExternalSemaphore` reads `&desc`, a live local `#[repr(C)]`
// `CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC` (layout-asserted below) outliving the synchronous
// call: `type_` is TIMELINE_SEMAPHORE_FD and `handle[0]` holds the fd in the union's
// `int fd` low bytes. `&mut sem` is a live null-init out-param the driver writes the
// imported handle into. Distinct locals → no aliasing. Wrapper → live table (caller holds
// the context current).
let r = unsafe { cuImportExternalSemaphore(&mut sem, &desc) };
if r != 0 {
// SAFETY: import failed (`r != 0`), so the driver did NOT take ownership of `fd`; we
// still own it and close it exactly once here. `libc::close` acts on the integer alone.
unsafe { libc::close(fd) };
bail!("cuImportExternalSemaphore failed ({r}) — timeline-semaphore fd export/import unsupported?");
}
Ok(ExternalSemaphore { sem })
}
/// Enqueue a signal that sets the timeline to `value` once all prior work on THIS THREAD's
/// copy stream (the stream `copy_stream_handle` exposes) completes. No CPU wait.
pub fn signal(&self, value: u64) -> Result<()> {
let params = CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS {
value,
..Default::default()
};
// SAFETY: `self.sem` is the live imported handle (this struct only exists after a
// successful import; destroyed only in `Drop`). `&self.sem`/`&params` are live locals the
// synchronous enqueue reads (count 1); the driver retains no pointer into Rust memory.
// The stream is this thread's live copy stream. Wrapper → live table (context current).
unsafe {
ck(
cuSignalExternalSemaphoresAsync(&self.sem, &params, 1, copy_stream()),
"cuSignalExternalSemaphoresAsync",
)
}
}
/// Enqueue a wait: work enqueued on THIS THREAD's copy stream after this call runs only once
/// the timeline reaches `value`. No CPU wait.
pub fn wait(&self, value: u64) -> Result<()> {
let params = CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS {
value,
..Default::default()
};
// SAFETY: same contract as `signal` — live handle, live locals across the synchronous
// enqueue, this thread's live copy stream. Wrapper → live table (context current).
unsafe {
ck(
cuWaitExternalSemaphoresAsync(&self.sem, &params, 1, copy_stream()),
"cuWaitExternalSemaphoresAsync",
)
}
}
}
impl Drop for ExternalSemaphore {
fn drop(&mut self) {
// SAFETY: `self.sem` is the valid imported handle this struct exclusively owns, destroyed
// exactly once here. The shared context is made current first because drop may run off
// the import thread (`VkSlotBlend` teardown quiesces the GPU before dropping, so no
// enqueued signal/wait still references the semaphore). Result ignored (best-effort).
unsafe {
if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0);
}
let _ = cuDestroyExternalSemaphore(self.sem);
}
}
}
/// Copy a pitched span starting at `src_ptr` (e.g. an [`ExternalDmabuf`] mapping at the chunk
/// offset) into `dst`. The shared context must be current on this thread.
pub fn copy_pitched_to_buffer(
@@ -1243,3 +1360,31 @@ pub fn copy_pitched_nv12_to_buffer(
copy_blocking(&uv, "cuMemcpy2DAsync_v2(ext->dev nv12 UV)")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::mem::{offset_of, size_of};
/// The external-semaphore param structs are hand-flattened from cuda.h unions — assert the
/// layout against the C definitions so a transcription slip fails in CI, not in the driver.
#[test]
fn external_semaphore_struct_layouts_match_cuda_h() {
// CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC: type(4)+pad(4)+union(16)+flags(4)+reserved(64) = 96.
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC>(), 96);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, handle), 8);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, flags), 24);
// CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS: params{fence(8)+nvSciSync(8)+keyedMutex(8)+
// reserved[12](48)} = 72, flags at 72, reserved[16] → 144 total.
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS>(), 144);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS, value), 0);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS, flags), 72);
// CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS: params{fence(8)+nvSciSync(8)+keyedMutex(16,
// tail-padded)+reserved[10](40)} = 72, flags at 72, reserved[16] → 144 total.
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS>(), 144);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS, value), 0);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS, flags), 72);
}
}
+117
View File
@@ -20,6 +20,7 @@ pub type CUdeviceptr = u64;
pub type CUgraphicsResource = *mut c_void;
pub type CUarray = *mut c_void;
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
pub type CUexternalSemaphore = *mut c_void; // opaque CUextSemaphore_st*
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
@@ -84,6 +85,60 @@ pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
/// `CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC` (cuda.h, 64-bit layout). Same union-flattening as the
/// memory desc above: `handle` is a union whose largest member is the win32 two-pointer struct
/// (16 bytes, align 8); for the fd-carrying types only the first 4 bytes (the `int fd`) are read.
/// No `size` field — a semaphore has none.
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC {
pub type_: c_uint, // CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD = 9
pub(crate) _pad: u32,
pub handle: [u64; 2], // union { int fd; {void*,void*} win32; const void* nvSciSyncObj }
pub flags: c_uint,
pub(crate) reserved: [c_uint; 16],
pub(crate) _pad2: u32,
}
/// `CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS` (cuda.h, 64-bit layout), flattened: `params` nests
/// `fence.value` (the only member we set — the timeline value to signal), the `nvSciSync` union,
/// `keyedMutex.key`, then 12 reserved words; `flags` + 16 reserved words follow. 144 bytes total
/// (layout-asserted in `super`'s tests).
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS {
pub value: u64, // params.fence.value — the timeline value this signal sets
pub(crate) _nv_sci_sync: u64,
pub(crate) _keyed_mutex_key: u64,
pub(crate) _params_reserved: [c_uint; 12],
pub flags: c_uint,
pub(crate) reserved: [c_uint; 16],
pub(crate) _pad: u32,
}
/// `CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS` (cuda.h, 64-bit layout), flattened like the signal
/// params. The C `keyedMutex` member is `{ u64 key; u32 timeoutMs; }` — size 16 with tail
/// padding, hence the explicit pad word before the 10 reserved words. 144 bytes total.
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS {
pub value: u64, // params.fence.value — wait until the timeline reaches this value
pub(crate) _nv_sci_sync: u64,
pub(crate) _keyed_mutex_key: u64,
pub(crate) _keyed_mutex_timeout: c_uint,
pub(crate) _keyed_mutex_pad: u32,
pub(crate) _params_reserved: [c_uint; 10],
pub flags: c_uint,
pub(crate) reserved: [c_uint; 16],
pub(crate) _pad: u32,
}
/// `CUexternalSemaphoreHandleType` (cuda.h): a Vulkan **timeline** semaphore exported as an
/// OPAQUE_FD (`vkGetSemaphoreFdKHR`). Needs driver ≥ 460 (CUDA 11.2) — far below the NVENC 12.1
/// floor this backend already requires, so import failure means "driver refused", not "too old
/// to try".
pub const CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD: c_uint = 9;
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
@@ -139,6 +194,23 @@ pub(crate) struct CudaApi {
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult,
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
cuImportExternalSemaphore: unsafe extern "C" fn(
*mut CUexternalSemaphore,
*const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
) -> CUresult,
cuDestroyExternalSemaphore: unsafe extern "C" fn(CUexternalSemaphore) -> CUresult,
cuSignalExternalSemaphoresAsync: unsafe extern "C" fn(
*const CUexternalSemaphore,
*const CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS,
c_uint,
CUstream,
) -> CUresult,
cuWaitExternalSemaphoresAsync: unsafe extern "C" fn(
*const CUexternalSemaphore,
*const CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS,
c_uint,
CUstream,
) -> CUresult,
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
@@ -206,6 +278,14 @@ pub(crate) fn cuda_api() -> Option<&'static CudaApi> {
.get(b"cuExternalMemoryGetMappedBuffer\0")
.ok()?,
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
// External-semaphore interop (the stream-ordered cursor blend): all four are
// CUDA 10.0 entry points, far older than anything else this table requires.
cuImportExternalSemaphore: *lib.get(b"cuImportExternalSemaphore\0").ok()?,
cuDestroyExternalSemaphore: *lib.get(b"cuDestroyExternalSemaphore\0").ok()?,
cuSignalExternalSemaphoresAsync: *lib
.get(b"cuSignalExternalSemaphoresAsync\0")
.ok()?,
cuWaitExternalSemaphoresAsync: *lib.get(b"cuWaitExternalSemaphoresAsync\0").ok()?,
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
@@ -381,6 +461,43 @@ pub(crate) unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUres
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuImportExternalSemaphore(
ext_sem_out: *mut CUexternalSemaphore,
sem_handle_desc: *const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuImportExternalSemaphore)(ext_sem_out, sem_handle_desc),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuDestroyExternalSemaphore(ext_sem: CUexternalSemaphore) -> CUresult {
match cuda_api() {
Some(a) => (a.cuDestroyExternalSemaphore)(ext_sem),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuSignalExternalSemaphoresAsync(
ext_sems: *const CUexternalSemaphore,
params: *const CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS,
count: c_uint,
stream: CUstream,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuSignalExternalSemaphoresAsync)(ext_sems, params, count, stream),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuWaitExternalSemaphoresAsync(
ext_sems: *const CUexternalSemaphore,
params: *const CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS,
count: c_uint,
stream: CUstream,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuWaitExternalSemaphoresAsync)(ext_sems, params, count, stream),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
+516 -162
View File
@@ -12,11 +12,24 @@
//!
//! The direct-SDK NVENC encoder allocates its input ring through [`VkSlotBlend::alloc_slot`]
//! instead of `cuMemAllocPitch`: same contiguous layouts (`InputSurface` docs), but the memory is
//! Vulkan external memory both APIs address. Per cursor-bearing frame the encoder CPU-syncs its
//! CUDA copy, then [`VkSlotBlend::blend`] dispatches the compute blend over the cursor's
//! rectangle and fence-waits — the same coherence ceremony [`super::vulkan::VkBridge`] ships for
//! its CSC (fence-ordered cross-API access on NVIDIA, no queue-family transfer needed). Frames
//! without a cursor never touch Vulkan, keeping the stream-ordered fast path intact.
//! Vulkan external memory both APIs address.
//!
//! Cursor-bearing frames blend one of two ways:
//!
//! * **Stream-ordered** ([`blend_ref_ordered`](VkSlotBlend::blend_ref_ordered), the fast path
//! when the driver exports a timeline semaphore to CUDA): the encoder enqueues its CUDA copy
//! with no CPU sync, CUDA signals the shared timeline on the copy stream, the blend submission
//! waits for and then advances it on the Vulkan queue, and CUDA waits the advanced value
//! before the encode — the whole copy→blend→encode chain orders on-device, so a visible
//! cursor costs the submit path nothing. (Before this, EVERY cursor frame took the CPU-synced
//! path below; under gamescope — which composites the pointer into every frame — that
//! serialized submit behind the game's GPU load and capped a 120 fps session near 80.)
//! * **CPU-synced** ([`blend_ref`](VkSlotBlend::blend_ref), the fallback when timeline bring-up
//! failed): the encoder blocks on its CUDA copy, the blend fence-waits — the same coherence
//! ceremony [`super::vulkan::VkBridge`] ships for its CSC (fence-ordered cross-API access on
//! NVIDIA, no queue-family transfer needed).
//!
//! Frames without a cursor never touch Vulkan at all.
//!
//! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite
//! mode degrades to no cursor (warned once) — never a failed session.
@@ -87,12 +100,36 @@ pub struct VkSlotRef {
}
/// One allocated slot's backing objects, freed together in reverse order (CUDA mapping first).
/// Each slot carries its own command buffer + descriptor set so ordered blends can be in flight
/// on several slots at once (the shared-single-set design raced the next recording against a
/// still-executing submission); the set's bindings are written once here — the slot buffer never
/// changes and the cursor staging buffer is shared.
struct SlotAlloc {
buffer: vk::Buffer,
memory: vk::DeviceMemory,
/// CUDA's import of the exported OPAQUE_FD — must drop BEFORE the Vulkan memory is freed.
cuda: cuda::ExternalDmabuf,
size: u64,
cmd: vk::CommandBuffer,
desc: vk::DescriptorSet,
}
/// The cross-API ordering state for stream-ordered blends: one Vulkan timeline semaphore,
/// exported as an OPAQUE_FD and imported into CUDA. `None` when the driver lacks timeline
/// semaphores or the export/import failed — blends then stay CPU-synced ([`VkSlotBlend::blend_ref`]).
struct Timeline {
sem: vk::Semaphore,
/// `vkWaitSemaphoresKHR` — the CPU-side quiesce for cursor-bitmap uploads and teardown
/// (the 1.1 device gets the entry point from `VK_KHR_timeline_semaphore`).
ts: ash::khr::timeline_semaphore::Device,
/// CUDA's import; its `signal`/`wait` enqueue on the encode thread's copy stream.
cuda: cuda::ExternalSemaphore,
/// Last timeline value handed out (monotonic for the device's lifetime, never reused —
/// each ordered blend consumes two: copy-done, then blend-done).
ticket: u64,
/// Last blend-done value an ACCEPTED `vkQueueSubmit` will signal — what upload/teardown
/// quiesce on. Only advanced on submit success: a value from a failed submit would never
/// be signaled and a quiesce on it would time out.
last_blend: u64,
}
/// 28-byte push-constant block matching `cursor_blend.comp`'s `Push`.
@@ -114,14 +151,12 @@ pub struct VkSlotBlend {
ext_fd: ash::khr::external_memory_fd::Device,
queue: vk::Queue,
cmd_pool: vk::CommandPool,
cmd: vk::CommandBuffer,
fence: vk::Fence,
mem_props: vk::PhysicalDeviceMemoryProperties,
shader: vk::ShaderModule,
desc_layout: vk::DescriptorSetLayout,
pipe_layout: vk::PipelineLayout,
desc_pool: vk::DescriptorPool,
desc_set: vk::DescriptorSet,
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
pipelines: [vk::Pipeline; 3],
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
@@ -129,6 +164,8 @@ pub struct VkSlotBlend {
cur_mem: vk::DeviceMemory,
cur_map: *mut u8,
slots: Vec<SlotAlloc>,
/// Stream-ordered blend support (`None` = CPU-synced blends only). See [`Timeline`].
timeline: Option<Timeline>,
}
// SAFETY: raw Vulkan handles + a persistently-mapped pointer, all uniquely owned by this struct
@@ -182,14 +219,44 @@ impl VkSlotBlend {
let qci = [vk::DeviceQueueCreateInfo::default()
.queue_family_index(qf)
.queue_priorities(&prio)];
let exts = [ash::khr::external_memory_fd::NAME.as_ptr()];
let device = match instance.create_device(
phys,
&vk::DeviceCreateInfo::default()
.queue_create_infos(&qci)
.enabled_extension_names(&exts),
None,
) {
// Timeline-semaphore export to CUDA (the stream-ordered blend) is optional: probe the
// device extensions + feature bit and enable them only where present, so a driver
// without them still gets a working (CPU-synced) blend device. NVIDIA has shipped
// both extensions since ~2019; the probe is for exotic/legacy stacks.
let want_timeline = {
let have_exts = instance
.enumerate_device_extension_properties(phys)
.map(|props| {
let has = |name: &std::ffi::CStr| {
props
.iter()
.any(|p| p.extension_name_as_c_str().is_ok_and(|n| n == name))
};
has(ash::khr::timeline_semaphore::NAME)
&& has(ash::khr::external_semaphore_fd::NAME)
})
.unwrap_or(false);
have_exts && {
let mut tl = vk::PhysicalDeviceTimelineSemaphoreFeatures::default();
let mut f2 = vk::PhysicalDeviceFeatures2::default().push_next(&mut tl);
instance.get_physical_device_features2(phys, &mut f2);
tl.timeline_semaphore == vk::TRUE
}
};
let mut exts = vec![ash::khr::external_memory_fd::NAME.as_ptr()];
if want_timeline {
exts.push(ash::khr::timeline_semaphore::NAME.as_ptr());
exts.push(ash::khr::external_semaphore_fd::NAME.as_ptr());
}
let mut tl_enable =
vk::PhysicalDeviceTimelineSemaphoreFeatures::default().timeline_semaphore(true);
let mut dci = vk::DeviceCreateInfo::default()
.queue_create_infos(&qci)
.enabled_extension_names(&exts);
if want_timeline {
dci = dci.push_next(&mut tl_enable);
}
let device = match instance.create_device(phys, &dci, None) {
Ok(d) => d,
Err(e) => {
instance.destroy_instance(None);
@@ -207,30 +274,93 @@ impl VkSlotBlend {
ext_fd,
queue,
cmd_pool: vk::CommandPool::null(),
cmd: vk::CommandBuffer::null(),
fence: vk::Fence::null(),
mem_props,
shader: vk::ShaderModule::null(),
desc_layout: vk::DescriptorSetLayout::null(),
pipe_layout: vk::PipelineLayout::null(),
desc_pool: vk::DescriptorPool::null(),
desc_set: vk::DescriptorSet::null(),
pipelines: [vk::Pipeline::null(); 3],
cur_buf: vk::Buffer::null(),
cur_mem: vk::DeviceMemory::null(),
cur_map: std::ptr::null_mut(),
slots: Vec::new(),
timeline: None,
};
me.init_objects(qf).inspect_err(|_| {
// `Drop` runs the same teardown and tolerates the nulls left by a partial init.
})?;
if want_timeline {
// Non-fatal: any failure (export refused, CUDA import refused) leaves
// `timeline` None and blends CPU-synced — never a failed bring-up.
if let Err(e) = me.init_timeline() {
tracing::info!(
error = %format!("{e:#}"),
"cursor blend timeline export unavailable — blends stay CPU-synced"
);
}
}
tracing::info!(
stream_ordered = me.timeline.is_some(),
"Vulkan slot blend ready (exportable NVENC inputs + SPIR-V cursor blend)"
);
Ok(me)
}
}
/// Bring up the [`Timeline`]: create an exportable timeline semaphore, export its OPAQUE_FD,
/// import it into CUDA. Only called when the device was created with the timeline extensions.
fn init_timeline(&mut self) -> Result<()> {
// SAFETY: ash calls on the live `self.device` with builder infos from locals outliving
// each synchronous call. The semaphore is destroyed on every failure path after its
// creation; the exported fd is owned by `import_owned_timeline_fd` (the driver takes it
// on success, it closes it on failure). The shared CUDA context is current: the encoder
// brings this device up from its encode thread with the context established.
unsafe {
let mut type_ci = vk::SemaphoreTypeCreateInfo::default()
.semaphore_type(vk::SemaphoreType::TIMELINE)
.initial_value(0);
let mut export = vk::ExportSemaphoreCreateInfo::default()
.handle_types(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD);
let sem = self
.device
.create_semaphore(
&vk::SemaphoreCreateInfo::default()
.push_next(&mut type_ci)
.push_next(&mut export),
None,
)
.context("create timeline semaphore")?;
let sem_fd = ash::khr::external_semaphore_fd::Device::new(&self.instance, &self.device);
let fd = match sem_fd.get_semaphore_fd(
&vk::SemaphoreGetFdInfoKHR::default()
.semaphore(sem)
.handle_type(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD),
) {
Ok(f) => f,
Err(e) => {
self.device.destroy_semaphore(sem, None);
return Err(e).context("vkGetSemaphoreFdKHR(timeline)");
}
};
let cuda_sem = match cuda::ExternalSemaphore::import_owned_timeline_fd(fd) {
Ok(c) => c,
Err(e) => {
self.device.destroy_semaphore(sem, None);
return Err(e);
}
};
self.timeline = Some(Timeline {
sem,
ts: ash::khr::timeline_semaphore::Device::new(&self.instance, &self.device),
cuda: cuda_sem,
ticket: 0,
last_blend: 0,
});
Ok(())
}
}
/// The non-device objects: command machinery, cursor staging, descriptor + pipelines.
fn init_objects(&mut self, qf: u32) -> Result<()> {
// SAFETY: same contract as `new` — ash calls on the live `self.device` with builder infos
@@ -246,14 +376,6 @@ impl VkSlotBlend {
None,
)
.context("create command pool")?;
self.cmd = d
.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(self.cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1),
)
.context("allocate command buffer")?[0];
self.fence = d
.create_fence(&vk::FenceCreateInfo::default(), None)
.context("create fence")?;
@@ -320,25 +442,21 @@ impl VkSlotBlend {
None,
)
.context("create pipeline layout")?;
// Per-slot sets (one per ring slot, written once at `alloc_slot`; freed by
// `free_slots` on ring rebuilds, hence FREE_DESCRIPTOR_SET). 64 is comfortably
// above any ring depth (the encoder's POOL is 8).
let pool_sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(2)];
.descriptor_count(128)];
self.desc_pool = d
.create_descriptor_pool(
&vk::DescriptorPoolCreateInfo::default()
.max_sets(1)
.flags(vk::DescriptorPoolCreateFlags::FREE_DESCRIPTOR_SET)
.max_sets(64)
.pool_sizes(&pool_sizes),
None,
)
.context("create descriptor pool")?;
let dls = [self.desc_layout];
self.desc_set = d
.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(self.desc_pool)
.set_layouts(&dls),
)
.context("allocate descriptor set")?[0];
// The shader + one pipeline per MODE (spec constant 0).
if CURSOR_SPV.len() % 4 != 0 {
@@ -465,6 +583,59 @@ impl VkSlotBlend {
return Err(e).context("cuImportExternalMemory(slot OPAQUE_FD)");
}
};
// The slot's own descriptor set + command buffer (see `SlotAlloc`). Bindings are
// written once here: binding 0 is this slot's buffer (immutable for its lifetime),
// binding 1 the shared cursor staging buffer.
let dls = [self.desc_layout];
let desc = match d.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(self.desc_pool)
.set_layouts(&dls),
) {
Ok(s) => s[0],
Err(e) => {
drop(ext); // CUDA's view of the memory goes first
d.free_memory(memory, None);
d.destroy_buffer(buffer, None);
return Err(e).context("allocate slot descriptor set");
}
};
let surf_info = [vk::DescriptorBufferInfo::default()
.buffer(buffer)
.offset(0)
.range(size)];
let cur_info = [vk::DescriptorBufferInfo::default()
.buffer(self.cur_buf)
.offset(0)
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
let writes = [
vk::WriteDescriptorSet::default()
.dst_set(desc)
.dst_binding(0)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&surf_info),
vk::WriteDescriptorSet::default()
.dst_set(desc)
.dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&cur_info),
];
d.update_descriptor_sets(&writes, &[]);
let cmd = match d.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(self.cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1),
) {
Ok(c) => c[0],
Err(e) => {
let _ = d.free_descriptor_sets(self.desc_pool, &[desc]);
drop(ext); // CUDA's view of the memory goes first
d.free_memory(memory, None);
d.destroy_buffer(buffer, None);
return Err(e).context("allocate slot command buffer");
}
};
let r = VkSlotRef {
ptr: ext.ptr,
pitch: pitch as usize,
@@ -475,7 +646,8 @@ impl VkSlotBlend {
buffer,
memory,
cuda: ext,
size,
cmd,
desc,
});
Ok(r)
}
@@ -485,12 +657,26 @@ impl VkSlotBlend {
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK
/// objects explicitly here).
pub fn free_slots(&mut self) {
// Ordered blends return with their work still on the queue — quiesce before freeing the
// buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission
// whose CUDA copy-done signal never fired is still covered; this tiny device idles in
// microseconds outside that pathological case. CPU-synced blends need nothing (they
// fence-wait before returning), so a `None` timeline skips it.
if self.timeline.is_some() && !self.slots.is_empty() {
// SAFETY: single-threaded owner; no other thread touches this device or its queue.
unsafe {
let _ = self.device.device_wait_idle();
}
}
for s in self.slots.drain(..) {
drop(s.cuda); // CUDA's view of the memory goes first
// SAFETY: `buffer`/`memory` were created in `alloc_slot`, are uniquely owned by the
// drained `SlotAlloc`, and are destroyed exactly once here. No queue work can be
// in flight: every `blend` fence-waits before returning.
// SAFETY: `buffer`/`memory`/`cmd`/`desc` were created in `alloc_slot`, are uniquely
// owned by the drained `SlotAlloc`, and are destroyed exactly once here. No queue
// work is in flight: CPU-synced blends fence-wait before returning, and ordered
// blends were quiesced by the `device_wait_idle` above.
unsafe {
let _ = self.device.free_descriptor_sets(self.desc_pool, &[s.desc]);
self.device.free_command_buffers(self.cmd_pool, &[s.cmd]);
self.device.destroy_buffer(s.buffer, None);
self.device.free_memory(s.memory, None);
}
@@ -498,21 +684,188 @@ impl VkSlotBlend {
}
/// Upload the cursor RGBA (`cw*ch*4`, tight rows) into the mapped staging buffer. Call only
/// when the bitmap changes; position moves are push constants.
/// when the bitmap changes; position moves are push constants. Quiesces any in-flight
/// ordered blend first (bitmap changes are rare — pointer-shape flips — so the occasional
/// bounded CPU wait here is the trade that keeps the per-frame path sync-free).
pub fn upload_cursor(&mut self, rgba: &[u8], cw: u32, ch: u32) {
if let Some(t) = &self.timeline {
if t.last_blend > 0 {
let sems = [t.sem];
let values = [t.last_blend];
// SAFETY: `t.sem` is the live timeline semaphore; the wait info's arrays are
// locals outliving the synchronous call. `last_blend` only holds values an
// accepted submit will signal, so the wait terminates (the timeout is the
// backstop for a wedged queue — then we proceed and risk one torn cursor
// bitmap, never UB: the staging buffer stays alive regardless).
let r = unsafe {
t.ts.wait_semaphores(
&vk::SemaphoreWaitInfo::default()
.semaphores(&sems)
.values(&values),
1_000_000_000,
)
};
if let Err(e) = r {
tracing::warn!(
error = ?e,
"cursor upload quiesce failed — proceeding (a torn cursor bitmap for \
one frame at worst)"
);
}
}
}
let cw = cw.min(CURSOR_MAX);
let ch = ch.min(CURSOR_MAX);
let len = (cw * ch * 4) as usize;
let len = len.min(rgba.len());
// SAFETY: `cur_map` is the live persistent mapping of the CURSOR_MAX²·4 host-coherent
// allocation (created in `init_objects`, unmapped only in `Drop`); `len` is clamped to
// both the source slice and the buffer capacity. No blend is in flight (every `blend`
// fence-waits before returning), so no GPU read races this host write.
// both the source slice and the buffer capacity. No blend reads race this host write:
// CPU-synced blends fence-wait before returning, and ordered blends were quiesced via
// the timeline wait above.
unsafe {
std::ptr::copy_nonoverlapping(rgba.as_ptr(), self.cur_map, len);
}
}
/// Stream-ordered blends available: the timeline semaphore was exported to CUDA at bring-up,
/// so [`blend_ref_ordered`](Self::blend_ref_ordered) can order copy→blend→encode on-device.
pub fn ordered_ready(&self) -> bool {
self.timeline.is_some()
}
/// Last timeline value handed out (0 = no ordered blend submitted yet) — test observability
/// for "did the ordered path actually run".
pub fn ordered_ticket(&self) -> u64 {
self.timeline.as_ref().map_or(0, |t| t.ticket)
}
/// Push constants + dispatch geometry for one blend (must match cursor_blend.comp): ARGB =
/// per cursor px; NV12/YUV444 = per word-aligned 4-px span × (2-row blocks | rows). `None`
/// when the clamped cursor rect is empty (nothing to dispatch).
fn blend_geometry(
slot: &VkSlotRef,
fmt: SlotFormat,
surf_w: u32,
cw: u32,
ch: u32,
ox: i32,
oy: i32,
) -> Option<(Push, u32, u32)> {
let cw = cw.min(CURSOR_MAX);
let ch = ch.min(CURSOR_MAX);
if cw == 0 || ch == 0 {
return None;
}
let push = Push {
pitch: slot.pitch as u32,
surf_w,
surf_h: slot.height,
cur_w: cw,
cur_h: ch,
ox,
oy,
};
let (gx, gy) = match fmt {
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
_ => {
let x0 = (ox >> 2) << 2;
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
let rows = match fmt {
SlotFormat::Nv12 => ch.div_ceil(2),
_ => ch,
};
(spans.div_ceil(8), rows.div_ceil(8))
}
};
Some((push, gx, gy))
}
/// Record the blend (barriers + bind + push constants + dispatch) into `id`'s own command
/// buffer and return it, ready for submission.
///
/// # Safety
/// The slot's previous submission must have completed: sync blends fence-wait before
/// returning; ordered blends rely on the encoder's ring-reuse discipline (a slot is reused
/// only after its encode — GPU-ordered after its blend — was polled).
unsafe fn record_blend(
&self,
id: usize,
fmt: SlotFormat,
push: &Push,
gx: u32,
gy: u32,
) -> Result<vk::CommandBuffer> {
let alloc = self
.slots
.get(id)
.ok_or_else(|| anyhow!("bad slot id {id}"))?;
let d = &self.device;
let cmd = alloc.cmd;
d.begin_command_buffer(
cmd,
&vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)
.context("begin blend cmd")?;
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
// the shader (external-memory coherence ceremony; NVIDIA honors this with the
// fence/semaphore ordering alone, the barrier is the spec-shaped belt-and-braces).
let acquire = [vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
d.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::TOP_OF_PIPE,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::DependencyFlags::empty(),
&acquire,
&[],
&[],
);
d.cmd_bind_pipeline(
cmd,
vk::PipelineBindPoint::COMPUTE,
self.pipelines[fmt.mode() as usize],
);
d.cmd_bind_descriptor_sets(
cmd,
vk::PipelineBindPoint::COMPUTE,
self.pipe_layout,
0,
&[alloc.desc],
&[],
);
let bytes = std::slice::from_raw_parts(
(push as *const Push) as *const u8,
std::mem::size_of::<Push>(),
);
d.cmd_push_constants(
cmd,
self.pipe_layout,
vk::ShaderStageFlags::COMPUTE,
0,
bytes,
);
d.cmd_dispatch(cmd, gx.max(1), gy.max(1), 1);
// Release the shader's writes so the downstream CUDA/NVENC reads (fence- or
// semaphore-ordered) see them.
let release = [vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
d.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
vk::DependencyFlags::empty(),
&release,
&[],
&[],
);
d.end_command_buffer(cmd).context("end blend cmd")?;
Ok(cmd)
}
/// Blend the uploaded cursor into `slot` at `(ox, oy)`: record, submit, fence-wait. The
/// caller has CPU-synced its CUDA frame copy first; the fence wait makes the shader's writes
/// visible to the subsequent NVENC encode (the `VkBridge` precedent: fence-ordered cross-API
@@ -528,129 +881,20 @@ impl VkSlotBlend {
ox: i32,
oy: i32,
) -> Result<()> {
let alloc = self
.slots
.get(slot.id)
.ok_or_else(|| anyhow!("bad slot id {}", slot.id))?;
let cw = cw.min(CURSOR_MAX);
let ch = ch.min(CURSOR_MAX);
if cw == 0 || ch == 0 {
let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
return Ok(());
}
let push = Push {
pitch: slot.pitch as u32,
surf_w,
surf_h: slot.height,
cur_w: cw,
cur_h: ch,
ox,
oy,
};
// Dispatch geometry (must match cursor_blend.comp): ARGB = per cursor px; NV12/YUV444 =
// per word-aligned 4-px span × (2-row blocks | rows).
let (gx, gy) = match fmt {
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
_ => {
let x0 = (ox >> 2) << 2;
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
let rows = match fmt {
SlotFormat::Nv12 => ch.div_ceil(2),
_ => ch,
};
(spans.div_ceil(8), rows.div_ceil(8))
}
};
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The descriptor
// update is safe because no prior submission is in flight (every blend fence-waits and
// the fence is reset before reuse). Buffer infos and barrier structs are locals outliving
// their synchronous calls. The dispatch's shader accesses stay in-bounds by the shader's
// own guards (surfW/surfH/curW/curH from `push`) against the slot allocation sized in
// `alloc_slot` for exactly that geometry.
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The slot's
// previous submission completed (`record_blend`'s contract: this path fence-waits every
// blend, and an earlier ORDERED blend on this slot finished before the encoder reused it
// — ring-reuse discipline). Submit-info arrays are locals outliving the synchronous
// calls. The dispatch's shader accesses stay in-bounds by the shader's own guards
// (surfW/surfH/curW/curH from `push`) against the slot allocation sized in `alloc_slot`
// for exactly that geometry.
unsafe {
let d = &self.device;
let surf_info = [vk::DescriptorBufferInfo::default()
.buffer(alloc.buffer)
.offset(0)
.range(alloc.size)];
let cur_info = [vk::DescriptorBufferInfo::default()
.buffer(self.cur_buf)
.offset(0)
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
let writes = [
vk::WriteDescriptorSet::default()
.dst_set(self.desc_set)
.dst_binding(0)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&surf_info),
vk::WriteDescriptorSet::default()
.dst_set(self.desc_set)
.dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&cur_info),
];
d.update_descriptor_sets(&writes, &[]);
d.begin_command_buffer(
self.cmd,
&vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)
.context("begin blend cmd")?;
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
// the shader (external-memory coherence ceremony; NVIDIA honors this with the fence
// ordering alone, the barrier is the spec-shaped belt-and-braces).
let acquire = [vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
d.cmd_pipeline_barrier(
self.cmd,
vk::PipelineStageFlags::TOP_OF_PIPE,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::DependencyFlags::empty(),
&acquire,
&[],
&[],
);
d.cmd_bind_pipeline(
self.cmd,
vk::PipelineBindPoint::COMPUTE,
self.pipelines[fmt.mode() as usize],
);
d.cmd_bind_descriptor_sets(
self.cmd,
vk::PipelineBindPoint::COMPUTE,
self.pipe_layout,
0,
&[self.desc_set],
&[],
);
let bytes = std::slice::from_raw_parts(
(&push as *const Push) as *const u8,
std::mem::size_of::<Push>(),
);
d.cmd_push_constants(
self.cmd,
self.pipe_layout,
vk::ShaderStageFlags::COMPUTE,
0,
bytes,
);
d.cmd_dispatch(self.cmd, gx.max(1), gy.max(1), 1);
// Release the shader's writes so the post-fence CUDA/NVENC reads see them.
let release = [vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
d.cmd_pipeline_barrier(
self.cmd,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
vk::DependencyFlags::empty(),
&release,
&[],
&[],
);
d.end_command_buffer(self.cmd).context("end blend cmd")?;
let cmds = [self.cmd];
let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
let cmds = [cmd];
let submit = [vk::SubmitInfo::default().command_buffers(&cmds)];
d.queue_submit(self.queue, &submit, self.fence)
.context("submit blend")?;
@@ -660,11 +904,121 @@ impl VkSlotBlend {
}
Ok(())
}
/// Stream-ordered blend — no CPU sync anywhere (the fix for cursor frames serializing the
/// NVENC submit path). The caller has ENQUEUED (not synced) its CUDA frame copy on this
/// thread's copy stream; this method then
/// 1. enqueues a CUDA signal of `copy_done` on that stream (fires once the copy completes),
/// 2. submits the blend waiting `copy_done` and signaling `blend_done` on the Vulkan queue,
/// 3. enqueues a CUDA wait of `blend_done` — so later stream work (the encode, via the
/// session's IO-stream binding) orders after the blend's writes.
///
/// Timeline values are fresh per call and never reused. Failure leaves no dangling waiter:
/// the CUDA wait is enqueued only after the submit was accepted, and an orphaned CUDA
/// signal is legal (timeline signals may skip values — a later, larger signal satisfies
/// any waiter).
#[allow(clippy::too_many_arguments)] // same unpacked kernel args as `blend_ref`
pub fn blend_ref_ordered(
&mut self,
slot: &VkSlotRef,
fmt: SlotFormat,
surf_w: u32,
cw: u32,
ch: u32,
ox: i32,
oy: i32,
) -> Result<()> {
let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
return Ok(());
};
let (copy_done, blend_done, sem) = {
let t = self
.timeline
.as_mut()
.ok_or_else(|| anyhow!("ordered blend without timeline support"))?;
t.ticket += 2;
(t.ticket - 1, t.ticket, t.sem)
};
// Signal FIRST: if this fails nothing was submitted and the fresh values are simply
// skipped. (The reverse order could wedge the queue — a submitted blend waiting on a
// copy-done signal that never got enqueued.)
self.timeline
.as_ref()
.expect("checked above")
.cuda
.signal(copy_done)
.context("cuSignalExternalSemaphoresAsync (copy done)")?;
// SAFETY: single-threaded record/submit on handles this struct owns. The slot's previous
// submission completed (`record_blend`'s contract — ring-reuse discipline, see above).
// All submit-info arrays and the timeline-submit chain are locals outliving the
// synchronous `queue_submit`. No fence: completion is observed through the timeline
// (the encode polls it via the CUDA wait below; teardown quiesces via
// `device_wait_idle`).
unsafe {
let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
let cmds = [cmd];
let wait_sems = [sem];
let wait_vals = [copy_done];
let wait_stages = [vk::PipelineStageFlags::COMPUTE_SHADER];
let sig_sems = [sem];
let sig_vals = [blend_done];
let mut tsi = vk::TimelineSemaphoreSubmitInfo::default()
.wait_semaphore_values(&wait_vals)
.signal_semaphore_values(&sig_vals);
let submit = [vk::SubmitInfo::default()
.command_buffers(&cmds)
.wait_semaphores(&wait_sems)
.wait_dst_stage_mask(&wait_stages)
.signal_semaphores(&sig_sems)
.push_next(&mut tsi)];
self.device
.queue_submit(self.queue, &submit, vk::Fence::null())
.context("submit ordered blend")?;
}
let t = self.timeline.as_mut().expect("checked above");
// Only now: `blend_done` WILL be signaled, so quiesces may rely on it.
t.last_blend = blend_done;
if let Err(e) = t.cuda.wait(blend_done) {
// The blend is in flight but the encode would no longer order after it — restore
// the ordering with a bounded CPU wait (the CPU-synced path's cost, once). The
// frame still composites correctly.
let sems = [t.sem];
let values = [blend_done];
// SAFETY: live timeline semaphore; wait-info arrays are locals outliving the
// synchronous call; `blend_done` was accepted for signaling just above, so the
// wait terminates (timeout backstops a wedged queue).
let r = unsafe {
t.ts.wait_semaphores(
&vk::SemaphoreWaitInfo::default()
.semaphores(&sems)
.values(&values),
1_000_000_000,
)
};
tracing::warn!(
error = %format!("{e:#}"),
cpu_wait = ?r,
"ordered cursor blend: CUDA-side wait enqueue failed — ordering restored with \
a CPU wait this frame"
);
}
Ok(())
}
}
impl Drop for VkSlotBlend {
fn drop(&mut self) {
self.free_slots();
if let Some(t) = self.timeline.take() {
drop(t.cuda); // CUDA's import of the semaphore goes first (mirrors the slot order)
// SAFETY: `sem` was created in `init_timeline`, is uniquely owned, and is destroyed
// exactly once here. No submission still references it: ordered blends only exist
// alongside allocated slots, and `free_slots` just quiesced (device_wait_idle) any
// in-flight work before this point.
unsafe {
self.device.destroy_semaphore(t.sem, None);
}
}
// SAFETY: every handle below was created in `new`/`init_objects` (or is null from a
// partial init — Vulkan destroy/free calls are defined no-ops on null handles) and is
// uniquely owned; each is destroyed exactly once here, pipelines/layouts/pools before the
+98 -14
View File
@@ -260,14 +260,13 @@ pub struct LeaseRequest {
/// The reference instant for adopting this launch's processes, in seconds since boot. Call it
/// **before** anything spawns; see [`LeaseRequest::launch_stamp`].
pub fn launch_clock() -> Option<f64> {
#[cfg(target_os = "linux")]
{
crate::procscan::launch_stamp()
}
#[cfg(not(target_os = "linux"))]
{
None
}
// Delegate unconditionally: `procscan::launch_stamp` already answers `None` on a platform with
// no matcher. Gating this on Linux here silently disabled the start-time floor on Windows —
// `find(spec, None)` skips the filter entirely — so a copy of the game the player already had
// open was adopted and then ended with the session (caught on glass, .173: Steam focused a
// running instance instead of starting one, and the lease took it). The Windows matcher is
// exactly where that rule matters most, since the host is SYSTEM and can see every process.
crate::procscan::launch_stamp()
}
/// What the watcher does when it concludes the game has exited.
@@ -466,7 +465,19 @@ fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_ex
// The child being alive counts as the game running for a `Child` lease, so a title with no
// detect signals is still fully tracked.
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
//
// But a launcher that is about to hand off and exit looks *exactly* like the game for its
// first few seconds. When the store gave us signals to recognize the real game by, wait out
// the shim window before believing this child is it — otherwise the lease leaves this phase
// on its very first poll, the reclassification above never gets to run, and the hand-off
// that follows is read as the game exiting. On Linux that ended a session ~7 s after
// launching any Steam title, before the game had even started (on glass, .41).
//
// With no signals the child is all we have, so it still counts immediately: a custom command
// is tracked exactly as before.
let child_alive = matches!(kind, LeaseKind::Child)
&& child.is_some()
&& (shared.spec.is_empty() || spawned_at.elapsed() >= SHIM_WINDOW);
let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive {
known = live.clone();
@@ -1136,6 +1147,59 @@ mod tests {
assert_eq!(readopt(Some("fp-151"), Some(b)), 1);
}
/// A launcher that hands off and exits must never be mistaken for the game.
///
/// This is the `steam steam://rungameid/…` shape: the host spawns a launcher as its own child,
/// the launcher tells the already-running Steam to start the game and exits within a couple of
/// seconds, and the game itself appears later (or, here, never). Ending the session on that exit
/// is the failure this guards — it killed Linux Steam launches ~7 s in, before the game started.
///
/// The spec deliberately matches nothing: what is asserted is that a *quick, successful* child
/// exit is treated as a hand-off to wait out, not as the game being gone.
#[cfg(target_os = "linux")]
#[test]
fn a_launcher_that_hands_off_and_exits_is_not_the_game_exiting() {
use std::sync::atomic::AtomicUsize;
static EXITS: AtomicUsize = AtomicUsize::new(0);
EXITS.store(0, Ordering::SeqCst);
let child = std::process::Command::new("/bin/true")
.spawn()
.expect("spawn the fake launcher");
let lease = open(
LeaseRequest {
game: GameRef {
id: Some("steam:999001".into()),
store: Some("steam".into()),
title: "Handoff".into(),
},
client: "test".into(),
plane: crate::events::Plane::Native,
// A real signal that no process will ever match — the game never shows up.
spec: DetectSpec::steam(999_001),
nested: false,
child: Some((child, false)),
launch_stamp: None,
},
Box::new(|| {
EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
// Past the shim window plus the exit-confirmation window: comfortably longer than the buggy
// path took to declare the game gone.
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(2));
assert_eq!(
EXITS.load(Ordering::SeqCst),
0,
"a launcher handing off must not end the session"
);
assert_ne!(
lease.shared().state(),
GameState::Exited,
"the game never ran, so it cannot have exited"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
@@ -1182,13 +1246,19 @@ mod tests {
let shared = lease.shared();
assert!(matches!(shared.kind(), LeaseKind::Child));
// Seen running on the first poll, because the host holds the child.
std::thread::sleep(Duration::from_millis(1_500));
assert_eq!(shared.state(), GameState::Running, "should be running");
// This script `exec`s a binary OUTSIDE the install dir, so neither the image path nor the
// command line carries the directory: the host's own child is the only signal there is.
// Which means the shim window gates it — a live child that might still turn out to be a
// launcher is not called the game until that window has passed. Waiting it out is the point
// of the assertion, not an accident of timing.
std::thread::sleep(SHIM_WINDOW + Duration::from_millis(1_500));
assert_eq!(
shared.state(),
GameState::Running,
"a child that outlives the shim window IS the game"
);
assert_eq!(EXITS.load(Ordering::SeqCst), 0, "nothing has exited yet");
// Past the shim window, so its exit counts as the game's rather than a launcher handing off.
std::thread::sleep(SHIM_WINDOW);
terminate(shared.clone(), "test asked");
// The ladder asks politely first; `sleep` dies on SIGTERM, so this resolves well inside the
@@ -1207,6 +1277,20 @@ mod tests {
);
}
/// Wherever the host can match processes at all, a launch MUST have a reference instant to
/// match them against — it is the only thing standing between this feature and ending a copy of
/// the game the player already had open. `None` here doesn't fail loudly; it silently turns the
/// filter off ([`crate::procscan::Scanner::find`] skips it), so nothing downstream can notice.
#[cfg(any(target_os = "linux", windows))]
#[test]
fn a_launch_always_gets_a_reference_instant_to_adopt_against() {
assert!(
launch_clock().is_some(),
"no launch reference on a platform that matches processes — every pre-existing \
instance of a title would be adoptable, and endable"
);
}
#[test]
fn state_strings_are_stable() {
// These reach the API and the console; renaming one is a wire change.
@@ -78,6 +78,10 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
// One-shot latch for the HDR-mode control message (0x010e); re-armed on Disconnect.
let mut hdr_sent = false;
let mut peer: Option<PeerID> = None;
// The session's GCM key, remembered from the last tick it was live. Ending a session
// clears `launch` — and the key lives there — so without this copy the one message that
// has to go out *because* the session ended could no longer be sealed.
let mut last_key: Option<[u8; 16]> = None;
loop {
loop {
match host.service() {
@@ -154,6 +158,56 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
}
}
}
// A session can also end from the HOST side: the launched game exited, the operator
// stopped it, or `/cancel` arrived on another connection. Tearing the media threads
// down is *silence*, not a signal — Moonlight holds this control stream for the
// whole session, so with no word from us it sits on its last frame until its own
// timeout eventually fires. The client froze instead of ending (on-glass, .173).
//
// Disconnecting the peer is how it learns. Moonlight treats a control-stream
// disconnect as the session being over and returns to its app list — the same place
// it lands when the user quits, which is exactly where "the game exited" should
// leave them.
//
// Merely dropping the peer is not enough either: the client reports that as a
// connection error (`-1` on glass, .173), because an ENet disconnect on its own is
// indistinguishable from the host falling over. The protocol has a word for this —
// a TERMINATION control message — so send that first and let the disconnect follow.
//
// `end_session` clears `launch`, so a tracked peer with no launch behind it *is*
// the ended session. Clearing `peer` first makes this fire once; the real
// `Disconnect` event that follows then takes the non-session-peer branch, which is
// why this arm repeats that branch's per-connection cleanup.
if let Some(pid) = peer {
let ended = state
.launch
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none();
if ended {
// Only sealable once the client's scheme is known; without it, fall through
// to the bare disconnect rather than send a packet it can't read.
if let (Some(scheme), Some(key)) = (detected, last_key) {
let pt = termination_plaintext();
let wire = encrypt_control(&key, &scheme, host_seq, &pt);
host_seq = host_seq.wrapping_add(1);
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
{
tracing::warn!(error = ?e, "control: termination send failed");
}
}
tracing::info!("control: the session ended — telling the client");
// `disconnect_later` flushes the queued termination first; a plain
// `disconnect` would race it off the wire and land us back at "-1".
host.peer_mut(pid).disconnect_later(0);
peer = None;
detected = None;
decrypt_fails = 0;
hdr_sent = false;
pads = GamepadManager::new();
pointer = super::pen::GsPointer::new();
}
}
// Service the pads' force-feedback protocol every tick (games block inside
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
//
@@ -171,6 +225,10 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
// use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD).
if let (Some(pid), Some(scheme)) = (peer, detected) {
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
// Remember it for the teardown message (see `last_key`).
if key.is_some() {
last_key = key;
}
if let Some(key) = key {
let mut out: Vec<Vec<u8>> = Vec::new();
// One-shot HDR-mode signal (type 0x010e / Sunshine `IDX_HDR_MODE`) once the
@@ -547,6 +605,42 @@ fn hdr_mode_plaintext(enabled: bool, m: &HdrMeta) -> Vec<u8> {
pt
}
/// Build the host→client TERMINATION control plaintext — "the session is over, on purpose".
///
/// Without it, ending a session host-side is invisible on the wire: the media threads simply stop,
/// which the client cannot tell from a host that fell over, so it either sits on its last frame or
/// (once we disconnect the peer) reports a connection error. This is the message that makes it a
/// clean end, and the client returns to its app list.
///
/// Verified against moonlight-common-c `ControlStream.c` (master, 2026-07-26):
///
/// * **Type `0x0109`**, from `packetTypesGen7Enc[IDX_TERMINATION]`. Which table the client reads is
/// decided by ONE thing — the version we advertise:
/// `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and
/// [`super::APP_VERSION`] is exactly `7.1.431`. So every client is on the encrypted table, where
/// termination is `0x0109`; the plain table's `0x0100` would be ignored.
///
/// This is *not* the same axis as [`NonceKind`], which only describes how the GCM nonce is built.
/// Deriving the type from the nonce scheme looks reasonable and is wrong — it sent `0x0100` to a
/// client reading the encrypted table, which ignored it silently and reported the session as `-1`
/// (on glass, .173). The HDR message could never have caught this: `0x010e` in both tables.
/// * **Payload** is a big-endian `u32` reason (the ≥6-byte "extended" branch; the short branch is a
/// little-endian `u16`, which is GFE's older shape).
/// * **`0x80030023`** is `NVST_DISCONN_SERVER_TERMINATED_CLOSED`, which the client maps to
/// `ML_ERROR_GRACEFUL_TERMINATION` *provided it has seen a frame* — true for any real session,
/// and the reason this reads as "the app quit" rather than an error.
fn termination_plaintext() -> Vec<u8> {
/// `packetTypesGen7Enc[IDX_TERMINATION]` — see the version gate above.
const TERMINATION: u16 = 0x0109;
/// `NVST_DISCONN_SERVER_TERMINATED_CLOSED` — a deliberate, graceful host-side end.
const GRACEFUL: u32 = 0x8003_0023;
let mut pt = Vec::with_capacity(8);
pt.extend_from_slice(&TERMINATION.to_le_bytes());
pt.extend_from_slice(&4u16.to_le_bytes()); // length = the reason that follows
pt.extend_from_slice(&GRACEFUL.to_be_bytes()); // big-endian: the extended branch
pt
}
/// Seal a host→client control message, mirroring the client's `detected` scheme with the
/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy
/// nonces keep their construction with our own independent `seq` counter. Wire layout matches
@@ -648,6 +742,41 @@ mod tests {
assert_eq!(decode_rfi_range(&rfi_msg(9, 4)), None); // last < first
}
/// The termination plaintext is what turns "the host went quiet" into "the app quit" on the
/// client. Getting the type from the wrong table, or the reason's byte order wrong, is silently
/// ignored by the client — which reads on glass as a frozen stream or a `-1` error, not as a
/// failed test. Pinned against moonlight-common-c `ControlStream.c`.
#[test]
fn termination_plaintext_wire_layout() {
let pt = super::termination_plaintext();
assert_eq!(pt.len(), 8);
// The ENCRYPTED table's entry — which is the one every client reads, see below.
assert_eq!(&pt[0..2], &0x0109u16.to_le_bytes());
assert_eq!(&pt[2..4], &4u16.to_le_bytes());
// The reason is BIG-endian: the client's >=6-byte "extended" branch.
assert_eq!(&pt[4..8], &0x8003_0023u32.to_be_bytes());
}
/// The termination type above is only correct because of the version we advertise:
/// moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and that
/// alone decides whether the client reads `packetTypesGen7Enc` (termination `0x0109`) or
/// `packetTypesGen7` (`0x0100`). Drop below that version and the message silently stops being
/// understood — the stream would end as an error again, with nothing failing here to say why.
#[test]
fn advertised_version_keeps_the_client_on_the_encrypted_packet_table() {
let q: Vec<i32> = super::super::APP_VERSION
.split('.')
.map(|p| p.parse().unwrap_or(0))
.collect();
let at_least = q[0] > 7 || (q[0] == 7 && (q[1] > 1 || (q[1] == 1 && q[2] >= 431)));
assert!(
at_least,
"APP_VERSION {} is below 7.1.431, so clients read the PLAIN packet table and \
termination must become 0x0100",
super::super::APP_VERSION
);
}
/// The HDR-mode plaintext must match what moonlight-common-c parses: `[u16 type=0x010e]
/// [u16 length=27][u8 enable][SS_HDR_METADATA]`, 31 bytes, all little-endian, primaries R,G,B.
#[test]
+54
View File
@@ -85,6 +85,57 @@ pub struct LaunchSpec {
pub value: String,
}
/// Descriptive metadata for a title — everything a richer library UI (details pane, platform
/// filter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to
/// absent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is
/// `#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat
/// wire shape everywhere.
///
/// Values are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)
/// each have their own vocabulary and the host has no business normalizing it.
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct GameMeta {
/// The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … Installed-store
/// scanners stamp `"PC"`; `GET /library?platform=` filters on it (case-insensitive).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = "PS2")]
pub platform: Option<String>,
/// Short blurb for a details pane.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub developer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
/// Year of first release — the granularity metadata sources reliably agree on.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = 2001)]
pub release_year: Option<u16>,
/// Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub genres: Vec<String>,
/// Free-form organizational labels (`"co-op"`, `"kids"`, `"finished"`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Release region — emulation-relevant (`"NTSC-U"`, `"PAL"`, `"NTSC-J"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
/// Maximum simultaneous (local) players.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub players: Option<u8>,
}
impl GameMeta {
/// The one field an installed-store scanner can assert about its own titles: they run on this
/// host, i.e. on a PC. Everything else stays absent (the launchers' local files don't carry it).
pub(crate) fn pc() -> Self {
GameMeta {
platform: Some("PC".into()),
..Default::default()
}
}
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
@@ -113,6 +164,9 @@ pub struct GameEntry {
#[serde(skip)]
#[schema(ignore)]
pub detect: DetectSpec,
/// Descriptive metadata, flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
/// A store that contributes titles to the library. The trait is the extension point for future
@@ -37,6 +37,9 @@ pub struct CustomEntry {
/// the host would otherwise lose the game the moment the shim returns.
#[serde(default, skip_serializing_if = "DetectHint::is_empty")]
pub detect: DetectHint,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
/// Request body to create or replace a custom entry (no `id` — the host owns it).
@@ -53,6 +56,10 @@ pub struct CustomInput {
/// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)]
pub detect: DetectHint,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced
/// wholesale on update, like `art`: an edit must round-trip every field it wants kept.
#[serde(flatten)]
pub meta: GameMeta,
}
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
@@ -74,6 +81,9 @@ pub struct ProviderEntryInput {
/// through the provider's own client still end its session when the player quits.
#[serde(default)]
pub detect: DetectHint,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
impl From<CustomEntry> for GameEntry {
@@ -98,6 +108,7 @@ impl From<CustomEntry> for GameEntry {
launch: c.launch,
provider: c.provider,
detect,
meta: c.meta,
}
}
}
@@ -188,6 +199,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
provider: None,
external_id: None,
detect: input.detect,
meta: input.meta,
};
entries.push(entry.clone());
save_custom(&entries)?;
@@ -210,6 +222,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
slot.launch = input.launch;
slot.prep = input.prep;
slot.detect = input.detect;
slot.meta = input.meta;
let updated = slot.clone();
save_custom(&entries)?;
emit_changed("manual");
@@ -305,6 +318,7 @@ fn reconcile_entries(
provider: Some(provider.to_string()),
external_id: Some(input.external_id),
detect: input.detect,
meta: input.meta,
});
}
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
@@ -383,6 +397,7 @@ mod tests {
provider: None,
external_id: None,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
}
@@ -394,6 +409,7 @@ mod tests {
launch: None,
prep: Vec::new(),
detect: DetectHint::default(),
meta: GameMeta::default(),
}
}
@@ -407,8 +423,43 @@ mod tests {
let mut e = manual("def456", "Synced");
e.provider = Some("romm".into());
e.external_id = Some("rom-1".into());
e.meta.platform = Some("PS2".into());
let g: GameEntry = e.into();
assert_eq!(g.provider.as_deref(), Some("romm"));
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
}
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional).
#[test]
fn meta_is_flat_and_optional_on_the_wire() {
let mut e = manual("abc123", "Shadow of the Colossus");
e.meta = GameMeta {
platform: Some("PS2".into()),
release_year: Some(2005),
genres: vec!["Adventure".into()],
..Default::default()
};
let v = serde_json::to_value(&e).unwrap();
assert_eq!(v["platform"], "PS2");
assert_eq!(v["release_year"], 2005);
assert_eq!(v["genres"][0], "Adventure");
assert!(v.get("meta").is_none(), "flattened, not nested");
assert!(v.get("description").is_none(), "absent fields are omitted");
assert!(v.get("tags").is_none(), "empty lists are omitted");
// A pre-metadata entry (what an existing library.json holds) still deserializes.
let old = r#"{"id":"abc","title":"Old"}"#;
let e: CustomEntry = serde_json::from_str(old).unwrap();
assert!(e.meta.platform.is_none());
// And a provider payload can carry the fields flat, next to `title` (RFC §8 shape).
let payload = r#"{"external_id":"rom-1","title":"OoT","platform":"N64",
"region":"NTSC-U","players":1,"tags":["kids"]}"#;
let p: ProviderEntryInput = serde_json::from_str(payload).unwrap();
assert_eq!(p.meta.platform.as_deref(), Some("N64"));
assert_eq!(p.meta.players, Some(1));
}
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
@@ -100,6 +100,7 @@ fn epic_entry(
};
Some(GameEntry {
provider: None,
meta: GameMeta::pc(),
id: format!("epic:{app_name}"),
store: "epic".into(),
title,
+1
View File
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
let detect = DetectSpec::exe(&exe).with_dir(&path);
out.push(GameEntry {
provider: None,
meta: GameMeta::pc(),
id,
store: "gog".into(),
title,
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
};
games.push(GameEntry {
provider: None,
meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
title,
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
for (id, slug, name, directory) in rows.flatten() {
games.push(GameEntry {
provider: None,
meta: GameMeta::pc(),
id: format!("lutris:{id}"),
store: "lutris".into(),
title: name,
@@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider {
.filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry {
provider: None,
meta: GameMeta::pc(),
id: format!("steam:{}", app.appid),
store: "steam".into(),
art: steam_art(app.appid),
@@ -382,6 +383,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
}
Some(GameEntry {
provider: None,
meta: GameMeta::pc(),
id: format!("steam:{}", sc.appid),
store: "steam".into(),
title: sc.name,
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry {
provider: None,
meta: GameMeta::pc(),
id,
store: "xbox".into(),
title,
+5
View File
@@ -333,6 +333,11 @@ fn real_main() -> Result<()> {
// names the inherited socketpair end.
#[cfg(target_os = "linux")]
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
// Hidden: the splash client every bare gamescope spawn backgrounds beside its nested app,
// so a fresh headless gamescope composites (and its PipeWire node delivers frames) from
// the first second — never run by hand; it needs a gamescope session's DISPLAY.
#[cfg(target_os = "linux")]
Some("gamescope-splash") => vdisplay::gamescope_splash_client(),
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
// on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
+13 -1
View File
@@ -8,6 +8,8 @@ use axum::http::header;
pub(crate) struct LibraryQuery {
/// Only entries owned by this external provider (RFC §8).
provider: Option<String>,
/// Only entries on this platform (case-insensitive).
platform: Option<String>,
}
/// List the game library
@@ -15,7 +17,8 @@ pub(crate) struct LibraryQuery {
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
/// entries a given external provider owns.
/// entries a given external provider owns; `?platform=` to one platform (case-insensitive —
/// installed-store titles are `PC`, custom/provider entries carry whatever was authored).
#[utoipa::path(
get,
path = "/library",
@@ -23,6 +26,7 @@ pub(crate) struct LibraryQuery {
operation_id = "getLibrary",
params(
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
("platform" = Option<String>, Query, description = "Only entries on this platform (case-insensitive, e.g. `PS2`)"),
),
responses(
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
@@ -36,6 +40,14 @@ pub(crate) async fn get_library(
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
}
if let Some(platform) = q.platform.filter(|p| !p.is_empty()) {
games.retain(|g| {
g.meta
.platform
.as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case(&platform))
});
}
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
// library size, and the client never sees an unreachable `C:\…`).
+7 -1
View File
@@ -542,8 +542,14 @@ mod tests {
// it from there. (A wrapper script that `exec`s something outside the directory would leave no
// trace of the directory in either the image path or the command line — worth knowing, and the
// reason a copied binary is the honest fixture here.)
//
// It has to keep the name `sleep`. Modern coreutils (uutils on Ubuntu 25.10+, busybox
// elsewhere) is a MULTI-CALL binary that dispatches on `argv[0]`: copied to any other name it
// prints "unknown program" and exits instantly, leaving nothing in `/proc` to find — which
// looks exactly like the matcher being broken. That cost a CI red, green on a distro with a
// standalone `sleep` and failing on one without.
let td = tempfile::tempdir().expect("tempdir");
let game = td.path().join("game");
let game = td.path().join("sleep");
std::fs::copy("/bin/sleep", &game).expect("copy a stand-in game binary");
let s = Scanner::system();
let before = s.now_stamp().expect("real /proc/uptime is readable");
+1 -1
View File
@@ -403,7 +403,7 @@ fn web_setup(args: &[String]) -> Result<()> {
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
// instead of stacking a second (possibly all-profiles) rule behind the new one.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args));
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
run_quiet(
"netsh",
&[
+34 -6
View File
@@ -718,8 +718,9 @@ fn install(args: &[String]) -> Result<()> {
// Firewall scope: Domain + Private by default; `--allow-public-network` opts into Public too.
// Persist the choice (so the startup warning respects an opt-in) and re-scope idempotently —
// remove any prior rules first so an upgrade tightens the scope instead of leaving a stale
// all-profiles rule behind the new one.
let allow_public = allow_public_network(args);
// all-profiles rule behind the new one. With the flag absent (every upgrade) this resolves to
// the previously recorded choice, so the rewrite below is a no-op rather than a silent reset.
let allow_public = allow_public_network(args)?;
set_fw_public_marker(allow_public);
remove_firewall_rules();
add_firewall_rules(allow_public);
@@ -889,10 +890,37 @@ pub(crate) fn firewall_profile_arg(allow_public: bool) -> &'static str {
}
}
/// The `--allow-public-network` install opt-in (the installer's "Allow connections on Public
/// networks" task forwards it). Absent = the secure default (Domain + Private only).
pub(crate) fn allow_public_network(args: &[String]) -> bool {
args.iter().any(|a| a == "--allow-public-network")
/// Resolve the Public-network firewall scope for this install/upgrade (the installer's "Allow
/// connections on Public networks" task forwards the flag).
///
/// Tri-state, mirroring `--gamestream=on|off`:
/// * `--allow-public-network` or `=on` -> `true` — explicit opt-in. The bare form is kept for
/// back-compat with existing scripts and docs that pass it that way.
/// * `--allow-public-network=off` -> `false` — explicit opt-out.
/// * absent -> whatever the previous install recorded.
///
/// The absent case is what makes an UPGRADE non-destructive. A silent upgrade (`winget upgrade`)
/// has no wizard to carry the old checkbox forward, so the installer omits the flag entirely on an
/// upgrade and the recorded choice stands. Re-applying the task default instead would quietly
/// re-scope the firewall on every upgrade, undoing an opt-in the user made once. On a first-ever
/// install there is no marker, so absence resolves to the secure default (Domain + Private only).
///
/// Strict on the value: a typo'd `=of` must NOT fall through to the marker, because the marker may
/// say `true` — i.e. a mistyped opt-OUT would silently leave Public open.
pub(crate) fn allow_public_network(args: &[String]) -> Result<bool> {
for a in args {
if let Some(v) = a.strip_prefix("--allow-public-network") {
return match v {
"" | "=on" => Ok(true),
"=off" => Ok(false),
_ => bail!(
"--allow-public-network must be 'on' or 'off' (got '{}')",
v.trim_start_matches('=')
),
};
}
}
Ok(fw_public_marker().exists())
}
/// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
+203
View File
@@ -0,0 +1,203 @@
Update whenever it suits you — the app and the machine you stream from can be updated one at a time, old and new work together in either combination, and everything you've already paired stays paired.
The headline: **Punktfunk now knows when your game ends.** Quit a game and the stream ends with it — on Windows and Linux, in the Punktfunk app and in Moonlight — instead of dropping you onto someone else's desktop. Turn it around if you want, and ending the stream can close the game too. The console and the tray show what is running and who is about to close it. Underneath that sits the largest sweep of the video encoder and screen capture Punktfunk has had: machines that could never stream at all now do, an idle stream on an AMD or Intel PC has stopped sending megabytes of padding every second, 4K at 120 fps holds its frame rate, HDR from an Intel PC is finally the right brightness, and a stack of pointer, controller and reconnect bugs are gone. Windows also gets its first real update path — install and upgrade the host with winget — and there is now a client for 64-bit ARM Linux machines, which you can pair over SSH without installing a desktop on them.
## New: the stream ends when the game does
Until now this only worked in one narrow case — a Steam game on a Linux machine running Steam's own Game Mode. Everywhere else the host launched your game and forgot about it, so finishing a game left you looking at a desktop until you disconnected by hand. It now works on **Windows and Linux**, for games launched through **Steam, Epic, GOG, Xbox, Lutris, Heroic** or added by hand, and in **Moonlight** as well as the Punktfunk app.
Punktfunk waits out the usual hand-off, so a launcher that starts your game and exits is no longer mistaken for the game itself — `steam://`, `epic://` and Playnite launches don't end the session the moment they begin. Where nothing can identify what was launched, the behaviour stays off and the host says so once, rather than guessing.
## New: and optionally, the game ends when the stream does
Off by default, because closing someone's game is destructive. Switch it on and disconnecting closes the game you launched — useful for a shared machine or a games box you stream to from the sofa.
It is careful about it:
- **A dropped connection is not a decision.** Wi-Fi cutting out gives you a **five-minute** window to come back; reconnecting reclaims your game and cancels the countdown.
- **Only ever the game this session started.** A copy you already had open is never touched.
- **It asks before it insists** — the game is asked to close normally, and only forced if it refuses.
- **A display you asked Punktfunk to keep open stays open**, regardless of this setting.
On macOS, which has no game-launching path at all, both controls are shown but disabled — "does nothing here" is worth knowing.
## New: see what's running, and end it from anywhere
- **The console dashboard has a running-game card** with box art, above the session card. "End now" ends a live game by stopping its session, or ends one already counting down after a disconnect.
- **The tray shows the running game too**, including the countdown for a game whose client walked away — visible at the machine without opening the console.
- **Automation gets an event when a game starts and when it stops**, filterable like every other event, so scripts can react to a game rather than to a stream. If you have been polling the host to find out when a game finished, you can stop.
- **The settings for both behaviours** sit next to the display keep-alive policy in the console, which is the same question one step out.
## New: richer library entries
Every library entry can now carry **platform, description, developer, publisher, release year, genres, tags, region and player count** — the things an emulation or retro library needs and a title-and-poster shape could not hold. The console's add/edit form gains a Details section, poster tiles show a platform badge and the year, and the library can be filtered by platform. Existing libraries, provider plugins and clients keep working untouched; every field is optional.
**Plugin authors:** a provider entry can now also say how to recognise its games — install directory, executable, or process name — so titles launched through a provider's own client get the same start-and-end tracking as a Steam or Epic title.
## New: install and update the Windows host with winget
Windows had **no update path at all** — no self-update, no package manager — so keeping a host current meant noticing that a release had happened and re-running an installer by hand. Now, after adding Punktfunk's package source once:
```
winget install unom.PunktfunkHost
```
and `winget upgrade` from then on. The source is ours rather than the public catalogue, and it knows about every release, so you can pin an older version or upgrade from one.
A silent install shows you the same disclosures the wizard does — including where the bundled virtual audio device comes from — and takes the same defaults the wizard offers, so installing without a screen doesn't quietly get you a different machine.
## New: a client for 64-bit ARM Linux machines
Punktfunk now has a client build for **64-bit ARM Linux** — the class of small board and mini-PC people put behind a TV — published to the apt repository alongside the Intel/AMD one. It is the full client, on-screen overlay included; your box picks it up automatically with no extra setup. The machine you stream *from* is still Intel/AMD only, because the hardware video encoders it uses are.
## New: pair a machine that has no desktop, over SSH
A box that only has a terminal can now enrol itself:
```
punktfunk-session --pair <PIN> --connect <host>
```
It runs the same pairing handshake as the app and prints the same confirmation. Until now the pairing screen only existed inside the desktop app, so setting up a kiosk or a headless media box meant installing a whole desktop on it first — or copying credentials across by hand.
## New: pick which graphics card drives the screen on a display-only box
On a machine with two graphics cards and no desktop running, Punktfunk's own display client used to pick a card and hope — regularly choosing the one already in use and failing to start, while the card with the actual monitor attached sat idle. You can now pin the card explicitly. And if starting the display still fails, the error names the card it tried and lists what to check, instead of a one-line internal failure.
## Fixed: computers that could never stream now can
Six separate reasons a machine would connect and then die on its first frame — or refuse to stream at all — each with its own kind of host behind it:
- **Laptops with both Intel and NVIDIA graphics.** Punktfunk assumed the first graphics device it found was the NVIDIA one. On these laptops it is the Intel chip, so setup failed with a message about graphics configuration on a machine whose NVIDIA drivers were perfectly healthy. It now looks for the NVIDIA card by identity, and every message on this path names the card it used.
- **Machines whose driver refuses the fast frame handoff now fall back by themselves.** Previously that host lost every session on the first frame, and every reconnect repeated it — while the very same machine streamed fine with the fast path switched off by hand. Punktfunk now notices after three refusals and switches that machine to the slower-but-working path on its own, with a log line saying so. One bad session, then a working computer.
- **Older Intel graphics.** The shared frame was handed to the driver declared as **empty** — most drivers work the real size out, older Intel ones answer "out of memory" on every frame instead. It now declares the true size — it is the first thing the driver reads, and it was wrong for every host we have ever handed a frame to; whether it is the whole story on the machine that reported this is not yet confirmed.
- **Linux desktops running gamescope** whose graphics drivers are missing the newer built-in video encoder. Punktfunk committed the capture to a format only that encoder can take, and there is deliberately no going back once it has — so the session died at its first frame. It now checks the encoder is really there *before* committing.
- **Wayland desktops that hand over 24-bit frames.** A common frame layout had no handling at all in one of the Linux encoders, so those sessions connected and immediately failed. They are now supported outright rather than refused.
- **PCs with no graphics card.** A host encoding in software advertised HEVC and AV1 to Moonlight — which its software encoder cannot produce — so Moonlight picked one and the session died at start-up. It now offers H.264 only, honestly. In the same vein, a resolution beyond what the software encoder supports is now refused at connect, with the reason, instead of connecting successfully and never delivering a single frame.
## Fixed: an idle stream was sending megabytes of nothing
On an **AMD or Intel PC streaming from Linux** — a Steam Deck included — a calm picture did not use less bandwidth. It used all of it. Once the stream settled, the driver padded every single frame back up to the full target bitrate with zeros, forever: measured on an AMD laptop chip, 300 frames of a calm desktop at 10 Mbps came to **5.63 MB, of which 98.5% was padding**. The same 300 frames now come to **83 KB with no padding at all**; on AV1 the padding went from 99.6% to zero.
That padding was also convincing the automatic quality control that the connection was comfortably full, so it never eased off. If you stream over anything metered, capped, or shared, this is the single biggest change in the release.
## Fixed: 4K at 120 fps holds its frame rate
A 4K120 stream on a fast local network climbed its way into a hole: the quality control kept raising the bitrate, the encoder quietly ran at a lower rate than it reported, and the stream kept rebuilding itself to chase a number it was never going to hit — landing at around 107 of 120 frames per second with visible stutter.
- **What the encoder actually delivers is now what gets reported** — to the pacing, to the web console, and back to your device. The bitrate control no longer climbs away from a phantom number.
- **Punktfunk learns the ceiling of your hardware** and stops re-discovering it the hard way — each discovery used to cost a full encoder rebuild mid-stream.
- **At 4K120 both encoder engines on the graphics card are used**, instead of the second one sitting idle in exactly the mode it exists for.
- **Your device notices when the host is struggling to encode**, not just when the network is, and eases off — on a fast LAN nothing else would ever have brought the rate back down.
## Improved: a stream that stumbles once no longer stays degraded
When a stream hits sustained trouble, Punktfunk trades a little latency for stability — buffering more, and switching the encoder into a mode that keeps up under load. That trade used to be **permanent**: one rough patch, however brief, and the session ran at higher latency until you reconnected.
It now winds back. After a sustained stretch of clean, on-time frames the stream steps back down — first to the low-latency encoder mode, then to minimum buffering — and it retries progressively less often, so a machine that genuinely needs the trade settles into keeping it rather than flapping.
## Fixed: garbled picture on 10-bit PyroWave streams
Streaming in **PyroWave at 10-bit** produced complete corruption on the client — scrambled tiles and saturated green, at any bitrate or refresh rate, with no error anywhere and clean network counters. Reported on an AMD host into an AMD laptop client; milder versions of the same fault were possible on NVIDIA clients. The client was reading the decoded picture through the wrong description of its own buffers, which most drivers execute anyway rather than refusing. 8-bit sessions were never affected.
## Fixed: HDR
- **From an Intel PC, HDR is the right brightness.** The video was tagged with a peak brightness **ten thousand times too low** — 0.1 nits where 1000 was meant. TVs and monitors tone-map from exactly that number, so the picture arrived wrong at the far end. Confirmed fixed by reading the tags back out of a real Intel-encoded stream.
- **Moonlight no longer gets an HDR label on an SDR picture.** If HDR capture failed to start — a timed-out negotiation, a monitor that left HDR mode at the wrong moment — the host went on telling the client the stream was HDR while capturing and encoding ordinary colour. The client then renders it as HDR: washed out, wrong colours, no error, and every reconnect repeated it until the host was restarted.
- **A graphics card that cannot encode 10-bit no longer rebuilds the encoder on every single frame** when HDR content is streamed to it — a full teardown and rebuild per frame.
- **When switching the streamed display into HDR fails, the session survives.** The capture correctly falls back to ordinary colour, but the encoder kept insisting on HDR — one of the three Windows encoders then failed every frame forever, and the session ended after burning through its recovery budget. The encoder now follows the pixels it is actually handed, and labels the stream honestly.
- **On a Windows machine with its own dedicated streaming display**, a mismatch between what the display was composing and what the encoder expected could leave the session in a permanent three-second reconnect loop.
## Fixed: the mouse pointer
- **On iPad, iPhone and Mac, the text cursor stopped disappearing.** A pointer whose new picture had not arrived yet was hidden outright rather than left as it was — routine, because the host announces a new pointer shape the moment it sends it. Reported on glass as "the I-beam never appears over text fields, every other cursor is fine".
- **On Windows, a pointer three times too large is fixed.** Windows quietly re-draws its cursors at a new size when display scaling changes, behind an unchanged internal handle — so Punktfunk latched whatever size the pointer had a second after the streamed display appeared, which is exactly when the scale is still settling, and streamed that size for the whole session.
- **Sessions that streamed with no pointer at all now have one.** Some encoders cannot draw the pointer into the video; the host now works that out *before* capture starts and asks the desktop to draw it instead, rather than warning and streaming a pointerless picture.
- **A stray space in a configuration file could make the pointer vanish.** A setting written as `=0 ` — with a trailing space, as an editor or shell script leaves behind — was read as the *opposite* of what it said, switching on a mode that cannot draw the pointer at all.
- **In Steam's Game Mode, the pointer lands where it should**, no longer drawn at a fraction of its real position when the game runs at a different resolution than the stream — and it no longer disappears for the rest of a game session when the game opens its own window.
- **AV1 at 1080p is now genuinely 1080p.** It was shipping eight rows of duplicated pixels along the bottom edge; playback now reports the true size and the padding is gone rather than cropped back off.
- **A visible pointer no longer costs you frame rate.** On a Linux machine with an NVIDIA card running Steam's Game Mode — where the pointer is drawn into every frame, unlike a normal game — a 120 fps session was capped at around 80, with the graphics card barely working. Drawing the pointer was making the encoder wait for the game's own work to finish, on every frame. It doesn't any more.
## Fixed: your controller type setting is honoured again
Choosing "emulate my controller as a DualShock 4" reached the host and then stopped working the instant the controller actually connected — every pad re-declared itself as what it physically was, so the setting appeared to do nothing. This affected the Punktfunk apps on Apple platforms, Windows, Linux and macOS (Android already did this correctly), and it has been broken since multi-controller support shipped in 0.19.x. Your explicit choice now applies to every controller slot; **Automatic** still detects each pad individually so a mixed session stays honest. Your controller keeps its own local features either way — a DualSense emulated as a DualShock 4 keeps its lightbar.
**And Windows Device Manager now names each emulated pad correctly** — all four types previously showed up as "punktfunk Virtual DualSense", which reads exactly like the setting being ignored.
## Fixed: Windows machines with a dedicated streaming display
- **The dedicated display stays dedicated.** Punktfunk switched the physical monitors off, verified it, and never looked again — and on laptops with two graphics chips the internal panel came back moments later on its own. The host still believed it had the screen to itself, so windows and the mouse could land off-stream and the lock screen could appear on the physical panel. It is now re-checked every couple of seconds while the session runs, and put back if something re-lights a display.
- **Choosing a graphics card in the console no longer wedges the host.** If your host configuration file pinned an encoder belonging to a different make of graphics card than the one you picked, every session failed at the same point and the client reconnected into the identical wall roughly every ten seconds, with no visible reason. The card you pick now wins, and the console shows the pin with a warning when the two disagree.
## Fixed: streaming from a Linux desktop, session after session
Four faults that all shared a shape — the first session worked, and something never came back afterwards:
- **Each Moonlight reconnect leaked a screen-recording session** with your desktop, until the compositor started refusing new ones — the very "another app is already recording" clash the pooling was there to prevent.
- **One compositor restart could wedge Moonlight video permanently**, ten seconds per reconnect attempt, until the host was restarted: a dead capture was put back in the pool and handed out again forever.
- **One failed negotiation switched the whole machine off the fast capture path** — every later session, on every encoder, until restart. It is now scoped to the one thing that actually failed.
- **A stalled moment cost you the wrong frames.** Under load the capture kept the *oldest* eight frames and discarded everything newer, so the picture caught up through stale frames instead of jumping to the current one. It now always hands over the freshest frame. A reused capture also no longer opens the next session showing the last frame of the previous one.
## Fixed: Moonlight
- **Moonlight returns to its app list when the host ends the session.** Whatever ended it — your game exiting, someone pressing stop in the console, another client taking over — the client simply froze on its last frame and eventually reported the session as a connection failure, because the host tore everything down without ever saying it had. It now says so, and Moonlight lands you back in the app list exactly as quitting does.
- **A wedged encoder no longer ends the stream.** If the graphics card's encoder locked up mid-stream, Moonlight clients got a full disconnect and had to reconnect. The Punktfunk app has recovered in place from this for a while; the Moonlight path now does the same, with the same bounded retries and backoff.
## Fixed: launching a game into a fresh Steam session streams from the first second
On a Linux machine where Punktfunk starts a **dedicated Steam session** for you — rather than attaching to one already running — launching a game showed you nothing at all, and the attempt was retried from scratch and eventually given up on. The cause was a chicken-and-egg: that display only produces a picture when something on it is drawing, and Steam draws nothing at all until its interface first appears, which takes far longer than the time allowed for a first frame. Sessions that reused an already-running display worked, which pointed away from the real cause for a long time.
Punktfunk now puts a quiet loading screen up from the first second, so there is always something to stream while Steam boots. The dedicated session also comes up in **Big Picture** now, instead of the desktop Steam window flashing through your stream on the way to the game.
## Fixed: an idle computer pinned a whole CPU core
Punktfunk's script and plugin runner burned **100% of one CPU core doing nothing** — in exactly the state its own documentation calls inert, with no scripts and no plugins installed. One report had it at 99.9% for two hours on a small laptop, starving the desktop badly enough to blank the screen. Three seconds idle used to cost three seconds of CPU; it now costs a tenth of that, all of it start-up.
## Fixed: Steam Game Mode takeover on Nobara and KDE machines
The 0.19.2 fix for switching a Linux machine into Steam's Game Mode needed a permission that was only ever a manual, documented step — so on a normal install it always fell back to mirroring, and with a monitor attached you got the desktop's own resolution mirrored instead of a proper takeover. The permission now ships **with the packages**, so entering Game Mode mid-stream takes the screen over as intended, and everything it changed is put back when you disconnect.
Punktfunk also now warns in its log when the installed gamescope is too old to support properly, instead of failing in ways that look unrelated.
## Fixed: the Windows installer stops undoing your choices
- **Re-installing or upgrading keeps your settings.** Both the Moonlight-compatibility option and the "allow Punktfunk through the firewall on Public networks" option were re-applied from scratch on every run — so an upgrade turned Moonlight support back off for anyone who had switched it on, and quietly closed the Public-network firewall rule for anyone who had opened it. Both are now decided on a fresh install only.
- **An unattended install can't stall on an invisible dialog.** The warning about another streaming host being present (Sunshine, Apollo) was a message box that appeared even in silent mode, so an install nobody was watching sat on a dialog nobody could click. It now declines and stops, which is the honest answer for a combination that warning already calls unsupported.
- **It calls itself "Punktfunk Host"** in Add/Remove Programs, the Start Menu group and the wizard, instead of the internal name. Install locations are untouched.
## Fixed: the tray icon, and the Windows web console after a fresh install
- **The tray said "Idle" through an entire stream.** It only counted Moonlight-style sessions as streaming, and ordinary Punktfunk sessions — the default — never registered. The icon and tooltip now track any live session.
- **"Open web console" no longer disappears from the tray menu.** It was built only if a live check of the console happened to answer within two seconds, so a console still starting up simply had no menu entry — with no discoverable way in. It is always there now, and says "(not responding)" when it genuinely is not.
- **No more false "conflicting host" alarm.** The tray warned about Sunshine being *installed* — not running, not listening — on every poll, and on Linux flagged the icon for attention over it. The host still reports genuine conflicts where that belongs.
- **New "Release kept display…" entry** when Punktfunk is holding a display open, and the menu entries deep-link to the pairing and display pages instead of all landing on the dashboard.
- **A fresh Windows install left the web console down until the next reboot.** The installer started it before the files it needs existed, the launcher exited, nothing retried — and the installer still reported success. It now waits for what it needs, verifies the console really came up, and says so honestly if it did not. The Linux service had the same flaw and would give up permanently after five quick restarts.
## Under the hood (for developers)
- **Nothing changed on the wire or at the API boundary.** The streaming protocol stays at version **2**, the embeddable core library stays at C ABI **13**, and the Windows virtual-display driver protocol stays at **6** — no embedder rebuild, and 0.18/0.19/0.20 hosts and clients mix freely. The library's new `GameMeta` fields are optional and flat on the wire, so existing `library.json` files, provider plugins and clients are unaffected; `GET /library` gains a case-insensitive `?platform=` filter beside `?provider=`, and the SDK and OpenAPI spec are regenerated. New `game.running` / `game.exited` events are filterable like every other kind, and `/status` reports `games[]` including a game whose session has gone and which is waiting out its window, with `POST /game/end` to end it.
- **Session⇄game lifetime.** `DetectSpec` (install dir / exe / process name / appid / env marker) comes from each store's scanner and from an optional provider `detect` hint; `procscan` turns it into live pids per OS; `gamelease` turns pids into a lifetime with four kinds — `nested` (gamescope owns it), `child` (host-spawned, own process group), `matched` (a launcher owns it, recognised by store signals) and `untracked`. A child exiting successfully within 5 s is a launcher handing off, and re-resolves to `matched` rather than reporting an exit — that single rule is what keeps `steam://`, `epic://` and `playnite://` from ending a session at launch. A pid is adopted only if it started after the launch, and re-verified against its start time immediately before being signalled, so a recycled pid is never touched. Provider-supplied hints never win over the host's own findings, and a blank field is absent rather than match-everything (an empty install dir would prefix-match every process on the box, and this feature ends processes). On Windows: Toolhelp + `QueryFullProcessImageNameW` + `GetProcessTimes`, `\\?\`-normalised case-insensitive path matching, Steam's per-app `Running` flag used only as a **veto** (it cannot be trusted to say a game *is* running, but it is exactly right for refusing to declare one gone), and the terminating thread binds to the input desktop before `EnumWindows`/`WM_CLOSE` — the host's own desktop is session 0 and holds none of the user's windows, so without the bind the polite pass finds nothing and every game dies unsaved. Job Objects are deferred with reasoning in the design doc. GameStream gained a session quit flag (RTSP carries no close code, so `/cancel`, a management stop and a game exit were indistinguishable from a client vanishing), and both planes now resolve a launch through one `resolve_launch`. Two on-glass runs each found a defect the fixtures could not: on Linux (.41) a `Child` lease left its waiting phase on the FIRST poll, because a live child counts as the game running — and the shim reclassification lives in that phase, so every Steam launch over the compat plane ended the session ~7 s later when `steam` handed off and exited; a bare live child now only counts once the shim window has passed, where the store gave us signals to recognise the real game by. On Windows (.173) `procscan::launch_stamp`'s wrapper was gated `#[cfg(target_os = "linux")]` and answered `None` everywhere else — and `None` doesn't fail, it turns the filter OFF, so every process under the install dir became adoptable and a deliberate stop closed a pre-existing copy of the game. The guard added there asserts the reference *exists* wherever processes can be matched, which is the shape of that failure: silent, and invisible downstream.
- **Vulkan encode moves from CBR to VBR**, which is the whole filler story: Vulkan exposes no filler-suppression control (no equivalent of AMF's `filler_data=false` or NVENC's default-off), so the rate-control *mode* is the only lever, and CBR under the shipped 1000 ms window overflows the CPB once the initial fill drains (~30 frames at 10 Mbps/60 fps) and pads every frame to the exact rate share thereafter. `VkVideoEncodeCapabilitiesKHR::rateControlModes` was previously ignored — CBR was hardcoded with no capability check; VBR now installs with average == max plus the house ~1-frame window when advertised, CBR-only drivers keep the loose window (tightening it just starts the stuffing earlier), and drivers advertising neither (ANV per current Mesa) keep the old install with a WARN. `PUNKTFUNK_VULKAN_RC=cbr|vbr` is the escape hatch and the on-box A/B control. No pacing claim: a burst A/B on the 780M is byte-identical between 1000 ms CBR and 17 ms VBR, so this firmware ignores the window for QP decisions entirely — the payload is filler elimination. ⚠ The first attempt at this was withdrawn after measuring a "36× bandwidth regression"; that measurement's baseline row was an 8-frame artifact, and the stuffing it feared was already live in shipped code. `maxBitrate` is now read from the same caps struct and clamps open + retarget, and `applied_bitrate_bps()` reports encoder-side truth pending-first.
- **Phases 38 of the pf-encode audit landed here.** Highlights not in the body: `ensure_cpu_rgb` cached its staging image on format alone while sizing it to the source (8× `VUID-vkCmdCopyBufferToImage-imageSubresource-07971` with `submit` returning `Ok` throughout); `reset()` re-armed `first_frame`, which also gated the begin-video-coding rate-control declaration (`VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08253`); AV1 at unaligned modes violated `VUID-vkCmdEncodeVideoKHR-flags-10324`/`-10325` on every frame under RGB-direct, fixed by making the sequence header, DPB setup and reference slots agree at the render size while keeping the EFC fast path; `VK_EXT_queue_family_foreign` was named as the dmabuf acquires' source family on four sites without ever being enabled (spec-invalid everywhere, tolerated by RADV) and is now enabled when advertised with a core-1.1 substitute otherwise; and a one-frame `VUID-...-08254` where a pending retarget was promoted into the session rate before recording, which correlated with the two triggers that fire together (ABR retarget + stall watchdog).
- **NVENC split-encode vs sub-frame is now arbitrated** against `nvEncodeAPI.h`'s own doc rather than the audit's one-liner: H.264 hard-disables split (it "is not applicable", so the rejection-retry used to re-open a byte-identical session), HEVC yields sub-frame when *we* force split for 4K120 throughput — keyed on the FORCED modes only, never `!= DISABLE`, since AUTO is the resolver's fallthrough for every sub-950 Mpix session and the wider key would have disarmed chunked polling fleet-wide — and AV1 is untouched. libav-NVENC open failures are classified by typed errno rather than by an English `strerror` substring over the whole context chain.
- **The pf-capture sweep (Phases 06).** Phase 0 put the crate under CI at all (`-p pf-capture --all-targets` on Windows: its `#[cfg(test)]` modules were compiled by nothing) and added `deny(unsafe_op_in_unsafe_fn)` — 119 operations across 16 functions gained SAFETY proofs, because in edition 2021 the existing `undocumented_unsafe_blocks` deny has nothing to fire on inside an `unsafe fn`, exempting the hardest FFI in the crate from its own program. Then a truth pass over comments and log strings, then the defect fixes: the portal thread parked on `future::pending()` so every dropped capturer leaked a thread, a 2-worker tokio runtime, a zbus connection *and* the compositor-side cast; `Capturer::is_alive()` now gates re-pooling; the "VAAPI" downgrade latch was really a global zero-copy kill switch fed by a hand-mirrored copy of `spawn_pipewire`'s negotiation decision, now one `negotiation_plan` resolver consumed by both; the frame channel was `sync_channel(8)` + `try_send` (drop-*newest*) against a trait documenting drop-oldest, replaced by a one-deep overwriting mailbox; four Linux buffer-geometry defects including a self-mmap that ignored `spa_data.mapoffset` and read the wrong buffer out of a pooled memfd; and on Windows the HDR pin wrote the *desired* state in place of the observed one (a permanent 3 s reconnect loop in one direction, a silently mismatched ring in the other), plus cursor rect staleness, recreate ordering, `LocalAlloc`/NT-handle leaks, and an `f32_to_f16` that swallowed the rounding carry (`1.9998779 → 1.0`) and so failed a correct shader. Phase 5 split `linux/mod.rs` 2,778 → 770 lines; Phase 6 took the suite from 6 to **38 Linux + 19 Windows tests**.
- **Cursor blending is negotiated ahead of capture.** `EncoderCaps::blends_cursor`'s contract said the host must fall back to capturer-side compositing, and that host half was never built — `open_video` warned and streamed pointerless (confirmed on the VAAPI dmabuf and libav-NVENC CUDA paths). `cursor_blend_capable()` is the pre-open dispatch mirror; the native plane grants the cursor channel only where the resolved backend composites, `SessionPlan::output_format` keeps cursor-blend sessions off producer-native NV12, Vulkan RGB-direct yields even when pinned, and the GameStream portal source asks for cursor-as-metadata only when the backend blends (the capturer pool keys on that mode). Zero-copy is preserved throughout — every fallback is a capture-negotiation change, never a readback.
- **The GameStream plane can now say a session ended.** `end_session` stops the media threads, and stopping a UDP sender is silence, not a signal — Moonlight holds the ENet control stream for the whole session and sat on its last frame until its own timeout fired. The control loop now watches for its session being cleared out from under it, sends the TERMINATION control message with `NVST_DISCONN_SERVER_TERMINATED_CLOSED`, then `disconnect_later`s the peer so the queued message reaches the wire instead of racing teardown. Two details that are silent when wrong, both verified against moonlight-common-c's `ControlStream.c` rather than recalled: the reason is BIG-endian on the ≥6-byte branch (the short branch is a little-endian u16, GFE's older shape), and the packet type comes from the table the CLIENT chose — `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and we advertise 7.1.431, so it is `0x0109`, not `0x0100`. Deriving that from `NonceKind` was the error; the nonce scheme describes how the GCM nonce is built and nothing else, and the HDR message could never have caught it because `0x010e` is identical in both tables. A test pins `APP_VERSION` above 7.1.431, since dropping below it silently restores the wrong type. The session's GCM key is cached per tick because ending a session clears the launch state the key lives in.
- **The direct-NVENC cursor blend is stream-ordered.** A cursor-bearing frame forced the CPU-synced submit path — a blocking CUDA copy plus a fence-waited Vulkan blend, both exposed to the running game's GPU load — on the assumption that games hide the pointer, which does not hold under gamescope, where the host composites the live pointer into every frame. Reported as an iPad on a 120 fps NVIDIA/gamescope session capped at ~80 fps with `repeat_fps` 0, zero loss, capture 0 µs, ASIC 15 µs and submit p50 at 10.2 ms — 81% of the loop period. `VkSlotBlend` now exports a timeline semaphore (`VK_KHR_timeline_semaphore` + `external_semaphore_fd`) into CUDA via `cuImportExternalSemaphore`: the enqueued copy signals it on the encode thread's copy stream, the blend waits and advances it on the Vulkan queue, and a CUDA-side wait orders the encode after the blend on the session's bound IO stream. Per-slot command buffers and descriptor sets keep several ordered blends in flight; drivers without the timeline export keep the CPU-synced path, and any failure degrades to "no cursor", never a dropped frame. The blocking multi-plane copies also pay one stream sync instead of one per plane (NV12 2→1, YUV444 3→1). Verified on an RTX 5070 Ti (driver 610.43.03): 12 on-hardware smokes green.
- **The GameStream HDR SDR-downgrade latch had exactly one consumer** (`open_portal_monitor`, dropping the HDR offer) while the RTSP negotiation consulted only `gnome_hdr_monitor_active()` — so a set latch meant advertising HDR while capturing and encoding SDR, which the client renders as PQ. Consulted at RTSP honor time rather than folded into `host_hdr_capable()`, which is the static serverinfo capability.
- **Phase 7 consolidations.** One Linux backend resolver (`resolve_linux_backend`) consumed by dispatch *and* the five partial hand-copies, matching what Windows has had; the slot-family and range-family RFI recovery policies extracted from three and two hand-copied twins respectively (the taint sweep had already reached AMF/QSV a commit before Vulkan was carved out, so Vulkan shipped without it); the AMF C-ABI mirror moved to `amf_sys.rs`; `vulkan_video.rs`'s ~820-line construction tail split into `vk_build.rs` (5,292 → 4,489 lines) — ⚠ with a trap recorded for future splits: an inline `use super::X` inside a moved fn body silently changes meaning. `TrackedEncoder`'s forwarding completeness is now guarded by a source-text set-equality test, a trap that has bitten three times (`set_wire_chunking`, `set_pipelined`, `applied_bitrate_bps` — a defaulted method the wrapper doesn't forward silently no-ops for every session).
- **PyroWave's GPU selection stays first-usable, by decision.** Two selection designs died in adversarial review — matching `pf_gpu::selected_gpu()` moves the encoder off the iGPU that can import the compositor's dmabufs on an Intel-compositor + NVIDIA-present laptop, and anchoring on the render node picks the idle iGPU on the common AMD-iGPU + NVIDIA-display desktop, because render minors are driver-bind-order artifacts. The correct oracle is which device *allocated* the capture buffers, which needs per-session producer identity threaded through; until then the open logs one greppable line (picked vendor/device, anchor node and its owner, and the console's selected GPU) with no WARN arm, since the wrong-pick direction inverts between topologies.
- **New tooling.** `scripts/wincheck.sh` type-checks and lints `#[cfg(target_os = "windows")]` Rust from a Linux box — a generated workspace whose members symlink `src` at the real crates plus a ~30-line stub `punktfunk-core`, keeping rustls/ring/opus out of the graph (the in-tree `--target x86_64-pc-windows-msvc` command dies in `audiopus_sys` and `ring` build scripts). Verified non-vacuous against a planted type error. Windows CI now *runs* pf-capture's tests rather than only type-checking them (18 of 19; the crate has no encoder dependency, so its test binary links against nothing the runner lacks). Also: `hdr-p010-selftest` silently ignored its size argument (`skip(2)` where the arm's own optionals start at index 1), so it had only ever validated 64×64 while printing PASS for `1920x1080`.
- **aarch64 Linux client.** `deb.yml` cross-builds it on the ordinary amd64 runner via a new `punktfunk-rust-ci-arm64cross` image; the job hard-fails on a non-AArch64 session binary. skia's `aarch64-linux-gnu` textlayout+vulkan prebuilt resolves, so ARM ships the full client, OSD included. `ci.yml` gains an aarch64 clippy leg plus a `--no-default-features` session build — `c_char` signedness cuts both ways and neither direction is visible from an x86-only CI (hardcoding `i8` fails to compile on ARM; `as *mut u8` fails the lint there), and it has already caught two defects. The RPM spec gained `%bcond_without host` (verified by a real `rpmbuild` in a native aarch64 Fedora 43 container), the Arch PKGBUILD drops `punktfunk-host` from `pkgname` on aarch64, and the flatpak manifest is architecture-generic — but no aarch64 rpm/Arch/flatpak is published yet, and those builds are not cross-compiles.
- **Encode CI coverage.** `nvenc` and `vulkan-encode` were previously linted by nothing — `enc/linux/nvenc_cuda.rs`, `enc/linux/vulkan_video.rs` and the vendored `vk_av1_encode` / `vk_valve_rgb` bindings, ~8,150 lines carrying ~70 `unsafe` blocks, so the crate's own `#![deny(clippy::undocumented_unsafe_blocks)]` was never enforced on them. Linux now lints and tests at the shipped feature set and Windows gets a `-p pf-encode --all-targets` leg (clippy rather than tests: MSVC link-imports the NVENC entry points). `--all-targets` is load-bearing — without it the feature-gated `#[cfg(test)]` modules are never compiled, which is how ten `E0061`s rotted undetected in the direct-NVENC test module. VAAPI and the Windows ffmpeg fallback gained unit-pinned decision logic (1,300 and 1,400 lines, 26 and 23 `unsafe`, zero tests before).
- **The 19 vendored `#[repr(C)]` Vulkan structs gained const size/alignment/per-field-offset assertions**, plus three tests pinning bitfield member order (where a wrong index means the driver reads `use_superres` where we meant `render_and_frame_size_different`). Field order was diffed against `vulkan_core.h` and `vulkan_video_codec_av1std_encode.h` from Vulkan-Headers `main` as of 2026-07-25 — no drift.
- **Windows NVENC teardown and session accounting.** Completion events are pushed to the teardown list before registration; a retrieve thread that has already blown one full 5 s completion budget drops later drains to 250 ms slices (routine teardown byte-identical — nothing is ever abandoned); and `LIVE_SESSION_UNITS` is refunded only on proof the driver released the session, with ambiguous failures parking the handle and `init_session` retrying the destroy under a gate that serialises against session opens.
- **Resource-lifetime work across the Linux encode paths.** Every dmabuf-import failure after `create_image` leaked a `VkImage` and a dup'd fd — with the sharp edge that a successful `vkAllocateMemory` transfers fd ownership, so the naive close-on-error is a double close. PyroWave's `open_inner` had ~20 fallible steps each leaking everything before it, now constructed early with null resources so `Drop` is the single unwind path. `sws_getContext` leaked on both of `open`'s early returns — up to ~10 per session on the EINVAL bitrate ladder. The PyroWave NT-handle contract is pinned callee-side (patch 0006), so `import_plane`'s close-on-failure is correct on every path.
- **Measured performance work.** The RGB-direct CPU upload builds the padded frame straight into mapped staging memory: 1080p `submit` p50 2315 → **1725 µs**, p99 2819 → **1972 µs** on a 780M, with an aligned-mode control run showing no delta. The packed 24-bpp expand moved from a `w*h` per-pixel loop to swscale's SIMD expanders (8.3M iterations per 4K frame, on the encode thread). Three per-frame costs came off the Windows HDR path, two of them inside the ring slot's keyed-mutex hold — plane RTVs and an immutable constant buffer are lifetime-of-mode facts, and a CCD `sdr_white_level_scale` query has no business contending the display-config lock while the driver's publisher is blocked. `std::env::var` came off three per-frame paths — worth recording that the audit ranked this as a hot-path defect and it measured **~122 ns/frame**, i.e. 0.003% of a frame budget; the fix is right, the severity was not.
- **Bitrate honesty end to end.** `Encoder::applied_bitrate_bps()` exposes the post-clamp rate; the codec-level ceiling is cached per GPU/config so an overshoot opens at the ceiling instead of re-running a ~6-open binary search plus a rebuild + IDR; `SPLIT_FORCE_PIXEL_RATE` moved to 950 Mpix/s because 4K120 (995,328,000 px/s) missed the old `> 1e9` gate by 0.47% and stayed on AUTO, which never engages at 2160 px height. Client-side, two consecutive identical short acks latch a host rate cap (mode-scoped, re-probed after ~60 s parked clean), and the per-AU `0xCF` `encode_us` feeds the controller through its own window accumulator, baseline-relative. The latency escalation de-escalates on a ~5 s clean run at 120 fps with 1 → 5 → 25 min backoff; `PUNKTFUNK_NVENC_ASYNC=1` refuses the wind-back.
- **`NV_ENC_ERR_INVALID_VERSION` is split on a `SESSION_OPENED` latch.** A version skew is static and cannot come and go inside one process, so a host that streams once per boot and then fails every later session is told to restart the host service, not to reboot. Diagnosis only — that field bug's root cause is still open.
- **A bare gamescope spawn now backgrounds a splash client.** gamescope composites — and only on a composite pushes a capture buffer — when a client paints, and a nested Steam bootstrap paints nothing until its UI's first frame, far past the 10 s first-frame budget; the native plane's retry ladder then killed the half-booted Steam on every attempt and the GameStream plane died on its single wait. Root-caused on .41 with a raw `pw_stream` probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately. The new hidden `gamescope-splash` subcommand paints a breathing bar at ~2.5 Hz, so damage arrives from the first second on both planes. In `--steam` mode gamescope composites only windows whose appid is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a painting vkcube gets zero composites without it), so the splash declares `STEAM_GAME=769` and seeds the baselayer iff unset — Steam's own rewrite at game launch hands composite focus over with no action on our side. `PUNKTFUNK_GAMESCOPE_SPLASH=0` opts out. The dedicated launch also uses `-gamepadui` rather than `-silent`, since the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built around — and gamescope's focus rules (game outranks the Steam UI appid) cover what `-silent` was working around.
- **The DM-stop takeover ships its privilege.** `libexec/punktfunk/pf-dm-helper` (verbs `stop`|`restore`) behind `io.unom.punktfunk.dm-helper` (`allow_any`, the same mechanism Nobara's `os-session-select` uses); the helper derives the DM unit from the `display-manager.service` symlink so callers never name a unit across the privilege boundary. Packaged in rpm/deb/arch; Nix keeps the manual polkit rule because store paths cannot match the probe.
- **Other knobs and internals:** `PUNKTFUNK_DRM_CARD=<n>` pins SDL's KMSDRM device index; `PUNKTFUNK_ZEROCOPY_RENDER_NODE` overrides the NVIDIA render-node scan; `PUNKTFUNK_EXCLUSIVE_REASSERT_MS` (default 2 s, 0 disables) paces the Windows exclusive-topology watchdog; `PUNKTFUNK_VULKAN_RGB_DIRECT` and `PUNKTFUNK_PIPEWIRE_NV12` now share pf-host-config's `env_on()` grammar with four in-crate copies (both previously read a trailing space as force-ON); the libav log-level save/restore around the capability probes is one RAII guard over a shared mutex (four overlapping sites — interleaved save/restore pinned the process at `AV_LOG_FATAL` permanently); PyroWave's RDO block-index cap moved into `validate_dimensions` checked against 4:2:0 (8192×8192 = 98304 blocks overflows the 16-bit packing, and the host's only use of the helper actively routed oversized modes into the unguarded branch); the write-only `EncoderCaps::supports_hdr_metadata` is deleted (both planes send the static HDR grade out-of-band unconditionally and no first-party client parses in-band SEI); and `Encoder::flush` is documented as deliberately not wired into the production loops. Also, `@punktfunk/host`'s publish workflow is unblocked twice over: bun 1.3 installs a `file:` dependency by symlinking each top-level *file* to itself, so `package.json` arrived as a dangling self-reference and resolution died at the front door — and the step added to repair that was itself named `Repair the file: dependency (…)`, where the unquoted `file: ` reads as a second YAML mapping key, so the workflow had been unparseable (and therefore silently never running) since it landed.
- **winget packaging.** Manifests ship in winget-pkgs' own format so upstream submission is later a copy rather than a rewrite, with a release-time generator substituting only version, URL, hash and notes link. The source is self-hosted — the community repo gates on Defender/SmartScreen validation the self-signed installer cert would not clear today — and implements three endpoints (`/information`, `/manifestSearch`, `/packageManifests/{id}`); the reference implementation's other twenty are its admin API for mutating a CosmosDB, and a catalogue generated at release time has nothing to mutate. It runs on unom-1 as a stock bun image with two bind-mounted `.mjs` files, the same shape as the flatpak server. The catalogue is derived from the RELEASES, not local files, because winget resolves `--version` and upgrade against the version list — a source that knew only the newest release could neither pin an older one nor show an upgrade path from it. `NormalizedPackageNameAndPublisher` is declared unsupported on purpose (winget derives it client-side with its own normalization, and a near-miss silently mis-correlates an installed host; `ProductCode` is exact and Inno gives us one). 28 checks drive the handler directly and CI gates on them, because a wrong response shape does not fail loudly — it just makes winget report "no package found".
- **Installer idempotence.** `--gamestream` and `--allow-public-network` become fresh-install-only, keyed off whether `host.env` already exists; `service install` already read an absent `--gamestream` as "keep `host.env` as-is", and the public-network flag becomes tri-state the same way, resolved from the marker the previous install recorded. It is strict on the value: a typo'd `=of` must not fall through to a marker that may say true, which would turn a mistyped opt-OUT into leaving Public open. The conflicting-host warning moves from `MsgBox` (which ignores `/SUPPRESSMSGBOXES` and displays under `/VERYSILENT`) to `SuppressibleMsgBox` with an `IDNO` default.
+57 -20
View File
@@ -88,12 +88,12 @@
[Setup]
AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}
AppName=punktfunk host
AppName=Punktfunk Host
AppVersion={#MyAppVersion}
AppPublisher=unom
AppPublisherURL=https://git.unom.io/unom/punktfunk
DefaultDirName={autopf}\punktfunk
DefaultGroupName=punktfunk
DefaultGroupName=Punktfunk
DisableProgramGroupPage=yes
UsePreviousAppDir=yes
PrivilegesRequired=admin
@@ -123,7 +123,7 @@ WizardStyle=modern
SetupIconFile={#BrandingDir}\punktfunk.ico
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
UninstallDisplayName=punktfunk host {#MyAppVersion}
UninstallDisplayName=Punktfunk Host {#MyAppVersion}
; The branded multi-size .ico (installed below). The host exe now embeds the same icon + a
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
; copy stays as the uninstall-entry icon.
@@ -140,7 +140,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
; Shown when MinVersion rejects the OS — name the actual requirement instead of Inno's generic
; "requires Windows version 10.0.22621" (users on Windows 10 LTSC hit this; see the pf-vdisplay
; IddCx 1.10 note at MinVersion above).
WinVersionTooLowError=punktfunk host requires Windows 11 22H2 (build 22621) or newer.%n%nIts virtual display driver needs the IddCx 1.10 framework, which is not available on older Windows — including all editions of Windows 10 (LTSC too) and Windows 11 21H2.
WinVersionTooLowError=Punktfunk Host requires Windows 11 22H2 (build 22621) or newer.%n%nIts virtual display driver needs the IddCx 1.10 framework, which is not available on older Windows — including all editions of Windows 10 (LTSC too) and Windows 11 21H2.
[Tasks]
#ifdef WithDriver
@@ -160,18 +160,18 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
#endif
; Host-config choice, applied via `service install --gamestream=on|off` (writes PUNKTFUNK_HOST_CMD
; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified
; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only).
; host (the common Windows setup); unchecked = the secure native-only host (Punktfunk clients only).
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked
; (default) = accept connections on Private + Domain networks only (the trusted-network profiles
; punktfunk is meant for). Check ONLY for a network you trust that Windows classifies as Public (e.g.
; some headless / no-gateway LAN setups) - it opens the streaming + console ports on Public too.
Name: "allowpublicfw"; Description: "Allow connections on Public networks (only for a trusted network Windows marks as Public)"; Flags: unchecked
Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)"
Name: "startservice"; Description: "Start the Punktfunk Host service now (also starts on every boot)"
; The per-user status tray (punktfunk-tray.exe): shows running/stopped/failed at a glance and
; offers open-console / start / stop / restart without a terminal. HKLM Run = every user who signs
; in to this host box gets one (each session keeps exactly one via a Local\ mutex).
Name: "trayicon"; Description: "Show the punktfunk status icon in the notification area at sign-in"
Name: "trayicon"; Description: "Show the Punktfunk status icon in the notification area at sign-in"
[Files]
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
@@ -280,15 +280,15 @@ Filename: "powershell.exe"; \
; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}.
; --gamestream=on|off carries the wizard's GameStream task choice into host.env's PUNKTFUNK_HOST_CMD.
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}{code:PublicFwParam}"; WorkingDir: "{app}"; \
StatusMsg: "Registering the punktfunk host service..."; Flags: runhidden waituntilterminated
StatusMsg: "Registering the Punktfunk Host service..."; Flags: runhidden waituntilterminated
Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \
StatusMsg: "Starting the punktfunk host service..."; Flags: runhidden waituntilterminated; Tasks: startservice
StatusMsg: "Starting the Punktfunk Host service..."; Flags: runhidden waituntilterminated; Tasks: startservice
#ifdef WithWeb
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
; open TCP 47992, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
StatusMsg: "Setting up the Punktfunk web console..."; Flags: runhidden waituntilterminated
#endif
#ifdef WithScripting
; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it
@@ -302,7 +302,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
; in the command, so no Inno {{ }} escaping needed.
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
StatusMsg: "Registering the punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
#endif
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
; icon appears without waiting for the next sign-in.
@@ -341,6 +341,19 @@ Filename: "powershell.exe"; \
#endif
[Code]
{ Captured in InitializeSetup - BEFORE [Run] calls `service install`, which creates host.env. True on
a first-ever install, False on an upgrade. Gates the install-time-only CONFIG choices (see
GamestreamParam): a silent run has no wizard on screen, so every task checkbox falls back to its
script default. Re-applying those defaults on an upgrade would overwrite a setting the user chose
on an earlier run, with nothing shown. `winget upgrade` runs us exactly that way. }
var
FreshHostInstall: Boolean;
function HostEnvPath: String;
begin
Result := ExpandConstant('{commonappdata}\punktfunk\host.env');
end;
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
Program Files directory. Sunshine and its forks all register a "<Name>Service" and install under
Program Files, so a registry + directory probe catches them without enumerating processes (which
@@ -359,6 +372,8 @@ var
Found: String;
begin
Result := True;
{ Record the fresh-vs-upgrade verdict while host.env still reflects the PREVIOUS run. }
FreshHostInstall := not FileExists(HostEnvPath);
Found := '';
if StreamHostPresent('SunshineService', 'Sunshine') then Found := Found + ' - Sunshine' + #13#10;
if StreamHostPresent('ApolloService', 'Apollo') then Found := Found + ' - Apollo' + #13#10;
@@ -366,7 +381,12 @@ begin
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10;
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10;
if Found <> '' then
Result := MsgBox(
{ SuppressibleMsgBox, NOT MsgBox: a plain MsgBox ignores /SUPPRESSMSGBOXES and displays even
under /VERYSILENT - i.e. an unattended install (winget) would block on a modal dialog with no
wizard on screen and nobody to click it. Suppressed, this returns Default = IDNO, so a silent
install onto a box that already runs Sunshine/Apollo ABORTS (Setup exits non-zero) instead of
proceeding into the unsupported dual-host state the message describes. }
Result := SuppressibleMsgBox(
'Another game-streaming host is already installed on this PC:' + #13#10#13#10 + Found + #13#10 +
'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' +
'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' +
@@ -374,15 +394,24 @@ begin
'already in use" errors and capture glitches.' + #13#10#13#10 +
'Stop and uninstall the other host before using Punktfunk.' + #13#10#13#10 +
'Continue with the installation anyway?',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES;
mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES;
end;
{ The GameStream task choice, forwarded to `service install` (which writes host.env's
PUNKTFUNK_HOST_CMD - only if it is unset or still one of the two canonical values, so a
hand-customized command line survives upgrades). }
hand-customized command line survives upgrades).
FRESH INSTALL ONLY. On an upgrade the flag is omitted entirely, which `service install` reads as
"keep host.env as-is" (windows/service.rs: "None = flag absent, keep host.env as-is"). Passing an
explicit on/off would rewrite PUNKTFUNK_HOST_CMD whenever it still holds either canonical value -
so a user who chose GameStream ON, then upgraded with the task unchecked (which is what EVERY
silent run does, since there is no wizard to carry the old choice forward), would have it turned
OFF with nothing on screen. Only a hand-edited command line survived that. }
function GamestreamParam(Param: String): String;
begin
if WizardIsTaskSelected('gamestream') then
if not FreshHostInstall then
Result := ''
else if WizardIsTaskSelected('gamestream') then
Result := '--gamestream=on'
else
Result := '--gamestream=off';
@@ -392,13 +421,21 @@ end;
(default = Private/Domain only). Forwarded to both `service install` and `web setup`. Returns a
LEADING SPACE so it concatenates after the preceding code-substitution param without a gap.
(Do NOT write a literal code-constant token in this comment: Inno's brace comments do not nest,
so its closing brace would end the comment early and break the [Code] parse.) }
so its closing brace would end the comment early and break the [Code] parse.)
FRESH INSTALL ONLY, for the same reason as GamestreamParam: on an upgrade the flag is omitted and
both `service install` and `web setup` resolve the scope from the marker the previous install
recorded. Re-applying the task default would re-scope the firewall on every upgrade - and since
this task is default-UNCHECKED, a silent upgrade would silently REVOKE a Public opt-in the user
made once. The explicit =on/=off form is used so a fresh install still states its choice. }
function PublicFwParam(Param: String): String;
begin
if WizardIsTaskSelected('allowpublicfw') then
Result := ' --allow-public-network'
if not FreshHostInstall then
Result := ''
else if WizardIsTaskSelected('allowpublicfw') then
Result := ' --allow-public-network=on'
else
Result := '';
Result := ' --allow-public-network=off';
end;
#ifdef WithWeb
@@ -443,7 +480,7 @@ var
begin
FreshWebInstall := not FileExists(WebPasswordPath);
WebPwPage := CreateInputQueryPage(wpSelectTasks,
'Web console', 'Set the punktfunk web console login password',
'Web console', 'Set the Punktfunk web console login password',
'The management console is served on https://this-computer:47992 and is login-gated. Keep the ' +
'secure password generated below (it is shown again on the final page) or enter your own - you ' +
'can change it later in %ProgramData%\punktfunk\web-password.');
+116
View File
@@ -0,0 +1,116 @@
# winget manifests — Windows host
The reviewed source of truth for the `unom.PunktfunkHost` winget package. Everything except
`PackageVersion` / `InstallerUrl` / `InstallerSha256` / `ReleaseNotesUrl` is edited **here**;
`scripts/ci/winget-manifest.ps1` only substitutes those four per release, so the switches,
agreements and installation notes stay under normal code review.
| File | Purpose |
| --- | --- |
| `unom.PunktfunkHost.yaml` | Version manifest — ties the other two together. |
| `unom.PunktfunkHost.installer.yaml` | Installer type, scope, silent switches, `ProductCode`, URL + hash. |
| `unom.PunktfunkHost.locale.en-US.yaml` | User-facing metadata, `Agreements`, `InstallationNotes`. |
## Why these choices
- **`InstallerType: inno`, `Scope: machine`, `ElevationRequirement: elevatesSelf`.** The host
registers a SYSTEM service, installs drivers and opens firewall ports; `PrivilegesRequired=admin`
in the `.iss` means Setup raises its own UAC prompt. There is no per-user scope.
- **`ProductCode: {7C9E6A52-…}_is1`** — Inno's ARP key is `<AppId>_is1`. This is what correlates an
installed host with the package for `winget list` / `winget upgrade`. **It must track `AppId` in
`packaging/windows/punktfunk-host.iss`** — if that GUID ever changes, change it here too or
upgrades silently stop being detected.
- **`interactive` is in `InstallModes`.** `winget install unom.PunktfunkHost --interactive` runs the
full existing wizard: every task checkbox, the web-console password page, the VB-CABLE notice.
Nothing about the installer changes to support it.
- **No `/MERGETASKS` in the silent switches.** A silent install deliberately takes the *same* task
defaults the wizard shows, so the product does not differ by install channel — a per-channel
default is a support trap ("it works when I install it by hand"). The disclosures the wizard puts
on screen are carried by `Agreements` instead, which winget shows *before* install and requires
the user to accept.
- **`UpgradeBehavior: install`** — Inno upgrades in place (`UsePreviousAppDir=yes`). Uninstalling
first would run the `[UninstallRun]` service + driver teardown between versions.
## Opting out of individual tasks
Inno's `/MERGETASKS` takes `!` prefixes to deselect a default-checked task. Use `--override`
(replaces winget's switches) rather than `--custom` (appends — you would end up with two
`/MERGETASKS` on one command line):
```powershell
winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream"
```
Task names: `installdriver`, `installgamepad`, `installaudiocable`, `installhdrlayer`,
`gamestream`, `allowpublicfw`, `startservice`, `trayicon`.
## Two installer behaviours that exist for this path
Both are in `packaging/windows/punktfunk-host.iss` and both also fix pre-existing bugs on the
plain double-click upgrade path:
- **`InitializeSetup` uses `SuppressibleMsgBox`, not `MsgBox`.** A plain `MsgBox` ignores
`/SUPPRESSMSGBOXES` and displays even under `/VERYSILENT` — an unattended install on a box that
already runs Sunshine/Apollo would block on an invisible modal dialog. Suppressed it returns
`IDNO`, so that install aborts (Setup exits non-zero) rather than proceeding into the unsupported
dual-host state.
- **`GamestreamParam` is fresh-install-only.** On an upgrade the flag is omitted entirely, which
`service install` reads as "keep host.env as-is". Passing an explicit on/off would rewrite
`PUNKTFUNK_HOST_CMD` whenever it still holds either canonical value — so a silent upgrade, where
no wizard carries the old choice forward, would flip a user's GameStream setting with nothing on
screen.
- **`PublicFwParam` is fresh-install-only too**, and `--allow-public-network` is now tri-state
(`=on` / `=off` / absent → keep the recorded choice, resolved from the `fw-allow-public` marker in
`windows/service.rs`). This task is default-*unchecked*, so without the change a silent upgrade
would have silently **revoked** a Public-network opt-in the user made once. The bare
`--allow-public-network` form still means `on` for existing scripts; a malformed value is a hard
error rather than a fall-through, since a typo'd opt-*out* must never resolve to "keep Public
open".
## Release flow
`.gitea/workflows/windows-host.yml` runs on stable `v*` tags only, **after** the installer is
attached to the Gitea release — winget validates the URL and hash, so a manifest must never be
published ahead of its artifact:
```powershell
scripts/ci/winget-manifest.ps1 -Version 0.19.2 `
-InstallerPath C:\t\out\punktfunk-host-setup-0.19.2.exe -OutDir C:\t\out\winget
```
The generated trio is attached to the same release. Canary builds are excluded: winget pins one
immutable artifact per version, so the rolling `canary/` alias has nothing it could point at.
## Validating a change
```powershell
winget validate --manifest packaging\winget
winget install --manifest packaging\winget # local install from the manifest
```
For a throwaway check, `winget-pkgs`' `Tools\SandboxTest.ps1` runs a manifest in Windows Sandbox.
Note the host needs a real GPU and installs drivers, so a Sandbox run exercises the *manifest*
(download, hash, switches, ARP correlation) rather than a working stream.
## Publishing
Through **our own REST source** on unom-1 — see [`server/`](server/README.md). It sits alongside the
docs (3220) and flatpak (3230) services, behind the same edge Caddy; `windows-host.yml` rebuilds and
ships its catalogue on every stable tag, so releasing is one pipeline with no manual step.
```powershell
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest # elevated, once
winget install unom.PunktfunkHost
```
These manifests stay in winget-pkgs' own format rather than a bespoke one, so submitting upstream
later is a copy, not a rewrite. Two things would need attention on that path: the signing note
below, and `Agreements` being verified-developers-only in the community repo.
> **Signing.** The installer is currently signed with a self-signed cert (`CN=unom`, subject ==
> issuer) and ships a `.cer` users import manually. winget does not sign anything; it downloads and
> runs the same binary, so SmartScreen behaves exactly as it does for a browser download. That is a
> pre-existing condition rather than something winget introduces — but the community repo
> (`microsoft/winget-pkgs`) gates on it via its `Binary-Validation-Error` /
> `Validation-Defender-Error` checks, so a submission there needs a publicly-trusted cert (Azure
> Trusted Signing is the cheap path). A self-hosted source has no such gate.
+5
View File
@@ -0,0 +1,5 @@
node_modules/
# Generated by build-data.mjs from the published release manifests, then rsynced to the box by
# windows-host.yml on each stable tag. Never hand-edited, and regenerating it is one command, so
# committing it would only produce a merge conflict on every release.
data/
+140
View File
@@ -0,0 +1,140 @@
# Punktfunk winget REST source
A self-hosted [winget REST source](https://github.com/microsoft/winget-cli-restsource) serving the
Punktfunk Windows host, so users can install and upgrade with `winget` instead of downloading a
`setup.exe` by hand.
```powershell
# one-time, from an ELEVATED prompt (winget requires admin to add a source)
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
winget install unom.PunktfunkHost # silent, wizard defaults
winget install unom.PunktfunkHost --interactive # the full wizard
winget upgrade unom.PunktfunkHost
```
## Why self-hosted rather than the community repo
`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` /
`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed
cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen
behaves identically whether the installer arrives by browser or by `winget` — but it does block that
route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry
`Agreements` (verified-developers-only upstream), and can serve channels the community repo would
never accept.
## What it implements
The winget client consumes exactly three endpoints. The reference implementation's other twenty
(`/packages/**`) are its **admin** API for mutating a CosmosDB; a catalogue generated at release
time has nothing to mutate, so they are not implemented.
| Endpoint | Notes |
| --- | --- |
| `GET /information` | Source identity, `ServerSupportedVersions`, declared capabilities. |
| `POST /manifestSearch` | `Query` / `Inclusions` (OR) / `Filters` (AND) / `FetchAllManifests`. **204** on no match — not an empty 200. |
| `GET /packageManifests/{id}` | Full manifest; honours `?Version=`. Identifier match is case-insensitive. |
| `GET /healthz` | Not part of the spec — liveness for compose. |
`NormalizedPackageNameAndPublisher` is deliberately declared **unsupported**. winget derives it
client-side from ARP entries with its own normalization (case folding plus stripping legal suffixes,
punctuation and version-like tails); claiming support would mean reimplementing that exactly, and a
near-miss silently mis-correlates an installed host. Correlation rides on `ProductCode` instead,
which Inno gives us exactly (`<AppId>_is1`).
## Layout
| File | Role |
| --- | --- |
| `handler.mjs` | The three endpoints. Pure `(Request, catalogue) -> Response`, no I/O, no deps. |
| `server.mjs` | node:http front. Loads the catalogue, reloads on mtime change. |
| `build-data.mjs` | Generates `data/data.json` from the published release manifests. Build-time only. |
| `test.mjs` | 28 checks driving `handler.mjs` directly. |
| `compose.production.yml` | The unom-1 service. |
## How it runs
```
Gitea releases (manifest trio per stable tag)
│ build-data.mjs ← walks ALL releases, no local state [CI, Linux job]
data/data.json (~8 KB) ← rsynced to unom-1 on each stable tag
bun + server.mjs on unom-1:3240 ← edge Caddy TLS ← winget.punktfunk.unom.io
```
Stock `oven/bun` image with the two `.mjs` files bind-mounted — the same shape as the flatpak server
(stock `caddy:2-alpine` + a bind-mounted Caddyfile). There is no image to build, publish or pull, so
a code change deploys by scp plus `docker compose up -d`.
The server has **zero dependencies**: it reads a generated JSON file. Parsing YAML and talking to
Gitea is `build-data.mjs`'s job, and that runs in CI. Only that build step needs `node_modules`.
**Content and config deploy separately**, matching the flatpak repo:
- `deploy-services.yml` (`winget` job) ships `compose.production.yml` + the two `.mjs` files.
- `windows-host.yml` (`winget-source` job) builds and ships `data/data.json` on each stable tag.
`server.mjs` reloads the catalogue when its mtime changes, so publishing a release needs no restart.
A half-written file mid-rsync keeps the previous catalogue rather than taking the source down.
### Why the catalogue is derived from releases
`build-data.mjs` walks the Gitea releases rather than reading local files. That is what makes
`winget install --version` and `winget upgrade` work: winget resolves both against the version
*list*, so a source that only knew the newest release could neither pin an older version nor show an
upgrade path from one.
It also means the source holds no state — re-running the build reproduces the same `data.json`, so a
lost deployment is one command away and nothing can drift.
## Local development
```bash
npm install
npm run build:local # data/data.json from ../*.yaml (single version — fine for dev)
npm test # 28 checks, no network, no server
npm start # http://localhost:3240
```
`npm test` is the gate that matters. A malformed response does not fail loudly — winget reports "no
package found", so a shape regression looks like a missing package rather than a broken source. CI
runs it before anything reaches the box.
Note a real `winget source add` will not accept a local instance: it requires HTTPS with a publicly
trusted certificate, which is what the edge vhost provides in production.
## First-time setup
1. **DNS**`winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone
(DNS-only, same as `docs`). ✅ *done*
2. **Caddy vhost** on unom-1, next to the existing `docs.punktfunk.unom.io` block:
```caddyfile
winget.punktfunk.unom.io {
reverse_proxy 127.0.0.1:3240
}
```
Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate
for a name it does not serve. That is the expected state, not a broken deploy.
3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until
a catalogue exists — expected, and visible on `/healthz`.
4. Publish a stable tag, or build and ship the catalogue by hand:
```bash
cd packaging/winget/server && npm install && npm run build && npm test
scp data/data.json <deploy-user>@<unom-1>:~/unom-winget/data/data.json
```
Then verify from anywhere:
```bash
curl -s https://winget.punktfunk.unom.io/information | jq .
curl -s -X POST https://winget.punktfunk.unom.io/manifestSearch \
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' | jq '.Data[].PackageIdentifier'
```
> Releases published before winget support shipped have no manifests attached, so `npm run build`
> skips them. Until the first stable tag lands with them, build with `--local ..` to serve the
> checked-in manifests, or backfill the manifest trio onto an existing release.
+155
View File
@@ -0,0 +1,155 @@
// Build src/data.json — the payload the Worker serves — from published winget manifests.
//
// Source of truth is the Gitea RELEASES, not local files: windows-host.yml attaches the manifest
// trio to each stable tag, so walking releases yields every version the source should offer. That
// matters because winget resolves `--version` and `upgrade` against the version LIST; a source that
// only knows the newest release can neither pin an older one nor show an upgrade path from it.
//
// Deriving from releases also means this script holds no state. Re-running it reproduces the same
// data.json, so a lost deployment is one command away and there is no accumulated file to drift.
//
// node build-data.mjs # from Gitea releases (what CI runs)
// node build-data.mjs --local ../ # from a local manifest dir (dev, single version)
// node build-data.mjs --out src/data.json
//
// The `yaml` dep is a BUILD-time dependency only — the Worker ships data.json, never a parser.
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
const HERE = dirname(fileURLToPath(import.meta.url));
const API = process.env.PF_GITEA_API ?? "https://git.unom.io/api/v1";
const REPO = process.env.PF_GITEA_REPO ?? "unom/punktfunk";
const PACKAGE_ID = "unom.PunktfunkHost";
const SOURCE_IDENTIFIER = process.env.PF_SOURCE_ID ?? "unom.punktfunk";
// The API contract this source implements. Advertised via /information so a future client can
// negotiate; bump only alongside a real change to the response shapes.
const SERVER_SUPPORTED_VERSIONS = ["1.1.0"];
const args = process.argv.slice(2);
const argVal = (flag) => {
const i = args.indexOf(flag);
return i >= 0 ? args[i + 1] : undefined;
};
const outPath = resolve(HERE, argVal("--out") ?? "src/data.json");
const localDir = argVal("--local");
/** One version's three manifests -> the shape the Worker serves. */
function toVersionEntry(installer, locale, productCodeFallback) {
// The REST manifest nests DefaultLocale + Installers under each version, whereas the YAML splits
// them across files keyed by PackageVersion. Strip the per-file bookkeeping that does not belong
// in the merged object.
const { PackageIdentifier: _i, PackageVersion, ManifestType: _t, ManifestVersion: _v, Installers, ...installerRest } = installer;
const { PackageIdentifier: _i2, PackageVersion: _pv, ManifestType: _t2, ManifestVersion: _v2, PackageLocale, ...localeRest } = locale;
// Installer-level fields are inherited by each entry in Installers unless overridden there. The
// REST shape has no inheritance, so fold the parent down into every installer.
const installers = (Installers ?? []).map((inst) => ({ ...installerRest, ...inst }));
return {
PackageVersion,
DefaultLocale: { PackageLocale, ...localeRest },
Installers: installers,
ProductCodes: [installer.ProductCode ?? productCodeFallback].filter(Boolean),
};
}
async function fetchJson(url) {
const res = await fetch(url, { headers: { accept: "application/json" } });
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
return res.json();
}
async function fetchText(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
return res.text();
}
async function versionsFromReleases() {
const releases = await fetchJson(`${API}/repos/${REPO}/releases?limit=100`);
const out = [];
for (const rel of releases) {
if (rel.draft) continue;
const assets = rel.assets ?? [];
const find = (suffix) =>
assets.find((a) => a.name === `${PACKAGE_ID}${suffix}`)?.browser_download_url;
const installerUrl = find(".installer.yaml");
const localeUrl = find(".locale.en-US.yaml");
// Releases from before winget support shipped have no manifests — skip, don't fail.
if (!installerUrl || !localeUrl) continue;
const [installer, locale] = await Promise.all([
fetchText(installerUrl).then(parseYaml),
fetchText(localeUrl).then(parseYaml),
]);
out.push({ entry: toVersionEntry(installer, locale), locale, prerelease: rel.prerelease });
console.log(` + ${rel.tag_name} (${installer.PackageVersion})`);
}
return out;
}
function versionsFromLocal(dir) {
const d = resolve(HERE, dir);
const names = readdirSync(d);
const read = (suffix) => {
const n = names.find((x) => x === `${PACKAGE_ID}${suffix}`);
if (!n) throw new Error(`missing ${PACKAGE_ID}${suffix} in ${d}`);
return parseYaml(readFileSync(join(d, n), "utf8"));
};
const installer = read(".installer.yaml");
const locale = read(".locale.en-US.yaml");
console.log(` + local (${installer.PackageVersion})`);
return [{ entry: toVersionEntry(installer, locale), locale, prerelease: false }];
}
/** Newest first, so winget's "latest" is the head of the list. */
function compareVersionsDesc(a, b) {
const parts = (v) => v.PackageVersion.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p));
const [pa, pb] = [parts(a), parts(b)];
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (x === y) continue;
// A numeric segment outranks a string one (0.20.0 > 0.20.0-rc1).
if (typeof x === typeof y) return x > y ? -1 : 1;
return typeof x === "number" ? -1 : 1;
}
return 0;
}
console.log(localDir ? `building from local dir ${localDir}` : `building from ${API}/repos/${REPO} releases`);
const collected = localDir ? versionsFromLocal(localDir) : await versionsFromReleases();
if (collected.length === 0) {
throw new Error("no winget manifests found - refusing to write an empty source");
}
const versions = collected.map((c) => c.entry).sort(compareVersionsDesc);
// Package-level search metadata comes from the NEWEST release's locale manifest, so a rename or a
// retagged description takes effect without rewriting history.
const newestLocale = collected.sort((a, b) => compareVersionsDesc(a.entry, b.entry))[0].locale;
const data = {
sourceIdentifier: SOURCE_IDENTIFIER,
serverSupportedVersions: SERVER_SUPPORTED_VERSIONS,
packages: [
{
PackageIdentifier: PACKAGE_ID,
PackageName: newestLocale.PackageName,
Publisher: newestLocale.Publisher,
Moniker: newestLocale.Moniker ?? null,
Tags: newestLocale.Tags ?? [],
// Union across versions: an installed 0.19.2 must still correlate after 0.20.0 ships.
ProductCodes: [...new Set(versions.flatMap((v) => v.ProductCodes))],
Versions: versions.map((v) => ({
PackageVersion: v.PackageVersion,
ProductCodes: v.ProductCodes,
})),
Manifest: { PackageIdentifier: PACKAGE_ID, Versions: versions },
},
],
};
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, `${JSON.stringify(data, null, 2)}\n`);
console.log(`wrote ${outPath} (${versions.length} version(s): ${versions.map((v) => v.PackageVersion).join(", ")})`);
@@ -0,0 +1,41 @@
# winget REST source for the punktfunk Windows host, on unom-1 (Hetzner Cloud).
#
# Caddy on that same box terminates TLS for winget.punktfunk.unom.io and reverse_proxies to :3240,
# exactly as it already does for docs.punktfunk.unom.io -> :3220. This inner service speaks plain
# HTTP and is not published beyond the box. `winget source add` refuses anything but HTTPS with a
# publicly trusted certificate, so that edge vhost is a hard requirement, not decoration.
#
# (Note: the sibling docs/flatpak compose files still carry stale comments about a
# `home-reverse-proxy-1` and 192.168.50.50 from an earlier home-lab topology. The public hostnames
# resolve straight to the hcloud box and are served by Caddy there — no local proxy is involved.)
#
# Stock bun image + bind-mounted sources, mirroring the flatpak server's stock-caddy approach: no
# image to build, publish or pull, so a code change deploys by scp + `docker compose up -d`.
#
# ./data/data.json is NOT shipped by deploy-services.yml — windows-host.yml rsyncs it on each stable
# tag (the same split the flatpak repo uses: config deploys separately from content). server.mjs
# reloads it on mtime change, so publishing a release needs no restart here.
name: punktfunk-winget-prod
services:
winget:
image: oven/bun:1-alpine
restart: unless-stopped
command: ["bun", "run", "/app/server.mjs"]
working_dir: /app
environment:
PORT: "3240"
PF_WINGET_DATA: /app/data/data.json
ports:
- "3240:3240"
volumes:
- ./server.mjs:/app/server.mjs:ro
- ./handler.mjs:/app/handler.mjs:ro
- ./data:/app/data:ro
healthcheck:
# Fails while the catalogue is missing or unparseable, which is otherwise invisible — a broken
# source answers requests fine and just reports "no package found" to the client.
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3240/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 60s
timeout: 5s
retries: 3
start_period: 10s
+204
View File
@@ -0,0 +1,204 @@
// Punktfunk's winget REST source — the read path only.
//
// The winget client consumes exactly three endpoints. `microsoft/winget-cli-restsource`'s other 20
// (`/packages/**` CRUD) are that reference implementation's ADMIN API for mutating a CosmosDB —
// they are not part of what a client calls, and a source whose data is generated at release time
// has nothing to mutate. So this serves:
//
// GET /information server identity + declared capabilities
// POST /manifestSearch search; 204 when nothing matches
// GET /packageManifests/{PackageIdentifier} the full manifest (optional ?Version=)
//
// Contract: documentation/WinGet-1.1.0.yaml in that repo. Every response is wrapped in `Data`
// (ResponseObjectSchema). Note the spec's content-type key is literally `application/Json`; the
// client is not picky about the response's own header, so we send standard `application/json`.
//
// Pure request->response logic with NO I/O and no dependencies: the catalogue is passed in, so
// server.mjs owns reloading it and test.mjs can drive this directly without a server or a network.
// `data` is produced by build-data.mjs from the published release manifests — see the README.
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
const json = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
// A bare 204 carries no body — this is how the spec says "no results", distinct from an empty array.
const noContent = () => new Response(null, { status: 204 });
const error = (status, message) =>
json({ ErrorCode: status, ErrorMessage: message }, status);
// Match fields we deliberately do NOT claim to serve.
//
// `NormalizedPackageNameAndPublisher` is the notable one: winget derives it client-side from ARP
// entries using its own normalization (case folding plus stripping legal suffixes, punctuation and
// version-like tails). Claiming support would mean reimplementing that exactly, and a near-miss
// produces silently wrong correlation between an installed host and this package. ProductCode is
// exact and Inno already gives us one, so correlation rides on that instead.
//
// Command / PackageFamilyName are genuinely absent (no declared commands; not an MSIX). Market is
// not modelled.
const UNSUPPORTED_MATCH_FIELDS = [
"Command",
"PackageFamilyName",
"NormalizedPackageNameAndPublisher",
"Market",
];
/** Compare one candidate string against a SearchRequestMatch. */
function matches(value, request) {
if (value == null || request == null) return false;
const keyword = String(request.KeyWord ?? "");
const hay = String(value).toLowerCase();
const needle = keyword.toLowerCase();
switch (request.MatchType) {
case "Exact":
return String(value) === keyword;
case "StartsWith":
return hay.startsWith(needle);
// Fuzzy variants are served as substring rather than pretending to a real fuzzy ranker. That
// over-matches slightly, which is the safe direction: winget re-ranks and filters what we return.
case "Substring":
case "Fuzzy":
case "FuzzySubstring":
return hay.includes(needle);
case "Wildcard":
return true;
case "CaseInsensitive":
default:
return hay === needle;
}
}
/** The candidate strings a package offers for a given PackageMatchField. */
function fieldValues(pkg, field) {
switch (field) {
case "PackageIdentifier":
return [pkg.PackageIdentifier];
case "PackageName":
return [pkg.PackageName];
case "Moniker":
return pkg.Moniker ? [pkg.Moniker] : [];
case "Tag":
return pkg.Tags ?? [];
case "ProductCode":
return pkg.ProductCodes ?? [];
default:
return []; // including everything in UNSUPPORTED_MATCH_FIELDS
}
}
/** A free-text `Query` sweeps the fields a human would expect to type. */
function matchesQuery(pkg, query) {
if (!query) return true; // no Query block = match everything, narrowed by Inclusions/Filters
return ["PackageIdentifier", "PackageName", "Moniker", "Tag"].some((f) =>
fieldValues(pkg, f).some((v) => matches(v, query)),
);
}
function matchesOne(pkg, entry) {
// Both Inclusions and Filters use SearchRequestInclusionAndFilterSchema.
const req = entry?.RequestMatch;
return fieldValues(pkg, entry?.PackageMatchField).some((v) => matches(v, req));
}
function search(data, body) {
const { Query, Inclusions, Filters, FetchAllManifests, MaximumResults } = body ?? {};
let hits = data.packages.filter((pkg) => {
if (FetchAllManifests) return true;
if (!matchesQuery(pkg, Query)) return false;
// Inclusions widen (OR); an empty/absent list imposes nothing.
if (Array.isArray(Inclusions) && Inclusions.length > 0) {
if (!Inclusions.some((i) => matchesOne(pkg, i))) return false;
}
// Filters narrow (AND) — every filter must hold.
if (Array.isArray(Filters) && Filters.length > 0) {
if (!Filters.every((f) => matchesOne(pkg, f))) return false;
}
return true;
});
if (Number.isInteger(MaximumResults) && MaximumResults > 0) {
hits = hits.slice(0, MaximumResults);
}
return hits;
}
async function handle(request, data) {
const url = new URL(request.url);
// Tolerate a mount under a sub-path (e.g. /winget/information) so the same Worker can sit behind
// a reverse proxy that does not strip a prefix.
const path = url.pathname.replace(/\/+$/, "");
const tail = (name) => path === `/${name}` || path.endsWith(`/${name}`);
if (tail("information") && request.method === "GET") {
return json({
Data: {
SourceIdentifier: data.sourceIdentifier,
ServerSupportedVersions: data.serverSupportedVersions,
UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
RequiredPackageMatchFields: [],
UnsupportedQueryParameters: [],
RequiredQueryParameters: [],
},
});
}
if (tail("manifestSearch")) {
if (request.method !== "POST") return error(405, "manifestSearch requires POST");
let body;
try {
body = await request.json();
} catch {
return error(400, "malformed JSON body");
}
const hits = search(data, body);
if (hits.length === 0) return noContent();
return json({
Data: hits.map((pkg) => ({
PackageIdentifier: pkg.PackageIdentifier,
PackageName: pkg.PackageName,
Publisher: pkg.Publisher,
Versions: pkg.Versions.map((v) => ({
PackageVersion: v.PackageVersion,
ProductCodes: v.ProductCodes ?? [],
})),
})),
RequiredPackageMatchFields: [],
UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
});
}
const manifestMatch = path.match(/\/packageManifests\/([^/]+)$/);
if (manifestMatch) {
if (request.method !== "GET") return error(405, "packageManifests requires GET");
const id = decodeURIComponent(manifestMatch[1]);
// PackageIdentifier is case-insensitive per the winget client's own handling.
const pkg = data.packages.find(
(p) => p.PackageIdentifier.toLowerCase() === id.toLowerCase(),
);
if (!pkg) return error(404, `unknown PackageIdentifier '${id}'`);
const wanted = url.searchParams.get("Version");
const versions = wanted
? pkg.Manifest.Versions.filter((v) => v.PackageVersion === wanted)
: pkg.Manifest.Versions;
if (versions.length === 0) return error(404, `version '${wanted}' not found`);
return json({ Data: { PackageIdentifier: pkg.Manifest.PackageIdentifier, Versions: versions } });
}
return error(404, `no route for ${request.method} ${url.pathname}`);
}
/** Wraps handle() so no request can escape as an unhandled rejection. */
export async function respond(request, data) {
try {
return await handle(request, data);
} catch (e) {
return error(500, `unhandled: ${e?.message ?? e}`);
}
}
export { handle, search, matches };
+29
View File
@@ -0,0 +1,29 @@
{
"name": "punktfunk-winget-source",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "punktfunk-winget-source",
"devDependencies": {
"yaml": "^2.5.0"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "punktfunk-winget-source",
"private": true,
"type": "module",
"description": "winget REST source for the Punktfunk Windows host (read path only).",
"scripts": {
"build": "node build-data.mjs --out data/data.json",
"build:local": "node build-data.mjs --local .. --out data/data.json",
"test": "node test.mjs",
"start": "node server.mjs"
},
"devDependencies": {
"yaml": "^2.5.0"
}
}
+109
View File
@@ -0,0 +1,109 @@
// HTTP front for the winget REST source. Runs on unom-1 under a stock `oven/bun` image with this
// file and the data bind-mounted — same shape as the flatpak server (stock caddy:2-alpine + a
// bind-mounted Caddyfile), so there is no image to build, publish or pull.
//
// Plain HTTP on :3240. Caddy on the same box terminates TLS for winget.punktfunk.unom.io and
// reverse-proxies here — that TLS is not optional decoration: `winget source add` refuses anything
// but HTTPS with a publicly trusted certificate.
//
// Zero dependencies on purpose. The catalogue is a generated JSON file; parsing YAML and talking to
// Gitea is build-data.mjs's job, which runs in CI. Nothing here needs node_modules, so the deploy
// is two files and `docker compose up -d`.
import { readFileSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:http";
import { respond } from "./handler.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const DATA_PATH = resolve(process.env.PF_WINGET_DATA ?? `${HERE}/data/data.json`);
const PORT = Number(process.env.PORT ?? 3240);
// Reload on mtime change rather than caching for the process lifetime. Publishing a release rsyncs
// a new data.json onto the box; without this it would serve the old catalogue until someone
// remembered to restart the container. Stat-per-request on an ~8 KB local file is free, and it
// keeps "deploy" and "publish" decoupled the way the flatpak repo already is.
let cache = { mtimeMs: 0, data: null, error: null };
function catalogue() {
let mtimeMs;
try {
mtimeMs = statSync(DATA_PATH).mtimeMs;
} catch (e) {
// Keep serving the last good catalogue if the file goes missing mid-flight (e.g. a partial
// rsync) — a transient publish must not take the source down.
if (cache.data) return cache.data;
cache.error = `cannot stat ${DATA_PATH}: ${e.message}`;
return null;
}
if (cache.data && mtimeMs === cache.mtimeMs) return cache.data;
try {
const parsed = JSON.parse(readFileSync(DATA_PATH, "utf8"));
if (!Array.isArray(parsed?.packages)) throw new Error("no `packages` array");
cache = { mtimeMs, data: parsed, error: null };
console.log(`[winget] loaded ${parsed.packages.length} package(s) from ${DATA_PATH}`);
return parsed;
} catch (e) {
// A half-written file during rsync parses as garbage; prefer stale-but-valid over an outage.
if (cache.data) {
console.error(`[winget] reload failed, keeping previous catalogue: ${e.message}`);
return cache.data;
}
cache.error = `cannot parse ${DATA_PATH}: ${e.message}`;
return null;
}
}
/** node:http request -> WHATWG Request, so handler.mjs stays runtime-agnostic. */
function toRequest(req) {
const url = `http://${req.headers.host ?? "localhost"}${req.url}`;
const init = { method: req.method, headers: req.headers };
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = req;
init.duplex = "half"; // required when a stream is used as a body
}
return new Request(url, init);
}
const server = createServer(async (req, res) => {
// Liveness for compose/monitoring — deliberately outside the winget routes so a broken catalogue
// is visible as 503 here rather than as a confusing "no package found" in the client.
if (req.url === "/healthz") {
const data = catalogue();
const ok = !!data;
res.writeHead(ok ? 200 : 503, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(ok ? { status: "ok", packages: data.packages.length } : { status: "error", error: cache.error }));
return;
}
const data = catalogue();
if (!data) {
res.writeHead(503, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ErrorCode: 503, ErrorMessage: cache.error ?? "catalogue unavailable" }));
return;
}
let response;
try {
response = await respond(toRequest(req), data);
} catch (e) {
res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ErrorCode: 500, ErrorMessage: String(e?.message ?? e) }));
return;
}
const headers = Object.fromEntries(response.headers.entries());
const body = response.status === 204 ? null : await response.text();
res.writeHead(response.status, headers);
res.end(body ?? undefined);
});
server.listen(PORT, () => {
console.log(`[winget] REST source on :${PORT}, data=${DATA_PATH}`);
// Warm the cache at boot so a bad mount is loud immediately, not on the first user request.
if (!catalogue()) console.error(`[winget] WARNING: ${cache.error}`);
});
for (const sig of ["SIGTERM", "SIGINT"]) {
process.on(sig, () => server.close(() => process.exit(0)));
}
+122
View File
@@ -0,0 +1,122 @@
// Drives the Worker's handle() directly — no Workers runtime, no network, no deploy.
//
// This is the gate that catches a broken source BEFORE winget sees it. A source that returns the
// wrong shape does not error loudly: winget reports "no package found" and the failure looks like a
// missing package rather than a malformed response.
//
// node test.mjs (run `npm run build:local` first so data/data.json exists)
import { readFileSync } from "node:fs";
import { handle } from "./handler.mjs";
// The catalogue is an argument, not an import — same way server.mjs passes it — so this suite runs
// against a generated data.json with no server, no network and no container.
const DATA = JSON.parse(readFileSync(new URL("./data/data.json", import.meta.url), "utf8"));
const BASE = "https://winget.example/";
let failed = 0;
function check(name, cond, detail = "") {
if (cond) {
console.log(`ok ${name}`);
} else {
failed++;
console.log(`FAIL ${name}${detail ? `${detail}` : ""}`);
}
}
const get = (p) => handle(new Request(BASE + p, { method: "GET" }), DATA);
const post = (p, body) =>
handle(new Request(BASE + p, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }), DATA);
// ── /information ────────────────────────────────────────────────────────────────────────────────
{
const res = await get("information");
const body = await res.json();
check("information: 200", res.status === 200, `got ${res.status}`);
check("information: wrapped in Data", !!body.Data);
check("information: has SourceIdentifier", typeof body.Data?.SourceIdentifier === "string");
check("information: advertises a version", Array.isArray(body.Data?.ServerSupportedVersions) && body.Data.ServerSupportedVersions.length > 0);
check(
"information: declares NormalizedPackageNameAndPublisher unsupported",
body.Data?.UnsupportedPackageMatchFields?.includes("NormalizedPackageNameAndPublisher"),
);
}
// ── /manifestSearch ─────────────────────────────────────────────────────────────────────────────
{
const res = await post("manifestSearch", { Query: { KeyWord: "punktfunk", MatchType: "Substring" } });
const body = await res.json();
check("search substring 'punktfunk': 200", res.status === 200, `got ${res.status}`);
check("search: Data is a non-empty array", Array.isArray(body.Data) && body.Data.length > 0);
const hit = body.Data?.[0];
check("search: PackageIdentifier present", hit?.PackageIdentifier === "unom.PunktfunkHost");
check("search: PackageName + Publisher present (schema-required)", !!hit?.PackageName && !!hit?.Publisher);
check("search: Versions non-empty (minItems 1)", Array.isArray(hit?.Versions) && hit.Versions.length > 0);
check("search: version carries ProductCodes for ARP correlation", Array.isArray(hit?.Versions?.[0]?.ProductCodes) && hit.Versions[0].ProductCodes.length > 0);
}
{
const res = await post("manifestSearch", { Query: { KeyWord: "definitely-not-a-package", MatchType: "Substring" } });
check("search miss: 204 No Content, not an empty 200", res.status === 204, `got ${res.status}`);
}
{
const res = await post("manifestSearch", { FetchAllManifests: true });
const body = await res.json();
check("FetchAllManifests returns everything", res.status === 200 && body.Data.length >= 1);
}
{
const res = await post("manifestSearch", {
Inclusions: [{ PackageMatchField: "PackageIdentifier", RequestMatch: { KeyWord: "unom.PunktfunkHost", MatchType: "CaseInsensitive" } }],
});
check("Inclusions on PackageIdentifier match", res.status === 200);
}
{
// A filter that cannot hold must exclude the package (Filters are AND).
const res = await post("manifestSearch", {
Filters: [{ PackageMatchField: "PackageIdentifier", RequestMatch: { KeyWord: "some.OtherPackage", MatchType: "Exact" } }],
});
check("non-matching Filter excludes (AND semantics)", res.status === 204, `got ${res.status}`);
}
{
// An unsupported field must never accidentally match — we told the client we don't serve it.
const res = await post("manifestSearch", {
Filters: [{ PackageMatchField: "NormalizedPackageNameAndPublisher", RequestMatch: { KeyWord: "punktfunkhostunom", MatchType: "Exact" } }],
});
check("unsupported match field yields no match", res.status === 204, `got ${res.status}`);
}
{
const res = await handle(new Request(BASE + "manifestSearch", { method: "GET" }), DATA);
check("manifestSearch rejects GET", res.status === 405, `got ${res.status}`);
}
// ── /packageManifests/{id} ──────────────────────────────────────────────────────────────────────
{
const res = await get("packageManifests/unom.PunktfunkHost");
const body = await res.json();
check("manifest: 200", res.status === 200, `got ${res.status}`);
const v = body.Data?.Versions?.[0];
check("manifest: Versions present", !!v);
check("manifest: version has DefaultLocale", !!v?.DefaultLocale?.PackageName);
check("manifest: version has Installers", Array.isArray(v?.Installers) && v.Installers.length > 0);
const inst = v?.Installers?.[0];
check("manifest: installer has URL + sha256", !!inst?.InstallerUrl && /^[0-9A-F]{64}$/i.test(inst?.InstallerSha256 ?? ""));
check("manifest: installer-level fields folded into the entry", inst?.InstallerType === "inno" && inst?.Scope === "machine");
check("manifest: ProductCode preserved for correlation", !!inst?.ProductCode || !!v?.ProductCodes?.length);
}
{
const res = await get("packageManifests/UNOM.PUNKTFUNKHOST");
check("manifest: PackageIdentifier is case-insensitive", res.status === 200, `got ${res.status}`);
}
{
const res = await get("packageManifests/nope.NotHere");
check("manifest: unknown id -> 404", res.status === 404, `got ${res.status}`);
}
{
const res = await get("packageManifests/unom.PunktfunkHost?Version=0.0.0-nope");
check("manifest: unknown ?Version -> 404", res.status === 404, `got ${res.status}`);
}
{
const res = await get("no/such/route");
check("unknown route -> 404", res.status === 404, `got ${res.status}`);
}
console.log(failed === 0 ? "\nall checks passed" : `\n${failed} CHECK(S) FAILED`);
process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,53 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json
#
# Installer manifest for the Windows host (packaging/windows/punktfunk-host.iss -> Inno Setup 6).
#
# The host is a machine-wide, elevated install: it registers a SYSTEM service, installs the
# pf-vdisplay + gamepad drivers, and opens firewall ports. There is no per-user scope.
PackageIdentifier: unom.PunktfunkHost
PackageVersion: 0.19.2
InstallerType: inno
Scope: machine
# PrivilegesRequired=admin in the .iss -> Setup raises its own UAC prompt.
ElevationRequirement: elevatesSelf
# Windows 11 22H2 floor: pf-vdisplay is built against IddCx 1.10 with no runtime downgrade, so on
# anything older the device fails to start with Code 10 (see the MinVersion gate in the .iss).
MinimumOSVersion: 10.0.22621.0
InstallModes:
# interactive keeps the FULL wizard — every task checkbox, the web-console password page, and the
# VB-CABLE notice text. `winget install unom.PunktfunkHost --interactive`.
- interactive
- silent
- silentWithProgress
InstallerSwitches:
# Spelled out rather than relying on winget's built-in `inno` defaults, so the unattended
# behaviour is reviewable here. No /MERGETASKS: a silent install deliberately takes the SAME
# task defaults the wizard shows, so the product does not differ by install channel. The
# disclosures the wizard puts on screen are carried by Agreements/InstallationNotes in the
# locale manifest instead.
#
# To opt out of individual tasks, callers use Inno's ! negation via --override, e.g.
# winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream"
# Task names: installdriver, installgamepad, installaudiocable, installhdrlayer,
# gamestream, allowpublicfw, startservice, trayicon
Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART /SP-
Log: /LOG="|LOGPATH|"
# Inno writes its ARP entry under <AppId>_is1; this is what correlates an installed host with the
# package for `winget list` / `winget upgrade`. Must track AppId in the .iss.
ProductCode: '{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}_is1'
# Inno upgrades in place (UsePreviousAppDir=yes) — do not uninstall first, that would run the
# [UninstallRun] service/driver teardown between versions.
UpgradeBehavior: install
Installers:
- Architecture: x64
InstallerUrl: https://git.unom.io/unom/punktfunk/releases/download/v0.19.2/punktfunk-host-setup-0.19.2.exe
InstallerSha256: 96964117125BFD5AC4556987DAFAA3966A4A57BBC29C838803857852D595BF4D
ManifestType: installer
ManifestVersion: 1.6.0
@@ -0,0 +1,72 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json
PackageIdentifier: unom.PunktfunkHost
PackageVersion: 0.19.2
PackageLocale: en-US
Publisher: unom
PublisherUrl: https://git.unom.io/unom/punktfunk
PublisherSupportUrl: https://git.unom.io/unom/punktfunk/issues
PackageName: Punktfunk Host
PackageUrl: https://docs.punktfunk.unom.io/docs/windows-host
License: MIT OR Apache-2.0
LicenseUrl: https://git.unom.io/unom/punktfunk/src/branch/main/LICENSE-MIT
Copyright: Copyright (c) unom
ShortDescription: Low-latency game-streaming host — stream your PC's games to any Punktfunk or Moonlight client.
Description: |-
Punktfunk Host turns a Windows 11 PC into a low-latency game-streaming server. It captures the
desktop or a dedicated virtual display, encodes with NVENC / AMF / QSV / Vulkan, and streams over
its native QUIC protocol to Punktfunk clients on Windows, macOS, iOS, tvOS, Android and Linux —
or over GameStream to stock Moonlight clients.
The installer bundles the pf-vdisplay virtual display driver (native-resolution and HDR streaming
without a physical monitor), virtual DualSense / DualShock 4 / Xbox 360 gamepads, and a web
management console served on port 47992.
Moniker: punktfunk-host
Tags:
- game-streaming
- remote-desktop
- moonlight
- gamestream
- streaming
- remote-play
ReleaseNotesUrl: https://git.unom.io/unom/punktfunk/src/branch/main/docs/releases/v0.19.2.md
Documentations:
- DocumentLabel: Windows Host Setup
DocumentUrl: https://docs.punktfunk.unom.io/docs/windows-host
# Shown BEFORE download/install; the user must accept or the install does not proceed. This is what
# carries — on the unattended path, where no wizard page is on screen — the disclosures the wizard
# puts in its task text. VB-Audio's bundling grant specifically requires the end user to see
# VB-CABLE's origin + donationware status at install time.
Agreements:
- AgreementLabel: Bundled virtual audio (VB-CABLE by VB-Audio)
Agreement: >-
Punktfunk's streaming microphone uses VB-CABLE by VB-Audio (www.vb-cable.com), which this
installer bundles and installs. VB-CABLE is donationware — all participations welcome. It is
redistributed under VB-Audio's bundling grant; the full notice is installed to
%ProgramFiles%\punktfunk\licenses\VB-CABLE-NOTICE.txt.
AgreementUrl: https://vb-audio.com/Cable/
- AgreementLabel: GameStream (Moonlight) compatibility is enabled by default
Agreement: >-
The default install enables GameStream so stock Moonlight clients can connect. GameStream uses
legacy plain-HTTP pairing and is intended for trusted LANs only. To install without it, use
--override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream", or run
--interactive and clear the GameStream checkbox. It can also be changed later via
PUNKTFUNK_HOST_CMD in %ProgramData%\punktfunk\host.env.
AgreementUrl: https://docs.punktfunk.unom.io/docs/windows-host
# Displayed after the install completes. The console password is generated per install, so it can
# only be pointed at, never printed here — this text is fixed at publish time.
InstallationNotes: |-
Web management console: https://<this-PC>:47992
The login password was generated during installation and is stored in
%ProgramData%\punktfunk\web-password (edit that file to change it).
Configuration: %ProgramData%\punktfunk\host.env
Logs: %ProgramData%\punktfunk\logs\
The host service starts automatically at boot. `punktfunk-host` is on the machine PATH — run
`punktfunk-host service status` from an elevated prompt to check it.
ManifestType: defaultLocale
ManifestVersion: 1.6.0
+10
View File
@@ -0,0 +1,10 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json
#
# Version manifest — the pointer that ties the installer + locale manifests together.
# Generated per stable tag by scripts/ci/winget-manifest.ps1; this checked-in copy is the
# template + the reference for what a release looks like. Validate with `winget validate`.
PackageIdentifier: unom.PunktfunkHost
PackageVersion: 0.19.2
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.6.0
+1
View File
@@ -21,6 +21,7 @@ export { type CacheStore, makeCacheStore } from "./cache-store.js";
export {
Artwork,
DetectHint,
GameMeta,
LaunchSpec,
PrepStep,
ProviderClient,
+24
View File
@@ -46,6 +46,29 @@ export const DetectHint = Schema.Struct({
});
export type DetectHint = typeof DetectHint.Type;
/** Descriptive metadata, flat on the wire beside `title` (mirrors the host's flattened
* `GameMeta`). All fields optional; values are free-form display strings the host does not
* normalize platform/genre vocabularies. */
export const GameMeta = Schema.Struct({
/** The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … */
platform: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** Short blurb for a details pane. */
description: Schema.optionalKey(Schema.NullOr(Schema.String)),
developer: Schema.optionalKey(Schema.NullOr(Schema.String)),
publisher: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** Year of first release. */
release_year: Schema.optionalKey(Schema.NullOr(Schema.Number)),
/** Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …). */
genres: Schema.optionalKey(Schema.Array(Schema.String)),
/** Free-form organizational labels (`"co-op"`, `"kids"`, …). */
tags: Schema.optionalKey(Schema.Array(Schema.String)),
/** Release region — `"NTSC-U"`, `"PAL"`, `"NTSC-J"`. */
region: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** Maximum simultaneous (local) players. */
players: Schema.optionalKey(Schema.NullOr(Schema.Number)),
});
export type GameMeta = typeof GameMeta.Type;
export const ProviderEntry = Schema.Struct({
external_id: Schema.String,
title: Schema.String,
@@ -53,5 +76,6 @@ export const ProviderEntry = Schema.Struct({
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
prep: Schema.optionalKey(Schema.Array(PrepStep)),
detect: Schema.optionalKey(DetectHint),
...GameMeta.fields,
});
export type ProviderEntry = typeof ProviderEntry.Type;
+75
View File
@@ -0,0 +1,75 @@
# Emit the winget manifest trio for a released Windows host installer.
#
# winget pins the installer by SHA256, so a manifest is only ever valid for ONE immutable artifact.
# The `latest/` channel alias in the generic registry is therefore NOT usable here — this always
# writes the versioned Gitea release URL, which the registry makes immutable (a re-upload 409s).
#
# The three files are templated from packaging/winget/, which holds the reviewed, hand-maintained
# copy: everything except PackageVersion / InstallerUrl / InstallerSha256 is edited THERE, not here.
# That keeps the switches, agreements and installation notes under normal code review rather than
# buried in a generator.
#
# Usage (from windows-host.yml, stable tags only):
# & scripts/ci/winget-manifest.ps1 -Version 0.19.2 -InstallerPath C:\t\out\punktfunk-host-setup-0.19.2.exe -OutDir C:\t\out\winget
#
# Emits: <OutDir>/unom.PunktfunkHost.yaml
# <OutDir>/unom.PunktfunkHost.installer.yaml
# <OutDir>/unom.PunktfunkHost.locale.en-US.yaml
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Version,
# The packed setup.exe — hashed here so the manifest can never disagree with what shipped.
[Parameter(Mandatory = $true)][string]$InstallerPath,
[Parameter(Mandatory = $true)][string]$OutDir,
[string]$TemplateDir = (Join-Path $PSScriptRoot '..\..\packaging\winget'),
# Overridable so a fork/mirror can retarget without editing the templates.
[string]$UrlBase = 'https://git.unom.io/unom/punktfunk/releases/download'
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $InstallerPath)) { throw "installer not found: $InstallerPath" }
$TemplateDir = (Resolve-Path $TemplateDir).Path
# winget's own tooling emits UPPERCASE hex; match it so a manifest diff against a wingetcreate-
# generated one is empty rather than a case-only churn.
$sha = (Get-FileHash -Path $InstallerPath -Algorithm SHA256).Hash.ToUpperInvariant()
$url = "$UrlBase/v$Version/$(Split-Path $InstallerPath -Leaf)"
Write-Host "winget manifest: version=$Version sha256=$sha"
Write-Host " url=$url"
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# PackageVersion appears in all three files; InstallerUrl/Sha256 only in the installer manifest.
# Anchored replacements (line-leading key) so a version string inside prose — e.g. the release-notes
# URL in the locale manifest — is never rewritten by accident.
$files = @(
'unom.PunktfunkHost.yaml',
'unom.PunktfunkHost.installer.yaml',
'unom.PunktfunkHost.locale.en-US.yaml'
)
foreach ($f in $files) {
$src = Join-Path $TemplateDir $f
if (-not (Test-Path $src)) { throw "template missing: $src" }
$text = Get-Content -Path $src -Raw
$text = [regex]::Replace($text, '(?m)^PackageVersion:.*$', "PackageVersion: $Version")
$text = [regex]::Replace($text, '(?m)^(\s*)InstallerUrl:.*$', "`${1}InstallerUrl: $url")
$text = [regex]::Replace($text, '(?m)^(\s*)InstallerSha256:.*$', "`${1}InstallerSha256: $sha")
# The release-notes link is per-version and lives outside the anchored keys above.
$text = [regex]::Replace($text, '(?m)^ReleaseNotesUrl:.*$',
"ReleaseNotesUrl: https://git.unom.io/unom/punktfunk/src/tag/v$Version/docs/releases/v$Version.md")
$dest = Join-Path $OutDir $f
# UTF-8 *without* BOM: winget's YAML parser rejects a BOM'd manifest.
[IO.File]::WriteAllText($dest, $text, (New-Object Text.UTF8Encoding $false))
Write-Host " wrote $dest"
}
# Fail loudly if a template ever loses one of the fields we rewrite — a silently un-substituted
# manifest would publish the PREVIOUS release's hash, which winget would then refuse to install.
$inst = Get-Content -Path (Join-Path $OutDir 'unom.PunktfunkHost.installer.yaml') -Raw
foreach ($needle in @($Version, $sha, $url)) {
if ($inst -notmatch [regex]::Escape($needle)) { throw "substitution failed - '$needle' missing from the installer manifest" }
}
Write-Host "winget manifests ready in $OutDir"
+15 -14
View File
@@ -135,10 +135,10 @@ export type RuntimeStatus = { readonly "active_sessions": number, readonly "audi
export const RuntimeStatus = Schema.Struct({ "active_sessions": Schema.Number.annotate({ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "games": Schema.Array(ActiveGame).annotate({ "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." }), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "stream": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "codec": ApiCodec, "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "last_resize_ms": Schema.optionalKey(Schema.Never), "min_fec": Schema.Number.annotate({ "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "packet_size": Schema.Number.annotate({ "description": "Video payload size per packet (bytes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "time_to_first_frame_ms": Schema.optionalKey(Schema.Never), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." })], { mode: "oneOf" })), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Live host status (changes as clients launch/end sessions)." })
export type DisplayStateResponse = { readonly "displays": ReadonlyArray<ApiDisplayInfo> }
export const DisplayStateResponse = Schema.Struct({ "displays": Schema.Array(ApiDisplayInfo) }).annotate({ "description": "The host's managed virtual displays right now." })
export type GpuState = { readonly "active"?: null | { readonly "backend": string, readonly "id": string, readonly "name": string, readonly "sessions": number, readonly "vendor": string }, readonly "env_override"?: string | null, readonly "gpus": ReadonlyArray<ApiGpu>, readonly "mode": string, readonly "preferred_available": boolean, readonly "preferred_id"?: string | null, readonly "preferred_name"?: string | null, readonly "selected"?: null | { readonly "id": string, readonly "name": string, readonly "source": string, readonly "vendor": string } }
export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." })
export type GameEntry = { readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "store": string, readonly "title": string }
export const GameEntry = Schema.Struct({ "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "One title in the unified library, regardless of which store it came from." })
export type GpuState = { readonly "active"?: null | { readonly "backend": string, readonly "id": string, readonly "name": string, readonly "sessions": number, readonly "vendor": string }, readonly "encoder_pin"?: string | null, readonly "env_override"?: string | null, readonly "gpus": ReadonlyArray<ApiGpu>, readonly "mode": string, readonly "preferred_available": boolean, readonly "preferred_id"?: string | null, readonly "preferred_name"?: string | null, readonly "selected"?: null | { readonly "id": string, readonly "name": string, readonly "source": string, readonly "vendor": string } }
export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "encoder_pin": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken." })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." })
export type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "store": string, readonly "title": string }
export const GameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." })
export type HooksConfig = { readonly "hooks"?: ReadonlyArray<HookEntry> }
export const HooksConfig = Schema.Struct({ "hooks": Schema.optionalKey(Schema.Array(HookEntry)) }).annotate({ "description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body." })
export type LogPage = { readonly "dropped": boolean, readonly "entries": ReadonlyArray<LogEntry>, readonly "next": number }
@@ -159,12 +159,12 @@ export type DisplayLayoutRequest = { readonly "positions"?: { readonly [x: strin
export const DisplayLayoutRequest = Schema.Struct({ "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "`{\"<identity_slot>\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits." }).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`)." })
export type Layout = { readonly "mode"?: LayoutMode, readonly "positions"?: { readonly [x: string]: Position } }
export const Layout = Schema.Struct({ "mode": Schema.optionalKey(LayoutMode), "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON)." })
export type CustomEntry = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "provider"?: string | null, readonly "title": string }
export const CustomEntry = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "title": Schema.String }).annotate({ "description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits." })
export type CustomInput = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "title": string }
export const CustomInput = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Request body to create or replace a custom entry (no `id` — the host owns it)." })
export type ProviderEntryInput = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "title": string }
export const ProviderEntryInput = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key." })
export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "provider"?: string | null, readonly "title": string }
export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." })
export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "title": string }
export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." })
export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "title": string }
export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." })
export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray<CatalogEntry>, readonly "sources": ReadonlyArray<SourceView> }
export const CatalogResponse = Schema.Struct({ "busy": Schema.Boolean.annotate({ "description": "True while a package operation is in flight — the console disables install buttons." }), "host": HostFacts, "plugins": Schema.Array(CatalogEntry), "sources": Schema.Array(SourceView) })
export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recovered": number, readonly "fps": number, readonly "frames_dropped": number, readonly "mbps": number, readonly "packets_dropped": number, readonly "repeat_fps": number, readonly "send_dropped": number, readonly "session_id": number, readonly "stages": ReadonlyArray<StageTiming>, readonly "t_ms": number }
@@ -316,8 +316,8 @@ export type GetHostInfo200 = HostInfo
export const GetHostInfo200 = HostInfo
export type GetHostInfo401 = ApiError
export const GetHostInfo401 = ApiError
export type GetLibraryParams = { readonly "provider"?: string }
export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String) })
export type GetLibraryParams = { readonly "provider"?: string, readonly "platform"?: string }
export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String), "platform": Schema.optionalKey(Schema.String) })
export type GetLibrary200 = ReadonlyArray<GameEntry>
export const GetLibrary200 = Schema.Array(GameEntry)
export type GetLibrary401 = ApiError
@@ -893,7 +893,7 @@ export const make = (
}))
),
"getLibrary": (options) => HttpClientRequest.get(`/api/v1/library`).pipe(
HttpClientRequest.setUrlParams({ "provider": options?.params?.["provider"] as any }),
HttpClientRequest.setUrlParams({ "provider": options?.params?.["provider"] as any, "platform": options?.params?.["platform"] as any }),
withResponse(options?.config)(HttpClientResponse.matchStatus({
"2xx": decodeSuccess(GetLibrary200),
"401": decodeError("GetLibrary401", GetLibrary401),
@@ -1447,7 +1447,8 @@ readonly "getHostInfo": <Config extends OperationConfig>(options: { readonly con
* Every installed-store title (Steam, read from the host's local files no Steam API key)
* merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
* fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
* entries a given external provider owns.
* entries a given external provider owns; `?platform=` to one platform (case-insensitive
* installed-store titles are `PC`, custom/provider entries carry whatever was authored).
*/
readonly "getLibrary": <Config extends OperationConfig>(options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect<WithOptionalResponse<typeof GetLibrary200.Type, Config>, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>>
/**
+14
View File
@@ -187,6 +187,20 @@
"library_field_logo": "Logo-Bild-URL",
"library_field_command": "Startbefehl",
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
"library_field_platform": "Plattform",
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
"library_field_description": "Beschreibung",
"library_field_developer": "Entwickler",
"library_field_publisher": "Publisher",
"library_field_release_year": "Erscheinungsjahr",
"library_field_genres": "Genres",
"library_field_genres_help": "Kommagetrennt, z. B. RPG, Plattformer.",
"library_field_tags": "Tags",
"library_field_tags_help": "Kommagetrennte Labels zum Organisieren, z. B. Koop, Kinder.",
"library_field_region": "Region",
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
"library_field_players": "Spieler",
"library_details_legend": "Details (optional)",
"library_save": "Speichern",
"library_create": "Hinzufügen",
"library_cancel": "Abbrechen",
+14
View File
@@ -187,6 +187,20 @@
"library_field_logo": "Logo art URL",
"library_field_command": "Launch command",
"library_field_command_help": "Optional. The command the host runs to launch this title.",
"library_field_platform": "Platform",
"library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.",
"library_field_description": "Description",
"library_field_developer": "Developer",
"library_field_publisher": "Publisher",
"library_field_release_year": "Release year",
"library_field_genres": "Genres",
"library_field_genres_help": "Comma-separated, e.g. RPG, Platformer.",
"library_field_tags": "Tags",
"library_field_tags_help": "Comma-separated labels for organizing, e.g. co-op, kids.",
"library_field_region": "Region",
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
"library_field_players": "Players",
"library_details_legend": "Details (optional)",
"library_save": "Save",
"library_create": "Add",
"library_cancel": "Cancel",
+8 -2
View File
@@ -56,14 +56,20 @@ const GameRow: FC<{
const waiting = game.state === "grace";
return (
<div className="flex items-center gap-3">
<div className="h-16 w-11 shrink-0 overflow-hidden rounded bg-muted">
{art && (
{/* Fixed slot so rows line up whether or not a title has a cover. Plenty won't: an
operator-typed command has no catalog entry behind it, a custom entry may carry no
art, and nothing does until `/library` has loaded an empty box reads as broken, so
the placeholder says "game" instead of nothing. */}
<div className="flex h-16 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
{art ? (
<img
src={art}
alt=""
loading="lazy"
className="size-full object-cover"
/>
) : (
<Gamepad2 className="size-5 text-muted-foreground/60" />
)}
</div>
<div className="min-w-0 flex-1">
+13 -1
View File
@@ -65,13 +65,20 @@ export const GameCard: FC<GameCardProps> = ({
{game.title}
</div>
)}
<div className="absolute left-2 top-2">
<div className="absolute left-2 top-2 flex flex-wrap gap-1">
<Badge
variant={isCustom ? "secondary" : "outline"}
className="bg-background/80 backdrop-blur"
>
{storeLabel(game.store)}
</Badge>
{/* Platform badge "PC" is implied by every installed store, so only
non-PC platforms (the emulation case) earn a second badge. */}
{game.platform && game.platform.toUpperCase() !== "PC" && (
<Badge variant="outline" className="bg-background/80 backdrop-blur">
{game.platform}
</Badge>
)}
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
@@ -102,6 +109,11 @@ export const GameCard: FC<GameCardProps> = ({
title={game.title}
>
{game.title}
{game.release_year != null && (
<span className="ml-1.5 font-normal text-muted-foreground">
{game.release_year}
</span>
)}
</div>
</Card>
);
+194 -69
View File
@@ -22,6 +22,17 @@ interface FormState {
header: string;
logo: string;
command: string;
// Details — the flattened GameMeta fields; numbers and lists are kept as the raw
// text the user typed and only parsed on submit.
platform: string;
description: string;
developer: string;
publisher: string;
releaseYear: string;
genres: string;
tags: string;
region: string;
players: string;
}
const emptyForm: FormState = {
@@ -31,6 +42,15 @@ const emptyForm: FormState = {
header: "",
logo: "",
command: "",
platform: "",
description: "",
developer: "",
publisher: "",
releaseYear: "",
genres: "",
tags: "",
region: "",
players: "",
};
function formFrom(entry: GameEntry): FormState {
@@ -41,17 +61,38 @@ function formFrom(entry: GameEntry): FormState {
header: entry.art.header ?? "",
logo: entry.art.logo ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
platform: entry.platform ?? "",
description: entry.description ?? "",
developer: entry.developer ?? "",
publisher: entry.publisher ?? "",
releaseYear: entry.release_year?.toString() ?? "",
genres: entry.genres?.join(", ") ?? "",
tags: entry.tags?.join(", ") ?? "",
region: entry.region ?? "",
players: entry.players?.toString() ?? "",
};
}
/** Map the form to the API body only attach `launch` when a command was given. `update_custom`
* REPLACES the whole `art`, so every field the form knows must round-trip (else editing a game with
* a `logo` would silently drop it). */
* REPLACES the whole entry (art AND the metadata fields), so every field the form knows must
* round-trip (else editing a game with a `logo` or a `platform` would silently drop it). */
function toInput(f: FormState): CustomInput {
const trim = (s: string) => {
const t = s.trim();
return t ? t : undefined;
};
// "RPG, Platformer" → ["RPG", "Platformer"]; empty input → omitted entirely.
const list = (s: string) => {
const items = s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
return items.length ? items : undefined;
};
const int = (s: string) => {
const n = Number.parseInt(s.trim(), 10);
return Number.isFinite(n) ? n : undefined;
};
const command = f.command.trim();
return {
title: f.title.trim(),
@@ -62,6 +103,15 @@ function toInput(f: FormState): CustomInput {
logo: trim(f.logo),
},
launch: command ? { kind: "command", value: command } : null,
platform: trim(f.platform),
description: trim(f.description),
developer: trim(f.developer),
publisher: trim(f.publisher),
release_year: int(f.releaseYear),
genres: list(f.genres),
tags: list(f.tags),
region: trim(f.region),
players: int(f.players),
};
}
@@ -101,6 +151,32 @@ export const GameFormSection: FC<{
);
};
/** One labeled text input bound to a FormState key — the form is a stack of these. */
const Field: FC<{
id: keyof FormState;
label: string;
value: string;
onChange: (value: string) => void;
help?: string;
type?: string;
required?: boolean;
}> = ({ id, label, value, onChange, help, type, required }) => (
<div className="space-y-2">
<Label htmlFor={`lib-${id}`}>{label}</Label>
<Input
id={`lib-${id}`}
type={type}
inputMode={
type === "url" ? "url" : type === "number" ? "numeric" : undefined
}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
/**
* The add/edit form card. Owns only its own field state (re-seeded per mount the
* parent keys it by target); reports a ready-to-send `CustomInput` on submit.
@@ -113,6 +189,8 @@ export const GameForm: FC<{
isSaving: boolean;
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
const [form, setForm] = useState<FormState>(initial);
const set = (key: keyof FormState) => (value: string) =>
setForm((f) => ({ ...f, [key]: value }));
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
@@ -138,74 +216,121 @@ export const GameForm: FC<{
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
<Input
id="lib-title"
required
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-portrait">{m.library_field_portrait()}</Label>
<Input
id="lib-portrait"
type="url"
inputMode="url"
value={form.portrait}
onChange={(e) =>
setForm((f) => ({ ...f, portrait: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
<Input
id="lib-hero"
type="url"
inputMode="url"
value={form.hero}
onChange={(e) => setForm((f) => ({ ...f, hero: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
<Input
id="lib-header"
type="url"
inputMode="url"
value={form.header}
onChange={(e) =>
setForm((f) => ({ ...f, header: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-logo">{m.library_field_logo()}</Label>
<Input
id="lib-logo"
type="url"
inputMode="url"
value={form.logo}
onChange={(e) => setForm((f) => ({ ...f, logo: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input
id="lib-command"
value={form.command}
onChange={(e) =>
setForm((f) => ({ ...f, command: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
{m.library_field_command_help()}
<Field
id="title"
label={m.library_field_title()}
value={form.title}
onChange={set("title")}
required
/>
<Field
id="portrait"
label={m.library_field_portrait()}
value={form.portrait}
onChange={set("portrait")}
type="url"
/>
<Field
id="hero"
label={m.library_field_hero()}
value={form.hero}
onChange={set("hero")}
type="url"
/>
<Field
id="header"
label={m.library_field_header()}
value={form.header}
onChange={set("header")}
type="url"
/>
<Field
id="logo"
label={m.library_field_logo()}
value={form.logo}
onChange={set("logo")}
type="url"
/>
<Field
id="command"
label={m.library_field_command()}
value={form.command}
onChange={set("command")}
help={m.library_field_command_help()}
/>
<fieldset className="space-y-4 border-t pt-2">
<legend className="sr-only">{m.library_details_legend()}</legend>
<p
aria-hidden
className="text-sm font-medium text-muted-foreground"
>
{m.library_details_legend()}
</p>
</div>
<Field
id="platform"
label={m.library_field_platform()}
value={form.platform}
onChange={set("platform")}
help={m.library_field_platform_help()}
/>
<Field
id="description"
label={m.library_field_description()}
value={form.description}
onChange={set("description")}
/>
<div className="grid grid-cols-2 gap-4">
<Field
id="developer"
label={m.library_field_developer()}
value={form.developer}
onChange={set("developer")}
/>
<Field
id="publisher"
label={m.library_field_publisher()}
value={form.publisher}
onChange={set("publisher")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
id="releaseYear"
label={m.library_field_release_year()}
value={form.releaseYear}
onChange={set("releaseYear")}
type="number"
/>
<Field
id="players"
label={m.library_field_players()}
value={form.players}
onChange={set("players")}
type="number"
/>
</div>
<Field
id="region"
label={m.library_field_region()}
value={form.region}
onChange={set("region")}
help={m.library_field_region_help()}
/>
<Field
id="genres"
label={m.library_field_genres()}
value={form.genres}
onChange={set("genres")}
help={m.library_field_genres_help()}
/>
<Field
id="tags"
label={m.library_field_tags()}
value={form.tags}
onChange={set("tags")}
help={m.library_field_tags_help()}
/>
</fieldset>
<div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}>
{mode === "edit" ? m.library_save() : m.library_create()}
+9
View File
@@ -13,6 +13,15 @@ const emptyForm = {
header: "",
logo: "",
command: "",
platform: "",
description: "",
developer: "",
publisher: "",
releaseYear: "",
genres: "",
tags: "",
region: "",
players: "",
};
// The overview grid and the add/edit form are separate components now, so the stories
+17
View File
@@ -153,6 +153,23 @@ export const library: GameEntry[] = [
art: noArt,
launch: null,
},
// An emulated title with the metadata fields filled — exercises the platform
// badge (non-PC) and the year in the caption.
{
id: "custom:sotc",
store: "custom",
title: "Shadow of the Colossus",
art: noArt,
launch: null,
platform: "PS2",
developer: "Team Ico",
publisher: "Sony Computer Entertainment",
release_year: 2005,
genres: ["Adventure"],
tags: ["favorite"],
region: "PAL",
players: 1,
},
];
// --- Performance (stats) page ------------------------------------------------