Compare commits

..
Author SHA1 Message Date
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
49 changed files with 3207 additions and 474 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 }}
+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
+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,
+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
+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",
+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 ------------------------------------------------