Compare commits

...
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 d08893383a chore(release): bump workspace version to 0.21.0
audit / cargo-audit (push) Successful in 2m21s
audit / bun-audit (push) Successful in 14s
apple / swift (push) Successful in 5m3s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m9s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m51s
ci / bench (push) Successful in 5m35s
ci / rust-arm64 (push) Successful in 9m19s
ci / rust (push) Successful in 22m18s
windows-host / package (push) Successful in 12m9s
android / android (push) Successful in 11m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m23s
android-screenshots / screenshots (push) Successful in 3m5s
release / apple (push) Successful in 30m41s
deb / build-publish (push) Successful in 9m31s
arch / build-publish (push) Successful in 12m29s
decky / build-publish (push) Successful in 21s
deb / build-publish-host (push) Successful in 9m45s
deb / build-publish-client-arm64 (push) Successful in 7m34s
flatpak / build-publish (push) Successful in 6m19s
linux-client-screenshots / screenshots (push) Successful in 7m11s
web-screenshots / screenshots (push) Successful in 3m5s
apple / screenshots (push) Successful in 23m51s
windows-host / winget-source (push) Successful in 37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m40s
docker / build-push-arm64cross (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
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 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m53s
docker / deploy-docs (push) Successful in 10s
42 commits since v0.20.0 (v0.20.1 was tagged but never announced;
superseded by this release rather than repointed, since real new
functionality landed after it was cut). Minor, not patch: the
headline is streaming one of the machine's own physical monitors
instead of always creating a virtual display — pick it from a new
console card or pin it in host.env, on KWin, Mutter, sway and
Hyprland, in both the Punktfunk app and Moonlight.

Also folds in the full v0.20.1 fix bundle: three Windows install
blockers found within hours of 0.20.0, GameStream/Moonlight compat
back to opt-in on fresh installs, three gamescope Game Mode takeover
faults, a laptop-panel stall misdiagnosis, and the PyroWave
high-bitrate latency-creep bundle — plus a KWin 6.7 regression found
while building the monitor feature (silent kscreen-doctor fallback
on every session) and a winget release-verification CI fix.

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6. GET /display/monitors is a new
additive endpoint; the display policy gains an optional
capture_monitor field. No embedder rebuild required.

Notes authored ahead of the tag per docs/releases/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:03:44 +02:00
enricobuehlerandClaude Sonnet 5 74179c4c2e fix(ci/winget): envs: must live under with:, not as a step sibling
24d2f97e added envs: GITHUB_REF_NAME to fix "Verify the served
catalogue" failing on every tag with `GITHUB_REF_NAME: unbound
variable` — but placed it as a sibling of with:/env: on the step,
which is not part of the step schema and is silently ignored by
Gitea's runner. appleboy/ssh-action never received it as an input,
so the step kept failing identically on v0.20.1's first tag run.

docker.yml and deploy-services.yml already forward REGISTRY_TOKEN
this way correctly: envs: nested inside with:. Moved to match.
Confirmed by parsing the workflow and checking envs lands in the
step's with: dict, not as a bare step key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:01:45 +02:00
enricobuehlerandClaude Opus 5 8fe71be424 fix(web): applying a saved preset kept switching the streamed screen off
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 5m27s
ci / bench (push) Successful in 7m9s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 9m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
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
android / android (push) Canceled after 10m20s
apple / screenshots (push) Canceled after 4m46s
arch / build-publish (push) Canceled after 10m24s
ci / rust (push) Canceled after 10m25s
ci / rust-arm64 (push) Canceled after 10m24s
deb / build-publish (push) Canceled after 9m14s
deb / build-publish-host (push) Canceled after 9m6s
deb / build-publish-client-arm64 (push) Canceled after 3m14s
decky / build-publish (push) Canceled after 0s
`applyCustomPreset` builds a FRESH policy object instead of spreading the
draft, so every field it doesn't name is dropped. `capture_monitor` was
not named — so picking one of your own saved presets silently took a host
that was mirroring a real monitor and put it back on a virtual display.
The same omission in the Custom switch would have done it there too.

Found on-glass against a running serve on .136: the PUT is whole-object,
so the console is the only thing keeping the orthogonal axes alive, and
these two sites were the ones building a policy from scratch rather than
from what was already in force.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 a98174ebfa feat(host): anchor-test, and libei says which output absolute input landed on
The absolute-region ladder existed for one case a unit test can only
simulate — two heads of the SAME size, where matching a libei region by
the streamed mode is a coin flip — and that case had never been run
against a real compositor. It was also unobservable: the log said which
regions the EIS server offered, never which one a coordinate went into,
so "the pointer is on the wrong monitor" could only be discovered by
looking at a monitor.

libei now reports the chosen region once per distinct answer, beside the
existing miss warning: one covers an anchor that named nothing, this one
covers an anchor that matched something, by saying which.

`anchor-test` is the gate that reads it, in the shape mirror-test
established: it enumerates the heads, SAYS whether the rig actually has
two of the same size (a green run on a single-head box proves nothing),
anchors at a named one (or `--none` for the A/B that makes the anchored
run mean anything), and walks the corners. It refuses to run off libei
rather than emitting a green result about a ladder that isn't there.

On-glass on KWin 6.7.3 with `kwin_wayland --virtual --output-count 2`
(regions 1920x1080+0+0 and +1920+0): unanchored picks +0+0, anchored at
Virtual-1 picks +1920+0, anchored at Virtual-0 picks +0+0. That is open
item 3 of design/per-monitor-portal-capture.md §8b, closed.

Clearing the pin now logs too — setting one always did, and the streamed
screen going back to virtual is the same size of change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 ea0c61fb0f docs: streaming a real monitor, and the CLI that names one
The console grew a "Streamed screen" card and the host grew a
PUNKTFUNK_CAPTURE_MONITOR knob and a list-monitors command, and the docs
knew about none of it — a control with no explanation anywhere.

virtual-displays.md gets the feature section: what it is for, that the
monitor is never touched, that its resolution wins and a client scales,
that a bad name is a hard error rather than a different screen, and that
there is no chooser dialog on any of the four backends (which is what
makes it work unattended). Plus the three troubleshooting entries the
shape of the feature predicts: settings that do nothing while mirroring,
a console card the env var has locked, and a pin that names no head.

host-cli.md documents list-monitors and mirror-test; configuration.md
gets the knob, including that it outranks the console on purpose;
running-as-a-service.md gets the desktop-session drop-in and states that
the host needs nothing exported to find its session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 19deac75fe feat(packaging): a desktop-login host restarts with its desktop
The shipped unit's PartOf=punktfunk-kde-session.service covers the
APPLIANCE route, where we start the compositor ourselves. A host on a
machine somebody logs into has no such unit: when Plasma or GNOME
restarts, the daemon keeps running while holding a Wayland socket and a
portal D-Bus connection that both died with the old compositor, and it
cannot recover either in-process. It fails quietly — the host still
listens, still answers, and every session it then serves dies at capture.
A host that mirrors a monitor idles for days between sessions, which is
the shape that finds this at the worst moment.

The drop-in binds the host to graphical-session.target: PartOf takes it
down with the session, WantedBy brings it back with the new one. Shipped
under /usr/share rather than as an active drop-in, because it is wrong
for the appliance route (which may never reach that target at all, and
would then leave the host permanently stopped) — the operator opts in.

Closes the restart half of design/per-monitor-portal-capture.md §6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 45f04f50fe test(mgmt): gamescope reports no monitors, and that is not a missing explanation
/display/monitors must never answer empty AND silent — the console reads
that as "the host is broken". But gamescope is nested: it owns no
physical heads by construction, so empty-and-silent is the correct answer
there, not an unexplained one.

Any dev box that has ever been in game mode keeps gamescope-0 sockets in
its runtime dir, so detect() resolves gamescope and the test failed on
the machine rather than on the code (found running the suite on .136).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 6afc05155b feat(vdisplay/session): derive SWAYSOCK — the last session var a --user host lacked
Session detection already hands every backend the live session's env:
WAYLAND_DISPLAY from a socket scan, XDG_RUNTIME_DIR from the uid, the
session bus, XDG_CURRENT_DESKTOP, and Hyprland's instance signature. sway
was the exception — SWAYSOCK had to be INHERITED from the login shell, so
a systemd --user host that never saw one had no sway IPC at all: no
output enumeration, no capture chooser, no wlroots backend (its
is_available() keys off that very variable).

It is derivable, and by identity rather than guess: sway names its socket
sway-ipc.<uid>.<pid>.sock, and detection already knows the compositor
PID. Ladder: a valid inherited value, then the exact PID's socket, then
the newest one we own (a sway re-exec can leave the name pointing at a
PID we didn't see). None on river — the other wlroots desktop ships no
sway IPC, and saying so is what stops apply_session_env exporting a
SWAYSOCK that points at nothing.

Closes the session-env half of design/per-monitor-portal-capture.md §6
for a desktop-login host: nothing has to be exported for the host to find
its session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 829bcb7962 fix(gamestream): the streamed-screen pin applies to the portal source too
PUNKTFUNK_CAPTURE_MONITOR is documented as "instead of creating a virtual
display OR taking whichever head the portal hands back", and the console
presents it as a host-wide setting. The compat plane's portal source did
neither: it went straight to the chooser, so a Moonlight client on a
pinned host silently got whatever screen the portal picked. That is the
one outcome the whole feature exists to prevent — showing the wrong
monitor is worse than showing none.

The portal arm now mirrors the pinned head instead, through the same
MirrorDisplay the virtual source reaches via vdisplay::open. It launches
nothing and creates no virtual output, so it needs neither the
game-lifetime machinery nor the registry (External ownership passes
through it anyway).

Which screen a pooled capturer is showing is now the third reuse key
beside HDR-ness and cursor mode. The pin is a LIVE setting — the console
can re-aim the host between two connects — so without it in the key the
second connect would keep streaming the previous screen, which is the
same silent-wrong-monitor failure by another route (§7.3, open item 5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 de3ef50434 fix(native): a mirrored monitor has a fixed mode — refuse to resize it
A physical head runs at the mode its owner set, and MirrorDisplay::create
ignores the requested one on purpose. So a mid-stream Reconfigure against
a mirror could only ever tear the cast down and rebuild the identical one
at the identical size — a hitch that buys nothing, and an invitation to
the worse reflex of reconfiguring the display someone is sitting at.

reconfig_allowed grows a third gate beside gamescope and per-client-mode
identity, and the session captures it at bring-up like the others: this
session opened its display under whatever the pin said then, so a console
change mid-session must not retroactively change the answer it gives.

Linux-only, because vdisplay::open only routes to the mirror there — a
pin left in a Windows host's settings streams nothing different and must
not disable resize as a side effect.

design/per-monitor-portal-capture.md §7.3, open item 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 b08226dbf1 feat(vdisplay): mirror a pinned monitor on sway and Hyprland
P5 of design/per-monitor-portal-capture.md, and the end of a hole worth
naming: the pin was accepted, persisted and offered in the console while
`create` bailed on these two backends, so a sway or Hyprland host with a
monitor selected failed EVERY session.

Both already ship the mechanism — a managed portal-chooser config plus a
per-session selection file (xdpw's `Monitor: NAME`, xdph's
`[SELECTION]screen:NAME`) — so mirroring is that same chooser pointed at a
physical connector instead of a headless output we created. The keepalive
stops the cast and nothing else; the monitor is the compositor's.

The selection write is now serialized against the handshake that reads it
(SELECTION_LOCK, applied to the virtual-output paths too). That file is one
per-user path: whoever writes last before the portal reads wins, and the
loser does not fail — it silently captures the other session's output.

`managed` is no longer grounds for refusal everywhere. It is conclusive
only where the name is ours by construction (KWin's `Virtual-punktfunk`,
Hyprland's `PF-N`); sway names EVERY headless output `HEADLESS-N`, its own
included, so the old rule would have refused to mirror any output on a
headless sway box — exactly the remote setup this feature serves. Found
on-glass, and now a test.

Verified on a two-output headless sway: asking for HEADLESS-1 yields
1280x720 frames and HEADLESS-2 yields 1920x1080, so the chooser really does
steer per request. Hyprland is compile-only — no Hyprland box exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 d461d889c3 feat: the streamed screen is a persisted setting, pickable from the console
P4 of design/per-monitor-portal-capture.md. The pin was an env var read
once at startup, which a console picker can never write — so it becomes a
field of the display policy (orthogonal to presets, like game_session), and
`vdisplay::capture_monitor()` resolves env-over-policy: an appliance that
pinned in its unit's host.env stays pinned there, and a console click
cannot re-aim a machine whose operator already declared the answer.

Read per `open` rather than cached, so a picker change takes effect on the
next session instead of the next host restart. The host re-resolves the
input anchor on every policy write — including clearing it when the pin is
cleared, or a later virtual-display session inherits an anchor aimed at a
monitor it is not showing.

`with_manual_layout` carries the pin through like the other orthogonal
axes: without that, saving a display ARRANGEMENT would silently swap the
streamed screen back to virtual.

Console: a "Streamed screen" card listing the host's real monitors beside
"Virtual screen (default)", saving on selection. A disabled head is listed
but not selectable (so "why isn't it here?" has an answer), and an
env-pinned host renders read-only with the reason rather than offering
controls that silently lose to the environment.

Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes
sessions to the mirror and anchors input; setting the env to a different
connector overrides it, exactly as documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 358cfa4be4 feat(host): mirror-test — the on-glass gate for per-monitor capture
Opens the display backend exactly as a session would, attaches a capturer
and reports the frames a named head actually produces, with no client
involved. `--monitor` names the head explicitly (it cannot go through
PUNKTFUNK_CAPTURE_MONITOR: pf_host_config parses the environment once and
startup already read it, so a tool setting it for itself would still see
the old snapshot — hence vdisplay::open_mirror); with no argument it
exercises the production routing through the pin.

A frame timeout is NOT treated as failure. Compositor capture is
damage-driven — the host's own capture diag logs new_fps=0 for virtual
outputs on an idle desktop for exactly this reason — so the first version
of this tool ended its measurement on the first idle gap and made a working
mirror look like a 2-frame stall.

Verified on KWin 6.7.3 (home-nobara-1), mirroring HDMI-A-1: 346 frames in
20s at 1920x1080, arriving while input was injected and pausing when the
desktop went quiet, on both the CPU (BGRx/MemFd) and zero-copy (NV12
dmabuf) paths, ownership=External.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 045deaf77a fix(vdisplay/kwin): bind the output-device REGISTRY — KWin 6.7 stopped
advertising per-output globals

Found on-glass while testing monitor enumeration: KWin 6.7.3 advertises
kde_output_device_registry_v2 and NOT one kde_output_device_v2 global per
output. This module only ever bound the globals, so on a current KWin it
saw zero devices.

That is not just an enumeration gap — it silently disabled the whole
in-process output-management path, which exists precisely because
kscreen-doctor wedges. The live 0.19.2 host on the test box logs
"kde_output_management unavailable — kscreen-doctor fallback" on EVERY
session, so every topology apply there has been going through the tool this
module was written to stop using.

Both models are supported now: the per-output globals older KWin sends, and
the registry's `output` events. Devices from the registry arrive a round
later than globals do, so the handshake takes one more barrier — skipped
when nothing is still awaiting its `done`, so the classic path costs
nothing.

Verified on KWin 6.7.3: enumeration now reports HDMI-A-1 1920x1080@60 at
+0,+0 scale 1.35 primary, matching kscreen-doctor exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 bf2f54a5fe feat(vdisplay): mirror a pinned monitor on Mutter, and scope the HDR probe
P3 of design/per-monitor-portal-capture.md. Mutter's mirror is the same
private ScreenCast session the virtual path already drives, one call
different — RecordMonitor instead of RecordVirtual — so it inherits what
makes that path work headlessly: a direct D-Bus API that needs no
interactive approval, unlike the xdg portal a background service could
never answer.

Deliberately not under TOPOLOGY_LOCK: that serializes operations which add
or remove a monitor or apply a monitor configuration, and mirroring does
neither. Nothing is created, no layout changes, and teardown is just Stop —
the SIGSEGV-adjacent ordering the virtual path must observe cannot arise
when no monitor is being removed.

The GNOME HDR probe now honors the pin. Asking "is ANY monitor in BT.2100
mode" was fair while capture took whatever head it was handed; once the
operator names one, an HDR TV on the next connector must not talk the host
into offering PQ formats for the SDR panel it is actually streaming. A pin
that names no live head reports SDR rather than falling back to "any" — the
session is about to fail on that same missing monitor, and an over-claimed
HDR offer would be a second, quieter wrong answer.

⚠️ RecordMonitor's exact signature is read from the interface, not yet
confirmed against a live gnome-shell; no GNOME box was reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 53c772702f feat(host): list-monitors CLI, and two bugs it caught on-glass
An operator configuring an unattended host has to learn the connector
names from somewhere, and "curl the management API before the host is
configured" is not it. `punktfunk-host list-monitors` prints them with
geometry and flags the pinned one.

Running it under a real sway immediately caught two defects the unit tests
could not:

  - the query used the `swaymsg` command helper, which inserts `--`, so
    `-t get_outputs` came back as "Unknown/invalid command '-t'". Queries
    now go through a `swaymsg_query` helper that cannot make that mistake.
  - compositors write the literal "Unknown" rather than leaving make/model
    empty, so the picker label read "Unknown Unknown". One `describe`
    helper now treats that as absent, for all four backends.

Verified on a headless two-output sway: names, modes, positions, scale and
the case-insensitive pin all resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 93c2765db7 feat(vdisplay): mirror a pinned physical monitor on KWin
P2 of design/per-monitor-portal-capture.md. zkde_screencast's
stream_output records an output KWin already has — the connector name IS
the selection, so there is no dialog, no portal and no chooser for a
background service to answer.

It arrives as a VirtualDisplay reporting DisplayOwnership::External, which
already means "someone else's display, merely mirrored: no keep-alive, no
topology, no reuse". So the rule that we must never disable, move or
"restore" a monitor the user is sitting in front of holds by construction
rather than by everyone remembering it — and create() ignores the client's
requested mode for the same reason: a panel runs at the mode its owner set.

Routing lives in vdisplay::open, the one place every session opens a
display, so the pin cannot be honored on one plane and ignored on another.
The host sets the libei anchor from the same pin (pf-vdisplay must not
depend on pf-inject), which is what makes absolute input land on the
mirrored head instead of a same-sized neighbour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 2eeee650b9 fix(inject/libei): absolute coordinates resolve by identity, not by size
libei hands the injector one region per logical monitor with no output
name attached, so picking one meant matching the streamed mode's SIZE —
a coin flip the moment two heads share a mode, and it already resolved
wrong on-glass once (GNOME, a dummy HDMI beside the virtual primary: the
seat cursor never entered the streamed monitor, so neither cursor path
could see it).

The ladder is now mapping_id -> origin -> size -> first. Two outputs can
share a size but never a top-left, and a mirrored head's region is not the
client's size at all, so the size rung cannot find it — this is what makes
per-monitor capture land its input on the monitor it is showing.

An anchor that matches nothing falls back down the ladder rather than
stranding input: the region set is the truth and the anchor is our belief
about it. It warns once per distinct anchor, because the failure this
exists to prevent is a pointer that silently lands elsewhere.

P1 of design/per-monitor-portal-capture.md — mechanism and tests only.
Nothing sets an anchor yet; the pin wires it in P2, and the doc comment
records why it is host-level and not per-session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 95e3314d9a feat(vdisplay): enumerate the host's physical monitors
P0 of per-monitor capture (design/per-monitor-portal-capture.md): nothing
in the tree could answer "what heads does this host have?", which both a
monitor pin and a console picker need first.

One read per compositor, each beside the code that already speaks that
dialect: KWin's kde_output_device_v2 (the topology session now also records
geometry + scale), Mutter's DisplayConfig.GetCurrentState, swaymsg
get_outputs, hyprctl monitors. Logical geometry throughout, because x/y is
what identifies a head — two monitors can share a size but never an origin.

PUNKTFUNK_CAPTURE_MONITOR parses here and is reported at startup, loudly
including that it is not yet enforced: a knob that is read but inert has to
say so, or "it didn't work" reads as a bug. GET /display/monitors always
answers 200 with an explained empty list, so a compositor-less host renders
as "nothing to pick", not as a broken console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Sonnet 5 3721b6816d chore(release): bump workspace version to 0.20.1
audit / bun-audit (push) Successful in 25s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m32s
audit / cargo-audit (push) Successful in 2m13s
apple / swift (push) Successful in 5m18s
ci / bench (push) Successful in 5m33s
ci / rust-arm64 (push) Successful in 13m8s
android-screenshots / screenshots (push) Successful in 3m13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m52s
deb / build-publish-host (push) Successful in 10m14s
ci / rust (push) Successful in 34m31s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
decky / build-publish (push) Successful in 28s
arch / build-publish (push) Successful in 12m48s
deb / build-publish (push) Successful in 13m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m48s
android / android (push) Successful in 18m46s
deb / build-publish-client-arm64 (push) Successful in 7m55s
linux-client-screenshots / screenshots (push) Successful in 8m3s
web-screenshots / screenshots (push) Successful in 3m3s
docker / build-push-arm64cross (push) Successful in 5m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13m15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
docker / deploy-docs (push) Successful in 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m38s
release / apple (push) Successful in 31m20s
apple / screenshots (push) Successful in 23m22s
windows-host / package (push) Successful in 12m1s
windows-host / winget-source (push) Failing after 12s
flatpak / build-publish (push) Successful in 6m40s
24 commits since v0.20.0. Patch, not minor: entirely fixes and
hardening on top of last release's session⇄game work, nothing new to
opt into. Headline fixes: three Windows install blockers reported
within hours of 0.20.0 (a false conflicting-host detection, a broken
winget log-path switch, missing package-source instructions),
GameStream/Moonlight compat flipped back to opt-in on fresh installs,
three gamescope Game Mode takeover faults that could black out a
Linux machine or strand it on a host restart, a deactivated laptop
panel misdiagnosed as a blind spot instead of a named stall cause,
and the PyroWave high-bitrate latency-creep bundle (clock re-sync
starvation, wire bitrate overshoot, backlog buildup, ABR probe
false-congestion).

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6, unchanged from 0.20.0 — no
embedder rebuild required, 0.18/0.19/0.20 hosts and clients mix
freely.

Notes authored ahead of the tag per docs/releases/README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 19:25:47 +02:00
enricobuehler 0e0d5b8b4d chore(api): regenerate openapi.json — the stats description drifted
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m12s
ci / bench (push) Successful in 7m31s
deb / build-publish (push) Successful in 10m0s
decky / build-publish (push) Successful in 27s
deb / build-publish-host (push) Successful in 10m21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
android / android (push) Successful in 12m31s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
windows-host / package (push) Successful in 11m40s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
windows-host / winget-source (push) Skipped
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
ci / rust-arm64 (push) Successful in 13m26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
docker / deploy-docs (push) Successful in 32s
deb / build-publish-client-arm64 (push) Successful in 10m7s
arch / build-publish (push) Successful in 18m11s
docker / build-push-arm64cross (push) Successful in 4m26s
apple / swift (push) Successful in 6m2s
ci / rust (push) Successful in 28m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m4s
apple / screenshots (push) Successful in 24m37s
`2c69cbda` rewrote StatsSample::mbps's doc comment (goodput → attempted
sealed wire bytes) without regenerating the snapshot, so
`openapi_document_is_complete_and_checked_in` fails on main today. Pure
regeneration, no API surface change: the description and the version stamp
are the only differences.
2026-07-27 19:04:20 +02:00
enricobuehler 6be865f33f fix(host): a host that is stopped hands the box's session back first
Until now the host had no signal handling at all: SIGTERM killed it
outright. That is fine for a host that owns nothing — but a managed
gamescope takeover owns the box's session, and on a mask-fragile display
manager (Nobara's plasmalogin) it has STOPPED that display manager for the
length of the stream. Killed there, the host leaves a box with no graphical
session and nothing left to restart it: the crash-restore state lives in
$XDG_RUNTIME_DIR, which logind removes along with the user manager, so not
even the next host start can heal it. `systemctl --user restart
punktfunk-host` mid-stream — or a package update doing it for you — was
enough to strand a box until someone reached a VT.

So SIGTERM and SIGINT now restore first and exit after. The restore runs on
a blocking thread (it shells out) under a 20s grace, well inside systemd's
TimeoutStopSec, and a host that took nothing over exits immediately.

`restore_takeover_now` is the synchronous sibling of the debounced
disconnect restore, and deliberately ignores the keep-alive policy the
latter honors: `keep_alive=forever` pins a session for the NEXT client,
which means nothing once the host that would serve them is exiting. Both
paths now share one `takeover_live()` predicate instead of repeating the
four-way "is anything ours" test.

Checked on Linux: clippy -D warnings on punktfunk-host + pf-vdisplay, tests,
rustfmt.
2026-07-27 19:04:20 +02:00
enricobuehler f3615f83a5 fix(gamescope): the takeover no longer kills the host it is running in
Field report, Nobara + 0.20.0: switching into Game Mode mid-stream
disconnects the client and the box never lights up again. His journal has
the whole mechanism, ten seconds apart:

  12:34:18.9  freed Steam: stopped the display manager for this stream
  12:34:29.0  systemd[1685]: Stopping punktfunk-host.service

Stopping the display manager ends the user's last login session. The
packaged host is a `systemd --user` unit, so logind then stops
user@1000.service once UserStopDelaySec elapses — 10s by default on Nobara
— and takes the host with it. The stream dies, the scheduled restore never
runs, and the display manager stays down with nothing left to restart it:
a black box that needs a VT to recover. Exactly the two symptoms reported,
and the intermittency is just whether an active Gaming Mode session was
there to trigger the takeover at all.

It never showed on the repro VM because lingering was enabled there for the
earlier sessionless tests (`Linger=yes` — confirmed on the VM today), which
is precisely the thing that breaks the dependency: logind keeps the user
manager alive with no session. Our KDE/GNOME/Arch setup guides already ask
for it; his box, following the KDE route, did not have it.

So the takeover now ensures lingering BEFORE it touches the display
manager: `loginctl enable-linger` directly (allow_active in logind's own
policy — verified working unprivileged on Nobara), else the packaged helper
gains a `linger` verb that enables it for PKEXEC_UID alone, never a
caller-named user. If neither works the takeover is refused with an
actionable error and managed degrades to attach — mirroring the box's own
session is strictly better than a screen that needs a VT. Hosts that cannot
be reached this way (root, a system unit) skip the check: the cgroup test
is a pure `cgroup_under_user_manager` with a unit test, matched against the
real shapes on a Nobara box (`user@1000.service/app.slice/...` vs a
`session-N.scope`).

Also documents the requirement in the gamescope guide's Nobara section.

Checked on Linux: clippy -D warnings, 78 tests, rustfmt; helper verified
with `sh -n`, and the linger enable/disable round-trip run live on the
Nobara VM.
2026-07-27 19:04:20 +02:00
enricobuehler eae1837a3a fix(gamescope): a managed takeover never waits on a dialog nobody can see, nor acts on a months-old sentinel
Two ways the Nobara DM-stop takeover could end a session and leave the box
dark, both found while triaging a 0.20.0 field report (managed round-trip:
"my client disconnects and my monitor doesn't turn back on", intermittent).
0.20.0 is the first release where that box runs the takeover for real —
before it, no package shipped the polkit privilege, so managed always
degraded to attach and never touched the display manager.

Privileged verbs are non-interactive now. `try_stop_display_manager` and
`restore_display_manager` try a plain system-bus `systemctl` first, and
without `--no-ask-password` that asks polkit for INTERACTIVE authorization.
On a box where the host runs inside the user's graphical session, polkit
hands that to the session's agent: a password dialog on the box's own
screen, which during a takeover is off or mid-switch. Nobody sees it,
nobody answers it, and the call blocks on the stream's own thread — the
capture-loss rebuild, or the restore worker — while the rebuild budget
burns. The takeover then lands after the session it was for already ended.
With the flag it fails immediately ("interactive authentication required"),
which is what both callers are already written for: the packaged pkexec
helper (allow_any, no agent needed) takes it from there.

A sentinel with no baseline is not a request. `session_select_requested`
read "no baseline recorded" as "the sentinel appeared during the session" —
but `~/.config/steamos-session-select` is a permanent file, so every box
whose user has ever switched sessions has one. The baseline was recorded
only after a SUCCESSFUL launch, leaving a real window: takeover succeeds,
launch fails, the client retries inside the 5s restore debounce (which the
retry cancels), and the honor gate fires on a months-old write — pushing
the box to the desktop the user never asked for, plus a 120s grace refusing
to relaunch game mode. Now the baseline is taken at takeover too (setting
STOPPED_DM is what arms the gate), and "never baselined" reads as no
request. `SESSION_SELECT_BASELINE` carries both states explicitly, and the
decision is a pure `sentinel_advanced` with a unit test for all six cases.

Checked on Linux: cargo check --all-targets, clippy -D warnings, 77 tests,
rustfmt.
2026-07-27 19:04:20 +02:00
enricobuehlerandClaude Fable 5 ba89b9fcd0 feat(tools): display-disturb learns the bench moves the field sessions needed
ci / rust-arm64 (push) Successful in 9m0s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m3s
android / android (push) Successful in 18m29s
ci / bench (push) Successful in 5m27s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 19s
arch / build-publish (push) Successful in 19m47s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
ci / rust (push) Failing after 20m23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 28s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
deb / build-publish (push) Successful in 9m55s
deb / build-publish-client-arm64 (push) Successful in 8m33s
deb / build-publish-host (push) Successful in 12m56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m54s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m10s
apple / swift (push) Successful in 5m28s
apple / screenshots (push) Successful in 23m31s
Three upgrades from the on-glass rounds on .173/.221:

- `ddc-open` timing: the handle-ACQUISITION cost per monitor is reported even
  when it yields no physical handles — the poller's first contact with a
  virtual display, exactly what the DDC-fail-fast driver work changes; the
  old code silently skipped handle-less monitors, which on a streaming host
  in exclusive topology is every monitor there is.
- Monitors are labeled by GDI device name (\\.\DISPLAYn) so a virtual
  display and a panel are tellable apart in the correlation log.
- `extend` mode: one SDC_TOPOLOGY_EXTEND poke — re-activates every
  attachable display, i.e. the exact 'non-managed display re-activated'
  event the exclusive watchdog evicts. One shot, not a loop: trigger a
  reassert round, observe the recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:52:31 +02:00
enricobuehlerandClaude Fable 5 622817954a fix(capture/win-display): a deactivated laptop panel is a named stall suspect, not a blind spot
audit / bun-audit (push) Successful in 14s
ci / web (push) Successful in 52s
windows-drivers / probe-and-proto (push) Successful in 44s
ci / docs-site (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 2m18s
windows-drivers / driver-build (push) Successful in 2m21s
apple / swift (push) Successful in 4m54s
ci / bench (push) Successful in 6m41s
ci / rust-arm64 (push) Successful in 10m4s
decky / build-publish (push) Successful in 42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 26s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
deb / build-publish-host (push) Successful in 10m31s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
ci / rust (push) Failing after 13m9s
deb / build-publish (push) Successful in 12m14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
android / android (push) Successful in 16m5s
arch / build-publish (push) Successful in 17m22s
deb / build-publish-client-arm64 (push) Successful in 9m46s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
flatpak / build-publish (push) Successful in 7m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m42s
release / apple (push) Successful in 27m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m34s
docker / deploy-docs (push) Successful in 54s
docker / build-push-arm64cross (push) Successful in 9m53s
apple / screenshots (push) Successful in 23m52s
Field A/B (reporter, Legion 5 Pro hybrid, 2026-07-27): the ~2 s capture-stall
metronome IS the exclusive isolate's own doing — deactivating the laptop panel
leaves a dark-but-connected eDP head whose driver-level servicing disturbs the
capture path (~1.7-2.8 s period); `topology: primary` (panel stays active)
stopped it outright (16.3 stalls/min → 0). Our own .221 logged the identical
signature the day before. The detector then steered AWAY from the cause:
connected_inactive filtered to EXTERNAL physicals only, so the one display
that mattered — on a hybrid laptop, the default config — reported as `none`.

- TargetInventory: new `internal_panel` class bit (eDP/LVDS/embedded).
- connected_inactive_externals → connected_inactive_physicals: internal
  panels join the suspect list (virtual/indirect stay excluded); a nameless
  panel renders as "laptop panel".
- The below-OS METRONOMIC warn names the dark-panel case and its actual
  cures (`topology: primary`, `pnp_disable_monitors`) — the old text's
  "unplug it" prescriptions are impossible for an internal panel.
- ddc_power_off: a zero-ack sweep now says at INFO that the axis did nothing
  (internal panels have no DDC/CI) instead of silently no-op'ing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:04:08 +02:00
enricobuehlerandClaude Fable 5 ab58fd2f0e feat(vdisplay/driver): DDC/CI against the virtual monitor fails fast
In exclusive topology the virtual display is the ONLY monitor on the desktop,
so monitor-control software (the Twinkle Tray / PowerToys PowerDisplay /
Monitorian class) aims its entire DDC traffic — brightness polls,
capabilities-string requests — at OUR monitor. There is no bus and no sink;
the only wrong answer is a slow one (a timeout-shaped failure occupies
win32k's physical-monitor path, serialized per monitor, for its full
duration). Register EvtIddCxMonitorI2CTransmit/Receive and answer every probe
with an immediate STATUS_NOT_SUPPORTED, making the virtual monitor the
cheapest thing on the "bus" to poll. (The receive DDI has no out-arg at the
callback — refusing synchronously refuses the whole transaction.) EDID needs
no equivalent: the OS serves descriptor queries from the blob supplied at
monitor creation without calling the driver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:37:06 +02:00
enricobuehlerandClaude Fable 5 1945052e66 fix(capture/vdisplay): descriptor-following defers while a topology reassert is in flight
The exclusive-topology reassert's forced re-commit transiently bounces the
virtual display's mode; the descriptor poller can sample that EVICTION state
and recreate the ring at a mode the recovery chain is about to undo (field
log 2026-07-27 10:30:44Z: hdr=true → false → recreate → recovery restores
true → second recreate). New pf-win-display::topology_churn latch (deadline
semantics — self-expiring, so a lost release can never wedge descriptor-
following off): the watchdog holds it for interval+3s each fighting round and
releases on "stable again"; poll_display_hdr consumes samples but acts on
nothing while held — which also keeps the negotiated-depth pin-back from
issuing a CCD write mid-eviction, where it would fight the reassert itself.
The deliberate recreate_ring_in_place recovery path is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:37:05 +02:00
enricobuehlerandClaude Fable 5 9e1a686795 feat(tools): display-disturb — deterministic display-stack disturbance generator
The stall-immunity bench needs both stall classes on demand, without a standby
TV or a monitor-tool storm: `ddc` replays the Twinkle-Tray/PowerDisplay-class
DDC/CI traffic (VCP reads, optional capabilities-string requests) through the
win32k I2C path; `modeset` re-commits the current mode with CDS_RESET — the
Level-Two modeset entry that idles the whole adapter with nothing Win32-visible
changing. Every op prints an epoch-stamped took_ms line so host.log stall
reports correlate line-for-line, and the op duration itself measures the
driver's service time per disturbance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:24:20 +02:00
enricobuehlerandClaude Fable 5 19d6f79d2d feat(vdisplay/driver): the frame pump survives MMCSS refusal and outranks GPU contention
Two stall-immunity hardenings for the swap-chain drain thread (branch-2 of the
disturbance-immunity program — failures in OUR delivery leg, as opposed to
adapter-wide display servicing, which no priority survives):

- MMCSS registration fails under the restricted WUDFHost token on some boxes;
  the drain thread then ran UNPRIORITIZED — the whole display's frame pump at
  normal priority, starvable by DDC/HPD-servicing DPC pressure into
  multi-hundred-ms delivery holes. Fall back to TIME_CRITICAL.

- IddCxSetRealtimeGPUPriority (IddCx 1.9) raises the processing device above
  every regular application's GPU priority. Slot availability comes from
  raising the exported IddMinimumVersionRequired 4 → 10, which was overdue
  independently: the drain loop already calls ReleaseAndAcquireBuffer2 (1.10)
  unconditionally, so binding a pre-1.10 framework would dispatch past the
  populated IddFunctions table — with 10 an old framework fails the bind
  cleanly instead. The product floor is already Win11 22H2 (installer
  MinVersion=10.0.22621, whose framework is 1.10), so no supported system
  changes behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:24:10 +02:00
enricobuehlerandClaude Fable 5 94f0207cbd feat(windows/client): logs persist — %LOCALAPPDATA%\punktfunk\logs\client.log
apple / swift (push) Successful in 4m54s
ci / web (push) Successful in 58s
decky / build-publish (push) Successful in 34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
ci / bench (push) Successful in 7m9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
deb / build-publish-client-arm64 (push) Successful in 7m35s
deb / build-publish (push) Successful in 10m19s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m25s
ci / rust-arm64 (push) Successful in 13m57s
deb / build-publish-host (push) Successful in 14m30s
ci / docs-site (push) Successful in 1m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m41s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m32s
android / android (push) Successful in 15m23s
arch / build-publish (push) Successful in 14m1s
flatpak / build-publish (push) Failing after 8m16s
docker / deploy-docs (push) Successful in 11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m25s
release / apple (push) Successful in 29m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m52s
ci / rust (push) Failing after 16m15s
docker / build-push-arm64cross (push) Failing after 4m25s
windows-host / package (push) Successful in 11m35s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m59s
apple / screenshots (push) Successful in 23m44s
The shell is a windows_subsystem binary and spawns punktfunk-session with
CREATE_NO_WINDOW + inherited stderr: a normal GUI/MSIX launch has no
console, so every log line — the shell's and the session's whole
receive/decode/present forensic trail — evaporated exactly when a user
hit something worth reporting. The 2026-07 PyroWave latency-sawtooth
report had to be triaged from host logs alone; the deciding evidence
(clock re-sync vs backlog-flush lines) lives client-side.

- New logfile module: %LOCALAPPDATA%\punktfunk\logs\client.log,
  the host's rotation convention (10 MB cap -> .old at next start, one
  generation), best-effort (no dir -> plain stderr, never a startup
  failure).
- The tracing subscriber tees to stderr AND the file (ANSI off — the
  file is what users send); startup logs the path.
- The spawned session's stderr is piped through the same tee,
  line-buffered — dev-terminal runs keep their interleaved output,
  GUI runs finally keep anything at all.
- The --console path and the punktfunk-console.exe MSIX shim forward
  the same way (a couch launch has no console either).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 2c69cbdab9 docs(host/stats): say what the stats actually measure — three field-triage traps
Three lines that each sent the 2026-07 PyroWave field triage down a wrong
path, made honest:

- The 'client accepts streamed AUs … will stream per-slice' log fires
  before the encoder exists and regardless of backend support (only Linux
  direct-NVENC implements chunked polling) — it now states the client
  capability only.
- StatsSample::mbps was documented as 'transmit goodput'; it is attempted
  sealed wire bytes at seal time — AU bytes + shard framing + FEC parity
  (+ PyroWave window padding), not reduced by socket send drops.
- The per-stage split's 'encode' stage is the poll() duration, which is
  ~0 by construction for synchronous backends (PyroWave encodes inside
  submit → its time shows as 'submit') — documented at the split's
  definition so 'encode 0.00' stops reading as an instrumentation hole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 d8d8c6c43d fix(core/abr): the capacity probe's own overload no longer reads as session congestion
The startup speed-test probe deliberately overloads the link (2 Gb/s for
800 ms) to measure capacity. The report tick is suppressed while it runs
and the window anchors are rebased when it ends — but two residues leak
past the rebase: probe frames still pending in the reassembler age out as
frames_dropped for another ~120 ms (the loss-window fuse), and the burst
can latch flush_in_window (a jump-to-live it caused itself). Either one
reads as a SEVERE window: the controller backs off ×0.7 and — worse —
slow start ends at the first congestion signal, permanently.

That is precisely the 2026-07 field report's Automatic session: 20→14
Mb/s exactly one report tick after the probe, then a purely additive
+6 %/4.5 s crawl that took the whole 4.5-minute match to reach 308 Mb/s
on a link the probe had just measured at ~1.2 Gb/s. Slow start (doubling
per clean window to the probed ceiling, 'seconds instead of minutes')
never got to run.

The first post-probe report window is now discarded outright: no
LossReport (probe residue would also spike the host's adaptive FEC), the
standing-latency detector closes it as not-loss-free (its clean-run just
resets), and the ABR is fed nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 b8b8ac336c feat(core/client): all-intra streams drain the frame channel to the newest AU
The pre-decode FrameChannel is strictly FIFO because H.26x reference
chains forbid mid-stream drops — falling behind is only recoverable via
the coarse jump-to-live thresholds (depth ≥6 sustained, or 400 ms behind
the capture clock) at the cost of a keyframe round-trip.

PyroWave has no reference chains: every AU decodes independently, so a
slow consumer can skip straight to the newest queued AU with zero
recovery cost. The pump now flags the channel all-intra for PyroWave
sessions and pop() drains to the newest, which caps any standing
pre-decode queue at ~1 frame structurally — the 2026-07 field report's
780M client could otherwise ratchet a real multi-frame backlog (and with
it the OSD latency) between those coarse thresholds.

Skips are counted separately from losses (they were delivered, just
superseded) and surfaced at debug on the report tick — the OSD 'lost'
line keeps meaning wire loss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 6d1baa0add fix(pf-encode/pyrowave): the bitrate pin holds on the WIRE, not the raw bitstream
A datagram-aligned PyroWave session inflates the codec bitstream ×1.2–1.3
on its way to the wire — greedy packing of few-hundred-byte atomic block
packets into 1408 B windows zero-pads most window tails, plus the 4-byte
prefixes and FRAG chains. The 2026-07 field report's 1440p60 10-bit
"Automatic" pin of 407 Mb/s put a measured 550 Mb/s on a 1 GbE link;
nothing enforced the pin past the rate controller.

New shared WireBudget (pyrowave_wire.rs, both backends): tracks the real
per-frame AU/bitstream ratio as a ×1024 fixed-point EMA (prior ×1.25,
weight 1/8, clamped ×1.0–×2.0) and deflates the budget handed to
pyrowave's rate control by it, so the windowed AU lands on the configured
rate. Sealed-datagram framing (+4.5%) and FEC parity stay uncompensated —
H.26x sessions carry those on top of the configured bitrate too, and the
pin must mean the same thing for every codec.

Dense (non-chunked) sessions are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 53640b8754 fix(core/clock): re-sync survives loaded links — floor baseline, spaced rounds, bounded staleness
The mid-stream clock re-sync starved on high-bitrate LAN sessions (2026-07
PyroWave-sawtooth field report, RX 9070 XT -> 780M @ 550 Mb/s): every batch
was judged against the CONNECT-TIME RTT, measured before the video data
plane existed, with a 2 ms floor — mid-stream control RTTs on a loaded GbE
link sit above that almost permanently, so batches were rejected for
minutes while the wall clocks drifted apart and the OSD e2e figure ramped
19->150 ms before snapping back on a lucky batch.

Three changes:
- Rounds are spaced 7 ms apart (stamped at send time, so the spacing never
  lands in the RTT). An 8-round batch used to complete inside ONE ~6 ms
  video burst — all rounds sampled the same congestion state; spacing walks
  them across the frame cycle so the min-RTT round finds a quiet gap.
- ResyncGuard replaces the static baseline: the guard band follows the best
  RTT the session has evidenced (connect RTT, then min over every completed
  batch — rejected ones included).
- Rejection streaks are bounded: after 3 consecutive rejections the best
  (min-RTT) batch of the streak is applied anyway. Its queueing bias is at
  most ~half its RTT; unbounded wall-clock drift costs more per minute.

Rejections now log at warn with the streak and floor — the starvation
signature is grep-able in exactly the logs users send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehler 94b7a834cb fix(installer/windows): GameStream is opt-in, not on by default
apple / swift (push) Successful in 3m19s
decky / build-publish (push) Successful in 35s
windows-host / package (push) Successful in 18m44s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m15s
apple / screenshots (push) Successful in 23m24s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m27s
ci / bench (push) Successful in 7m15s
ci / rust-arm64 (push) Successful in 11m47s
deb / build-publish-client-arm64 (push) Successful in 10m21s
android / android (push) Failing after 11m52s
deb / build-publish-host (push) Successful in 11m34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m42s
deb / build-publish (push) Successful in 17m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m30s
arch / build-publish (push) Successful in 18m29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13m59s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13m1s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Successful in 30m15s
docker / build-push-arm64cross (push) Successful in 9m8s
A user found this in their log and had never been offered a choice:

  WARN GameStream/Moonlight compat ENABLED (--gamestream): its pairing runs over
  plain HTTP and its legacy control encryption can reuse GCM nonces
  (security-review #5/#9) — an on-path LAN attacker could MITM pairing or
  recover input. Enable only on a TRUSTED network.

They were right. The `gamestream` task carried no `Flags: unchecked`, so it was
pre-ticked; and since a silent install takes the wizard's own task defaults
(1839d756), a winget install turned it on with no checkbox ever shown. The host
warns that this plane is a security downgrade on every single start, so having
the installer enable it by default cannot be squared with our own advice — least
of all on the path where nobody sees a wizard.

Now unchecked, matching `allowpublicfw`, the other task with a security
consequence. A default install therefore lands `PUNKTFUNK_HOST_CMD=serve`, the
native-only host; Punktfunk's own clients are unaffected, since the native
punktfunk/1 plane is always on. Unattended opt-in is `/MERGETASKS=gamestream`.

Scope, deliberately: this changes FRESH installs only. `GamestreamParam` already
omits the flag entirely unless `FreshHostInstall`, so no existing host has its
choice touched by an upgrade, and anyone relying on Moonlight today keeps it.
`DEFAULT_HOST_CMD` (the service's fallback when host.env carries no
PUNKTFUNK_HOST_CMD line at all) is deliberately NOT flipped here: the installer
always writes the line explicitly, so it does not affect a normal install, and
inverting it would silently disable Moonlight for anyone whose host.env lost that
line — a runtime regression that belongs in its own change, if at all.

Docs corrected everywhere they stated the old default, since three of them now
told users the opposite of what ships: the winget Agreement shown BEFORE install
(it said "enabled by default" and gave the opt-OUT override), the Windows
packaging README, and host.env.example. The `--override` example in the installer
manifest is inverted with it (`/MERGETASKS=gamestream` to add, `!` to remove).

Verified: [Tasks] + [Code] compile with ISCC 6.7.1 on the windows-amd64 runner
(all optional features defined), and the winget catalogue rebuilds and passes
29/29 with the edited manifests.
2026-07-27 12:26:47 +02:00
enricobuehler 4114dfeff7 fix(winget): the Log switch used |LOGPATH|, which winget never substitutes
Second field report on 0.20.0's winget path, and independent of the conflict
abort in 37781a61: an INTERACTIVE install through UniGetUI dies before the
wizard with Inno's "Error creating log file: The filename, directory name, or
volume label syntax is incorrect."

`<LOGPATH>` is winget's own token and the only spelling it replaces — the
schema this manifest declares says so verbatim ("<LOGPATH> token can be included
in the switch value so that winget will replace the token with user provided
path"). We shipped `|LOGPATH|`, which is not a token, so winget passed the
literal string to Inno; `|` is not legal in a Windows filename, and Inno refuses
before doing anything else.

Worse than the conflict abort in two ways: it is not confined to silent installs
— it fires in EVERY mode for any caller that requests a log — and UniGetUI
requests one by default, so a GUI user cannot install at all. Confirmed against
the live source, which is serving the broken switch to everyone right now:
  curl -s https://winget.punktfunk.unom.io/packageManifests/unom.PunktfunkHost
  → "Log": "/LOG=\"|LOGPATH|\""

Guarded by the suite that exists for exactly this class of defect (a wrong
response shape does not fail loudly — winget just says "no package found"). The
new check accepts an absent Log switch, requires <LOGPATH> when present, and
rejects any |TOKEN| spelling. Mutation-verified: restoring |LOGPATH| fails the
check naming the offending value, and the corrected manifest passes 29/29.

⚠ Fixing the template does NOT fix the live catalogue: it is derived from the
manifest trio attached to each release, so the corrected switch only reaches
users when a release carries it.
2026-07-27 10:28:59 +02:00
enricobuehler 37781a610f fix(installer/windows): only a host that will actually RUN is a conflict
docker / deploy-docs (push) Successful in 41s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m50s
apple / swift (push) Successful in 5m41s
ci / bench (push) Successful in 7m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 2m3s
ci / rust-arm64 (push) Successful in 9m51s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
deb / build-publish-host (push) Successful in 11m32s
deb / build-publish-client-arm64 (push) Successful in 10m59s
decky / build-publish (push) Successful in 41s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6m19s
android / android (push) Successful in 17m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m35s
windows-host / package (push) Successful in 11m21s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 19m9s
docker / build-push-arm64cross (push) Successful in 5m7s
ci / rust (push) Successful in 22m25s
deb / build-publish (push) Successful in 10m49s
apple / screenshots (push) Successful in 23m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m34s
Field report within hours of 0.20.0 (HitFrostbite): `winget install
unom.PunktfunkHost` fails with "Installer failed with exit code: 1",
0x8A150006, after the hash verifies and the install starts.

That is Setup aborting on purpose. `StreamHostPresent` counted a conflicting
host as present if its SCM service key existed at ANY start type, OR if a bare
Program Files\<Name> directory existed — so a disabled service, or a folder left
behind by an uninstall, read as a live conflict. 3d893013 then made the warning
a SuppressibleMsgBox defaulting to IDNO so unattended installs could not hang on
it; under winget's /VERYSILENT /SUPPRESSMSGBOXES that default aborts
InitializeSetup, and Inno exits "failed to initialize" = code 1. Net effect:
anyone with Sunshine on disk — the exact audience most likely to migrate — could
not install via winget at all, with no visible reason.

The other candidate for exit 1 is excluded rather than assumed: the manifest
declares MinimumOSVersion 10.0.22621.0 matching the script's MinVersion, so
winget refuses an unsupported OS BEFORE downloading, and the report shows the
hash verified and the install starting. The conflict check is also the only
`Result := False` in InitializeSetup.

Detection now keys on the service's `Start` value (0 boot / 1 system /
2 automatic / 3 manual / 4 disabled), counting only 0-2 — a host that comes up
on its own is the only kind that can take the GameStream ports or load a second
virtual-display driver. Directories no longer count at all: they were the
noisiest signal and the one that survives an uninstall. This makes the installer
agree with the tray, which dropped its always-on warning over a merely-INSTALLED
Sunshine in this same release (3e782852) for exactly the same reason. The
message now says "installed and set to start automatically" and offers disabling
the service as a remedy, both of which are what is actually detected.

A conflicting host running as a plain user process is deliberately not detected:
pure Pascal cannot enumerate processes, it is a runtime rather than install-time
condition, and the host already reports it via the `punktfunk::detect` startup
warning, `detect-conflicts`, and /api/v1/local/summary.

Verified by compiling the [Code] section with ISCC 6.7.1 on the windows-amd64
runner (WithWeb defined, as CI does). That caught two defects in my own first
draft that review would not have: a continuation line STARTING with #13#10, which
ISPP reads as a preprocessor directive ("Unknown preprocessor directive", compile
aborted), and `{commonpf}` written inside a `{ }` comment, which closes it early
since Inno comments do not nest. Both are noted at the sites so they are not
reintroduced.
2026-07-27 09:00:34 +02:00
enricobuehler ec9aa415f6 chore(web): override brace-expansion to 5.0.8 — clears GHSA-mh99-v99m-4gvg
audit / bun-audit (push) Successful in 19s
ci / docs-site (push) Successful in 2m18s
audit / cargo-audit (push) Successful in 3m22s
ci / web (push) Successful in 1m6s
apple / swift (push) Successful in 5m10s
ci / bench (push) Successful in 5m49s
ci / rust-arm64 (push) Successful in 9m52s
arch / build-publish (push) Successful in 13m17s
decky / build-publish (push) Successful in 54s
deb / build-publish-client-arm64 (push) Successful in 12m15s
android / android (push) Successful in 18m58s
deb / build-publish-host (push) Successful in 16m33s
windows-host / package (push) Successful in 19m55s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 11m50s
apple / screenshots (push) Successful in 23m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m25s
ci / rust (push) Successful in 33m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 36s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 28s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 34s
docker / build-push-arm64cross (push) Successful in 5m16s
docker / deploy-docs (push) Successful in 38s
bun-audit has been red since the advisory published, so it was already failing
before v0.20.0 was cut (same failure on 0323158a). High-severity DoS: unbounded
expansion length causing an OOM crash. All three paths are dev tooling —
orval→typedoc→minimatch, @storybook/react-vite→glob→minimatch, and
nitro-v2-vite-plugin→nitropack→archiver→archiver-utils→glob→minimatch — so
nothing shipped to a user was exposed, but the leg gates every lockfile change.

Done as an `overrides` entry, joining the eight already there for exactly this
purpose. `bun update brace-expansion` is NOT the fix and was reverted: for a
package that is only transitive it adds a spurious DIRECT dependency, and it
does not even resolve the advisory — it hoists 5.0.8 for the new direct dep and
leaves `minimatch/brace-expansion` pinned at the vulnerable 5.0.7.

The advisory's affected range is `<= 5.0.7` across ALL majors with 5.0.8 the
only patched version, so the two nested 2.1.2 copies had to move too — a major
bump for consumers declaring ^2.0.1/^2.0.2. Checked rather than assumed: 5.0.8
is dual ESM/CJS with a real `require` branch in its exports map (2.1.2 was
plain CJS), so those consumers still resolve. Proven by building: the nitro
build is itself one of the forced consumers.

Verified: `bun audit` reports no vulnerabilities, both nested entries are gone
from the lock, and the full CI web sequence is green —
`bun install --frozen-lockfile --ignore-scripts`, `bun run build`, `bun run lint`.
2026-07-27 07:32:08 +02:00
enricobuehler 24d2f97eae fix(ci/winget): forward GITHUB_REF_NAME into the remote shell
The catalogue-verify step has failed on every stable tag since it landed,
v0.20.0 included: `bash: line 6: GITHUB_REF_NAME: unbound variable`, twice,
then exit 1.

`env:` populates the RUNNER's environment. appleboy/ssh-action executes its
`script` on the REMOTE host over SSH, which inherits none of it — the action
has a separate `envs:` input naming the variables to forward. Without it the
`set -u` at the top of the script turns the first expansion into a fatal, and
the two `curl`s either side report only the collateral: `Failed writing body`
is curl losing its pipe when the `grep` it was feeding died.

The failure is maximally misleading, because everything it guards ALREADY
WORKED: the catalogue is built, tested and shipped in the preceding steps, and
only the proof-of-serving fails. Confirmed by hand against the live source
during the v0.20.0 release — it serves unom.PunktfunkHost 0.20.0 correctly.

The pattern was already right everywhere else: both "Pull and start docs" steps
(deploy-services.yml, docker.yml) pair `env:` with `envs:`. A sweep of the
other three ssh-action steps found no further instance — the remaining two pass
no variables at all.
2026-07-27 07:31:55 +02:00
enricobuehler c9b8f666cd docs(release): winget needs its source added first — say so
Reported within an hour of the announcement: `winget install unom.PunktfunkHost`
answers "no package found". That is correct behaviour, not a broken package —
Punktfunk is served from its own REST source, and winget only searches sources
it has been told about. The notes said "after adding Punktfunk's package source
once" without ever giving the command, so there was no way to act on it.

Both commands are now spelled out, the `winget source add` one flagged as
needing an Administrator terminal (it does), and the failure mode is stated
explicitly so someone who hits it recognises it rather than filing it as a bug.

The lead-in is untouched, so the Discord embed for v0.20.0 stays accurate and
this needs no re-announcement — the release body is re-synced from this file.
2026-07-27 07:31:37 +02:00
enricobuehler 35b6c835fd docs: on a gamescope session the display outranks the end-game setting
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m19s
decky / build-publish (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 26s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
ci / bench (push) Successful in 5m32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 9m2s
windows-host / package (push) Successful in 20m22s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 14m46s
docker / build-push-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 16m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m36s
ci / rust (push) Successful in 27m3s
apple / swift (push) Successful in 6m5s
apple / screenshots (push) Successful in 25m23s
deb / build-publish-host (push) Successful in 16m6s
deb / build-publish-client-arm64 (push) Successful in 11m21s
deb / build-publish (push) Successful in 11m28s
On-glass on .41: with `game_on_session_end: keep` — "leave it running, nothing
is ever closed" — a deliberate stop ended the game, and a drop ended it ~14 s
later. Both times the LEASE did the right thing (no `ending the launched game`
line); the game died because the virtual display was torn down, and a nested
launch runs inside that gamescope.

So on a dedicated gamescope session the game's lifetime IS the display's, and
keep-alive decides it — not this setting:

  * a deliberate stop skips the linger by design, so the game goes at once,
  * a drop lingers the keep-alive window, then takes the game with it,
  * only keep-alive Forever actually keeps it — and `always` still ends it at
    the reconnect window, which is the documented precedence and was verified
    (a `forever`-pinned display released at grace expiry, 60.006 s).

That makes the console's promise untrue on the most common Linux setup, so the
card now says so for every option rather than only warning about `always`, and
the docs get the three cases as a table. Worded so a non-gamescope host reads it
and moves on — a desktop-session launch is an ordinary process and none of it
applies.

Not changing behavior here: making `keep` pin the display would pit it against
"a stop must not leave a ghost display", and which wins is a product call rather
than something to decide silently mid-test.

web: codegen + tsc + biome clean, en/de complete. docs-site: build + tsc clean.
2026-07-27 01:43:40 +02:00
enricobuehler 39a9f09f04 fix(mgmt/web): the running-game cover slot says "game" when there is no art
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m46s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 1m1s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
deb / build-publish-client-arm64 (push) Successful in 8m0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 31s
deb / build-publish-host (push) Successful in 10m43s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
ci / docs-site (push) Successful in 1m57s
arch / build-publish (push) Successful in 13m21s
android / android (push) Successful in 17m20s
ci / rust-arm64 (push) Successful in 13m46s
deb / build-publish (push) Successful in 14m47s
ci / rust (push) Successful in 21m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
ci / bench (push) Successful in 6m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m21s
docker / build-push-arm64cross (push) Successful in 8m21s
docker / deploy-docs (push) Successful in 40s
apple / swift (push) Successful in 5m37s
apple / screenshots (push) Successful in 23m3s
windows-host / package (push) Successful in 18m16s
windows-host / winget-source (push) Skipped
Rendered the Dashboard stories headlessly and looked at them, which is how this
turned up: with no cover the slot was an empty grey rectangle, reading as
something that failed to load rather than something absent.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:00:30 +02:00
130 changed files with 9443 additions and 1614 deletions
+38
View File
@@ -99,3 +99,41 @@ jobs:
mkdir -p ~/unom-flatpak/site/repo mkdir -p ~/unom-flatpak/site/repo
cd ~/unom-flatpak cd ~/unom-flatpak
docker compose -f compose.production.yml up -d 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 # 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 # 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. # 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 working-directory: plugin-kit
run: | run: |
# -f follows the link, so this is true only when the manifest actually resolves. # -f follows the link, so this is true only when the manifest actually resolves.
+87
View File
@@ -359,3 +359,90 @@ jobs:
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) { foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f } 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:` below populates the RUNNER's environment; this action runs `script` on the
# REMOTE host, which inherits nothing from it. `envs:` is the action's OWN input —
# it must live under `with:` (matching docker.yml/deploy-services.yml's REGISTRY_TOKEN
# forwarding) — naming the variables to forward into the remote shell. A prior fix put
# it as a step-level sibling of `with:`/`env:` instead: that key is not part of the
# step schema, so appleboy/ssh-action never received it as an input and the step kept
# failing ("GITHUB_REF_NAME: unbound variable") on every tag after 24d2f97e too.
envs: GITHUB_REF_NAME
env:
GITHUB_REF_NAME: ${{ gitea.ref_name }}
Generated
+35 -27
View File
@@ -1020,6 +1020,13 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "display-disturb"
version = "0.21.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@@ -2194,7 +2201,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.19.2" version = "0.21.0"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2299,7 +2306,7 @@ dependencies = [
[[package]] [[package]]
name = "libvpl-sys" name = "libvpl-sys"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-capture" name = "pf-capture"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2844,7 +2851,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2868,7 +2875,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-clipboard" name = "pf-clipboard"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2886,7 +2893,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2907,7 +2914,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-encode" name = "pf-encode"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2931,7 +2938,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2940,7 +2947,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-frame" name = "pf-frame"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@@ -2952,7 +2959,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-gpu" name = "pf-gpu"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-host-config", "pf-host-config",
@@ -2966,11 +2973,11 @@ dependencies = [
[[package]] [[package]]
name = "pf-host-config" name = "pf-host-config"
version = "0.19.2" version = "0.21.0"
[[package]] [[package]]
name = "pf-inject" name = "pf-inject"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2999,14 +3006,14 @@ dependencies = [
[[package]] [[package]]
name = "pf-paths" name = "pf-paths"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"tracing", "tracing",
] ]
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3021,7 +3028,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -3048,11 +3055,12 @@ dependencies = [
"wayland-client", "wayland-client",
"wayland-scanner", "wayland-scanner",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"x11rb",
] ]
[[package]] [[package]]
name = "pf-win-display" name = "pf-win-display"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-paths", "pf-paths",
@@ -3064,7 +3072,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-zerocopy" name = "pf-zerocopy"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3272,7 +3280,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3288,7 +3296,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3304,7 +3312,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3319,7 +3327,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
@@ -3338,7 +3346,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3370,7 +3378,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3454,7 +3462,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3468,7 +3476,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
@@ -3491,7 +3499,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "pyrowave-sys" name = "pyrowave-sys"
version = "0.19.2" version = "0.21.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
+2 -1
View File
@@ -28,6 +28,7 @@ members = [
"clients/session", "clients/session",
"clients/windows", "clients/windows",
"clients/android/native", "clients/android/native",
"tools/display-disturb",
"tools/latency-probe", "tools/latency-probe",
"tools/loss-harness", "tools/loss-harness",
] ]
@@ -48,7 +49,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.19.2" version = "0.21.0"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) | | **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) | | **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) | | **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). `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; After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
+257 -7
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.19.2" "version": "0.20.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -190,6 +190,38 @@
} }
} }
}, },
"/api/v1/display/monitors": {
"get": {
"tags": [
"display"
],
"summary": "Physical monitors",
"description": "The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and\nfor rendering a picker. Read-only: this never creates, moves or disables anything. Note these\nare *not* the managed virtual displays — those are `/display/state`. See\n`design/per-monitor-portal-capture.md` §5.1.",
"operationId": "getDisplayMonitors",
"responses": {
"200": {
"description": "The host's physical monitors",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MonitorsResponse"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/presets": { "/api/v1/display/presets": {
"get": { "get": {
"tags": [ "tags": [
@@ -965,7 +997,7 @@
"library" "library"
], ],
"summary": "List the game 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", "operationId": "getLibrary",
"parameters": [ "parameters": [
{ {
@@ -976,6 +1008,15 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "platform",
"in": "query",
"description": "Only entries on this platform (case-insensitive, e.g. `PS2`)",
"required": false,
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -3637,6 +3678,66 @@
} }
} }
}, },
"ApiMonitorInfo": {
"type": "object",
"description": "One physical monitor this host has, as the compositor reports it.",
"required": [
"connector",
"description",
"mode",
"x",
"y",
"scale",
"primary",
"enabled",
"managed",
"selected"
],
"properties": {
"connector": {
"type": "string",
"description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes."
},
"description": {
"type": "string",
"description": "Human label for a picker (`make model`, else the connector)."
},
"enabled": {
"type": "boolean",
"description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing."
},
"managed": {
"type": "boolean",
"description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)."
},
"mode": {
"type": "string",
"description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)."
},
"primary": {
"type": "boolean",
"description": "The compositor's primary/focused head."
},
"scale": {
"type": "number",
"format": "double",
"description": "Logical scale factor."
},
"selected": {
"type": "boolean",
"description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor."
},
"x": {
"type": "integer",
"format": "int32",
"description": "Desktop-space top-left — what makes a head identifiable when two share a size."
},
"y": {
"type": "integer",
"format": "int32"
}
}
},
"ApiSelectedGpu": { "ApiSelectedGpu": {
"type": "object", "type": "object",
"description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)", "description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)",
@@ -4012,8 +4113,13 @@
} }
}, },
"CustomEntry": { "CustomEntry": {
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
},
{
"type": "object", "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": [ "required": [
"id", "id",
"title" "title"
@@ -4065,10 +4171,18 @@
"type": "string" "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": { "CustomInput": {
"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."
},
{
"type": "object", "type": "object",
"description": "Request body to create or replace a custom entry (no `id` — the host owns it).",
"required": [ "required": [
"title" "title"
], ],
@@ -4101,6 +4215,9 @@
"type": "string" "type": "string"
} }
} }
}
],
"description": "Request body to create or replace a custom entry (no `id` — the host owns it)."
}, },
"CustomPreset": { "CustomPreset": {
"type": "object", "type": "object",
@@ -4226,6 +4343,13 @@
"type": "object", "type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": { "properties": {
"capture_monitor": {
"type": [
"string",
"null"
],
"description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it."
},
"ddc_power_off": { "ddc_power_off": {
"type": "boolean", "type": "boolean",
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched." "description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
@@ -4750,8 +4874,13 @@
] ]
}, },
"GameEntry": { "GameEntry": {
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata, flattened — see [`GameMeta`]."
},
{
"type": "object", "type": "object",
"description": "One title in the unified library, regardless of which store it came from.",
"required": [ "required": [
"id", "id",
"store", "store",
@@ -4794,6 +4923,82 @@
"type": "string" "type": "string"
} }
} }
}
],
"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": "Short blurb for a details pane."
},
"developer": {
"type": [
"string",
"null"
]
},
"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\"`, …)."
}
}
}, },
"GameOnSessionEnd": { "GameOnSessionEnd": {
"type": "string", "type": "string",
@@ -5614,6 +5819,43 @@
"reject" "reject"
] ]
}, },
"MonitorsResponse": {
"type": "object",
"description": "The host's physical monitors + which one capture is pinned to.",
"required": [
"monitors"
],
"properties": {
"compositor": {
"type": [
"string",
"null"
],
"description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved."
},
"error": {
"type": [
"string",
"null"
],
"description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"."
},
"monitors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiMonitorInfo"
},
"description": "The heads, ordered left-to-right by desktop position."
},
"pinned": {
"type": [
"string",
"null"
],
"description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"."
}
}
},
"NativeClient": { "NativeClient": {
"type": "object", "type": "object",
"description": "A paired native (punktfunk/1) client.", "description": "A paired native (punktfunk/1) client.",
@@ -6003,8 +6245,13 @@
} }
}, },
"ProviderEntryInput": { "ProviderEntryInput": {
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
},
{
"type": "object", "type": "object",
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.",
"required": [ "required": [
"external_id", "external_id",
"title" "title"
@@ -6042,6 +6289,9 @@
"type": "string" "type": "string"
} }
} }
}
],
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key."
}, },
"ProviderRemoved": { "ProviderRemoved": {
"type": "object", "type": "object",
@@ -6519,7 +6769,7 @@
"mbps": { "mbps": {
"type": "number", "type": "number",
"format": "float", "format": "float",
"description": "Transmit goodput (Mb/s)." "description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops."
}, },
"packets_dropped": { "packets_dropped": {
"type": "integer", "type": "integer",
+1
View File
@@ -620,6 +620,7 @@ fn mock_library() -> (
store: store.to_string(), store: store.to_string(),
title: title.to_string(), title: title.to_string(),
art: crate::library::Artwork::default(), art: crate::library::Artwork::default(),
platform: None,
}; };
let games = vec![ let games = vec![
game("steam:570", "steam", "Dota 2"), game("steam:570", "steam", "Dota 2"),
+17 -1
View File
@@ -13,8 +13,17 @@
// console behind the couch UI looks like a crash. // console behind the couch UI looks like a crash.
#![cfg_attr(windows, windows_subsystem = "windows")] #![cfg_attr(windows, windows_subsystem = "windows")]
// The shared client log sink (std-only): a couch launch has no console, so the session's
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
// initializes + forwards; the module's subscriber-side surface stays unused here.
#[cfg(windows)]
#[path = "../logfile.rs"]
#[allow(dead_code)]
mod logfile;
#[cfg(windows)] #[cfg(windows)]
fn main() { fn main() {
logfile::init();
// The session binary ships beside us in the package; fall back to PATH for a dev run. // The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe() let session = std::env::current_exe()
.ok() .ok()
@@ -27,7 +36,14 @@ fn main() {
if !std::env::args().any(|a| a == "--windowed") { if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen"); cmd.arg("--fullscreen");
} }
match cmd.status() { cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)), Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1), Err(_) => std::process::exit(1),
} }
+98
View File
@@ -0,0 +1,98 @@
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
//!
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
//! latency-sawtooth field report had to be triaged from host logs alone because the client
//! side had nowhere to land.
//!
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
/// Rotate at the next start once the file exceeds this (the host's cap).
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
fn log_dir() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
}
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
pub(crate) fn path() -> Option<PathBuf> {
Some(log_dir()?.join("client.log"))
}
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
/// subscriber installs; every later [`tee`] shares the handle.
pub(crate) fn init() {
SINK.get_or_init(|| {
let dir = log_dir()?;
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("client.log");
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
let old = dir.join("client.log.old");
// Windows `rename` refuses an existing destination — drop the old generation first.
let _ = std::fs::remove_file(&old);
let _ = std::fs::rename(&path, &old);
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()?;
Some(Arc::new(Mutex::new(file)))
});
}
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
/// `with_writer` factory and the session-stderr forwarder both use it.
pub(crate) struct Tee;
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
pub(crate) fn tee() -> Tee {
Tee
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let _ = io::stderr().write_all(buf);
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().write_all(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
let _ = io::stderr().flush();
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().flush();
}
Ok(())
}
}
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
/// pipe (child exit).
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
let _ = std::thread::Builder::new()
.name("punktfunk-session-log".into())
.spawn(move || {
let mut reader = io::BufReader::new(stderr);
let mut line = String::new();
let mut tee = Tee;
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
let _ = tee.write_all(line.as_bytes());
line.clear();
}
});
}
+21 -1
View File
@@ -26,6 +26,8 @@ mod discovery;
#[cfg(windows)] #[cfg(windows)]
mod gpu; mod gpu;
#[cfg(windows)] #[cfg(windows)]
mod logfile;
#[cfg(windows)]
mod probe; mod probe;
#[cfg(windows)] #[cfg(windows)]
mod shell_window; mod shell_window;
@@ -49,11 +51,20 @@ fn main() {
} }
set_app_user_model_id(); set_app_user_model_id();
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
logfile::init();
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_ansi(false)
.with_writer(logfile::tee)
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
) )
.init(); .init();
if let Some(p) = logfile::path() {
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
}
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let flag = |name: &str| args.iter().any(|a| a == name); let flag = |name: &str| args.iter().any(|a| a == name);
@@ -88,7 +99,16 @@ fn main() {
if !flag("--windowed") { if !flag("--windowed") {
cmd.arg("--fullscreen"); cmd.arg("--fullscreen");
} }
match cmd.status() { // Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
// (Start-menu tile, Steam shortcut) has no console to inherit either.
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)), Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => { Err(e) => {
eprintln!("could not start the console UI: {e}"); eprintln!("could not start the console UI: {e}");
+7 -1
View File
@@ -169,13 +169,19 @@ fn spawn_with(
cmd.stdin(Stdio::null()) cmd.stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs) // Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
// had, and GUI runs — which have no console — finally keep the session's whole
// receive/decode/present log in the client log file.
.stderr(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW); .creation_flags(CREATE_NO_WINDOW);
let mut child = cmd let mut child = cmd
.spawn() .spawn()
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?; .map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
tracing::info!(host = %host_label, "session binary spawned"); tracing::info!(host = %host_label, "session binary spawned");
if let Some(stderr) = child.stderr.take() {
crate::logfile::forward_child_stderr(stderr);
}
let stdout = child.stdout.take().expect("piped stdout"); let stdout = child.stdout.take().expect("piped stdout");
// Park the child where the kill handle (and the reader, for the final reap) reach it. // Park the child where the kill handle (and the reader, for the final reap) reach it.
*slot.0.lock().unwrap() = Some(child); *slot.0.lock().unwrap() = Some(child);
+67 -5
View File
@@ -10,13 +10,18 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd; use std::os::fd::OwnedFd;
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the /// Whether the monitor this host would mirror is currently in BT.2100 (HDR) colour mode — the
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+; /// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ). /// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property /// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48 /// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer. /// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only. /// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
///
/// **Scoped to `PUNKTFUNK_CAPTURE_MONITOR` when it is set** (`design/per-monitor-portal-capture.md`
/// §7.4). Without a pin this asks "is ANY monitor in HDR mode", which was a fair heuristic while the
/// capture path took whatever monitor it was handed — but once the operator names the head, an
/// HDR-capable *neighbour* must not talk this host into offering PQ formats for an SDR panel.
pub fn gnome_hdr_monitor_active() -> bool { pub fn gnome_hdr_monitor_active() -> bool {
use ashpd::zbus; use ashpd::zbus;
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is // GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
@@ -74,12 +79,21 @@ pub fn gnome_hdr_monitor_active() -> bool {
.body() .body()
.deserialize() .deserialize()
.context("parse GetCurrentState")?; .context("parse GetCurrentState")?;
Ok(monitors.iter().any(|(_spec, _modes, props)| { // `spec.0` is the connector; "color-mode" 1 is META_COLOR_MODE_BT2100.
props let heads: Vec<(&str, bool)> = monitors
.iter()
.map(|(spec, _modes, props)| {
let hdr = props
.get("color-mode") .get("color-mode")
.and_then(|v| u32::try_from(v).ok()) .and_then(|v| u32::try_from(v).ok())
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100 .is_some_and(|mode| mode == 1);
})) (spec.0.as_str(), hdr)
})
.collect();
Ok(hdr_offer_for(
&heads,
pf_host_config::config().capture_monitor.as_deref(),
))
}) })
}; };
match probe() { match probe() {
@@ -91,6 +105,24 @@ pub fn gnome_hdr_monitor_active() -> bool {
} }
} }
/// Should this host offer the HDR (10-bit PQ) formats, given each head as `(connector, is_bt2100)`
/// and the `PUNKTFUNK_CAPTURE_MONITOR` pin?
///
/// Pinned: only that head's colour mode counts — an HDR-capable neighbour must not talk the host
/// into offering PQ for the SDR panel it is actually streaming. A pin naming no live head reports
/// SDR rather than falling back to "any": the session is about to fail on that same missing
/// monitor, and an over-claimed HDR offer would be a second, quieter wrong answer.
/// Unpinned: the pre-existing "any monitor is in HDR mode" heuristic, unchanged.
fn hdr_offer_for(heads: &[(&str, bool)], pinned: Option<&str>) -> bool {
match pinned {
Some(want) => heads
.iter()
.find(|(connector, _)| connector.eq_ignore_ascii_case(want))
.is_some_and(|(_, hdr)| *hdr),
None => heads.iter().any(|(_, hdr)| *hdr),
}
}
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`). /// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap /// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position + /// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
@@ -355,3 +387,33 @@ pub(super) fn portal_thread_remote_desktop(
// See `portal_thread`: drop the runtime before the caller's completion signal. // See `portal_thread`: drop the runtime before the caller's completion signal.
drop(rt); drop(rt);
} }
#[cfg(test)]
mod hdr_offer_tests {
use super::hdr_offer_for;
#[test]
fn unpinned_keeps_the_any_monitor_heuristic() {
assert!(hdr_offer_for(&[("DP-1", false), ("HDMI-A-1", true)], None));
assert!(!hdr_offer_for(&[("DP-1", false)], None));
}
/// The regression this exists to prevent: an HDR TV on HDMI while the pinned head is an SDR
/// desk monitor. Before scoping, the host offered PQ formats for a panel that can't show them.
#[test]
fn a_pin_ignores_an_hdr_neighbour() {
let heads = [("DP-1", false), ("HDMI-A-1", true)];
assert!(!hdr_offer_for(&heads, Some("DP-1")));
assert!(hdr_offer_for(&heads, Some("HDMI-A-1")));
}
#[test]
fn a_pin_matches_case_insensitively_like_the_resolver() {
assert!(hdr_offer_for(&[("HDMI-A-1", true)], Some("hdmi-a-1")));
}
#[test]
fn a_pin_naming_no_live_head_reports_sdr() {
assert!(!hdr_offer_for(&[("DP-1", true)], Some("DP-9")));
}
}
+12
View File
@@ -732,6 +732,18 @@ impl IddPushCapturer {
return; // no new sample since last consume return; // no new sample since last consume
} }
self.desc_seq = seq; self.desc_seq = seq;
// A topology reassert is in flight (the exclusive watchdog announced it): every sample in
// this window is potentially the TRANSIENT eviction state, and acting on one recreates
// the ring at a mode the reassert's recovery chain (`recreate_ring_in_place`, keyed off
// the reassert generation) is about to undo — the field hdr=true→false→true double
// recreate. Consume the sample, disarm the debounce, act on nothing; a REAL change that
// races the window survives it (the descriptor still differs once the hold clears, and
// the poller re-samples in ~250 ms). This also keeps the negotiated-depth pin-back below
// from issuing a CCD write mid-eviction, where it would fight the reassert itself.
if pf_win_display::topology_churn::held() {
self.pending_desc = None;
return;
}
// Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR" // Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR"
// flip — flip the display back and treat the descriptor as the negotiated state (so the ring // flip — flip the display back and treat the descriptor as the negotiated state (so the ring
// is never recreated at the wrong format): // is never recreated at the wrong format):
@@ -115,7 +115,7 @@ impl StallWatch {
delivered no frame for the gap; the present path stalled below capture" delivered no frame for the gap; the present path stalled below capture"
); );
if let Some(period) = stall.metronomic { if let Some(period) = stall.metronomic {
let suspects = pf_win_display::display_events::connected_inactive_externals(); let suspects = pf_win_display::display_events::connected_inactive_physicals();
let suspects = if suspects.is_empty() { let suspects = if suspects.is_empty() {
"none".to_string() "none".to_string()
} else { } else {
@@ -151,10 +151,13 @@ impl StallWatch {
display-poller software (the SteelSeries-GG/SignalRGB class — \ display-poller software (the SteelSeries-GG/SignalRGB class — \
correlate 'slow display-descriptor poll' lines), or the DWM present \ correlate 'slow display-descriptor poll' lines), or the DWM present \
clock (try a different refresh rate). If connected_inactive lists a \ clock (try a different refresh rate). If connected_inactive lists a \
display, its standby probing is the prime suspect: unplug it at the \ display, its standby servicing is the prime suspect. For a LAPTOP \
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \ PANEL (the exclusive isolate deactivated it — the dark-but-connected \
CEC off), use an HPD-holding adapter/dummy, or keep it active while \ head is itself the disturbance on hybrid laptops): keep it active \
streaming" with `topology: primary`, or try the `pnp_disable_monitors` axis. \
For an external display: unplug it at the GPU, disable its OSD auto \
input scan (TVs: instant-on/quick-start + CEC off), use an \
HPD-holding adapter/dummy, or keep it active while streaming"
); );
} }
} }
+10 -1
View File
@@ -62,6 +62,10 @@ pub struct GameEntry {
pub title: String, pub title: String,
#[serde(default)] #[serde(default)]
pub art: Artwork, 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"). /// 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() { fn game_entry_decodes_the_wire_shape() {
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored). // The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
let json = r#"[ 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"}, "art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
"launch":{"kind":"steam_appid","value":"570"}}, "launch":{"kind":"steam_appid","value":"570"}},
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}} {"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(); let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
assert_eq!(games.len(), 2); assert_eq!(games.len(), 2);
assert_eq!(games[0].id, "steam:570"); 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].art.portrait.is_none());
assert!(
games[1].platform.is_none(),
"pre-metadata hosts still parse"
);
} }
#[test] #[test]
+120 -15
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 // `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 // 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). // 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 let base_ordered =
// between the CUDA copy and the encode, and its cross-API ordering is fence/CPU- self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
// established, not stream-ordered. Frames without a cursor (games hide it; client-draws // Cursor-bearing frames stay on the fast path when the blend itself can be stream-
// sessions strip it) keep the stream-ordered fast path untouched. // ordered: the Vulkan dispatch waits/advances a timeline semaphore CUDA also holds, so
let ordered = self.stream_ordered // copy→blend→encode orders entirely on-device (`VkSlotBlend::blend_ref_ordered`). Where
&& self.async_rt.is_none() // that isn't available (no timeline export, or the ring fell back to plain CUDA slots)
&& self.pending.is_empty() // a cursor forces the CPU-synced path: the blend's cross-API ordering is then fence/CPU-
&& captured.cursor.is_none(); // 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(); let t0 = std::time::Instant::now();
// Copy the captured buffer into this slot's input surface before encoding it. // Copy the captured buffer into this slot's input surface before encoding it.
@@ -1629,21 +1637,26 @@ impl Encoder for NvencCudaEncoder {
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface via the // 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 // 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 // dmabuf). On the `cursor_ordered` path the enqueued copy, the dispatch, and the encode
// completed before the Vulkan dispatch and the fence-waited dispatch completes before // are ordered on-device through the timeline semaphore (no CPU sync — see the gate
// the encode below — the cross-API ordering is CPU-established. Any failure degrades to // above). Otherwise `ordered` is false: the CUDA copy completed before the Vulkan
// no cursor, never a dropped frame. // 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(ov) = &captured.cursor {
if let (Some(vk), SlotSurface::Vk(vref)) = if let (Some(vk), SlotSurface::Vk(vref)) =
(self.vk_blend.as_mut(), &self.ring[slot].surface) (self.vk_blend.as_mut(), &self.ring[slot].surface)
{ {
if self.cursor_serial != ov.serial { 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); vk.upload_cursor(ov.rgba.as_slice(), ov.w, ov.h);
self.cursor_serial = ov.serial; self.cursor_serial = ov.serial;
} }
// surfW = content width; the blend derives plane strides from the slot's luma // 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. // height. Cursor pixels past the content land in cropped padding rows — harmless.
let r = vk.blend_ref( let r = if cursor_ordered {
vk.blend_ref_ordered(
vref, vref,
slot_fmt_of(self.buffer_fmt), slot_fmt_of(self.buffer_fmt),
self.width, self.width,
@@ -1651,7 +1664,18 @@ impl Encoder for NvencCudaEncoder {
ov.h, ov.h,
ov.x, ov.x,
ov.y, 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 let Err(e) = r {
if !self.cursor_blend_warned { if !self.cursor_blend_warned {
self.cursor_blend_warned = true; 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 // 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 // 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 // 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 // which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
// blocking lock additionally proves the enqueued copy completed). // blocking lock additionally proves the enqueued copy completed).
unsafe { 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 — /// 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 /// `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 /// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
+21 -1
View File
@@ -395,6 +395,9 @@ pub struct PyroWaveEncoder {
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` = /// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
/// one packet per AU (the dense MVP shape). /// one packet per AU (the dense MVP shape).
wire_chunk: Option<usize>, wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
wire_budget: crate::pyrowave_wire::WireBudget,
bitstream: Vec<u8>, bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>, pending: VecDeque<EncodedFrame>,
frame_count: u64, frame_count: u64,
@@ -653,6 +656,7 @@ impl PyroWaveEncoder {
chroma444, chroma444,
frame_budget: budget_for(bitrate, fps), frame_budget: budget_for(bitrate, fps),
wire_chunk: None, wire_chunk: None,
wire_budget: crate::pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(), bitstream: Vec::new(),
pending: VecDeque::new(), pending: VecDeque::new(),
frame_count: 0, frame_count: 0,
@@ -1120,6 +1124,16 @@ impl PyroWaveEncoder {
Ok(self.cpu_img.unwrap().2) Ok(self.cpu_img.unwrap().2)
} }
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command /// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`. /// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> { unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
@@ -1175,6 +1189,8 @@ impl PyroWaveEncoder {
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s // paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID, // before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
// which the next `begin` may implicitly reset. // which the next `begin` may implicitly reset.
// Resolved before the closure (which borrows `self` mutably for the recording calls).
let rate_budget = self.rate_budget();
let record_and_submit = (|| -> Result<()> { let record_and_submit = (|| -> Result<()> {
dev.begin_command_buffer( dev.begin_command_buffer(
self.cmd, self.cmd,
@@ -1396,7 +1412,7 @@ impl PyroWaveEncoder {
], ],
}; };
let rc = pw::pyrowave_rate_control { let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget, maximum_bitstream_size: rate_budget,
}; };
pw::pyrowave_device_set_command_buffer( pw::pyrowave_device_set_command_buffer(
self.pw_dev, self.pw_dev,
@@ -1476,6 +1492,10 @@ impl PyroWaveEncoder {
// single packet, or the datagram-aligned windowed AU (§4.4). // single packet, or the datagram-aligned windowed AU (§4.4).
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect(); let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk); let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.frame_count += 1; self.frame_count += 1;
self.pending.push_back(EncodedFrame { self.pending.push_back(EncodedFrame {
data: au, data: au,
+86
View File
@@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32
count count
} }
/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode.
///
/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing
/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the
/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At
/// 1440p/~850 KiB frames that is ×1.21.3 — the 2026-07 field report's "Automatic" 407 Mb/s
/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget
/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and
/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing
/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions
/// carry those on top of the configured bitrate too, and the pin must mean the same thing for
/// every codec.
pub(crate) struct WireBudget {
/// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point.
scale_x1024: u32,
}
impl WireBudget {
/// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA
/// converges onto the session's real ratio within ~a second of frames.
const PRIOR_X1024: u32 = 1280;
/// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change
/// re-converges in ~16 frames.
const EMA_SHIFT: u32 = 3;
/// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never
/// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely).
const MIN_X1024: u32 = 1024;
const MAX_X1024: u32 = 2048;
pub(crate) fn new() -> WireBudget {
WireBudget {
scale_x1024: Self::PRIOR_X1024,
}
}
/// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the
/// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire).
pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) {
if bitstream_len == 0 {
return;
}
let sample = ((au_len as u64 * 1024) / bitstream_len as u64)
.clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32;
let ema = self.scale_x1024 as i64;
self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32;
}
/// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the
/// currently-measured inflation.
pub(crate) fn deflate(&self, budget: usize) -> usize {
let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64;
((budget as u64 * 1024) / scale) as usize
}
}
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU. /// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed /// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
/// datagram-aligned AU (a whole number of `chunk`-sized windows). /// datagram-aligned AU (a whole number of `chunk`-sized windows).
@@ -259,6 +314,37 @@ mod tests {
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32); assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
} }
/// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation,
/// deflates the budget by exactly that ratio, and clamps runaway samples.
#[test]
fn wire_budget_converges_and_deflates() {
let mut wb = WireBudget::new();
// Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative.
assert_eq!(wb.deflate(1_024_000), 819_200);
// Feed a steady ×1.30 inflation; the EMA must converge onto it.
for _ in 0..64 {
wb.observe(1000, 1300);
}
let b = wb.deflate(1_024_000);
let expect = 1_024_000_u64 * 1000 / 1300;
assert!(
(b as i64 - expect as i64).unsigned_abs() < 8_000,
"budget {b} should approach {expect}"
);
// A dense-ish run (×1.0) walks it back down to no deflation.
for _ in 0..64 {
wb.observe(1000, 1000);
}
assert_eq!(wb.deflate(1_024_000), 1_024_000);
// Garbage samples are clamped: an absurd ratio can at most halve the budget…
for _ in 0..256 {
wb.observe(10, 1000);
}
assert!(wb.deflate(1_024_000) >= 512_000);
// …and a zero-length observation is ignored, never a division by zero.
wb.observe(0, 1000);
}
#[test] #[test]
fn stamp_color_bits_sets_range_and_hdr_bits() { fn stamp_color_bits_sets_range_and_hdr_bits() {
let mut bs = vec![0u8; 16]; let mut bs = vec![0u8; 16];
+19 -1
View File
@@ -159,6 +159,9 @@ pub struct PyroWaveEncoder {
frame_budget: usize, frame_budget: usize,
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU. /// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
wire_chunk: Option<usize>, wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]).
wire_budget: pyrowave_wire::WireBudget,
bitstream: Vec<u8>, bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>, pending: VecDeque<EncodedFrame>,
} }
@@ -288,6 +291,7 @@ impl PyroWaveEncoder {
hdr16, hdr16,
frame_budget, frame_budget,
wire_chunk: None, wire_chunk: None,
wire_budget: pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(), bitstream: Vec::new(),
pending: VecDeque::new(), pending: VecDeque::new(),
}) })
@@ -441,6 +445,16 @@ impl PyroWaveEncoder {
/// ///
/// # Safety /// # Safety
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns. /// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> { unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than // A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
// handing null to pyrowave (see the Linux twin). // handing null to pyrowave (see the Linux twin).
@@ -616,7 +630,7 @@ impl PyroWaveEncoder {
sync: std::mem::zeroed(), sync: std::mem::zeroed(),
}; };
let rc = pw::pyrowave_rate_control { let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget, maximum_bitstream_size: self.rate_budget(),
}; };
pw_check( pw_check(
pw::pyrowave_encoder_encode_gpu_synchronous( pw::pyrowave_encoder_encode_gpu_synchronous(
@@ -664,6 +678,10 @@ impl PyroWaveEncoder {
} }
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect(); let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk); let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.pending.push_back(EncodedFrame { self.pending.push_back(EncodedFrame {
data: au, data: au,
pts_ns: frame.pts_ns, pts_ns: frame.pts_ns,
+24
View File
@@ -96,6 +96,14 @@ pub struct HostConfig {
pub perf: bool, pub perf: bool,
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic). /// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
pub video_source: Option<String>, pub video_source: Option<String>,
/// `PUNKTFUNK_CAPTURE_MONITOR` — pin capture at a NAMED physical monitor (`DP-1`, `HDMI-A-2`),
/// instead of creating a virtual display or taking whichever head the portal hands back. The
/// point of the knob is an unattended host: a background `systemd --user` service has nobody to
/// answer a chooser dialog, so the monitor has to be config, not a prompt. A name that matches
/// no head is a hard error at session open (never a silent fall-back to a different screen —
/// showing the wrong monitor is worse than showing none). Linux-only today; see
/// `design/per-monitor-portal-capture.md`.
pub capture_monitor: Option<String>,
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected /// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
/// session — this one is a constant operator knob; `apply_session_env` never writes it. /// session — this one is a constant operator knob; `apply_session_env` never writes it.
pub compositor: Option<String>, pub compositor: Option<String>,
@@ -115,6 +123,14 @@ pub struct HostConfig {
/// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles /// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles
/// and menus, so it's opt-in per host until validated on-glass. /// and menus, so it's opt-in per host until validated on-glass.
pub gamescope_grab_cursor: bool, pub gamescope_grab_cursor: bool,
/// `PUNKTFUNK_GAMESCOPE_SPLASH` — run the host's built-in splash client inside every bare
/// headless gamescope spawn. gamescope only composites (and only then pushes a PipeWire capture
/// buffer) when a client paints, and a dedicated Steam launch paints NOTHING
/// for the whole Steam bootstrap — so without the splash a fresh spawn's capture starves: format
/// negotiated, zero buffers, first-frame timeout, and every retry kills the booting Steam and
/// starts over (the "fresh gamescope output never delivers frames" field failure). Default ON;
/// explicit-off grammar (`=0` disables, the on-glass A/B + emergency escape hatch).
pub gamescope_splash: bool,
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO /// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell /// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up /// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
@@ -162,6 +178,11 @@ impl HostConfig {
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true), chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
perf: flag("PUNKTFUNK_PERF"), perf: flag("PUNKTFUNK_PERF"),
video_source: val("PUNKTFUNK_VIDEO_SOURCE"), video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
// Trimmed + emptied-to-None: `PUNKTFUNK_CAPTURE_MONITOR=` in a host.env means "not
// set", not "match the monitor named empty string".
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
compositor: val("PUNKTFUNK_COMPOSITOR"), compositor: val("PUNKTFUNK_COMPOSITOR"),
gamepad: val("PUNKTFUNK_GAMEPAD"), gamepad: val("PUNKTFUNK_GAMEPAD"),
vdisplay: val("PUNKTFUNK_VDISPLAY"), vdisplay: val("PUNKTFUNK_VDISPLAY"),
@@ -177,6 +198,9 @@ impl HostConfig {
"1" | "true" | "yes" | "on" "1" | "true" | "yes" | "on"
) )
}), }),
// Default ON, explicit-off grammar: the splash is what makes a fresh bare spawn deliver
// its first frames at all; `=0` is the A/B + escape hatch.
gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD") recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()), .filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()), on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
+249 -16
View File
@@ -20,6 +20,7 @@
//! key events) is enough. //! key events) is enough.
use super::{gs_button_to_evdev, vk_to_evdev, InputInjector}; use super::{gs_button_to_evdev, vk_to_evdev, InputInjector};
use crate::AbsoluteAnchor;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use ashpd::desktop::{ use ashpd::desktop::{
remote_desktop::{ remote_desktop::{
@@ -374,25 +375,98 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Opti
} }
/// One EI device and its emulation state. /// One EI device and its emulation state.
/// Pick the region to map absolute coordinates into: the one whose logical size matches the /// Pick the region to map absolute coordinates into. The device advertises one region per logical
/// streamed mode (the session's virtual output). The device advertises one region per logical /// monitor and blindly taking `first()` next to a physical monitor put the pointer — and every
/// monitor, and blindly taking `first()` next to a physical monitor put the pointer — and every
/// click — on whichever output the compositor happened to announce first (on-glass: GNOME with a /// click — on whichever output the compositor happened to announce first (on-glass: GNOME with a
/// dummy HDMI beside the virtual primary; the seat cursor never entered the streamed monitor, so /// dummy HDMI beside the virtual primary; the seat cursor never entered the streamed monitor, so
/// neither embedded nor metadata cursor capture could see it). Size is the only key available /// neither embedded nor metadata cursor capture could see it).
/// today: regions carry no output name, and matching the screencast stream's `mapping_id` needs ///
/// the stream id plumbed across crates (follow-up). Two same-sized monitors stay ambiguous. /// The ladder, most identifying first:
fn region_for_mode( ///
regions: &[reis::event::Region], /// 1. **`mapping_id`** from the session's [`AbsoluteAnchor`] — the protocol's own key for
/// correlating a region with a video stream.
/// 2. **origin** from the anchor — two outputs can share a size, never a top-left. This is what
/// makes a *mirrored physical monitor* land correctly (`design/per-monitor-portal-capture.md`
/// §7.2): its region is not the client's size, so the size rung can't find it.
/// 3. **size** — the streamed mode. Correct for a client-sized virtual output, ambiguous the
/// moment two heads share a mode, which is exactly why the rungs above exist.
/// 4. `first()`.
///
/// An anchor that matches nothing falls through rather than failing: the region set is the truth
/// and the anchor is our belief about it. The caller logs that miss ([`anchor_missed`]).
fn region_for_mode<'a>(
regions: &'a [reis::event::Region],
w: f32, w: f32,
h: f32, h: f32,
) -> Option<&reis::event::Region> { anchor: Option<&AbsoluteAnchor>,
) -> Option<&'a reis::event::Region> {
if let Some(a) = anchor {
if let Some(id) = a.mapping_id.as_deref() {
if let Some(r) = regions.iter().find(|r| r.mapping_id.as_deref() == Some(id)) {
return Some(r);
}
}
if let Some((x, y)) = a.origin {
// EI region offsets are unsigned; a compositor places every output at a non-negative
// origin in its own global space, so a negative anchor simply matches nothing.
if x >= 0 && y >= 0 {
if let Some(r) = regions.iter().find(|r| r.x == x as u32 && r.y == y as u32) {
return Some(r);
}
}
}
}
regions regions
.iter() .iter()
.find(|r| r.width as f32 == w && r.height as f32 == h) .find(|r| r.width as f32 == w && r.height as f32 == h)
.or_else(|| regions.first()) .or_else(|| regions.first())
} }
/// Report which region absolute coordinates actually landed in, once per distinct answer.
///
/// The ladder above is only *observable* through where the pointer ends up, which on a two-head box
/// is precisely the thing that is hard to see and easy to get wrong — the anchor exists because it
/// already resolved wrong on-glass once, silently. `warn_anchor_miss` covers the anchor naming
/// nothing; this covers the other half, an anchor that matched *something*, by saying which. Once
/// per distinct region so a live session logs one line, not one per motion event.
fn note_abs_region(region: &reis::event::Region, anchor: Option<&AbsoluteAnchor>) {
static LAST: std::sync::Mutex<Option<(u32, u32, u32, u32)>> = std::sync::Mutex::new(None);
let key = (region.x, region.y, region.width, region.height);
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if *last == Some(key) {
return;
}
*last = Some(key);
tracing::info!(
region = %format!("{}x{}+{}+{}", region.width, region.height, region.x, region.y),
mapping_id = ?region.mapping_id,
anchor_origin = ?anchor.and_then(|a| a.origin),
anchor_mapping_id = ?anchor.and_then(|a| a.mapping_id.clone()),
"libei: absolute input maps into this output"
);
}
/// Did an anchor name an output this region set doesn't have? Drives the one-shot warning — a
/// silently mis-mapped pointer is the failure this whole ladder exists to prevent, so when the
/// anchor can't be honored that has to be visible in the log rather than inferred from "clicks land
/// on the wrong screen".
fn anchor_missed(regions: &[reis::event::Region], anchor: Option<&AbsoluteAnchor>) -> bool {
let Some(a) = anchor else {
return false;
};
if let Some(id) = a.mapping_id.as_deref() {
if regions.iter().any(|r| r.mapping_id.as_deref() == Some(id)) {
return false;
}
}
if let Some((x, y)) = a.origin {
if x >= 0 && y >= 0 && regions.iter().any(|r| r.x == x as u32 && r.y == y as u32) {
return false;
}
}
true
}
struct DeviceSlot { struct DeviceSlot {
device: reis::event::Device, device: reis::event::Device,
/// The device is resumed (allowed to emit). Devices arrive paused and may pause again. /// The device is resumed (allowed to emit). Devices arrive paused and may pause again.
@@ -430,6 +504,30 @@ struct EiState {
output_hint: Option<(u32, u32)>, output_hint: Option<(u32, u32)>,
} }
/// The anchor whose miss we last warned about, so a persistently unmatchable anchor logs once per
/// *change* instead of once per pointer sample (absolute motion arrives at client frame rate).
static LAST_WARNED_ANCHOR: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// Warn — once per distinct anchor — that the session's anchor names an output this EIS doesn't
/// advertise, so absolute coordinates fell back to size matching. See [`region_for_mode`].
fn warn_anchor_miss(anchor: &AbsoluteAnchor, regions: &[reis::event::Region]) {
let key = format!("{anchor:?}");
let mut last = LAST_WARNED_ANCHOR.lock().unwrap_or_else(|e| e.into_inner());
if last.as_deref() == Some(key.as_str()) {
return;
}
*last = Some(key);
tracing::warn!(
?anchor,
regions = ?regions
.iter()
.map(|r| (r.x, r.y, r.width, r.height, r.mapping_id.clone()))
.collect::<Vec<_>>(),
"libei: the session's absolute-coordinate anchor matches no EIS region — falling back to \
size matching, so the pointer may land on the wrong monitor"
);
}
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates /// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
/// into? gamescope advertises a degenerate `(0,0,INT32_MAX,INT32_MAX)` "everything" region on /// into? gamescope advertises a degenerate `(0,0,INT32_MAX,INT32_MAX)` "everything" region on
/// its virtual input device, meaning "absolute coordinates are raw"; normalizing into it /// its virtual input device, meaning "absolute coordinates are raw"; normalizing into it
@@ -763,13 +861,23 @@ impl EiState {
// raw client pixels as the last resort. // raw client pixels as the last resort.
let nx = (ev.x as f32 / w).clamp(0.0, 1.0); let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
let ny = (ev.y as f32 / h).clamp(0.0, 1.0); let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
let (x, y) = match region_for_mode(slot.regions(), w, h) let anchor = crate::absolute_anchor();
if let Some(a) = anchor
.as_ref()
.filter(|a| anchor_missed(slot.regions(), Some(a)))
{
warn_anchor_miss(a, slot.regions());
}
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
.filter(|r| sane_region(r)) .filter(|r| sane_region(r))
{ {
Some(region) => ( Some(region) => {
note_abs_region(region, anchor.as_ref());
(
region.x as f32 + nx * region.width as f32, region.x as f32 + nx * region.width as f32,
region.y as f32 + ny * region.height as f32, region.y as f32 + ny * region.height as f32,
), )
}
// Degenerate/absent region: scale into the relay-file output hint // Degenerate/absent region: scale into the relay-file output hint
// (correct even when the client streams at a different resolution // (correct even when the client streams at a different resolution
// than the session runs); raw client pixels as the last resort. // than the session runs); raw client pixels as the last resort.
@@ -847,14 +955,20 @@ impl EiState {
Some(t) if w > 0.0 && h > 0.0 => { Some(t) if w > 0.0 && h > 0.0 => {
let nx = (ev.x as f32 / w).clamp(0.0, 1.0); let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
let ny = (ev.y as f32 / h).clamp(0.0, 1.0); let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
// Same region-selection + degenerate fallback ladder as MouseMoveAbs. // Same region-selection + degenerate fallback ladder as MouseMoveAbs
let (x, y) = match region_for_mode(slot.regions(), w, h) // (including the session anchor — touch must land on the same monitor the
// pointer does, or a mirrored session's taps go to a different screen).
let anchor = crate::absolute_anchor();
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
.filter(|r| sane_region(r)) .filter(|r| sane_region(r))
{ {
Some(region) => ( Some(region) => {
note_abs_region(region, anchor.as_ref());
(
region.x as f32 + nx * region.width as f32, region.x as f32 + nx * region.width as f32,
region.y as f32 + ny * region.height as f32, region.y as f32 + ny * region.height as f32,
), )
}
None => match self.output_hint { None => match self.output_hint {
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32), Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
None => (ev.x as f32, ev.y as f32), None => (ev.x as f32, ev.y as f32),
@@ -913,3 +1027,122 @@ impl EiState {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn region(x: u32, y: u32, w: u32, h: u32, mapping_id: Option<&str>) -> reis::event::Region {
reis::event::Region {
x,
y,
width: w,
height: h,
scale: 1.0,
mapping_id: mapping_id.map(str::to_string),
}
}
/// The case the anchor exists for: two heads at the SAME size, so the size rung is a coin
/// flip. The origin picks the right one.
#[test]
fn the_origin_disambiguates_two_same_size_monitors() {
let regions = [
region(0, 0, 1920, 1080, None),
region(1920, 0, 1920, 1080, None),
];
let anchor = AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
};
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (1920, 0));
// Without the anchor the same call takes the first same-sized region — the old behavior,
// preserved deliberately for the client-sized virtual-output path.
let picked = region_for_mode(&regions, 1920.0, 1080.0, None).unwrap();
assert_eq!((picked.x, picked.y), (0, 0));
}
/// `mapping_id` outranks the origin: it is the protocol's own stream↔region correlation, so a
/// stale/rounded origin can't override it.
#[test]
fn mapping_id_outranks_the_origin() {
let regions = [
region(0, 0, 1920, 1080, Some("head-a")),
region(1920, 0, 1920, 1080, Some("head-b")),
];
let anchor = AbsoluteAnchor {
origin: Some((0, 0)),
mapping_id: Some("head-b".into()),
};
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!(picked.mapping_id.as_deref(), Some("head-b"));
}
/// A mirrored monitor's region is NOT the client's streamed size, so the size rung would miss
/// it entirely — the origin is what makes this land.
#[test]
fn the_anchor_finds_a_monitor_the_streamed_size_does_not_match() {
let regions = [
region(0, 0, 1920, 1080, None),
region(1920, 0, 3840, 2160, None),
];
// Client streams 1280x720 of a 4K head parked to the right.
let anchor = AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
};
let picked = region_for_mode(&regions, 1280.0, 720.0, Some(&anchor)).unwrap();
assert_eq!((picked.width, picked.height), (3840, 2160));
}
/// An anchor that names nothing present must not strand input: fall back down the ladder
/// (size, then first) — and say so, which `anchor_missed` drives.
#[test]
fn an_unmatched_anchor_falls_back_and_is_reported() {
let regions = [region(0, 0, 1920, 1080, None)];
let anchor = AbsoluteAnchor {
origin: Some((5000, 5000)),
mapping_id: None,
};
assert!(anchor_missed(&regions, Some(&anchor)));
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (0, 0), "fell back to the size match");
// A matched anchor is never reported as missed.
let ok = AbsoluteAnchor {
origin: Some((0, 0)),
mapping_id: None,
};
assert!(!anchor_missed(&regions, Some(&ok)));
assert!(!anchor_missed(&regions, None), "no anchor is not a miss");
}
/// EI region offsets are unsigned; a negative anchor origin can only ever match nothing, and
/// must not be cast into a huge u32 that accidentally matches something.
#[test]
fn a_negative_origin_matches_nothing_rather_than_wrapping() {
let regions = [region(0, 0, 1920, 1080, None)];
let anchor = AbsoluteAnchor {
origin: Some((-1920, 0)),
mapping_id: None,
};
assert!(anchor_missed(&regions, Some(&anchor)));
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (0, 0));
}
/// An empty anchor is the same as no anchor — so a caller can build one unconditionally and
/// let the setter drop it.
#[test]
fn an_empty_anchor_is_dropped_by_the_setter() {
crate::set_absolute_anchor(Some(AbsoluteAnchor::default()));
assert_eq!(crate::absolute_anchor(), None);
crate::set_absolute_anchor(Some(AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
}));
assert_eq!(crate::absolute_anchor().unwrap().origin, Some((1920, 0)));
crate::set_absolute_anchor(None);
assert_eq!(crate::absolute_anchor(), None);
}
}
+58
View File
@@ -107,6 +107,64 @@ pub fn open(_backend: Backend) -> Result<Box<dyn InputInjector>> {
anyhow::bail!("no input-injection backend on this platform") anyhow::bail!("no input-injection backend on this platform")
} }
/// Which output the session's **absolute** coordinates belong to, by identity rather than by size.
///
/// libei hands the injector one region per logical monitor and the region set carries no output
/// name, so the backend has to decide which one a normalized client position maps into. Matching on
/// *size* — all it could do before — is a coin flip the moment two heads share a mode, and it
/// resolved wrong on-glass once already (GNOME, a dummy HDMI beside the virtual primary: the seat
/// cursor never entered the streamed monitor). These are the two keys that actually identify a
/// region: the protocol's own `mapping_id`, and the origin (two outputs can share a size; they can
/// never share a top-left).
///
/// Best-effort by design: an anchor that matches no region warns and falls back to the size ladder,
/// because the region set is the truth and the anchor is our belief about it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AbsoluteAnchor {
/// The target output's top-left in the compositor's global logical space.
pub origin: Option<(i32, i32)>,
/// The EI `mapping_id` of the target output, when the capture side knows it — the protocol's
/// blessed way to correlate a region with a video stream, so it wins over the origin.
pub mapping_id: Option<String>,
}
impl AbsoluteAnchor {
/// Nothing to match on — treated as "no anchor" so callers can build one unconditionally.
pub fn is_empty(&self) -> bool {
self.origin.is_none() && self.mapping_id.is_none()
}
}
/// The current absolute-coordinate anchor. A `RwLock` rather than an env var: the injector is
/// host-lifetime and lives behind a channel, so a *session* can only reach it through process
/// state — and process state that is typed and lock-guarded beats the `set_var` pattern the
/// backend-select still uses (security-review 2026-06-28 #7).
static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::RwLock::new(None);
/// Anchor absolute coordinates at a specific output. `None` (the default) keeps the size-matched
/// behavior.
///
/// ⚠️ **This is a HOST-level pin, not per-session state.** The injector is host-lifetime and every
/// concurrent session's input flows through the same one, so an anchor set per session would apply
/// to all of them — the last connect silently re-aiming everyone else's pointer. That is fine for
/// what this exists for (`PUNKTFUNK_CAPTURE_MONITOR`, a host-wide pin — the host-pinned decision of
/// record in `design/per-monitor-portal-capture.md` §5.3) and wrong for anything per-client. A
/// per-session anchor needs the injector to become session-aware first; don't call this from a
/// session path until it is.
pub fn set_absolute_anchor(anchor: Option<AbsoluteAnchor>) {
let anchor = anchor.filter(|a| !a.is_empty());
tracing::debug!(?anchor, "input: absolute-coordinate anchor set");
*ABSOLUTE_ANCHOR.write().unwrap_or_else(|e| e.into_inner()) = anchor;
}
/// The anchor an injector should map absolute coordinates into, if any.
pub fn absolute_anchor() -> Option<AbsoluteAnchor> {
ABSOLUTE_ANCHOR
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no /// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the /// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input /// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
+3
View File
@@ -48,6 +48,9 @@ wayland-backend = "0.3"
# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums # wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs). # (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
bitflags = "2" bitflags = "2"
# The gamescope bare-spawn splash client (gamescope/splash.rs): pure-Rust X11 core protocol (the
# same no-libxcb-link stance as pf-capture's XFixes cursor source), no extension features needed.
x11rb = { version = "0.13", default-features = false }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs). # The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
+65 -1
View File
@@ -77,7 +77,7 @@ pub use session::{session_epoch, try_recover_session};
#[path = "vdisplay/routing.rs"] #[path = "vdisplay/routing.rs"]
pub(crate) mod routing; pub(crate) mod routing;
pub use routing::{ pub use routing::{
apply_input_env, managed_session_available, restore_managed_session, apply_input_env, managed_session_available, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session, restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
}; };
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -288,8 +288,35 @@ pub fn rebuild_probe_active() -> bool {
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0 REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
} }
/// The monitor this host mirrors instead of creating a virtual display, or `None` for the normal
/// virtual-display path.
///
/// Precedence: **`PUNKTFUNK_CAPTURE_MONITOR` wins over the stored policy.** An appliance pins in
/// `host.env` and must stay pinned there — a console click (or a stale settings file) should not be
/// able to re-aim a machine whose operator declared the answer in its unit's environment. With the
/// env unset, the console's persisted choice applies, which is what makes a picker possible at all:
/// the env is read once at startup, so it could never be what a UI writes.
///
/// Read per `open` rather than cached, so a console change takes effect on the next session instead
/// of at the next host restart.
pub fn capture_monitor() -> Option<String> {
if let Some(env) = pf_host_config::config().capture_monitor.as_deref() {
return Some(env.to_string());
}
policy::prefs().get().capture_monitor
}
/// Open the virtual-display driver for `compositor`. /// Open the virtual-display driver for `compositor`.
///
/// A [`capture_monitor`] pin routes to the **mirror** backend instead: the host streams that
/// physical head and creates no virtual display at all. Deliberately resolved here, at the one place
/// every session opens a display, so the pin can't be honored on one plane and ignored on another —
/// it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3).
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> { pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
#[cfg(target_os = "linux")]
if let Some(connector) = capture_monitor() {
return Ok(Box::new(mirror::MirrorDisplay::new(compositor, connector)?));
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
match compositor { match compositor {
@@ -321,6 +348,18 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
} }
} }
/// Open the **mirror** backend for a specific monitor, bypassing the `PUNKTFUNK_CAPTURE_MONITOR`
/// pin that [`open`] consults. For tools that name the head explicitly (`punktfunk-host
/// mirror-test`) — the pin can't serve them, since `pf_host_config` parses the environment once at
/// startup, so a tool setting the variable for itself would be reading a snapshot taken before it.
#[cfg(target_os = "linux")]
pub fn open_mirror(compositor: Compositor, connector: &str) -> Result<Box<dyn VirtualDisplay>> {
Ok(Box::new(mirror::MirrorDisplay::new(
compositor,
connector.to_string(),
)?))
}
/// Readiness probe for `compositor`: is it up and able to create a virtual output *right /// Readiness probe for `compositor`: is it up and able to create a virtual output *right
/// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate /// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate
/// on actual readiness instead of racing the compositor with a blind sleep. /// on actual readiness instead of racing the compositor with a blind sleep.
@@ -359,6 +398,21 @@ pub fn probe(compositor: Compositor) -> Result<()> {
#[path = "vdisplay/policy.rs"] #[path = "vdisplay/policy.rs"]
pub mod policy; pub mod policy;
// Read-only physical-monitor enumeration (the heads the compositor ALREADY has — not ours), for
// pinning capture at one of them + the console picker. Platform-neutral facade; the per-backend
// reads live beside the code that already speaks each dialect. See
// `design/per-monitor-portal-capture.md` §5.1.
#[path = "vdisplay/monitors.rs"]
pub mod monitors;
// The monitor-mirror backend: stream a head the compositor ALREADY has (the
// `PUNKTFUNK_CAPTURE_MONITOR` pin) instead of creating one. Implements `VirtualDisplay` so the
// session machinery is unchanged, but reports `DisplayOwnership::External` so none of the
// virtual-display lifecycle policy is applied to someone else's monitor.
#[cfg(target_os = "linux")]
#[path = "vdisplay/mirror.rs"]
mod mirror;
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and // The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
// property-tested; the registry executes the side effects its transitions dictate. // property-tested; the registry executes the side effects its transitions dictate.
#[path = "vdisplay/lifecycle.rs"] #[path = "vdisplay/lifecycle.rs"]
@@ -425,6 +479,16 @@ pub fn effective_topology() -> policy::Topology {
#[path = "vdisplay/linux/gamescope.rs"] #[path = "vdisplay/linux/gamescope.rs"]
mod gamescope; mod gamescope;
/// Entry point for the hidden `gamescope-splash` host subcommand: the tiny X11 client every bare
/// gamescope spawn backgrounds beside its nested app, so the fresh compositor composites — and its
/// PipeWire node delivers frames — from the first second instead of starving the first-frame wait
/// while the nested Steam bootstrap paints nothing (see `vdisplay/linux/gamescope/splash.rs`).
/// Blocks for the session's lifetime; gamescope's reaper tears it down with the session.
#[cfg(target_os = "linux")]
pub fn gamescope_splash_client() -> anyhow::Result<()> {
gamescope::splash_run()
}
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID + // Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows // ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step. // consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
+8 -3
View File
@@ -181,9 +181,14 @@ pub fn panel_off_except(exclude_gdi: &str) -> u32 {
acked += set_power(m.hmon, &m.device, POWER_OFF); acked += set_power(m.hmon, &m.device, POWER_OFF);
} }
if acked == 0 { if acked == 0 {
tracing::debug!( // INFO, not debug: the user opted into this axis, so "it did nothing" is an answer they
"DDC/CI: no physical panel accepted the DPMS-off command \ // asked for. The common case is a laptop — internal eDP/LVDS panels have NO DDC/CI at
(no DDC/CI-capable panel besides the virtual display)" // all (their brightness/power runs over the driver's own channel), so `ddc_power_off`
// is structurally a no-op for them (reporter feedback 2026-07-27).
tracing::info!(
"DDC/CI: no panel accepted the DPMS-off command — the ddc_power_off axis did \
nothing on this display set (internal eDP/LVDS panels expose no DDC/CI; external \
monitors may have it disabled in the OSD or dropped by a dock/KVM)"
); );
} }
acked acked
@@ -21,6 +21,8 @@ use std::time::{Duration, Instant};
#[path = "gamescope/discovery.rs"] #[path = "gamescope/discovery.rs"]
mod discovery; mod discovery;
#[path = "gamescope/splash.rs"]
mod splash;
use discovery::{ use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
gamescope_node_present, poll_managed_node, wait_for_node, gamescope_node_present, poll_managed_node, wait_for_node,
@@ -29,6 +31,7 @@ pub(crate) use discovery::{
game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit, game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit,
SteamGameWatch, SteamGameWatch,
}; };
pub(crate) use splash::run as splash_run;
/// The gamescope virtual-display driver. Three modes by env, in precedence order: /// The gamescope virtual-display driver. Three modes by env, in precedence order:
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session /// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
@@ -68,14 +71,23 @@ static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(
/// master — live-proven on the Nobara repro VM 2026-07-24). /// master — live-proven on the Nobara repro VM 2026-07-24).
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None); static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// mtime of the `steamos-session-select` sentinel at managed-session launch — the baseline the /// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
/// in-stream "Switch to Desktop" detector compares against. Steam's session-select script writes /// "Switch to Desktop" detector compares against. Steam's session-select script writes
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its /// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's /// display-manager checks — so it advances even under a DM-stop takeover, where the script's
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*; /// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is /// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
/// therefore the one durable trace of the user's switch request. /// therefore the one durable trace of the user's switch request.
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<std::time::SystemTime>> = ///
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
/// in-stream request: the sentinel is a permanent file, so any box whose user has EVER switched
/// sessions has one, and comparing against a missing baseline made that ancient write look like
/// a live "Switch to Desktop".
/// * **`Some(None)`** — baselined while no sentinel existed yet; a later one was created inside the
/// session, which IS a request.
/// * **`Some(Some(t))`** — baselined at mtime `t`; anything newer is a request.
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<Option<std::time::SystemTime>>> =
std::sync::Mutex::new(None); std::sync::Mutex::new(None);
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused — /// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
@@ -1152,8 +1164,8 @@ const DM_HELPER_PATHS: &[&str] = &[
"/usr/lib/punktfunk/pf-dm-helper", "/usr/lib/punktfunk/pf-dm-helper",
]; ];
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't /// Run the packaged DM helper (`stop` | `restore` | `linger`) via pkexec. `false` when the helper
/// installed (tarball/old package), pkexec is missing, or polkit denies the action. /// isn't installed (tarball/old package), pkexec is missing, or polkit denies the action.
fn dm_helper(verb: &str) -> bool { fn dm_helper(verb: &str) -> bool {
let Some(helper) = DM_HELPER_PATHS let Some(helper) = DM_HELPER_PATHS
.iter() .iter()
@@ -1169,18 +1181,102 @@ fn dm_helper(verb: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// `systemctl` on the SYSTEM bus, **never interactively**. Every privileged verb below runs on the
/// stream's own thread — the capture-loss rebuild, or the restore worker — with no way to answer a
/// question. Without `--no-ask-password`, systemctl asks polkit for interactive authorization, and
/// on a box whose desktop session is still alive (the host is a `--user` unit inside it) polkit
/// hands that to the session's agent: a password dialog on the box's OWN screen, which during a
/// managed takeover is off or mid-switch. Nobody sees it, nobody answers it, and the call blocks
/// while the rebuild budget burns — the takeover then lands after the session it was for already
/// ended. `--no-ask-password` turns that into the immediate "interactive authentication required"
/// failure the callers are written for, so the pkexec helper (`allow_any`, no agent needed) takes
/// over instead of a dialog. Field-suspect in the 0.20.0 Nobara report (intermittent disconnect +
/// a screen that never comes back), where the timing is a race against the KDE agent's own death.
fn systemctl_system(args: &[&str]) -> bool {
let mut cmd = Command::new("systemctl");
cmd.arg("--no-ask-password").args(args);
cmd.status().map(|s| s.success()).unwrap_or(false)
}
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login
/// session. logind then stops `user@<uid>.service` once `UserStopDelaySec` (10 s by default)
/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart
/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0**
/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0
/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was
/// enabled there for the sessionless tests.
///
/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is
/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure
/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then
/// degrades to attach, which mirrors the box's own session and never stops the DM.
fn ensure_host_survives_dm_stop() -> bool {
if !host_is_under_user_manager() {
return true; // root / a system unit — the DM stop cannot reach us
}
if linger_enabled() {
return true;
}
// `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the
// user's session can do this itself; a sessionless one (the packaged unit) goes through the
// helper, whose grant is scoped to the calling uid.
let uid = uid_string();
let _ = Command::new("loginctl")
.args(["--no-ask-password", "enable-linger", &uid])
.status();
if linger_enabled() || (dm_helper("linger") && linger_enabled()) {
tracing::info!(
uid,
"enabled lingering for this user — the managed takeover stops the display manager, \
which ends this login session, and without lingering logind would stop the host \
along with it (`loginctl disable-linger` reverts it)"
);
return true;
}
false
}
/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager
/// stop take us down)? Read from our own cgroup path.
fn host_is_under_user_manager() -> bool {
std::fs::read_to_string("/proc/self/cgroup")
.as_deref()
.map(cgroup_under_user_manager)
.unwrap_or(false)
}
/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a
/// `user@<uid>.service` manager? Pure + unit-tested. A system unit
/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from
/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only
/// reaches units the user manager owns.
fn cgroup_under_user_manager(cgroup: &str) -> bool {
cgroup.contains("user@")
}
/// Our uid as a string — what `loginctl` wants for a user argument.
fn uid_string() -> String {
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
unsafe { libc::getuid() }.to_string()
}
/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)?
fn linger_enabled() -> bool {
Command::new("loginctl")
.args(["show-user", &uid_string(), "-p", "Linger", "--value"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
.unwrap_or(false)
}
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on /// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit /// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the /// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to /// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
/// attach. /// attach.
fn try_stop_display_manager(dm: &str) -> bool { fn try_stop_display_manager(dm: &str) -> bool {
let direct = Command::new("systemctl") systemctl_system(&["stop", dm]) || dm_helper("stop")
.args(["stop", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("stop")
} }
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start /// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
@@ -1189,15 +1285,8 @@ fn try_stop_display_manager(dm: &str) -> bool {
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same /// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
/// two steps as root. /// two steps as root.
fn restore_display_manager(dm: &str) -> bool { fn restore_display_manager(dm: &str) -> bool {
let _ = Command::new("systemctl") let _ = systemctl_system(&["reset-failed", dm]);
.args(["reset-failed", dm]) systemctl_system(&["restart", dm]) || dm_helper("restore")
.status();
let direct = Command::new("systemctl")
.args(["restart", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("restore")
} }
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the /// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
@@ -1223,24 +1312,39 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
std::fs::metadata(path).ok()?.modified().ok() std::fs::metadata(path).ok()?.modified().ok()
} }
/// Record the sentinel baseline at managed-session launch, so a LATER write (the user's in-stream /// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
/// "Switch to Desktop") is distinguishable from the switch that led into this session. /// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
/// as a live request and pushing the box to the desktop the user never asked for.
fn record_session_select_baseline() { fn record_session_select_baseline() {
*SESSION_SELECT_BASELINE *SESSION_SELECT_BASELINE
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime(); .unwrap_or_else(|e| e.into_inner()) = Some(session_select_mtime());
} }
/// Did a session-select run inside the managed session since its launch (sentinel newer than the /// Did a session-select run inside the managed session since the baseline? Inside a managed game
/// recorded baseline, or newly created)? Inside a managed game session the only switch Steam /// session the only switch Steam offers is TO the desktop, so an advanced sentinel reads as that
/// offers is TO the desktop, so an advanced sentinel reads as that request. /// request.
fn session_select_requested() -> bool { fn session_select_requested() -> bool {
let baseline = *SESSION_SELECT_BASELINE let baseline = *SESSION_SELECT_BASELINE
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()); .unwrap_or_else(|e| e.into_inner());
match (baseline, session_select_mtime()) { sentinel_advanced(baseline, session_select_mtime())
(Some(base), Some(now)) => now > base, }
(None, Some(_)) => true, // created during the session
/// [`session_select_requested`]'s decision, as a pure function of the two readings (the
/// unit-testable core). No baseline ⇒ no request: see [`SESSION_SELECT_BASELINE`] for why a
/// missing baseline must not be read as "the sentinel appeared during the session".
fn sentinel_advanced(
baseline: Option<Option<std::time::SystemTime>>,
now: Option<std::time::SystemTime>,
) -> bool {
match (baseline, now) {
(Some(Some(base)), Some(now)) => now > base,
(Some(None), Some(_)) => true, // no sentinel at baseline — created during the session
_ => false, _ => false,
} }
} }
@@ -1392,6 +1496,19 @@ fn stop_autologin_sessions() -> Result<()> {
return Ok(()); return Ok(());
} }
let dm = dm.expect("!is_none_or ⇒ Some"); let dm = dm.expect("!is_none_or ⇒ Some");
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
// box's display manager down and nobody left to bring it back. Degrading to attach is
// strictly better than a black screen that needs a VT to recover.
if !ensure_host_survives_dm_stop() {
bail!(
"stopping {dm} ends this user's last login session, and without lingering logind \
would stop the user manager and this host with it about 10s later, leaving \
the box with no display manager and nothing to restore it; enabling lingering \
failed, so the managed takeover is unavailable (run `sudo loginctl enable-linger \
$USER` once, as the setup docs ask, then reconnect)"
);
}
if !try_stop_display_manager(&dm) { if !try_stop_display_manager(&dm) {
bail!( bail!(
"the box's gaming session is driven by {dm}, which does not survive a masked \ "the box's gaming session is driven by {dm}, which does not survive a masked \
@@ -1405,6 +1522,11 @@ fn stop_autologin_sessions() -> Result<()> {
%dm, %dm,
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)" "freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
); );
// Baseline the switch sentinel HERE, not just at a successful launch: setting STOPPED_DM
// is what arms the honor gate, so from this instant an unbaselined sentinel would read as
// an in-stream "Switch to Desktop" — including the write from the switch that just brought
// the box INTO game mode. A successful launch re-baselines (tighter still).
record_session_select_baseline();
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm); *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
} }
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect(); let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
@@ -1564,21 +1686,7 @@ fn restore_delay() -> Option<Duration> {
/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless /// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless
/// box). Idempotent / safe to call on every session end. /// box). Idempotent / safe to call on every session end.
pub fn schedule_restore_tv_session() { pub fn schedule_restore_tv_session() {
let nothing_to_restore = STOPPED_AUTOLOGIN if !takeover_live() {
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
&& STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none()
// A managed session that took nothing over (started beside a live desktop — e.g. a client
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
// was ORPHANED forever after disconnect ("closing the app does not end the session",
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
&& MANAGED_SESSION
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none();
if nothing_to_restore {
return; // nothing was taken over → nothing to restore (also the non-managed path) return; // nothing was taken over → nothing to restore (also the non-managed path)
} }
match restore_delay() { match restore_delay() {
@@ -1601,6 +1709,48 @@ pub fn schedule_restore_tv_session() {
} }
} }
/// Is anything of the box's own session ours right now — an autologin unit we stopped, a stopped
/// display manager, a SteamOS target we re-pointed, or a managed session we launched beside a live
/// desktop? The precondition for every restore path.
fn takeover_live() -> bool {
!STOPPED_AUTOLOGIN
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
|| *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|| STOPPED_DM
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
// A managed session that took nothing over (started beside a live desktop — e.g. a client
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
// was ORPHANED forever after disconnect ("closing the app does not end the session",
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
|| MANAGED_SESSION
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
}
/// Give the box its own session back **now**, synchronously — the host is going away (SIGTERM from
/// `systemctl --user stop`/`restart`, a package update, Ctrl-C) and a live takeover must not
/// outlive it. On a DM-flavor takeover the display manager is STOPPED: nothing else on the box
/// will ever restart it, and the persisted crash-restore state lives in `$XDG_RUNTIME_DIR`, which
/// logind removes along with the user manager — so not even the next host start can heal it. The
/// box would stay dark until someone reached a VT.
///
/// Deliberately ignores the keep-alive policy that [`schedule_restore_tv_session`] honors:
/// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host
/// that would serve them is exiting. No-op when nothing was taken over.
pub fn restore_takeover_now() {
if !takeover_live() {
return;
}
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
do_restore_tv_session();
}
/// Does any DRM connector report a physically `connected` display? Scans /// Does any DRM connector report a physically `connected` display? Scans
/// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a /// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a
/// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A /// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A
@@ -1963,11 +2113,6 @@ pub fn ei_socket_file() -> std::path::PathBuf {
crate::with_env_lock(pf_paths::gamescope_ei_socket_file) crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
} }
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-silent` inserted so
/// the game is the gamescope focus with no Steam client window to navigate
/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam
/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested.
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's /// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
/// single instance free before a dedicated spawn (B1). Pure + unit-tested. /// single instance free before a dedicated spawn (B1). Pure + unit-tested.
fn is_steam_launch(cmd: &str) -> bool { fn is_steam_launch(cmd: &str) -> bool {
@@ -1975,12 +2120,21 @@ fn is_steam_launch(cmd: &str) -> bool {
it.next() == Some("steam") && cmd.contains("steam://") it.next() == Some("steam") && cmd.contains("steam://")
} }
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-gamepadui` inserted
/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built
/// around (it's what SteamOS/Bazzite game mode runs): the boot shows the gamepad UI instead of the
/// desktop Steam client window (field report 2026-07-27: the desktop UI flashing through the
/// stream "looks bad"), and gamescope's focus rules already prefer the game window over the Steam
/// UI appid, which is what the previous `-silent` shaping was working around. Operator-typed
/// custom commands and non-Steam launches are returned unchanged. Idempotent (never
/// double-inserts). Pure + unit-tested.
fn shape_dedicated_command(app: &str) -> String { fn shape_dedicated_command(app: &str) -> String {
let mut it = app.split_whitespace(); let mut it = app.split_whitespace();
if it.next() == Some("steam") { if it.next() == Some("steam") {
let rest: Vec<&str> = it.collect(); let rest: Vec<&str> = it.collect();
if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) { if !rest.contains(&"-gamepadui") && rest.iter().any(|t| t.starts_with("steam://")) {
return format!("steam -silent {}", rest.join(" ")); return format!("steam -gamepadui {}", rest.join(" "));
} }
} }
app.to_string() app.to_string()
@@ -2016,7 +2170,11 @@ fn add_bare_gamescope_args(
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session). /// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
/// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny /// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny
/// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`] /// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
/// so the input injector can connect to gamescope's EIS server from outside. /// so the input injector can connect to gamescope's EIS server from outside — and (unless
/// `PUNKTFUNK_GAMESCOPE_SPLASH=0`) backgrounds the host's splash client first, so the fresh
/// compositor has a painting window from the first second: gamescope pushes capture buffers only
/// when it composites, and a nested Steam bootstrap paints nothing until the gamepad UI's first
/// frame — far longer than any first-frame budget (see `gamescope/splash.rs`).
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> { fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
// A non-empty per-session command (set via `set_launch_command`) wins; else the // A non-empty per-session command (set via `set_launch_command`) wins; else the
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps // `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
@@ -2033,8 +2191,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
// cursor-grab flag below. // cursor-grab flag below.
let game_launch = app.is_some(); let game_launch = app.is_some();
let app = app.unwrap_or_else(|| "sleep infinity".to_string()); let app = app.unwrap_or_else(|| "sleep infinity".to_string());
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the // Dedicated-launch command shaping (Part B): a Steam URI runs with `-gamepadui` so the nested
// gamescope focus with no Steam client window to navigate. // Steam is Big Picture — the identity gamescope's `--steam` mode is built around — instead of
// the desktop client window.
let app = shape_dedicated_command(&app); let app = shape_dedicated_command(&app);
let relay = ei_socket_file(); let relay = ei_socket_file();
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
@@ -2047,18 +2206,26 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
// cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook. // cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook.
// Default OFF (it forces relative mode, which would break absolute-pointer games/menus). // Default OFF (it forces relative mode, which would break absolute-pointer games/menus).
let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor; let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor;
// The splash client (see `gamescope/splash.rs`): without a painting client gamescope pushes NO
// capture buffers, and a nested Steam bootstrap paints nothing for far longer than any
// first-frame budget — so every bare spawn backgrounds the host's own splash beside the nested
// app. Skipped only via the PUNKTFUNK_GAMESCOPE_SPLASH=0 escape hatch (or if the host can't
// name its own executable, where the old starve-prone behaviour is still better than no spawn).
let splash_exe = pf_host_config::config()
.gamescope_splash
.then(std::env::current_exe)
.and_then(|r| {
r.map_err(|e| tracing::warn!(error = %e, "gamescope: current_exe failed — no splash"))
.ok()
});
let mut cmd = Command::new("gamescope"); let mut cmd = Command::new("gamescope");
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor); add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
cmd.args([ let script = nested_wrapper_script(&relay, splash_exe.is_some());
"sh", cmd.args(["sh", "-c", &script, "sh"]);
"-c", if let Some(exe) = &splash_exe {
&format!( cmd.arg(exe);
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"", }
relay.display() cmd.args(app.split_whitespace())
),
"sh",
])
.args(app.split_whitespace())
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after // A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
@@ -2077,11 +2244,34 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
} else { } else {
cmd.stdout(Stdio::null()).stderr(Stdio::null()); cmd.stdout(Stdio::null()).stderr(Stdio::null());
} }
tracing::info!(w, h, hz, steam_mode, %app, log = %log.display(), "spawning gamescope (headless)"); tracing::info!(
w, h, hz, steam_mode,
splash = splash_exe.is_some(),
%app,
log = %log.display(),
"spawning gamescope (headless)"
);
cmd.spawn() cmd.spawn()
.context("spawn gamescope (is it installed? `apt install gamescope`)") .context("spawn gamescope (is it installed? `apt install gamescope`)")
} }
/// The nested-command wrapper script for a bare spawn: relay gamescope's `LIBEI_SOCKET` to the
/// injector's file, optionally background the splash client (`"$1"` is the host executable — passed
/// as an argv so its path never needs shell-escaping), then exec the real app. Pure + unit-tested.
fn nested_wrapper_script(relay: &std::path::Path, with_splash: bool) -> String {
if with_splash {
format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; \"$1\" gamescope-splash & shift; exec \"$@\"",
relay.display()
)
} else {
format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
relay.display()
)
}
}
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual /// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
/// output down. /// output down.
struct GamescopeProc { struct GamescopeProc {
@@ -2104,10 +2294,66 @@ impl Drop for GamescopeProc {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under, cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
dm_survives_masked_unit, is_steam_launch, shape_dedicated_command, display_manager_unit_under, dm_survives_masked_unit, is_steam_launch,
nested_wrapper_script, sentinel_advanced, shape_dedicated_command,
}; };
#[test]
fn user_manager_lifetime_detection() {
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
// login session the DM stop ends — this is the case that needs lingering.
assert!(cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n"
));
assert!(cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n"
));
// A system unit outlives every session — the DM stop cannot reach it.
assert!(!cgroup_under_user_manager(
"0::/system.slice/punktfunk-host.service\n"
));
// Started from a login shell: owned by the session scope, not the user manager.
assert!(!cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/session-2.scope\n"
));
}
#[test]
fn session_select_sentinel_needs_a_baseline() {
let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
let t1 = t0 + std::time::Duration::from_secs(1);
// Never baselined: the sentinel is a permanent file, so a box whose user EVER switched
// sessions has one — that ancient write is not a live "Switch to Desktop" request. This is
// the case that pushed a Nobara box to the desktop after a failed managed launch.
assert!(!sentinel_advanced(None, Some(t0)));
assert!(!sentinel_advanced(None, None));
// Baselined with no sentinel yet, then one appeared inside the session: a real request.
assert!(sentinel_advanced(Some(None), Some(t0)));
assert!(!sentinel_advanced(Some(None), None));
// Baselined at an mtime: only a NEWER one is the user's in-stream switch. The write that
// brought the box into game mode is the baseline itself, so it reads as no request.
assert!(sentinel_advanced(Some(Some(t0)), Some(t1)));
assert!(!sentinel_advanced(Some(Some(t0)), Some(t0)));
assert!(!sentinel_advanced(Some(Some(t1)), Some(t0)));
assert!(!sentinel_advanced(Some(Some(t0)), None));
}
#[test]
fn nested_wrapper_script_shapes() {
let relay = std::path::Path::new("/run/user/1000/pf-ei");
// Plain: relay + exec, no splash machinery.
let plain = nested_wrapper_script(relay, false);
assert!(plain.contains("/run/user/1000/pf-ei"));
assert!(plain.ends_with("exec \"$@\""));
assert!(!plain.contains("gamescope-splash"));
// Splash: `"$1"` is the host exe (an argv, never shell-interpolated), backgrounded and
// shifted away so `exec "$@"` still runs the untouched app tokens.
let splash = nested_wrapper_script(relay, true);
assert!(splash.contains("\"$1\" gamescope-splash &"));
assert!(splash.contains("shift; exec \"$@\""));
}
#[test] #[test]
fn display_manager_flavor_detection() { fn display_manager_flavor_detection() {
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id())); let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
@@ -2169,15 +2415,15 @@ mod tests {
#[test] #[test]
fn dedicated_command_shaping() { fn dedicated_command_shaping() {
// Steam URI → -silent inserted so the game is the gamescope focus. // Steam URI → -gamepadui inserted so the nested Steam is Big Picture (not the desktop UI).
assert_eq!( assert_eq!(
shape_dedicated_command("steam steam://rungameid/570"), shape_dedicated_command("steam steam://rungameid/570"),
"steam -silent steam://rungameid/570" "steam -gamepadui steam://rungameid/570"
); );
// Idempotent: an already-silent command is left alone. // Idempotent: an already-gamepadui command is left alone.
assert_eq!( assert_eq!(
shape_dedicated_command("steam -silent steam://rungameid/570"), shape_dedicated_command("steam -gamepadui steam://rungameid/570"),
"steam -silent steam://rungameid/570" "steam -gamepadui steam://rungameid/570"
); );
// Non-Steam launches and operator custom commands are untouched. // Non-Steam launches and operator custom commands are untouched.
assert_eq!(shape_dedicated_command("vkcube"), "vkcube"); assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
@@ -0,0 +1,159 @@
//! The bare-spawn **splash client** — the reason a fresh headless gamescope delivers frames at all.
//!
//! gamescope only composites (and only on a composite pushes a buffer to its PipeWire capture
//! node) when a client paints. A dedicated Steam launch paints NOTHING for the whole Steam
//! bootstrap (no window maps until the gamepad UI's first frame) — so a fresh
//! spawn's capture negotiated a format, reached `Streaming`, and then starved: zero buffers, the
//! 10 s first-frame timeout, teardown — and every native-plane retry killed the half-booted Steam
//! and started from zero, while the GameStream plane died on its single wait (the "fresh gamescope
//! output never delivers frames" field failure; root-caused on-box 2026-07-27 with a raw pw_stream
//! probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately).
//!
//! This client runs INSIDE the spawned gamescope (the nested-command wrapper backgrounds it before
//! exec'ing the real app) and guarantees damage from the first second: a dark window with a subtle
//! breathing bar painted at ~2.5 Hz. Two gamescope focus facts, both live-proven on c31743d:
//!
//! * In `--steam` mode gamescope composites ONLY windows whose Steam appid is listed in the root
//! window's `GAMESCOPECTRL_BASELAYER_APPID` property (a plain mapped+painting window — even
//! vkcube — gets zero composites). So the splash declares itself as the Steam client UI
//! (`STEAM_GAME=769`) and seeds the baselayer with that appid iff nobody has written it yet.
//! When Steam finishes booting it rewrites the baselayer as part of launching the game, which
//! hands composite focus to the real game window with no action on our side (verified: 2.5 Hz
//! splash cadence → 60 Hz game cadence on the same capture stream, no gap).
//! * In plain (non-`--steam`) mode any mapped painting window composites; the atoms are inert.
//!
//! The splash never exits on its own while the session lives: after the game takes focus it keeps
//! painting unfocused (damage on an unfocused window schedules no composite — harmless), so if the
//! game's window briefly goes away (Steam updater closed, level-load window swap) and gamescope
//! re-focuses the splash, liveness returns instead of a frozen last frame. It dies with the session:
//! gamescope's reaper kills it at teardown, and any X error (the connection dropping) exits cleanly.
use anyhow::{Context, Result};
use std::time::Duration;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
AtomEnum, ChangeGCAux, ConnectionExt, CreateGCAux, CreateWindowAux, PropMode, Rectangle,
WindowClass,
};
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as _;
/// The Steam client UI's appid — the identity the splash borrows so `--steam`-mode gamescope
/// treats it as focusable before (and beneath) any real game.
const STEAM_UI_APPID: u32 = 769;
/// Window/bar palette: near-black background, greys the bar breathes through.
const BG: u32 = 0x0d0d0d;
/// How often the splash repaints (each paint is the damage that makes gamescope push a capture
/// buffer). 400 ms ≈ 2.5 fps — far inside every first-frame budget, invisible in CPU terms.
const TICK: Duration = Duration::from_millis(400);
/// Entry point for the hidden `gamescope-splash` host subcommand. Blocks for the session's
/// lifetime; returns (or errors) only when the X connection is gone or never came up.
pub(crate) fn run() -> Result<()> {
// gamescope execs its nested command only once Xwayland is ready and DISPLAY is set, so the
// first attempt normally lands — the retry covers a slow Xwayland under driver-init load.
let (conn, screen_num) = connect_with_retry()?;
let screen = &conn.setup().roots[screen_num];
let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
let win = conn.generate_id()?;
conn.create_window(
x11rb::COPY_DEPTH_FROM_PARENT,
win,
screen.root,
0,
0,
w,
h,
0,
WindowClass::INPUT_OUTPUT,
screen.root_visual,
&CreateWindowAux::new().background_pixel(BG),
)?;
conn.change_property8(
PropMode::REPLACE,
win,
AtomEnum::WM_NAME,
AtomEnum::STRING,
b"Punktfunk",
)?;
// STEAM_GAME on the window + GAMESCOPECTRL_BASELAYER_APPID on the root — the pair that makes a
// `--steam` gamescope composite us. Seed the baselayer only while unset: once Steam is up it
// owns that property (its rewrite at game launch is exactly the handover we want).
let steam_game = conn.intern_atom(false, b"STEAM_GAME")?.reply()?.atom;
conn.change_property32(
PropMode::REPLACE,
win,
steam_game,
AtomEnum::CARDINAL,
&[STEAM_UI_APPID],
)?;
let baselayer = conn
.intern_atom(false, b"GAMESCOPECTRL_BASELAYER_APPID")?
.reply()?
.atom;
let existing = conn
.get_property(false, screen.root, baselayer, AtomEnum::CARDINAL, 0, 4)?
.reply()?;
if existing.value_len == 0 {
conn.change_property32(
PropMode::REPLACE,
screen.root,
baselayer,
AtomEnum::CARDINAL,
&[STEAM_UI_APPID],
)?;
}
conn.map_window(win)?;
let gc = conn.generate_id()?;
conn.create_gc(gc, win, &CreateGCAux::new().foreground(BG))?;
conn.flush()?;
tracing::info!(
w,
h,
seeded_baselayer = existing.value_len == 0,
"gamescope splash: mapped"
);
// The breathing bar. Server-side fills generate the damage; the background pixel handles
// exposures, so no event loop is needed. Any request failing = the session (or its Xwayland)
// is gone = a clean exit.
let bar_w = 120i16;
let bar_h = 8i16;
let bar = Rectangle {
x: (w as i16) / 2 - bar_w / 2,
y: (h as i16) / 2 - bar_h / 2,
width: bar_w as u16,
height: bar_h as u16,
};
let mut t: u32 = 0;
loop {
// 8-step triangle pulse through dark greys (0x1a…0x40 per channel).
let phase = (t % 8) as i32;
let tri = if phase < 4 { phase } else { 8 - phase };
let grey = 0x1a + 0x0c * tri as u32;
let colour = (grey << 16) | (grey << 8) | grey;
conn.change_gc(gc, &ChangeGCAux::new().foreground(colour))?;
conn.poly_fill_rectangle(win, gc, &[bar])?;
conn.flush()
.context("gamescope splash: X connection lost")?;
t = t.wrapping_add(1);
std::thread::sleep(TICK);
}
}
/// Connect to the session's `DISPLAY`, retrying briefly — gamescope sets the variable before
/// exec'ing the nested command, but a slow Xwayland under cold driver init gets a grace window.
fn connect_with_retry() -> Result<(RustConnection, usize)> {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
match x11rb::connect(None) {
Ok(ok) => return Ok(ok),
Err(e) if std::time::Instant::now() >= deadline => {
return Err(e).context("gamescope splash: could not connect to the session DISPLAY")
}
Err(_) => std::thread::sleep(Duration::from_millis(200)),
}
}
}
+102 -19
View File
@@ -174,25 +174,12 @@ impl VirtualDisplay for HyprlandDisplay {
set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?; set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?;
// Steer xdph's custom picker at our new output, then run the portal handshake on its own // Steer xdph's custom picker at our new output, then run the portal handshake on its own
// thread (it parks to keep the cast alive, like the other backends). // thread (it parks to keep the cast alive, like the other backends). Serialized: the
ensure_xdph_config()?; // selection is one per-user file, so a concurrent session's write between ours and xdph's
let sel = selection_file(); // read would silently capture the wrong output (see `SELECTION_LOCK`).
std::fs::write(&sel, picker_selection_line(&name)) let (fd, node_id, stop) = {
.with_context(|| format!("write {sel}"))?; let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
select_and_cast(&name, self.hw_cursor)?
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let hw_cursor = self.hw_cursor;
thread::Builder::new()
.name("punktfunk-hypr-vout".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn hyprland portal thread")?;
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
}; };
tracing::info!( tracing::info!(
node_id, node_id,
@@ -272,6 +259,102 @@ fn hyprctl(args: &[&str]) -> Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} }
/// Serializes **write-the-selection → complete-the-handshake**, process-wide — see the wlroots
/// backend's `SELECTION_LOCK`. The xdph selection is likewise one per-user file, so a concurrent
/// write between ours and xdph's read would silently steer capture at the other session's output.
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
ensure_xdph_config()?;
let sel = selection_file();
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
thread::Builder::new()
.name("punktfunk-hypr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn hyprland portal thread")?;
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
}
/// Record an **existing** Hyprland monitor — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L3): the same custom-picker mechanism the virtual-output
/// path uses, pointed at a physical connector, so no GUI picker is involved.
///
/// The keepalive stops the cast only — the monitor is Hyprland's, not ours.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
keepalive: Box::new(StopGuard(stop)),
})
}
/// Every head Hyprland reports, for [`crate::monitors::list`].
///
/// `hyprctl -j monitors all` (rather than plain `monitors`) so **disabled** heads are listed too —
/// a picker that silently omits the monitor the operator is trying to name is worse than one that
/// shows it greyed out. Hyprland reports geometry post-transform in logical pixels, which is the
/// space `crate::monitors` documents.
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
let raw = hyprctl(&["-j", "monitors", "all"])?;
let parsed: serde_json::Value =
serde_json::from_str(&raw).context("parse hyprctl -j monitors all")?;
let mut out: Vec<_> = parsed
.as_array()
.context("hyprctl monitors: not an array")?
.iter()
.filter_map(|m| {
let connector = m.get("name")?.as_str()?.to_string();
let num = |k: &str| m.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
// Hyprland's `description` is already a "make model (connector)" string; treat it as
// the make and let the shared helper drop it when it is empty/Unknown.
let description = crate::monitors::describe(
m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
"",
&connector,
);
Some(crate::monitors::PhysicalMonitor {
connector,
description,
width: num("width").max(0) as u32,
height: num("height").max(0) as u32,
// `refreshRate` is Hz as a float.
refresh_mhz: (m.get("refreshRate").and_then(|v| v.as_f64()).unwrap_or(0.0) * 1000.0)
as u32,
x: num("x") as i32,
y: num("y") as i32,
scale: m
.get("scale")
.and_then(|v| v.as_f64())
.filter(|s| *s > 0.0)
.unwrap_or(1.0),
primary: m.get("focused").and_then(|v| v.as_bool()).unwrap_or(false),
enabled: !m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false),
// Our headless outputs are named `PF-<n>` (see `next_output_name`).
managed: m
.get("name")
.and_then(|v| v.as_str())
.is_some_and(|n| n.starts_with("PF-")),
})
})
.collect();
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
Ok(out)
}
/// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by /// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by
/// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to /// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to
/// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the /// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the
+205 -11
View File
@@ -32,6 +32,7 @@ use std::sync::mpsc::Sender;
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use wayland_client::protocol::wl_output::{self, WlOutput};
use wayland_client::protocol::wl_registry::{self, WlRegistry}; use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle}; use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
@@ -914,6 +915,10 @@ struct State {
node_id: Option<u32>, node_id: Option<u32>,
failed: Option<String>, failed: Option<String>,
closed: bool, closed: bool,
/// Every `wl_output` KWin advertises, keyed by the proxy, with its connector name once the
/// `name` event arrives. Only the monitor-mirror path ([`stream_existing_output`]) needs these
/// — `stream_output` takes a `wl_output` object, so the connector has to be resolved to one.
outputs: Vec<(WlOutput, Option<String>)>,
} }
impl Dispatch<WlRegistry, ()> for State { impl Dispatch<WlRegistry, ()> for State {
@@ -934,6 +939,34 @@ impl Dispatch<WlRegistry, ()> for State {
if interface == Screencast::interface().name { if interface == Screencast::interface().name {
let v = version.min(MAX_VERSION); let v = version.min(MAX_VERSION);
state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ())); state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ()));
} else if interface == WlOutput::interface().name {
// v4 is where `wl_output.name` (the connector) arrives; bind at least that when the
// compositor offers it, else bind what it has and let the resolve fail loudly
// rather than mirroring an unidentifiable head.
let v = version.min(WL_OUTPUT_MAX_VERSION);
let out = registry.bind::<WlOutput, _, _>(name, v, qh, ());
state.outputs.push((out, None));
}
}
}
}
/// `wl_output` version we bind at: 4 brings the `name` event carrying the connector
/// (`DP-1`, …) — the only way to tell KWin's outputs apart on this connection.
const WL_OUTPUT_MAX_VERSION: u32 = 4;
impl Dispatch<WlOutput, ()> for State {
fn event(
state: &mut Self,
output: &WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
if let Some(slot) = state.outputs.iter_mut().find(|(o, _)| o == output) {
slot.1 = Some(name);
} }
} }
} }
@@ -988,6 +1021,55 @@ fn virtual_output_thread(
} }
} }
/// Start recording the existing KWin output named `connector` (the monitor-mirror path), returning
/// its PipeWire node id and the keepalive whose drop stops the recording.
///
/// `hw_cursor` selects the pointer mode exactly as the virtual-output path does: metadata for a
/// cursor-channel session, embedded otherwise, so the cursor behaves the same whichever source a
/// session is on.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let pointer_mode = if hw_cursor {
POINTER_METADATA
} else {
POINTER_EMBEDDED
};
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let connector_thread = connector.to_string();
thread::Builder::new()
.name("punktfunk-kwin-mirror".into())
.spawn(move || {
if let Err(e) = run_existing(&connector_thread, pointer_mode, &setup_tx, &stop_thread) {
let _ = setup_tx.send(Err(format!("{e:#}")));
}
})
.context("spawn KWin monitor-mirror thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("KWin monitor mirror failed: {e}"),
Err(_) => bail!("timed out recording the KWin output {connector:?}"),
};
Ok(crate::mirror::MirrorStream {
node_id,
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
remote_fd: None,
keepalive: Box::new(StopOnDrop(stop)),
})
}
/// Stops the mirror thread (and thus the recording) when the capturer drops it.
struct StopOnDrop(Arc<AtomicBool>);
impl Drop for StopOnDrop {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm /// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what /// the privileged `zkde_screencast` global is actually advertised. This is exactly what
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll /// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
@@ -1019,6 +1101,106 @@ pub fn is_available() -> bool {
probe().is_ok() probe().is_ok()
} }
/// Stream an **existing** KWin output — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L1). Same privileged global and the same thread/keepalive
/// shape as the virtual-output path; `stream_output` simply takes a `wl_output` instead of minting
/// one, so there is no dialog, no portal, and no chooser: the connector name IS the selection.
///
/// Returns the PipeWire node id. The thread parks until `stop`, holding the Wayland connection that
/// is the cast's lifetime — dropping it stops the recording and leaves the monitor untouched (we
/// never created it, so there is nothing to tear down; §7.1).
fn run_existing(
connector: &str,
pointer_mode: u32,
setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool,
) -> Result<()> {
let conn = Connection::connect_to_env()
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut state = State::default();
// Two roundtrips: the first processes the globals (binding screencast + every wl_output), the
// second drains each output's property burst — the `name` event we resolve the connector by.
queue.roundtrip(&mut state).context("registry roundtrip")?;
queue
.roundtrip(&mut state)
.context("wl_output property roundtrip")?;
let screencast = state.screencast.clone().ok_or_else(|| {
anyhow!(
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
)
})?;
// Resolve the connector to a bound wl_output. A miss is a hard error naming what IS there:
// mirroring some other monitor because the requested one is unplugged shows the operator a
// screen they did not ask for, which is worse than a session that refuses with a reason.
let named: Vec<&str> = state
.outputs
.iter()
.filter_map(|(_, n)| n.as_deref())
.collect();
let output = state
.outputs
.iter()
.find(|(_, n)| n.as_deref() == Some(connector))
.or_else(|| {
state.outputs.iter().find(|(_, n)| {
n.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(connector))
})
})
.map(|(o, _)| o.clone())
.ok_or_else(|| {
if named.is_empty() {
anyhow!(
"KWin advertised no named wl_output (needs wl_output v4 for the connector \
name) cannot mirror {connector:?}"
)
} else {
anyhow!(
"KWin has no output named {connector:?} — it has: {}",
named.join(", ")
)
}
})?;
let stream = screencast.stream_output(&output, pointer_mode, &qh, ());
tracing::info!(
connector,
embedded_pointer = pointer_mode != POINTER_METADATA,
"KWin: recording an existing output; awaiting PipeWire node"
);
let node_id = loop {
queue
.blocking_dispatch(&mut state)
.context("wayland dispatch (awaiting created)")?;
if let Some(node) = state.node_id {
break node;
}
if let Some(e) = state.failed.take() {
bail!("stream_output failed: {e}");
}
if state.closed {
bail!("KWin closed the stream before it was created");
}
};
setup_tx
.send(Ok(node_id))
.map_err(|_| anyhow!("monitor-mirror opener went away"))?;
park_until_stopped(&conn, &mut queue, &mut state, stop, connector, node_id)?;
stream.close();
let _ = conn.flush();
Ok(())
}
fn run( fn run(
width: u32, width: u32,
height: u32, height: u32,
@@ -1080,15 +1262,31 @@ fn run(
.send(Ok(node_id)) .send(Ok(node_id))
.map_err(|_| anyhow!("virtual-output opener went away"))?; .map_err(|_| anyhow!("virtual-output opener went away"))?;
// Keep the connection (and thus the virtual output) alive until told to stop, observing park_until_stopped(&conn, &mut queue, &mut state, stop, name, node_id)?;
// `closed`. blocking_dispatch can't be interrupted, so poll the connection fd with a short
// timeout so `stop` is honored within ~200 ms. // Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
stream.close();
let _ = conn.flush();
Ok(())
}
/// Keep the connection (and thus the stream) alive until told to stop, observing `closed`.
/// `blocking_dispatch` can't be interrupted, so poll the connection fd with a short timeout and
/// honor `stop` within ~200 ms. Shared by the virtual-output and monitor-mirror paths — for a
/// virtual output this connection IS the output's lifetime; for a mirror it is only the
/// recording's, and the monitor itself is untouched either way.
fn park_until_stopped(
conn: &Connection,
queue: &mut wayland_client::EventQueue<State>,
state: &mut State,
stop: &AtomicBool,
output: &str,
node_id: u32,
) -> Result<()> {
while !stop.load(Ordering::Relaxed) { while !stop.load(Ordering::Relaxed) {
queue queue.dispatch_pending(state).context("dispatch_pending")?;
.dispatch_pending(&mut state)
.context("dispatch_pending")?;
if state.closed { if state.closed {
tracing::warn!(output = %name, node_id, "KWin closed the virtual-output stream"); tracing::warn!(output = %output, node_id, "KWin closed the screencast stream");
break; break;
} }
conn.flush().context("wayland flush")?; conn.flush().context("wayland flush")?;
@@ -1110,10 +1308,6 @@ fn run(
let _ = guard.read(); let _ = guard.read();
} // else: timeout or signal — drop the guard, re-check `stop` } // else: timeout or signal — drop the guard, re-check `stop`
} }
// Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
stream.close();
let _ = conn.flush();
Ok(()) Ok(())
} }
@@ -69,6 +69,9 @@ pub mod management {
} }
use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode}; use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode};
use device::kde_output_device_registry_v2::{
Event as RegistryEvent, KdeOutputDeviceRegistryV2 as DeviceRegistry,
};
use device::kde_output_device_v2::{Event as DeviceEvent, KdeOutputDeviceV2 as OutputDevice}; use device::kde_output_device_v2::{Event as DeviceEvent, KdeOutputDeviceV2 as OutputDevice};
use management::kde_mode_list_v2::KdeModeListV2 as ModeList; use management::kde_mode_list_v2::KdeModeListV2 as ModeList;
use management::kde_output_configuration_v2::{ use management::kde_output_configuration_v2::{
@@ -82,10 +85,16 @@ use management::kde_output_management_v2::KdeOutputManagementV2 as OutputManagem
/// always in range on any KWin that advertises the globals. /// always in range on any KWin that advertises the globals.
const MGMT_MAX: u32 = 22; const MGMT_MAX: u32 = 22;
const DEVICE_MAX: u32 = 24; const DEVICE_MAX: u32 = 24;
/// `kde_output_device_registry_v2` — the newer way KWin hands out output devices (verified live on
/// KWin 6.7.3, which advertises this at v23 and NO per-output `kde_output_device_v2` globals).
const DEVICE_REGISTRY_MAX: u32 = 24;
/// The opcode of `kde_output_device_v2.mode` (0-based event index) — the event that creates a child /// The opcode of `kde_output_device_v2.mode` (0-based event index) — the event that creates a child
/// `kde_output_device_mode_v2`. Kept in sync with the vendored `kde-output-device-v2.xml`. /// `kde_output_device_mode_v2`. Kept in sync with the vendored `kde-output-device-v2.xml`.
const DEVICE_MODE_EVENT_OPCODE: u16 = 2; const DEVICE_MODE_EVENT_OPCODE: u16 = 2;
/// The opcode of `kde_output_device_registry_v2.output` — the event that creates a child
/// `kde_output_device_v2`. `finished` is event 0, `output` is event 1.
const REGISTRY_OUTPUT_EVENT_OPCODE: u16 = 1;
/// Overall budget for one enumerate-then-apply operation. Generous next to a healthy roundtrip (a /// Overall budget for one enumerate-then-apply operation. Generous next to a healthy roundtrip (a
/// few ms); it exists only so a wedged compositor can't pin the session's stream thread. /// few ms); it exists only so a wedged compositor can't pin the session's stream thread.
@@ -131,6 +140,14 @@ struct DeviceState {
name: Option<String>, name: Option<String>,
uuid: Option<String>, uuid: Option<String>,
enabled: bool, enabled: bool,
/// Top-left in the compositor's global logical space, from the `geometry` event — the key that
/// makes a head identifiable when two of them share a size (`monitors::PhysicalMonitor`).
position: (i32, i32),
/// `make` / `model` from the same event, for the picker label.
make: Option<String>,
model: Option<String>,
/// Logical scale from the `scale` event. `None` ⇒ the protocol's documented default of 1.
scale: Option<f64>,
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18). /// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
priority: Option<u32>, priority: Option<u32>,
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`]. /// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
@@ -148,6 +165,9 @@ struct DeviceState {
struct State { struct State {
management: Option<OutputManagement>, management: Option<OutputManagement>,
mgmt_name_version: Option<(u32, u32)>, mgmt_name_version: Option<(u32, u32)>,
/// The `kde_output_device_registry_v2` on KWin ≥ 6.7, held so it keeps announcing outputs for
/// the life of the session (dropping it would end the announcements).
device_registry: Option<DeviceRegistry>,
devices: HashMap<ObjectId, DeviceState>, devices: HashMap<ObjectId, DeviceState>,
/// mode object id → `(width, height, refresh_mHz)`. /// mode object id → `(width, height, refresh_mHz)`.
mode_dims: HashMap<ObjectId, (u32, u32, u32)>, mode_dims: HashMap<ObjectId, (u32, u32, u32)>,
@@ -185,6 +205,17 @@ impl Dispatch<WlRegistry, ()> for State {
let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name); let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name);
let id = dev.id(); let id = dev.id();
state.devices.entry(id).or_default().proxy = Some(dev); state.devices.entry(id).or_default().proxy = Some(dev);
} else if interface == DeviceRegistry::interface().name {
// KWin ≥ 6.7 (Plasma 6.7.3 verified) no longer advertises ONE
// `kde_output_device_v2` global per output — it advertises this registry and
// hands the devices out through its `output` events instead. Binding it is what
// makes this whole module work on a current KWin: without it the device list
// comes back EMPTY, which reads as "no outputs" and silently degrades every
// topology apply to the `kscreen-doctor` shell-out this module exists to avoid.
// Both models are kept — the per-output globals are still what older KWin sends.
let v = version.min(DEVICE_REGISTRY_MAX);
state.device_registry =
Some(registry.bind::<DeviceRegistry, _, _>(name, v, qh, ()));
} }
} }
wl_registry::Event::GlobalRemove { .. } => {} wl_registry::Event::GlobalRemove { .. } => {}
@@ -193,6 +224,30 @@ impl Dispatch<WlRegistry, ()> for State {
} }
} }
/// The device registry hands out one `kde_output_device_v2` per output via its `output` event
/// (a `new_id`, so the child is created by the `event_created_child!` binding below). Devices that
/// arrive this way have no global `name` number — the newest-wins supersede tie-break uses 0 for
/// them, which is fine: that tie-break only matters for the per-output-global model.
impl Dispatch<DeviceRegistry, ()> for State {
fn event(
state: &mut Self,
_: &DeviceRegistry,
event: RegistryEvent,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let RegistryEvent::Output { output } = event {
let id = output.id();
state.devices.entry(id).or_default().proxy = Some(output);
}
}
event_created_child!(State, DeviceRegistry, [
REGISTRY_OUTPUT_EVENT_OPCODE => (OutputDevice, 0u32),
]);
}
// Management has no events. // Management has no events.
impl Dispatch<OutputManagement, ()> for State { impl Dispatch<OutputManagement, ()> for State {
fn event( fn event(
@@ -237,6 +292,15 @@ impl Dispatch<OutputDevice, u32> for State {
match event { match event {
DeviceEvent::Name { name } => entry.name = Some(name), DeviceEvent::Name { name } => entry.name = Some(name),
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid), DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
DeviceEvent::Geometry {
x, y, make, model, ..
} => {
entry.position = (x, y);
entry.make = Some(make);
entry.model = Some(model);
}
// `fixed` decodes to f64 in wayland-rs.
DeviceEvent::Scale { factor } => entry.scale = Some(factor),
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0, DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
DeviceEvent::Priority { priority } => entry.priority = Some(priority), DeviceEvent::Priority { priority } => entry.priority = Some(priority),
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()), DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
@@ -349,6 +413,15 @@ impl Session {
if !s.sync_barrier(deadline) { if !s.sync_barrier(deadline) {
return None; return None;
} }
// Phase 3 (KWin ≥ 6.7, the registry model): the devices themselves only arrive as the
// registry's `output` events during phase 2, so their property bursts are one round further
// out than they are for per-output globals. One more barrier — skipped when no device is
// still waiting for its `done`, so the classic path costs nothing.
if s.state.device_registry.is_some() && s.state.devices.values().any(|d| !d.seen_done) {
if !s.sync_barrier(deadline) {
return None;
}
}
Some(s) Some(s)
} }
@@ -455,6 +528,54 @@ fn mode_spec(dims: (u32, u32, u32)) -> String {
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins). /// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
const MANAGED_PREFIX: &str = "Virtual-punktfunk"; const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// Every head KWin reports, for [`crate::monitors::list`].
///
/// Reuses the same bounded enumerate-only session the topology path opens — no configuration is
/// built and nothing is applied, so this is a pure read. A device that never completed its `done`
/// burst is skipped rather than reported half-read (its geometry would be a guess, and geometry is
/// exactly what callers key on).
pub(crate) fn list_monitors() -> anyhow::Result<Vec<crate::monitors::PhysicalMonitor>> {
let session = Session::open().ok_or_else(|| {
anyhow::anyhow!(
"KWin did not answer kde_output_management_v2 (not a KWin session, the protocol is \
not advertised to this client, or the compositor is wedged)"
)
})?;
let mut out: Vec<_> = session
.state
.devices
.values()
.filter(|d| d.seen_done)
.filter_map(|d| {
let connector = d.name.clone()?;
let dims = session.current_dims(d);
Some(crate::monitors::PhysicalMonitor {
managed: connector.starts_with(MANAGED_PREFIX),
description: crate::monitors::describe(
d.make.as_deref().unwrap_or(""),
d.model.as_deref().unwrap_or(""),
&connector,
),
// A disabled output has no current mode — report 0s rather than inventing one.
width: dims.map(|d| d.0).unwrap_or(0),
height: dims.map(|d| d.1).unwrap_or(0),
refresh_mhz: dims.map(|d| d.2).unwrap_or(0),
x: d.position.0,
y: d.position.1,
scale: d.scale.filter(|s| *s > 0.0).unwrap_or(1.0),
// KWin ranks outputs; 1 is the primary.
primary: d.priority == Some(1),
enabled: d.enabled,
connector,
})
})
.collect();
// The device globals arrive in bind order, which is not stable across runs; sort by desktop
// position so a picker (and a log line) reads left-to-right the way the desk looks.
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
Ok(out)
}
/// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the /// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the
/// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`. /// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`.
/// See the module docs for why this is done in-process instead of via `kscreen-doctor`. /// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
@@ -817,4 +938,12 @@ mod tests {
fn mode_event_opcode_is_two() { fn mode_event_opcode_is_two() {
assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2); assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2);
} }
/// Same hazard for the registry's `output` event (`finished` is 0, `output` is 1): the
/// `event_created_child!` binding hardcodes the opcode, so a reorder in the vendored XML would
/// bind the child to the wrong event and produce outputs with no properties.
#[test]
fn registry_output_event_opcode_is_one() {
assert_eq!(REGISTRY_OUTPUT_EVENT_OPCODE, 1);
}
} }
@@ -376,6 +376,190 @@ fn session_thread(
}); });
} }
/// Record an **existing** monitor by connector — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L2). Returns the PipeWire node id and the keepalive
/// whose drop stops the recording.
///
/// Same private ScreenCast API as the virtual path, one call different: `RecordMonitor` instead of
/// `RecordVirtual`. So it inherits what makes that path work headlessly — Mutter's *direct* D-Bus
/// API needs no interactive approval, unlike the xdg portal a background service could never answer.
///
/// Deliberately **not** under [`TOPOLOGY_LOCK`]: that lock serializes operations which add/remove a
/// monitor or apply a monitor configuration, and mirroring does neither. It creates nothing, changes
/// no layout, and leaves the head exactly as its owner set it.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let connector_thread = connector.to_string();
thread::Builder::new()
.name("punktfunk-mutter-mirror".into())
.spawn(move || mirror_thread(setup_tx, stop_thread, connector_thread, hw_cursor))
.context("spawn Mutter monitor-mirror thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("Mutter monitor mirror failed: {e}"),
Err(_) => bail!("timed out recording the Mutter output {connector:?}"),
};
Ok(crate::mirror::MirrorStream {
node_id,
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
remote_fd: None,
keepalive: Box::new(MirrorStop(stop)),
})
}
/// Stops the mirror thread — and with it the recording — when the capturer drops it.
struct MirrorStop(Arc<AtomicBool>);
impl Drop for MirrorStop {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
/// Owns the D-Bus connection behind a mirrored monitor's cast: connect, hand back the node id,
/// park until stopped, then `Stop` the session.
fn mirror_thread(
setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>,
connector: String,
hw_cursor: bool,
) {
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
return;
}
};
rt.block_on(async move {
let session = match connect_monitor(&connector, hw_cursor).await {
Ok(s) => s,
Err(e) => {
let _ = setup_tx.send(Err(format!("{e:#}")));
return;
}
};
let _ = setup_tx.send(Ok(session.node_id));
while !stop.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(200)).await;
}
// Stop the cast. Nothing else to undo: no virtual output was added, so there is no monitor
// removal for Mutter to rebuild around — the SIGSEGV-adjacent teardown ordering the virtual
// path has to observe simply doesn't arise here.
let _ = session.rd_session.call_method("Stop", &()).await;
});
}
/// The `RecordMonitor` handshake: RemoteDesktop session → ScreenCast session anchored to it →
/// record `connector` → node id on `PipeWireStreamAdded` after `Start`.
async fn connect_monitor(connector: &str, hw_cursor: bool) -> Result<MutterSession> {
let conn = zbus::Connection::session()
.await
.context("connect session D-Bus")?;
let rd = zbus::Proxy::new(
&conn,
BUS_RD,
"/org/gnome/Mutter/RemoteDesktop",
"org.gnome.Mutter.RemoteDesktop",
)
.await
.context("RemoteDesktop proxy (is gnome-shell running?)")?;
let rd_path: OwnedObjectPath = rd
.call("CreateSession", &())
.await
.context("RemoteDesktop.CreateSession")?;
let rd_session = zbus::Proxy::new(
&conn,
BUS_RD,
rd_path,
"org.gnome.Mutter.RemoteDesktop.Session",
)
.await?;
let session_id: String = rd_session
.get_property("SessionId")
.await
.context("read SessionId")?;
let sc = zbus::Proxy::new(
&conn,
BUS_SC,
"/org/gnome/Mutter/ScreenCast",
"org.gnome.Mutter.ScreenCast",
)
.await
.context("ScreenCast proxy")?;
let mut props: HashMap<&str, Value> = HashMap::new();
props.insert("remote-desktop-session-id", Value::from(session_id));
let sc_path: OwnedObjectPath = sc
.call("CreateSession", &(props,))
.await
.context("ScreenCast.CreateSession")?;
let sc_session = zbus::Proxy::new(
&conn,
BUS_SC,
sc_path,
"org.gnome.Mutter.ScreenCast.Session",
)
.await?;
// `RecordMonitor(connector, properties) -> stream_path`. Only `cursor-mode` is set: the mode
// belongs to the monitor's owner, not to us.
let mut rec: HashMap<&str, Value> = HashMap::new();
rec.insert(
"cursor-mode",
Value::from(if hw_cursor {
CURSOR_METADATA
} else {
CURSOR_EMBEDDED
}),
);
let stream_path: OwnedObjectPath = sc_session
.call("RecordMonitor", &(connector, rec))
.await
.with_context(|| format!("Session.RecordMonitor({connector:?})"))?;
let stream = zbus::Proxy::new(
&conn,
BUS_SC,
stream_path,
"org.gnome.Mutter.ScreenCast.Stream",
)
.await?;
let mut added = stream
.receive_signal("PipeWireStreamAdded")
.await
.context("subscribe PipeWireStreamAdded")?;
rd_session
.call_method("Start", &())
.await
.context("RemoteDesktop.Session.Start")?;
let msg = tokio::time::timeout(Duration::from_secs(10), added.next())
.await
.map_err(|_| anyhow!("PipeWireStreamAdded did not arrive within 10s"))?
.ok_or_else(|| anyhow!("signal stream ended before PipeWireStreamAdded"))?;
let (node_id,): (u32,) = msg
.body()
.deserialize()
.context("PipeWireStreamAdded body")?;
tracing::info!(connector, node_id, "mutter: recording an existing monitor");
Ok(MutterSession {
rd_session,
_sc_session: sc_session,
_conn: conn,
node_id,
})
}
/// The live session objects (held for the stream's lifetime) + the PipeWire node id. /// The live session objects (held for the stream's lifetime) + the PipeWire node id.
struct MutterSession { struct MutterSession {
rd_session: zbus::Proxy<'static>, rd_session: zbus::Proxy<'static>,
@@ -811,6 +995,56 @@ fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
.map(|l| l.2) .map(|l| l.2)
} }
/// Every head Mutter reports, for [`crate::monitors::list`].
///
/// A pure `GetCurrentState` read on its own short-lived connection + runtime — no session, no
/// `ApplyMonitorsConfig`, so it never touches the topology and never contends [`TOPOLOGY_LOCK`].
/// Geometry comes from the **logical** monitors (`state.2`), which is the coordinate space that
/// matters (see `crate::monitors`); a monitor absent from every logical monitor is disabled, and is
/// reported as such at the origin rather than dropped.
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build tokio runtime (monitor enumeration)")?;
let state = rt.block_on(async {
let dc = display_config().await?;
get_state(&dc).await
})?;
let mut out: Vec<_> = state
.1
.iter()
.map(|(spec, _modes, _props)| {
let (connector, vendor, product, _serial) = spec;
// The logical monitor carrying this connector: its (x, y), scale and primary flag.
let logical = state
.2
.iter()
.find(|l| l.5.iter().any(|s| &s.0 == connector));
let (w, h, refresh) = current_mode_full(&state, connector)
.map(|(_id, w, h, hz)| (w.max(0) as u32, h.max(0) as u32, (hz * 1000.0) as u32))
.unwrap_or((0, 0, 0));
crate::monitors::PhysicalMonitor {
connector: connector.clone(),
description: crate::monitors::describe(vendor, product, connector),
width: w,
height: h,
refresh_mhz: refresh,
x: logical.map(|l| l.0).unwrap_or(0),
y: logical.map(|l| l.1).unwrap_or(0),
scale: logical.map(|l| l.2).filter(|s| *s > 0.0).unwrap_or(1.0),
primary: logical.map(|l| l.4).unwrap_or(false),
enabled: logical.is_some(),
// Mutter names a `RecordVirtual` monitor indistinguishably from a physical one —
// no prefix to key on, and the connector is minted per session. See the field doc.
managed: false,
}
})
.collect();
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
Ok(out)
}
/// Read the virtual output's current scale and, when the user changed it (GNOME Settings /// Read the virtual output's current scale and, when the user changed it (GNOME Settings
/// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it. /// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it.
/// Best-effort: read failures (teardown races, shell restart) are silently skipped. /// Best-effort: read failures (teardown races, shell restart) are silently skipped.
+129 -24
View File
@@ -111,25 +111,13 @@ impl VirtualDisplay for WlrootsDisplay {
swaymsg(&["output", &name, "enable"]) swaymsg(&["output", &name, "enable"])
.with_context(|| format!("swaymsg output {name} enable"))?; .with_context(|| format!("swaymsg output {name} enable"))?;
// Steer xdpw's headless output chooser at our new output, then run the portal // Steer xdpw's headless output chooser at our new output, then run the portal handshake on
// handshake on its own thread (it parks to keep the cast alive, like the other backends). // its own thread (it parks to keep the cast alive, like the other backends). Serialized:
ensure_xdpw_config()?; // the chooser is one per-user file, so a concurrent session's write between ours and xdpw's
let chooser = chooser_file(); // read would silently capture the wrong output (see `SELECTION_LOCK`).
std::fs::write(&chooser, format!("Monitor: {name}\n")) let (fd, node_id, stop) = {
.with_context(|| format!("write {chooser}"))?; let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>(); select_and_cast(&name, self.hw_cursor)?
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let hw_cursor = self.hw_cursor;
thread::Builder::new()
.name("punktfunk-wlr-vout".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn wlroots portal thread")?;
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
}; };
tracing::info!( tracing::info!(
node_id, node_id,
@@ -207,20 +195,29 @@ fn swaymsg(args: &[&str]) -> Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} }
/// Current output names from `swaymsg -t get_outputs` (JSON). /// Run a swaymsg **query** (`-t <kind> --raw`) and parse its JSON.
fn output_names() -> Result<Vec<String>> { ///
/// ⚠️ Deliberately NOT [`swaymsg`]: that helper inserts `--` so its arguments are read as a sway
/// *command*, which is right for `create_output` and wrong for a query — `-t` after `--` comes back
/// as `Unknown/invalid command '-t'` (caught on-glass writing the monitor enumeration).
fn swaymsg_query(kind: &str) -> Result<serde_json::Value> {
let out = Command::new("swaymsg") let out = Command::new("swaymsg")
.args(["-t", "get_outputs", "--raw"]) .args(["-t", kind, "--raw"])
.output() .output()
.context("run swaymsg (is sway installed?)")?; .context("run swaymsg (is sway installed?)")?;
if !out.status.success() { if !out.status.success() {
bail!( bail!(
"swaymsg -t get_outputs failed: {}", "swaymsg -t {kind} failed: {}",
String::from_utf8_lossy(&out.stderr).trim() String::from_utf8_lossy(&out.stderr).trim()
); );
} }
let raw = String::from_utf8_lossy(&out.stdout).into_owned(); let raw = String::from_utf8_lossy(&out.stdout).into_owned();
let outputs: serde_json::Value = serde_json::from_str(&raw).context("parse get_outputs")?; serde_json::from_str(&raw).with_context(|| format!("parse {kind}"))
}
/// Current output names from `swaymsg -t get_outputs` (JSON).
fn output_names() -> Result<Vec<String>> {
let outputs = swaymsg_query("get_outputs")?;
Ok(outputs Ok(outputs
.as_array() .as_array()
.context("get_outputs: not an array")? .context("get_outputs: not an array")?
@@ -229,6 +226,114 @@ fn output_names() -> Result<Vec<String>> {
.collect()) .collect())
} }
/// Serializes **write-the-chooser → complete-the-handshake**, process-wide.
///
/// The chooser is a single per-user file: whoever writes last before xdpw reads wins. Two sessions
/// starting at once (or a mirror starting beside a virtual output) would otherwise race, and the
/// loser doesn't fail — it silently captures the *other* session's output. Held across the portal
/// handshake, not just the write, because the read happens inside it.
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
ensure_xdpw_config()?;
let chooser = chooser_file();
std::fs::write(&chooser, format!("Monitor: {output}\n"))
.with_context(|| format!("write {chooser}"))?;
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
thread::Builder::new()
.name("punktfunk-wlr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn wlroots portal thread")?;
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
}
/// Record an **existing** sway output — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L3). Same chooser mechanism the virtual-output path
/// uses, pointed at a physical connector instead of a headless one we created, so it inherits the
/// "no GUI picker" property a background service needs.
///
/// The keepalive stops the cast and nothing else: sway keeps the monitor, because we never made it.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
keepalive: Box::new(StopGuard(stop)),
})
}
/// Every head sway reports, for [`crate::monitors::list`].
///
/// `swaymsg -t get_outputs` reports `rect` in the logical coordinate space (post-scale,
/// post-transform) — what `crate::monitors` documents. An inactive output has no `current_mode`, so
/// its mode reads as zeros rather than a guess.
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
let parsed = swaymsg_query("get_outputs")?;
let mut out: Vec<_> = parsed
.as_array()
.context("get_outputs: not an array")?
.iter()
.filter_map(|o| {
let connector = o.get("name")?.as_str()?.to_string();
let rect = |k: &str| {
o.get("rect")
.and_then(|r| r.get(k))
.and_then(|v| v.as_i64())
.unwrap_or(0)
};
let mode = |k: &str| {
o.get("current_mode")
.and_then(|m| m.get(k))
.and_then(|v| v.as_i64())
.unwrap_or(0)
};
let str_field = |k: &str| o.get(k).and_then(|v| v.as_str()).unwrap_or("").trim();
Some(crate::monitors::PhysicalMonitor {
description: crate::monitors::describe(
str_field("make"),
str_field("model"),
&connector,
),
width: mode("width").max(0) as u32,
height: mode("height").max(0) as u32,
// sway reports `refresh` in mHz already.
refresh_mhz: mode("refresh").max(0) as u32,
x: rect("x") as i32,
y: rect("y") as i32,
scale: o
.get("scale")
.and_then(|v| v.as_f64())
.filter(|s| *s > 0.0)
.unwrap_or(1.0),
primary: o
.get("primary")
.and_then(|v| v.as_bool())
.or_else(|| o.get("focused").and_then(|v| v.as_bool()))
.unwrap_or(false),
enabled: o.get("active").and_then(|v| v.as_bool()).unwrap_or(true),
// Sway auto-names headless outputs `HEADLESS-N` and that is what `create` adds. A
// sway started with its own headless output would match too — hence best-effort.
managed: connector.starts_with("HEADLESS-"),
connector,
})
})
.collect();
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
Ok(out)
}
/// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N). /// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N).
fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> { fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
+269
View File
@@ -0,0 +1,269 @@
//! The monitor-mirror display backend: stream a **physical** head the compositor already has,
//! instead of creating a virtual one. See `design/per-monitor-portal-capture.md`.
//!
//! It implements [`VirtualDisplay`] so it drops into the session machinery unchanged — but it
//! creates nothing, and that difference is load-bearing:
//!
//! * [`DisplayOwnership::External`] ("someone else's display, merely mirrored: no keep-alive, no
//! topology, no reuse") is exactly the contract for a head we don't own, so the registry's
//! pooling/linger/exclusive machinery passes it through. §7.1 of the design doc — never disable,
//! re-position or "restore" a monitor the user is sitting in front of — holds *by construction*
//! here rather than by everyone remembering it.
//! * `create()` ignores the requested [`Mode`]. A panel runs at the mode the user set; the client
//! scales. Reconfiguring someone's physical display to match a phone would be the same class of
//! rudeness as blanking it (§7.3).
//!
//! Selection is a **host-level pin** (`PUNKTFUNK_CAPTURE_MONITOR`), not a per-session choice —
//! the host-pinned decision of record (§5.3), which is also what makes the input anchor below
//! sound: one pin for the whole host, so the shared injector can't be re-aimed per session.
use super::backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
use super::monitors;
use crate::{Compositor, Mode};
use anyhow::{bail, Context, Result};
/// What a backend hands back when it starts recording an existing head.
///
/// `remote_fd` is the split: KWin and Mutter publish the node on the user's own PipeWire daemon
/// (nothing to carry), while the portal-based backends (sway/xdpw, Hyprland/xdph) hand back a
/// sandboxed remote fd that the capturer must connect through — the same distinction their
/// *virtual*-output paths already make.
pub(crate) struct MirrorStream {
pub node_id: u32,
pub remote_fd: Option<std::os::fd::OwnedFd>,
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
pub keepalive: Box<dyn Send>,
}
/// Streams an existing monitor, named by connector.
pub struct MirrorDisplay {
compositor: Compositor,
connector: String,
hw_cursor: bool,
}
impl MirrorDisplay {
pub fn new(compositor: Compositor, connector: String) -> Result<Self> {
Ok(MirrorDisplay {
compositor,
connector,
hw_cursor: false,
})
}
}
impl VirtualDisplay for MirrorDisplay {
fn name(&self) -> &'static str {
"mirror"
}
fn set_hw_cursor(&mut self, on: bool) {
self.hw_cursor = on;
}
fn hw_cursor(&self) -> bool {
self.hw_cursor
}
fn create(&mut self, _mode: Mode) -> Result<VirtualOutput> {
// Resolve the pin against the live head list FIRST: it yields the geometry the input anchor
// needs, and it turns "that monitor is gone" into one clear error before any compositor
// call. `resolve` refuses to substitute a different head (see its doc).
let monitors = monitors::list(self.compositor)
.with_context(|| format!("enumerate monitors to mirror {:?}", self.connector))?;
let target = monitors::resolve(&monitors, &self.connector)?;
check_mirrorable(target, self.compositor)?;
let origin = (target.x, target.y);
let dims = (target.width, target.height, refresh_hz(target.refresh_mhz));
let stream = match self.compositor {
#[cfg(target_os = "linux")]
Compositor::Kwin => {
crate::kwin::stream_existing_output(&target.connector, self.hw_cursor)?
}
#[cfg(target_os = "linux")]
Compositor::Mutter => {
crate::mutter::stream_existing_output(&target.connector, self.hw_cursor)?
}
#[cfg(target_os = "linux")]
Compositor::Wlroots => {
crate::wlroots::stream_existing_output(&target.connector, self.hw_cursor)?
}
#[cfg(target_os = "linux")]
Compositor::Hyprland => {
crate::hyprland::stream_existing_output(&target.connector, self.hw_cursor)?
}
// gamescope is nested — it has no physical heads of its own, and `monitors::list`
// already returned an empty set, so `resolve` failed before we got here. This arm
// exists for exhaustiveness, not as a reachable path.
other => bail!(
"mirroring an existing monitor is not supported on the {} backend",
other.id()
),
};
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
// rather than set here per session.
tracing::info!(
connector = %target.connector,
mode = %target.mode_label(),
at = %format!("+{}+{}", origin.0, origin.1),
node_id = stream.node_id,
"mirroring an existing monitor (no virtual display created)"
);
// The keepalive IS the recording: dropping it stops the cast and leaves the monitor exactly
// as it was (we created nothing, so there is nothing to restore).
let mut out = VirtualOutput::owned(stream.node_id, Some(dims), stream.keepalive);
// Portal-based backends (sway/xdpw, Hyprland/xdph) publish on a sandboxed remote the
// capturer must connect through; KWin/Mutter use the user's own daemon.
out.remote_fd = stream.remote_fd;
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
out.ownership = DisplayOwnership::External;
Ok(out)
}
}
/// Can this head be mirrored at all? Each rejection is something a compositor would otherwise
/// answer with silence or a black stream, so it is caught here where the reason can be stated.
fn check_mirrorable(target: &monitors::PhysicalMonitor, compositor: Compositor) -> Result<()> {
if !target.enabled {
bail!(
"monitor {:?} is disabled — enable it before streaming it",
target.connector
);
}
if target.managed {
// `managed` is only *conclusive* where the name is ours by construction: KWin's
// `Virtual-punktfunk` prefix, Hyprland's `PF-N`. Sway names EVERY headless output
// `HEADLESS-N` — its own included — so refusing there would block a legitimate setup
// (a headless sway box whose outputs are all HEADLESS-N is exactly the remote case this
// feature serves). Warn and continue; a user who really did pin our own virtual display
// sees a mirror of it and the log says why.
if names_ours_conclusively(compositor) {
bail!(
"monitor {:?} is one of punktfunk's own virtual displays, not a physical head — \
clear the streamed-screen setting to use the normal virtual-display path",
target.connector
);
}
tracing::warn!(
connector = %target.connector,
"the pinned monitor looks like a headless output — on this compositor that name is \
ambiguous (it may be one of punktfunk's own virtual displays), mirroring it anyway"
);
}
if target.width == 0 || target.height == 0 {
bail!(
"monitor {:?} reports no current mode ({}x{}) — it is not driving a signal",
target.connector,
target.width,
target.height
);
}
Ok(())
}
/// Does this compositor's `managed` flag mean "ours, for certain"? KWin outputs carry the
/// `Virtual-punktfunk` prefix we chose, and Hyprland's are `PF-N` — both ours by construction.
/// Sway's `HEADLESS-N` is sway's own generic naming, so it is a hint, not proof.
fn names_ours_conclusively(compositor: Compositor) -> bool {
matches!(compositor, Compositor::Kwin | Compositor::Hyprland)
}
/// mHz → whole Hz for [`VirtualOutput::preferred_mode`], never 0 (the negotiation treats 0 as
/// "unset", and a head that didn't report a refresh is still running at *some* rate — 60 is the
/// same assumption KWin's own virtual outputs are born with).
fn refresh_hz(mhz: u32) -> u32 {
let hz = (mhz as f64 / 1000.0).round() as u32;
if hz == 0 {
60
} else {
hz
}
}
#[cfg(test)]
mod tests {
use super::*;
fn head(connector: &str) -> monitors::PhysicalMonitor {
monitors::PhysicalMonitor {
connector: connector.into(),
description: "ACME 27".into(),
width: 2560,
height: 1440,
refresh_mhz: 144_000,
x: 1920,
y: 0,
scale: 1.0,
primary: false,
enabled: true,
managed: false,
}
}
#[test]
fn a_real_enabled_head_is_mirrorable() {
assert!(check_mirrorable(&head("DP-2"), Compositor::Kwin).is_ok());
}
/// Streaming a dark head would be a black rectangle with no explanation — say why instead.
#[test]
fn a_disabled_head_is_refused_with_the_reason() {
let mut m = head("DP-2");
m.enabled = false;
let err = check_mirrorable(&m, Compositor::Kwin)
.unwrap_err()
.to_string();
assert!(err.contains("disabled"), "{err}");
}
/// Mirroring our OWN virtual display is a loop with extra steps; catch the misconfiguration
/// rather than streaming something confusing.
#[test]
fn one_of_our_own_virtual_displays_is_refused() {
let mut m = head("Virtual-punktfunk-1");
m.managed = true;
let err = check_mirrorable(&m, Compositor::Kwin)
.unwrap_err()
.to_string();
assert!(err.contains("virtual displays"), "{err}");
}
/// Sway names every headless output `HEADLESS-N`, its own included, so the `managed` hint is
/// NOT proof there — refusing would block a headless sway box, which is exactly the remote
/// setup this feature serves. (Found on-glass: a two-output headless sway had both heads
/// flagged, and the KWin-strength rule would have refused to mirror either.)
#[test]
fn a_headless_sway_output_is_mirrored_despite_the_ambiguous_name() {
let mut m = head("HEADLESS-2");
m.managed = true;
assert!(check_mirrorable(&m, Compositor::Wlroots).is_ok());
// Hyprland's `PF-N` IS our naming, so it stays conclusive.
assert!(check_mirrorable(&m, Compositor::Hyprland).is_err());
}
/// A head listed but not driving a mode (enabled yet modeless) would negotiate a 0x0 stream.
#[test]
fn a_head_with_no_current_mode_is_refused() {
let mut m = head("DP-2");
m.width = 0;
m.height = 0;
let err = check_mirrorable(&m, Compositor::Kwin)
.unwrap_err()
.to_string();
assert!(err.contains("no current mode"), "{err}");
}
#[test]
fn refresh_rounds_and_never_reports_zero() {
assert_eq!(refresh_hz(60000), 60);
assert_eq!(refresh_hz(59940), 60);
assert_eq!(refresh_hz(119_920), 120);
// An unreported refresh must not become a 0 Hz preferred mode.
assert_eq!(refresh_hz(0), 60);
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Physical-monitor enumeration — "what heads does this host actually have?".
//!
//! This is the read-only counterpart to the rest of this crate: everything else here *creates* and
//! owns virtual outputs, while this module only *reports* the heads the compositor already has, so
//! an operator can pin capture at one of them (`PUNKTFUNK_CAPTURE_MONITOR`) and the console can
//! render a picker. See `design/per-monitor-portal-capture.md` §5.1.
//!
//! It lives in pf-vdisplay because monitor enumeration is a **per-compositor** question and this
//! crate already speaks every one of those dialects — KWin's `kde_output_device_v2`, Mutter's
//! `DisplayConfig.GetCurrentState`, `swaymsg -t get_outputs`, `hyprctl -j monitors`. Each backend's
//! implementation sits beside the code that already talks to it; this module is the shared type and
//! the dispatch.
//!
//! **The geometry is the point.** `x`/`y` are what make a head *identifiable*: two monitors can
//! share a size (and then a size-keyed match is a coin flip — see `pf-inject`'s absolute-coordinate
//! region selection), but they can never share an origin in the compositor's global space.
use crate::Compositor;
use anyhow::{bail, Result};
/// One head as the compositor currently reports it. Logical (post-scale) geometry throughout —
/// the same coordinate space libei regions and compositor layout use, *not* pixels.
#[derive(Clone, Debug, PartialEq)]
pub struct PhysicalMonitor {
/// Connector name — `DP-1`, `HDMI-A-2`, `eDP-1`. The id `PUNKTFUNK_CAPTURE_MONITOR` names.
pub connector: String,
/// Human label for a picker (`make model`, else the connector). Never used for matching.
pub description: String,
/// Current mode, in pixels.
pub width: u32,
pub height: u32,
/// Refresh in mHz (60000 = 60 Hz). 0 when the backend doesn't report it.
pub refresh_mhz: u32,
/// Top-left in the compositor's global logical space — the identity key (see the module doc).
pub x: i32,
pub y: i32,
/// Logical scale factor (1.0 when unreported).
pub scale: f64,
/// The compositor's primary/focused head, when it says.
pub primary: bool,
/// Enabled (driven). A disabled head is still listed, so "why can't I pick it?" has an answer.
pub enabled: bool,
/// **Best-effort**: this output is one WE created (a managed virtual display), not a real head.
/// Only KWin can say so reliably (managed outputs carry a name prefix); the other backends
/// name virtual outputs indistinguishably from physical ones, so this stays `false` there
/// rather than guessing. Callers use it to grey out nonsense choices, never to filter blindly.
pub managed: bool,
}
/// Build the picker label from a compositor's make/model, falling back to the connector.
///
/// Compositors fill unknown fields with the literal string `"Unknown"` rather than leaving them
/// empty (seen on-glass: sway reports `"Unknown Unknown"` for a headless output), so treat that as
/// absent too — a picker row reading "Unknown Unknown" is worse than one reading "DP-1".
pub(crate) fn describe(make: &str, model: &str, connector: &str) -> String {
let known = |s: &str| {
let s = s.trim();
!s.is_empty() && !s.eq_ignore_ascii_case("unknown")
};
let label = [make, model]
.iter()
.map(|s| s.trim())
.filter(|s| known(s))
.collect::<Vec<_>>()
.join(" ");
if label.is_empty() {
connector.to_string()
} else {
label
}
}
impl PhysicalMonitor {
/// `1920x1080@60` — for logs and pickers.
pub fn mode_label(&self) -> String {
if self.refresh_mhz == 0 {
format!("{}x{}", self.width, self.height)
} else {
format!(
"{}x{}@{}",
self.width,
self.height,
(self.refresh_mhz as f64 / 1000.0).round() as u32
)
}
}
}
/// Every head `compositor` reports, in the compositor's own order.
///
/// Errors when the backend can't be reached (compositor not running, IPC unavailable) — that is
/// deliberately distinct from `Ok(vec![])`, which means "reached it, it has no heads" (a headless
/// session). Callers that only want to *offer* a picker can treat both as "nothing to show";
/// callers resolving a pinned monitor must not (see [`resolve`]).
pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
match compositor {
#[cfg(target_os = "linux")]
Compositor::Kwin => crate::kwin_output_mgmt::list_monitors(),
#[cfg(target_os = "linux")]
Compositor::Mutter => crate::mutter::list_monitors(),
#[cfg(target_os = "linux")]
Compositor::Wlroots => crate::wlroots::list_monitors(),
#[cfg(target_os = "linux")]
Compositor::Hyprland => crate::hyprland::list_monitors(),
// gamescope is nested: it has no physical heads of its own, and mirroring the desktop it
// runs on is a question for the compositor UNDER it. Not an error — just nothing to offer.
#[cfg(target_os = "linux")]
Compositor::Gamescope => Ok(Vec::new()),
#[cfg(not(target_os = "linux"))]
_ => bail!("physical-monitor enumeration is implemented for the Linux backends only"),
}
}
/// Resolve a configured monitor name against `monitors`, exactly then case-insensitively.
///
/// **A miss is a hard error carrying the available names**, never a silent fall-back to some other
/// head: an operator who pinned `DP-2` and gets `DP-1` streamed has been shown the wrong screen,
/// which is worse than a host that refuses to start a session and says why
/// (`design/per-monitor-portal-capture.md` §5.2).
pub fn resolve<'a>(monitors: &'a [PhysicalMonitor], want: &str) -> Result<&'a PhysicalMonitor> {
if let Some(m) = monitors.iter().find(|m| m.connector == want) {
return Ok(m);
}
if let Some(m) = monitors
.iter()
.find(|m| m.connector.eq_ignore_ascii_case(want))
{
return Ok(m);
}
let available = monitors
.iter()
.map(|m| m.connector.as_str())
.collect::<Vec<_>>()
.join(", ");
if available.is_empty() {
bail!("no monitor named {want:?} — this compositor reports no monitors at all");
}
bail!("no monitor named {want:?} — this host has: {available}");
}
#[cfg(test)]
mod tests {
use super::*;
fn mon(connector: &str) -> PhysicalMonitor {
PhysicalMonitor {
connector: connector.into(),
description: String::new(),
width: 1920,
height: 1080,
refresh_mhz: 60000,
x: 0,
y: 0,
scale: 1.0,
primary: false,
enabled: true,
managed: false,
}
}
#[test]
fn resolve_matches_exactly_then_case_insensitively() {
let ms = [mon("DP-1"), mon("HDMI-A-2")];
assert_eq!(resolve(&ms, "DP-1").unwrap().connector, "DP-1");
assert_eq!(resolve(&ms, "hdmi-a-2").unwrap().connector, "HDMI-A-2");
}
#[test]
fn resolve_prefers_the_exact_match_over_a_case_fold() {
// Connector names are case-sensitive to the compositor; if both spellings exist, the
// exact one wins rather than whichever the fold happened to reach first.
let ms = [mon("dp-1"), mon("DP-1")];
assert_eq!(resolve(&ms, "DP-1").unwrap().connector, "DP-1");
}
#[test]
fn a_miss_lists_what_is_available() {
let ms = [mon("DP-1"), mon("HDMI-A-2")];
let err = resolve(&ms, "DP-9").unwrap_err().to_string();
assert!(err.contains("DP-1") && err.contains("HDMI-A-2"), "{err}");
}
#[test]
fn a_miss_with_no_monitors_says_so() {
let err = resolve(&[], "DP-1").unwrap_err().to_string();
assert!(err.contains("no monitors at all"), "{err}");
}
/// Compositors write the literal "Unknown" instead of leaving make/model empty (sway does it
/// for headless outputs), so a picker must not end up showing "Unknown Unknown".
#[test]
fn describe_falls_back_to_the_connector_for_empty_or_unknown_fields() {
assert_eq!(describe("ACME", "U2720Q", "DP-1"), "ACME U2720Q");
assert_eq!(describe("Unknown", "Unknown", "HEADLESS-1"), "HEADLESS-1");
assert_eq!(describe("", "", "DP-1"), "DP-1");
// A half-known pair keeps the half that means something.
assert_eq!(describe("Unknown", "U2720Q", "DP-1"), "U2720Q");
assert_eq!(describe(" ", "unknown", "DP-2"), "DP-2");
}
#[test]
fn mode_label_drops_an_unknown_refresh() {
let mut m = mon("DP-1");
assert_eq!(m.mode_label(), "1920x1080@60");
m.refresh_mhz = 0;
assert_eq!(m.mode_label(), "1920x1080");
}
}
+32 -4
View File
@@ -246,6 +246,16 @@ pub struct DisplayPolicy {
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off. /// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)] #[serde(default)]
pub pnp_disable_monitors: bool, pub pnp_disable_monitors: bool,
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
///
/// Orthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and
/// `#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a
/// **host-wide** setting, not per-client — the host-pinned decision of record in
/// `design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see
/// [`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it.
#[serde(default)]
pub capture_monitor: Option<String>,
} }
fn one() -> u32 { fn one() -> u32 {
@@ -271,6 +281,7 @@ impl Default for DisplayPolicy {
game_session: GameSession::default(), game_session: GameSession::default(),
ddc_power_off: false, ddc_power_off: false,
pnp_disable_monitors: false, pnp_disable_monitors: false,
capture_monitor: None,
} }
} }
} }
@@ -316,6 +327,12 @@ impl DisplayPolicy {
pub fn sanitized(mut self) -> Self { pub fn sanitized(mut self) -> Self {
self.version = 1; self.version = 1;
self.max_displays = self.max_displays.clamp(1, 16); self.max_displays = self.max_displays.clamp(1, 16);
// A picker that clears its selection sends `""`; that means "no pin", not "match the
// monitor named empty string" — same normalization the env knob does.
self.capture_monitor = self
.capture_monitor
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
self self
} }
} }
@@ -332,6 +349,7 @@ impl EffectivePolicy {
game_session: GameSession, game_session: GameSession,
ddc_power_off: bool, ddc_power_off: bool,
pnp_disable_monitors: bool, pnp_disable_monitors: bool,
capture_monitor: Option<String>,
) -> DisplayPolicy { ) -> DisplayPolicy {
DisplayPolicy { DisplayPolicy {
version: 1, version: 1,
@@ -345,10 +363,13 @@ impl EffectivePolicy {
positions, positions,
}, },
max_displays: self.max_displays, max_displays: self.max_displays,
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them). // Preserve the orthogonal axes (EffectivePolicy doesn't carry them). Dropping any of
// them here would mean "saving a display arrangement silently cleared my setting" —
// for `capture_monitor` that would swap the streamed screen out from under the operator.
game_session, game_session,
ddc_power_off, ddc_power_off,
pnp_disable_monitors, pnp_disable_monitors,
capture_monitor,
} }
} }
} }
@@ -791,12 +812,19 @@ mod tests {
let mut positions = BTreeMap::new(); let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 0, y: 0 }); positions.insert("1".to_string(), Position { x: 0, y: 0 });
positions.insert("7".to_string(), Position { x: 2560, y: 0 }); positions.insert("7".to_string(), Position { x: 2560, y: 0 });
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true); let p = eff.with_manual_layout(
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through positions,
// the transform. GameSession::Dedicated,
true,
true,
Some("DP-2".into()),
);
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
// preserved through the transform — arranging displays must not clear an unrelated setting.
assert_eq!(p.game_session, GameSession::Dedicated); assert_eq!(p.game_session, GameSession::Dedicated);
assert!(p.ddc_power_off); assert!(p.ddc_power_off);
assert!(p.pnp_disable_monitors); assert!(p.pnp_disable_monitors);
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
// Preset drops to Custom so the explicit fields (incl. the layout) rule… // Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom); assert_eq!(p.preset, Preset::Custom);
// …every other behavior axis is preserved verbatim… // …every other behavior axis is preserved verbatim…
@@ -268,6 +268,18 @@ pub fn restore_takeover_on_startup() {
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn restore_takeover_on_startup() {} pub fn restore_takeover_on_startup() {}
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
/// to restart it. No-op when nothing was taken over.
#[cfg(target_os = "linux")]
pub fn restore_takeover_now() {
gamescope::restore_takeover_now();
}
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_now() {}
#[cfg(all(test, target_os = "linux"))] #[cfg(all(test, target_os = "linux"))]
mod tests { mod tests {
use super::*; use super::*;
+159 -1
View File
@@ -163,8 +163,15 @@ pub struct SessionEnv {
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for /// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket; /// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the /// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor. /// session env. `None` for every other compositor.
pub hyprland_signature: Option<String>, pub hyprland_signature: Option<String>,
/// `SWAYSOCK` of the live sway instance (`Some` only for a sway [`ActiveKind::DesktopWlroots`]).
/// `swaymsg` needs it, and it was the LAST session variable the host could not derive: a
/// `systemd --user` host that never inherited the login shell's environment had no sway IPC at
/// all, so output enumeration and the chooser both failed. Derived from the detected compositor
/// PID like the Hyprland signature above. `None` on river (wlroots, but no sway IPC) and every
/// other compositor.
pub sway_socket: Option<String>,
} }
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it. /// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
@@ -316,6 +323,12 @@ pub fn detect_active_session() -> ActiveSession {
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid), ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
_ => None, _ => None,
}; };
// Same idea for sway's IPC socket: without it `swaymsg` has nothing to talk to, and a
// `systemd --user` host never inherited it.
let sway_socket = match kind {
ActiveKind::DesktopWlroots => find_sway_socket(&xdg_runtime_dir, uid, winning_pid),
_ => None,
};
ActiveSession { ActiveSession {
kind, kind,
env: SessionEnv { env: SessionEnv {
@@ -324,6 +337,7 @@ pub fn detect_active_session() -> ActiveSession {
dbus_session_bus_address: dbus, dbus_session_bus_address: dbus,
xdg_current_desktop, xdg_current_desktop,
hyprland_signature, hyprland_signature,
sway_socket,
}, },
compositor_pid: winning_pid, compositor_pid: winning_pid,
} }
@@ -361,6 +375,46 @@ fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
cands.into_iter().next().map(|(_, n)| n) cands.into_iter().next().map(|(_, n)| n)
} }
/// Find the live sway IPC socket (`SWAYSOCK`) for our uid. Trust a valid inherited value first (the
/// host launched inside the session); then the exact `sway-ipc.<uid>.<pid>.sock` for the compositor
/// PID detection already picked — a name sway builds from those two numbers, so it is an identity
/// match rather than a guess; then the newest-mtime `sway-ipc.<uid>.*.sock` we own, for the case
/// where the socket name does not match the PID we saw (sway re-exec, a wrapper process).
///
/// `None` on river: it is the other [`ActiveKind::DesktopWlroots`] compositor and has no sway IPC —
/// which is the honest answer, since the wlroots backend drives sway through `swaymsg`.
#[cfg(target_os = "linux")]
fn find_sway_socket(runtime: &str, uid: u32, pid: Option<u32>) -> Option<String> {
use std::os::unix::fs::MetadataExt;
if let Ok(s) = std::env::var("SWAYSOCK") {
if !s.is_empty() && std::path::Path::new(&s).exists() {
return Some(s);
}
}
if let Some(pid) = pid {
let exact = std::path::Path::new(runtime).join(format!("sway-ipc.{uid}.{pid}.sock"));
if exact.exists() {
return Some(exact.to_string_lossy().into_owned());
}
}
let prefix = format!("sway-ipc.{uid}.");
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
for e in std::fs::read_dir(runtime).ok()?.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.starts_with(&prefix) || !name.ends_with(".sock") {
continue;
}
let Ok(md) = e.metadata() else { continue };
if md.uid() != uid {
continue;
}
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
cands.push((mtime, e.path().to_string_lossy().into_owned()));
}
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
cands.into_iter().next().map(|(_, p)| p)
}
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn detect_active_session() -> ActiveSession { pub fn detect_active_session() -> ActiveSession {
ActiveSession::none() ActiveSession::none()
@@ -426,6 +480,15 @@ pub fn apply_session_env(active: &ActiveSession) {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig), Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"), None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
} }
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the capture
// chooser) is unreachable without it, so a systemd `--user` host that never inherited the login
// environment had no sway backend at all. Cleared when nothing sway-shaped is live, so a
// sway→Hyprland switch can't leave `swaymsg` aimed at a dead socket. `wlroots::is_available()`
// keys off this variable, so setting it here is also what makes the backend visible at all.
match &e.sway_socket {
Some(sock) => std::env::set_var("SWAYSOCK", sock),
None => std::env::remove_var("SWAYSOCK"),
}
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous // NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash // connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept // (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
@@ -564,3 +627,98 @@ pub fn settle_desktop_portal(chosen: Compositor) {
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn settle_desktop_portal(_chosen: Compositor) {} pub fn settle_desktop_portal(_chosen: Compositor) {}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
/// A scratch runtime dir with the sway-ipc sockets named in `pids`, plus the uid the names are
/// built from. Removed on drop.
struct FakeRuntime {
dir: std::path::PathBuf,
uid: u32,
}
impl FakeRuntime {
fn new(tag: &str, pids: &[u32]) -> FakeRuntime {
// SAFETY: parameterless POSIX call, returns the calling process's uid; touches no memory.
let uid = unsafe { libc::getuid() };
let dir =
std::env::temp_dir().join(format!("pf-swaysock-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for pid in pids {
std::fs::write(dir.join(format!("sway-ipc.{uid}.{pid}.sock")), b"").unwrap();
}
FakeRuntime { dir, uid }
}
fn path(&self) -> &str {
self.dir.to_str().unwrap()
}
}
impl Drop for FakeRuntime {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// Run `f` with `SWAYSOCK` unset, so the "trust what we inherited" rung can't decide the test.
/// Serialized on the crate's env lock — these tests mutate process-global state.
fn without_inherited_swaysock<R>(f: impl FnOnce() -> R) -> R {
crate::with_env_lock(|| {
let prev = std::env::var_os("SWAYSOCK");
std::env::remove_var("SWAYSOCK");
let out = f();
if let Some(p) = prev {
std::env::set_var("SWAYSOCK", p);
}
out
})
}
/// The point of deriving it: the socket that belongs to the compositor detection actually found,
/// not merely *a* sway socket. A stale socket from a previous sway (crash, re-login) sitting in
/// the same runtime dir must not win.
#[test]
fn the_socket_matching_the_detected_pid_wins() {
let rt = FakeRuntime::new("exact", &[4242, 9999]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(4242)));
assert_eq!(
got,
Some(format!("{}/sway-ipc.{}.4242.sock", rt.path(), rt.uid))
);
}
/// sway re-exec (or a wrapper) can leave the socket named for a PID we didn't see. One socket in
/// the dir is still unambiguous — better to hand `swaymsg` the real thing than nothing.
#[test]
fn an_unmatched_pid_falls_back_to_the_socket_that_is_there() {
let rt = FakeRuntime::new("fallback", &[777]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(12345)));
assert_eq!(
got,
Some(format!("{}/sway-ipc.{}.777.sock", rt.path(), rt.uid))
);
}
/// river is the other wlroots desktop and ships no sway IPC. Reporting `None` is what keeps
/// `apply_session_env` from exporting a `SWAYSOCK` that points at nothing — an exported lie
/// would make `wlroots::is_available()` claim a backend that cannot answer.
#[test]
fn no_sway_ipc_socket_reports_none() {
let rt = FakeRuntime::new("none", &[]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(1)));
assert_eq!(got, None);
}
/// Someone else's socket in a shared runtime dir is not ours to talk to.
#[test]
fn another_uids_socket_is_ignored() {
let rt = FakeRuntime::new("otheruid", &[]);
let other = rt.uid.wrapping_add(1);
std::fs::write(rt.dir.join(format!("sway-ipc.{other}.500.sock")), b"").unwrap();
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(500)));
assert_eq!(got, None);
}
}
@@ -865,11 +865,21 @@ impl VirtualDisplayManager {
reasserts = fighting, reasserts = fighting,
"exclusive topology stable again — no non-managed display active" "exclusive topology stable again — no non-managed display active"
); );
// Close the churn window early — descriptor-following resumes now
// instead of waiting out the hold's self-expiry.
pf_win_display::topology_churn::release();
} }
fighting = 0; fighting = 0;
continue; continue;
} }
fighting += 1; fighting += 1;
// Announce the churn BEFORE evicting: every descriptor the capturer's poller
// samples from here until "stable again" (or self-expiry) is potentially the
// TRANSIENT eviction state — acting on it would recreate the ring at a mode
// the recovery chain is about to undo (the field hdr=true→false→true double
// recreate). Window = watchdog interval + recovery/debounce margin, refreshed
// every fighting round.
pf_win_display::topology_churn::hold(interval + Duration::from_secs(3));
match fighting { match fighting {
1..=3 => tracing::warn!( 1..=3 => tracing::warn!(
survivors, survivors,
+18 -6
View File
@@ -156,15 +156,27 @@ pub fn summarize(events: &[DisplayEvent]) -> String {
out.join(", ") out.join(", ")
} }
/// The prime suspects for link-probe disturbances, from the cached inventory: external physical /// The prime suspects for link-probe/dark-head disturbances, from the cached inventory: PHYSICAL
/// displays that are CONNECTED but not part of the desktop (standby TV / input-switched monitor). /// displays — external connectors AND internal panels — that are CONNECTED but not part of the
/// Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock. /// desktop. External = the classic standby TV / input-switched monitor; internal = the laptop
pub fn connected_inactive_externals() -> Vec<String> { /// panel the exclusive isolate deactivated, whose driver-level servicing produces the identical
/// ~2 s metronome on hybrid laptops (field A/B 2026-07-27: reporter's Legion, exclusive
/// 16.3 stalls/min vs primary 0 — the old external-only filter reported `none` there and steered
/// the diagnosis AWAY from the real cause for hours). Virtual/indirect targets stay excluded
/// (precision rule). Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
pub fn connected_inactive_physicals() -> Vec<String> {
let st = state().lock().unwrap(); let st = state().lock().unwrap();
st.inventory st.inventory
.iter() .iter()
.filter(|t| t.external_physical && !t.active) .filter(|t| (t.external_physical || t.internal_panel) && !t.active)
.map(|t| format!("{} ({})", t.friendly, t.tech)) .map(|t| {
let name = if t.friendly.is_empty() && t.internal_panel {
"laptop panel"
} else {
&t.friendly
};
format!("{} ({})", name, t.tech)
})
.collect() .collect()
} }
+3
View File
@@ -16,6 +16,9 @@ pub mod display_events;
mod input_desktop; mod input_desktop;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub mod monitor_devnode; pub mod monitor_devnode;
/// Cross-crate "topology churn in flight" latch (pure std — no Windows surface, so unconditionally
/// compiled and unit-tested on every platform).
pub mod topology_churn;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub mod win_display; pub mod win_display;
@@ -0,0 +1,74 @@
//! Cross-crate "topology churn in flight" latch (stall-immunity program): the exclusive-topology
//! reassert watchdog (pf-vdisplay's manager) announces that it is evicting/restoring displays, and
//! the IDD-push capturer's descriptor follower (pf-capture) defers acting on descriptor changes
//! while the window is open.
//!
//! Why: the reassert's forced re-commit transiently bounces the virtual display's mode — the
//! descriptor poller can sample the EVICTION state (field log 2026-07-27 10:30:44Z: `hdr=true` →
//! `hdr=false` → recreate → recovery restores `hdr=true` → second recreate). Every descriptor
//! sampled inside the window is potentially that transient, and acting on it recreates the ring at
//! a mode the recovery chain is about to undo. The deliberate recovery rebuild
//! (`recreate_ring_in_place`, keyed off the reassert generation) is NOT affected — only the
//! passive descriptor-following is.
//!
//! Deadline semantics, not a flag: a `hold()` self-expires, so a holder that dies mid-churn (or a
//! release lost to a teardown race) can never wedge descriptor-following off forever. `release()`
//! just expires the deadline early on the watchdog's "stable again" observation.
use std::sync::{
atomic::{AtomicU64, Ordering},
OnceLock,
};
use std::time::{Duration, Instant};
/// Deadline as milliseconds on the process-local [`clock`]; `0` = no hold.
static HOLD_UNTIL_MS: AtomicU64 = AtomicU64::new(0);
/// Process-local monotonic epoch (an `Instant` cannot live in an atomic).
fn clock() -> u64 {
static EPOCH: OnceLock<Instant> = OnceLock::new();
EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
}
/// Open (or extend) the churn window for `dur` from now. `fetch_max` so overlapping holders — a
/// reassert round racing a slot transition — never shorten each other's window.
pub fn hold(dur: Duration) {
let until = clock().saturating_add(dur.as_millis() as u64);
HOLD_UNTIL_MS.fetch_max(until, Ordering::Relaxed);
}
/// Expire the window now (the watchdog observed a stable topology again).
pub fn release() {
HOLD_UNTIL_MS.store(0, Ordering::Relaxed);
}
/// Is a churn window open? Cheap enough for a per-frame path (one atomic load + a monotonic read).
#[must_use]
pub fn held() -> bool {
clock() < HOLD_UNTIL_MS.load(Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
/// The latch's contract end-to-end: closed at rest, open after `hold`, extended by the longer
/// of two overlapping holds, closed by `release`, and self-expiring without one.
#[test]
fn hold_release_expire() {
release();
assert!(!held());
hold(Duration::from_secs(60));
assert!(held());
// A shorter overlapping hold must not shorten the window.
hold(Duration::from_millis(1));
assert!(held());
release();
assert!(!held());
// Self-expiry: a millisecond-scale hold lapses on its own.
hold(Duration::from_millis(30));
assert!(held());
std::thread::sleep(Duration::from_millis(60));
assert!(!held());
}
}
+10 -3
View File
@@ -844,15 +844,21 @@ pub unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option<u32> {
} }
/// One CONNECTED display target from a full (`QDC_ALL_PATHS`) CCD sweep — the disturbance- /// One CONNECTED display target from a full (`QDC_ALL_PATHS`) CCD sweep — the disturbance-
/// attribution inventory. `external_physical` is the load-bearing bit: a standby TV/monitor on a /// attribution inventory. `external_physical` and `internal_panel` are the load-bearing bits: a
/// real connector is the prime suspect for the periodic link-probe stutter class, while internal /// standby TV/monitor on a real connector is the classic suspect for the periodic link-probe
/// panels and indirect/virtual targets (our own IDD included) are not. /// stutter class, and a laptop panel the exclusive isolate DEACTIVATED is the hybrid-laptop
/// variant of the same disturbance (field A/B 2026-07-27: dark-but-connected eDP head on the
/// iGPU → ~2 s stall metronome; `topology: primary` → zero) — only indirect/virtual targets
/// (our own IDD included) can never be suspects.
pub struct TargetInventory { pub struct TargetInventory {
pub target_id: u32, pub target_id: u32,
/// Whether any active path drives this target (part of the desktop right now). /// Whether any active path drives this target (part of the desktop right now).
pub active: bool, pub active: bool,
/// External physical connector (HDMI/DP/DVI/…): candidate for standby link-probe churn. /// External physical connector (HDMI/DP/DVI/…): candidate for standby link-probe churn.
pub external_physical: bool, pub external_physical: bool,
/// Internal panel (eDP/LVDS/embedded): candidate for dark-head servicing churn when
/// connected-but-inactive (the exclusive isolate on a laptop creates exactly that state).
pub internal_panel: bool,
/// Short connector label for logs (`"HDMI"`, `"DisplayPort"`, `"internal-panel"`, …). /// Short connector label for logs (`"HDMI"`, `"DisplayPort"`, `"internal-panel"`, …).
pub tech: &'static str, pub tech: &'static str,
/// The monitor's friendly name (`"LG TV SSCR2"`); empty when the EDID carries none. /// The monitor's friendly name (`"LG TV SSCR2"`); empty when the EDID carries none.
@@ -953,6 +959,7 @@ pub unsafe fn target_inventory() -> Vec<TargetInventory> {
target_id: t.id, target_id: t.id,
active: active.contains(&key), active: active.contains(&key),
external_physical, external_physical,
internal_panel: tech == "internal-panel",
tech, tech,
friendly: utf16z_str(&req.monitorFriendlyDeviceName), friendly: utf16z_str(&req.monitorFriendlyDeviceName),
monitor_device_path: utf16z_str(&req.monitorDevicePath), monitor_device_path: utf16z_str(&req.monitorDevicePath),
+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) 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` /// `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. /// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> { 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, Height: h / 2,
..Default::default() ..Default::default()
}; };
// SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared // SAFETY: two unsafe `copy_async` device→device enqueues + an optional stream sync; the
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each // caller must have the shared context current (documented). `&y`/`&uv` are live local
// enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live // `CUDA_MEMCPY2D`s outliving each enqueue. All four device pointers are valid:
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are // `src.ptr`/`src_uv_ptr` come from a live NV12 `DeviceBuffer` (its `.uv` presence was
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy // checked via `ok_or_else`), `y_dst`/`uv_dst` are the caller's live NVENC surface planes;
// `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation // the luma copy is `w`×`h`, the chroma copy `(w/2)*2`×`h/2`, each within its planes. With
// to the caller (documented above). Wrappers → live table. // `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 { unsafe {
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?; copy_async(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync) 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 /// 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, Height: h,
..Default::default() ..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; // 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` // `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 // 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 // both. Completion is the trailing stream sync below (`sync`) or the caller's
// above). Wrapper → live table. // stream-ordering obligation (`sync: false`, documented above). Wrapper → live table.
unsafe { copy_issue(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? }; 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(()) 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 /// 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. /// offset) into `dst`. The shared context must be current on this thread.
pub fn copy_pitched_to_buffer( 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)") 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 CUgraphicsResource = *mut c_void;
pub type CUarray = *mut c_void; pub type CUarray = *mut c_void;
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st* 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. /// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2; 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; 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 /// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by /// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C /// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
@@ -139,6 +194,23 @@ pub(crate) struct CudaApi {
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC, *const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult, ) -> CUresult,
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> 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, cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult, cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult, cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
@@ -206,6 +278,14 @@ pub(crate) fn cuda_api() -> Option<&'static CudaApi> {
.get(b"cuExternalMemoryGetMappedBuffer\0") .get(b"cuExternalMemoryGetMappedBuffer\0")
.ok()?, .ok()?,
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\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()?, cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern // CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
// driver exports `_v2`, but accept the unsuffixed one too (same signature). // 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, 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 { pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
match cuda_api() { match cuda_api() {
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr), Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
+515 -161
View File
@@ -12,11 +12,24 @@
//! //!
//! The direct-SDK NVENC encoder allocates its input ring through [`VkSlotBlend::alloc_slot`] //! 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 //! 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 //! Vulkan external memory both APIs address.
//! 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 //! Cursor-bearing frames blend one of two ways:
//! 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. //! * **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 //! 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. //! 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). /// 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 { struct SlotAlloc {
buffer: vk::Buffer, buffer: vk::Buffer,
memory: vk::DeviceMemory, memory: vk::DeviceMemory,
/// CUDA's import of the exported OPAQUE_FD — must drop BEFORE the Vulkan memory is freed. /// CUDA's import of the exported OPAQUE_FD — must drop BEFORE the Vulkan memory is freed.
cuda: cuda::ExternalDmabuf, 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`. /// 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, ext_fd: ash::khr::external_memory_fd::Device,
queue: vk::Queue, queue: vk::Queue,
cmd_pool: vk::CommandPool, cmd_pool: vk::CommandPool,
cmd: vk::CommandBuffer,
fence: vk::Fence, fence: vk::Fence,
mem_props: vk::PhysicalDeviceMemoryProperties, mem_props: vk::PhysicalDeviceMemoryProperties,
shader: vk::ShaderModule, shader: vk::ShaderModule,
desc_layout: vk::DescriptorSetLayout, desc_layout: vk::DescriptorSetLayout,
pipe_layout: vk::PipelineLayout, pipe_layout: vk::PipelineLayout,
desc_pool: vk::DescriptorPool, desc_pool: vk::DescriptorPool,
desc_set: vk::DescriptorSet,
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant). /// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
pipelines: [vk::Pipeline; 3], pipelines: [vk::Pipeline; 3],
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped. /// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
@@ -129,6 +164,8 @@ pub struct VkSlotBlend {
cur_mem: vk::DeviceMemory, cur_mem: vk::DeviceMemory,
cur_map: *mut u8, cur_map: *mut u8,
slots: Vec<SlotAlloc>, 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 // 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() let qci = [vk::DeviceQueueCreateInfo::default()
.queue_family_index(qf) .queue_family_index(qf)
.queue_priorities(&prio)]; .queue_priorities(&prio)];
let exts = [ash::khr::external_memory_fd::NAME.as_ptr()]; // Timeline-semaphore export to CUDA (the stream-ordered blend) is optional: probe the
let device = match instance.create_device( // device extensions + feature bit and enable them only where present, so a driver
phys, // without them still gets a working (CPU-synced) blend device. NVIDIA has shipped
&vk::DeviceCreateInfo::default() // 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) .queue_create_infos(&qci)
.enabled_extension_names(&exts), .enabled_extension_names(&exts);
None, if want_timeline {
) { dci = dci.push_next(&mut tl_enable);
}
let device = match instance.create_device(phys, &dci, None) {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
instance.destroy_instance(None); instance.destroy_instance(None);
@@ -207,30 +274,93 @@ impl VkSlotBlend {
ext_fd, ext_fd,
queue, queue,
cmd_pool: vk::CommandPool::null(), cmd_pool: vk::CommandPool::null(),
cmd: vk::CommandBuffer::null(),
fence: vk::Fence::null(), fence: vk::Fence::null(),
mem_props, mem_props,
shader: vk::ShaderModule::null(), shader: vk::ShaderModule::null(),
desc_layout: vk::DescriptorSetLayout::null(), desc_layout: vk::DescriptorSetLayout::null(),
pipe_layout: vk::PipelineLayout::null(), pipe_layout: vk::PipelineLayout::null(),
desc_pool: vk::DescriptorPool::null(), desc_pool: vk::DescriptorPool::null(),
desc_set: vk::DescriptorSet::null(),
pipelines: [vk::Pipeline::null(); 3], pipelines: [vk::Pipeline::null(); 3],
cur_buf: vk::Buffer::null(), cur_buf: vk::Buffer::null(),
cur_mem: vk::DeviceMemory::null(), cur_mem: vk::DeviceMemory::null(),
cur_map: std::ptr::null_mut(), cur_map: std::ptr::null_mut(),
slots: Vec::new(), slots: Vec::new(),
timeline: None,
}; };
me.init_objects(qf).inspect_err(|_| { me.init_objects(qf).inspect_err(|_| {
// `Drop` runs the same teardown and tolerates the nulls left by a partial init. // `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!( 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)" "Vulkan slot blend ready (exportable NVENC inputs + SPIR-V cursor blend)"
); );
Ok(me) 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. /// The non-device objects: command machinery, cursor staging, descriptor + pipelines.
fn init_objects(&mut self, qf: u32) -> Result<()> { fn init_objects(&mut self, qf: u32) -> Result<()> {
// SAFETY: same contract as `new` — ash calls on the live `self.device` with builder infos // SAFETY: same contract as `new` — ash calls on the live `self.device` with builder infos
@@ -246,14 +376,6 @@ impl VkSlotBlend {
None, None,
) )
.context("create command pool")?; .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 self.fence = d
.create_fence(&vk::FenceCreateInfo::default(), None) .create_fence(&vk::FenceCreateInfo::default(), None)
.context("create fence")?; .context("create fence")?;
@@ -320,25 +442,21 @@ impl VkSlotBlend {
None, None,
) )
.context("create pipeline layout")?; .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() let pool_sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_BUFFER) .ty(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(2)]; .descriptor_count(128)];
self.desc_pool = d self.desc_pool = d
.create_descriptor_pool( .create_descriptor_pool(
&vk::DescriptorPoolCreateInfo::default() &vk::DescriptorPoolCreateInfo::default()
.max_sets(1) .flags(vk::DescriptorPoolCreateFlags::FREE_DESCRIPTOR_SET)
.max_sets(64)
.pool_sizes(&pool_sizes), .pool_sizes(&pool_sizes),
None, None,
) )
.context("create descriptor pool")?; .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). // The shader + one pipeline per MODE (spec constant 0).
if CURSOR_SPV.len() % 4 != 0 { if CURSOR_SPV.len() % 4 != 0 {
@@ -465,6 +583,59 @@ impl VkSlotBlend {
return Err(e).context("cuImportExternalMemory(slot OPAQUE_FD)"); 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 { let r = VkSlotRef {
ptr: ext.ptr, ptr: ext.ptr,
pitch: pitch as usize, pitch: pitch as usize,
@@ -475,7 +646,8 @@ impl VkSlotBlend {
buffer, buffer,
memory, memory,
cuda: ext, cuda: ext,
size, cmd,
desc,
}); });
Ok(r) Ok(r)
} }
@@ -485,12 +657,26 @@ impl VkSlotBlend {
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK /// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK
/// objects explicitly here). /// objects explicitly here).
pub fn free_slots(&mut self) { 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(..) { for s in self.slots.drain(..) {
drop(s.cuda); // CUDA's view of the memory goes first drop(s.cuda); // CUDA's view of the memory goes first
// SAFETY: `buffer`/`memory` were created in `alloc_slot`, are uniquely owned by the // SAFETY: `buffer`/`memory`/`cmd`/`desc` were created in `alloc_slot`, are uniquely
// drained `SlotAlloc`, and are destroyed exactly once here. No queue work can be // owned by the drained `SlotAlloc`, and are destroyed exactly once here. No queue
// in flight: every `blend` fence-waits before returning. // work is in flight: CPU-synced blends fence-wait before returning, and ordered
// blends were quiesced by the `device_wait_idle` above.
unsafe { 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.destroy_buffer(s.buffer, None);
self.device.free_memory(s.memory, 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 /// 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) { 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 cw = cw.min(CURSOR_MAX);
let ch = ch.min(CURSOR_MAX); let ch = ch.min(CURSOR_MAX);
let len = (cw * ch * 4) as usize; let len = (cw * ch * 4) as usize;
let len = len.min(rgba.len()); let len = len.min(rgba.len());
// SAFETY: `cur_map` is the live persistent mapping of the CURSOR_MAX²·4 host-coherent // 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 // 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` // both the source slice and the buffer capacity. No blend reads race this host write:
// fence-waits before returning), so no GPU read races this host write. // CPU-synced blends fence-wait before returning, and ordered blends were quiesced via
// the timeline wait above.
unsafe { unsafe {
std::ptr::copy_nonoverlapping(rgba.as_ptr(), self.cur_map, len); 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 /// 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 /// 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 /// visible to the subsequent NVENC encode (the `VkBridge` precedent: fence-ordered cross-API
@@ -528,129 +881,20 @@ impl VkSlotBlend {
ox: i32, ox: i32,
oy: i32, oy: i32,
) -> Result<()> { ) -> Result<()> {
let alloc = self let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
.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 {
return Ok(()); 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 = // SAFETY: single-threaded record/submit/wait on handles this struct owns. The slot's
// per word-aligned 4-px span × (2-row blocks | rows). // previous submission completed (`record_blend`'s contract: this path fence-waits every
let (gx, gy) = match fmt { // blend, and an earlier ORDERED blend on this slot finished before the encoder reused it
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)), // — 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
let x0 = (ox >> 2) << 2; // (surfW/surfH/curW/curH from `push`) against the slot allocation sized in `alloc_slot`
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32; // for exactly that geometry.
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.
unsafe { unsafe {
let d = &self.device; let d = &self.device;
let surf_info = [vk::DescriptorBufferInfo::default() let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
.buffer(alloc.buffer) let cmds = [cmd];
.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 submit = [vk::SubmitInfo::default().command_buffers(&cmds)]; let submit = [vk::SubmitInfo::default().command_buffers(&cmds)];
d.queue_submit(self.queue, &submit, self.fence) d.queue_submit(self.queue, &submit, self.fence)
.context("submit blend")?; .context("submit blend")?;
@@ -660,11 +904,121 @@ impl VkSlotBlend {
} }
Ok(()) 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 { impl Drop for VkSlotBlend {
fn drop(&mut self) { fn drop(&mut self) {
self.free_slots(); 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 // 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 // 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 // uniquely owned; each is destroyed exactly once here, pipelines/layouts/pools before the
+2 -2
View File
@@ -52,8 +52,8 @@ pub(crate) struct Negotiated {
/// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks). /// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks).
pub(crate) clock_offset_ns: i64, pub(crate) clock_offset_ns: i64,
/// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered — /// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered —
/// mid-stream re-syncs are pointless then and stay off. The re-sync acceptance guard /// mid-stream re-syncs are pointless then and stay off. Seeds the re-sync admission
/// compares each batch against this baseline ([`accept_resync`]). /// guard's session-floor baseline ([`ResyncGuard`]).
pub(crate) clock_rtt_ns: Option<u64>, pub(crate) clock_rtt_ns: Option<u64>,
/// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session. /// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session.
pub(crate) bit_depth: u8, pub(crate) bit_depth: u8,
@@ -248,7 +248,16 @@ pub(crate) struct EncodeLatAcc {
/// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on /// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on
/// its own, so a clump never costs a keyframe. /// its own, so a clump never costs a keyframe.
/// ///
/// **All-intra exception** ([`set_all_intra`], PyroWave): every AU is independently decodable, so
/// the reference-chain reasoning above does not apply — a consumer that falls behind can skip
/// straight to the newest queued AU with zero recovery cost (no keyframe round-trip, no corrupt
/// dependents). [`pop`] then drains to the newest instead of returning the oldest, which caps any
/// standing queue at ~1 frame structurally; the 2026-07 field report's 780M client otherwise
/// ratcheted a genuine multi-frame backlog between the coarse jump-to-live thresholds.
///
/// [`clear`]: FrameChannel::clear /// [`clear`]: FrameChannel::clear
/// [`set_all_intra`]: FrameChannel::set_all_intra
/// [`pop`]: FrameChannel::pop
pub(crate) struct FrameChannel { pub(crate) struct FrameChannel {
inner: Mutex<FrameQueue>, inner: Mutex<FrameQueue>,
ready: Condvar, ready: Condvar,
@@ -259,6 +268,11 @@ struct FrameQueue {
/// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended /// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended
/// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop). /// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop).
closed: bool, closed: bool,
/// Every AU decodes independently (PyroWave): [`FrameChannel::pop`] drains to the newest.
all_intra: bool,
/// AUs skipped by the all-intra drain since the last [`FrameChannel::take_skipped`] — NOT
/// losses (the wire delivered them); the pump surfaces them at debug on its report tick.
skipped_total: u64,
} }
/// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s /// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s
@@ -275,11 +289,25 @@ impl FrameChannel {
inner: Mutex::new(FrameQueue { inner: Mutex::new(FrameQueue {
q: VecDeque::new(), q: VecDeque::new(),
closed: false, closed: false,
all_intra: false,
skipped_total: 0,
}), }),
ready: Condvar::new(), ready: Condvar::new(),
} }
} }
/// Pump side, once at session start: mark the stream all-intra (every AU independently
/// decodable) — [`Self::pop`] then drains to the newest queued AU instead of strict FIFO.
pub(crate) fn set_all_intra(&self, all_intra: bool) {
self.inner.lock().unwrap().all_intra = all_intra;
}
/// Pump side: AUs skipped by the all-intra drain since the last call (reset on read).
pub(crate) fn take_skipped(&self) -> u64 {
let mut st = self.inner.lock().unwrap();
std::mem::take(&mut st.skipped_total)
}
/// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop /// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop
/// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is /// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is
/// already in flight by the time this can bite). /// already in flight by the time this can bite).
@@ -312,12 +340,21 @@ impl FrameChannel {
self.ready.notify_all(); self.ready.notify_all();
} }
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive. /// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive. On an
/// all-intra stream ([`Self::set_all_intra`]) a multi-deep queue drains to the NEWEST AU
/// instead — the skipped ones are already superseded and decode independently, so showing
/// them only adds latency.
pub(crate) fn pop(&self, timeout: Duration) -> FramePop { pub(crate) fn pop(&self, timeout: Duration) -> FramePop {
let mut st = self.inner.lock().unwrap(); let mut st = self.inner.lock().unwrap();
if st.q.is_empty() && !st.closed { if st.q.is_empty() && !st.closed {
st = self.ready.wait_timeout(st, timeout).unwrap().0; st = self.ready.wait_timeout(st, timeout).unwrap().0;
} }
if st.all_intra && st.q.len() > 1 {
st.skipped_total += (st.q.len() - 1) as u64;
let newest = st.q.pop_back().expect("len > 1");
st.q.clear();
return FramePop::Frame(newest);
}
if let Some(f) = st.q.pop_front() { if let Some(f) = st.q.pop_front() {
FramePop::Frame(f) FramePop::Frame(f)
} else if st.closed { } else if st.closed {
@@ -364,6 +401,25 @@ mod frame_channel_tests {
assert_eq!(ch.depth(), 0); assert_eq!(ch.depth(), 0);
} }
/// The all-intra exception: a multi-deep queue drains to the NEWEST AU (every frame
/// decodes independently — older queued ones are superseded), the skips are counted
/// separately from losses, and a single-deep queue behaves exactly like FIFO.
#[test]
fn all_intra_drains_to_newest_and_counts_skips() {
let ch = FrameChannel::new();
ch.set_all_intra(true);
for i in 1..=3 {
ch.push(frame(i));
}
assert_eq!(popped(&ch), Some(3));
assert_eq!(ch.depth(), 0);
assert_eq!(ch.take_skipped(), 2);
assert_eq!(ch.take_skipped(), 0); // reset on read
ch.push(frame(4));
assert_eq!(popped(&ch), Some(4)); // depth 1 = plain FIFO, no skip accounting
assert_eq!(ch.take_skipped(), 0);
}
#[test] #[test]
fn empty_pop_times_out_not_closed() { fn empty_pop_times_out_not_closed() {
let ch = FrameChannel::new(); let ch = FrameChannel::new();
+2 -2
View File
@@ -11,8 +11,8 @@ use crate::abr::BitrateController;
use crate::config::Role; use crate::config::Role;
use crate::packet::FLAG_PROBE; use crate::packet::FLAG_PROBE;
use crate::quic::{ use crate::quic::{
accept_resync, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
ClockResync, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
ResyncStep, SetBitrate, Start, Welcome, ResyncStep, SetBitrate, Start, Welcome,
}; };
use crate::session::Session; use crate::session::Session;
@@ -52,6 +52,14 @@ impl ControlTask {
// the read arm below; only when the host answered the connect-time handshake — an // the read arm below; only when the host answered the connect-time handshake — an
// old host would just eat the probes. // old host would just eat the probes.
let mut resync = ClockResync::new(); let mut resync = ClockResync::new();
let mut resync_guard = clock_rtt_ns.map(ResyncGuard::new);
// Inter-round spacing: without it the whole 8-round batch completes inside one
// ~6 ms video burst and every round samples the same congestion state — a batch
// that starts mid-burst is then wholly congested and gets rejected. 7 ms staggers
// the rounds across the ~16.7 ms frame cycle so the min-RTT round almost always
// lands in a quiet inter-burst gap, even at PyroWave-class bitrates.
const RESYNC_ROUND_SPACING: std::time::Duration = std::time::Duration::from_millis(7);
let mut staged_round: Option<tokio::time::Instant> = None;
let mut resync_tick = tokio::time::interval_at( let mut resync_tick = tokio::time::interval_at(
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL, tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
CLOCK_RESYNC_INTERVAL, CLOCK_RESYNC_INTERVAL,
@@ -72,6 +80,7 @@ impl ControlTask {
if clock_rtt_ns.is_none() { if clock_rtt_ns.is_none() {
continue; // no connect-time handshake — host can't answer continue; // no connect-time handshake — host can't answer
} }
staged_round = None; // a new batch abandons any staged round
resync.begin(wall_clock_ns()).encode() resync.begin(wall_clock_ns()).encode()
} }
CtrlRequest::ClipControl(c) => c.encode(), CtrlRequest::ClipControl(c) => c.encode(),
@@ -83,11 +92,21 @@ impl ControlTask {
} }
} }
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => { _ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
staged_round = None; // a new batch abandons any staged round
let probe = resync.begin(wall_clock_ns()); let probe = resync.begin(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() { if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break; break;
} }
} }
_ = async { tokio::time::sleep_until(staged_round.unwrap()).await },
if staged_round.is_some() => {
staged_round = None;
// Stamped at send time so the inter-round spacing stays out of the RTT.
let probe = resync.next_probe(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break;
}
}
msg = ctrl_recv.read_msg() => { msg = ctrl_recv.read_msg() => {
let Ok(msg) = msg else { break }; // stream closed let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) { if let Ok(ack) = Reconfigured::decode(&msg) {
@@ -132,16 +151,36 @@ impl ControlTask {
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps); *bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
} else if let Ok(echo) = ClockEcho::decode(&msg) { } else if let Ok(echo) = ClockEcho::decode(&msg) {
match resync.on_echo(&echo, wall_clock_ns()) { match resync.on_echo(&echo, wall_clock_ns()) {
ResyncStep::Probe(p) => { ResyncStep::MoreRounds => {
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() { staged_round = Some(
break; tokio::time::Instant::now() + RESYNC_ROUND_SPACING,
} );
} }
ResyncStep::Done { offset_ns, rtt_ns } => { ResyncStep::Done { offset_ns, rtt_ns } => {
// Never let a congested window bias the offset (frames read let Some(guard) = resync_guard.as_mut() else {
// late exactly then) — keep the old estimate and let the next continue; // no connect handshake — batches never start
// periodic batch try again. };
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) { let (apply, best_of_streak) = match guard.admit(offset_ns, rtt_ns)
{
ResyncAdmit::Fresh => (Some((offset_ns, rtt_ns)), false),
ResyncAdmit::BestOfStreak { offset_ns, rtt_ns } => {
(Some((offset_ns, rtt_ns)), true)
}
ResyncAdmit::Rejected { streak } => {
// warn, not debug: repeated rejections are exactly the
// stale-offset starvation signature the 2026-07
// PyroWave-sawtooth report had to be diagnosed without.
tracing::warn!(
rtt_us = rtt_ns / 1000,
floor_us = guard.floor_rtt_ns() / 1000,
streak,
"clock re-sync batch rejected — RTT above the \
session floor (congested window)"
);
(None, false)
}
};
if let Some((offset_ns, rtt_ns)) = apply {
// info, not debug: ≤1/min, and it is THE forensic // info, not debug: ≤1/min, and it is THE forensic
// trail for a stale-offset (stepped/slewed wall clock) // trail for a stale-offset (stepped/slewed wall clock)
// latency plateau — the 2026-07 two-pair investigation // latency plateau — the 2026-07 two-pair investigation
@@ -149,16 +188,11 @@ impl ControlTask {
tracing::info!( tracing::info!(
offset_ns, offset_ns,
rtt_us = rtt_ns / 1000, rtt_us = rtt_ns / 1000,
best_of_streak,
"mid-stream clock re-sync applied" "mid-stream clock re-sync applied"
); );
clock_offset.store(offset_ns, Ordering::Relaxed); clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed); clock_gen.fetch_add(1, Ordering::Relaxed);
} else {
tracing::info!(
rtt_us = rtt_ns / 1000,
"clock re-sync batch discarded — RTT above the \
connect-time baseline (congested window)"
);
} }
} }
ResyncStep::Idle => {} ResyncStep::Idle => {}
+45 -3
View File
@@ -87,6 +87,10 @@ impl DataPump {
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard // above its floor, and the climb probe's VBV reasoning doesn't apply to hard
// per-frame CBR — controller and capacity probe stay off (0 = permanently off). // per-frame CBR — controller and capacity probe stay off (0 = permanently off).
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE; let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
// All-intra streams have no reference chains: the frame channel drains to the newest
// AU instead of strict FIFO (see `FrameChannel::set_all_intra`), so a slow consumer
// caps its standing queue at ~1 frame with zero recovery cost.
frames.set_all_intra(negotiated_codec == crate::quic::CODEC_PYROWAVE);
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned { let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
resolved_bitrate_kbps resolved_bitrate_kbps
} else { } else {
@@ -114,6 +118,15 @@ impl DataPump {
// in; the embedder path had neither, so an unanswered request wedged the report tick and a // in; the embedder path had neither, so an unanswered request wedged the report tick and a
// finished one left the ABR window anchored before the burst. // finished one left the ABR window anchored before the burst.
let mut was_probing = false; let mut was_probing = false;
// Set when a probe ends: the FIRST post-probe report window is discarded outright (no
// LossReport, no standing-latency close, no ABR feed). The `last_*` rebase below cannot
// fully clean it — probe frames still pending in the reassembler age out as
// `frames_dropped` for another LOSS_WINDOW (~120 ms) AFTER the rebase, and the burst may
// have latched `flush_in_window` — and either reads as SEVERE congestion. The 2026-07
// field report's Automatic session backed off 20→14 Mb/s one second in (exactly one
// report tick after its capacity probe) and, with slow start dead from that first
// "congestion", crawled additively for the entire match.
let mut discard_abr_window = false;
let mut probe_watchdog: Option<Instant> = None; let mut probe_watchdog: Option<Instant> = None;
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32); let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
let mut flush_in_window = false; let mut flush_in_window = false;
@@ -193,6 +206,8 @@ impl DataPump {
last_dropped = st.frames_dropped; last_dropped = st.frames_dropped;
last_bytes = st.bytes_received; last_bytes = st.bytes_received;
last_report = Instant::now(); last_report = Instant::now();
discard_abr_window = true;
flush_in_window = false;
} }
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores // Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below) // `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
@@ -289,6 +304,14 @@ impl DataPump {
resync_wanted = false; resync_wanted = false;
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync); let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
} }
// All-intra drain-to-newest skips are NOT losses (the wire delivered them) —
// surface them at debug so a slow consumer is visible without alarming the
// OSD loss counters.
let skipped = frames.take_skipped();
if skipped > 0 {
tracing::debug!(skipped, "all-intra frame channel drained to newest");
}
let discard = std::mem::take(&mut discard_abr_window);
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped); let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
let loss_ppm = window_loss_ppm( let loss_ppm = window_loss_ppm(
st.fec_recovered_shards.wrapping_sub(last_recovered), st.fec_recovered_shards.wrapping_sub(last_recovered),
@@ -296,14 +319,26 @@ impl DataPump {
st.packets_received.wrapping_sub(last_received), st.packets_received.wrapping_sub(last_received),
window_dropped, window_dropped,
); );
if discard {
// Probe-tail residue (see `discard_abr_window`): a LossReport from this
// window would also spike the host's adaptive FEC off deliberate overload.
tracing::debug!(
loss_ppm,
window_dropped,
"discarding the first post-probe ABR window (probe-tail residue)"
);
} else {
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm })); let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
}
// Standing-latency bleed: close the detector's window with this report's loss // Standing-latency bleed: close the detector's window with this report's loss
// verdict and run its escalation ladder — re-sync first (free; a stale offset // verdict and run its escalation ladder — re-sync first (free; a stale offset
// from a stepped wall clock produces exactly this signature and the applied // from a stepped wall clock produces exactly this signature and the applied
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real // re-sync rebases the floor), then a bounded flush+keyframe (drains a real
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a // sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
// loud disarm (the path latency itself changed; nothing local fixes that). // loud disarm (the path latency itself changed; nothing local fixes that).
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) { // A discard window closes the detector as NOT-loss-free: its clean-run resets
// (conservative) and no action can fire off probe residue.
match standing_lat.on_window(!discard && loss_ppm == 0 && window_dropped == 0) {
StandingLatAction::None => {} StandingLatAction::None => {}
StandingLatAction::Resync { above_ms } => { StandingLatAction::Resync { above_ms } => {
tracing::info!( tracing::info!(
@@ -386,7 +421,12 @@ impl DataPump {
let window_ms = last_report.elapsed().as_millis().max(1) as u64; let window_ms = last_report.elapsed().as_millis().max(1) as u64;
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8) let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
/ window_ms) as u32; / window_ms) as u32;
if let Some(kbps) = abr.on_window( // A discard window feeds the controller NOTHING — its signals are probe-tail
// residue, and one "congestion" verdict here ends slow start for good.
let verdict = if discard {
None
} else {
abr.on_window(
Instant::now(), Instant::now(),
window_dropped, window_dropped,
loss_ppm, loss_ppm,
@@ -395,7 +435,9 @@ impl DataPump {
encode_mean_us, encode_mean_us,
actual_kbps, actual_kbps,
flush_in_window, flush_in_window,
) { )
};
if let Some(kbps) = verdict {
// Log the window's signals alongside the decision so an on-glass session can // Log the window's signals alongside the decision so an on-glass session can
// tell a decode-/encode-driven re-target (the new signals — elevated with // tell a decode-/encode-driven re-target (the new signals — elevated with
// loss/OWD flat) from a network-driven one. // loss/OWD flat) from a network-driven one.
+155 -17
View File
@@ -82,8 +82,12 @@ pub fn wall_clock_ns() -> u64 {
pub enum ResyncStep { pub enum ResyncStep {
/// Nothing — the echo was stale (a previous batch) or no batch is in flight. /// Nothing — the echo was stale (a previous batch) or no batch is in flight.
Idle, Idle,
/// Send this next-round probe and keep feeding echoes. /// The round was recorded and the batch wants another: wait the inter-round spacing, then
Probe(ClockProbe), /// stamp + send [`ClockResync::next_probe`]. Spacing the rounds makes the batch sample
/// several phases of the periodic video-burst cycle instead of completing inside one burst
/// — at high bitrates the whole 8-round batch otherwise fits in a single ~6 ms burst and
/// every round reads the same congested (or same quiet) instant.
MoreRounds,
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`]. /// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
Done { offset_ns: i64, rtt_ns: u64 }, Done { offset_ns: i64, rtt_ns: u64 },
} }
@@ -118,6 +122,13 @@ impl ClockResync {
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`. /// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
pub fn begin(&mut self, now_ns: u64) -> ClockProbe { pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
self.samples.clear(); self.samples.clear();
self.next_probe(now_ns)
}
/// Stamp + arm the next round's probe at `now_ns` — send it immediately. Called after
/// [`ResyncStep::MoreRounds`] once the caller's inter-round spacing has elapsed; stamping
/// at send time keeps that spacing out of the measured RTT.
pub fn next_probe(&mut self, now_ns: u64) -> ClockProbe {
self.pending_t1 = Some(now_ns); self.pending_t1 = Some(now_ns);
ClockProbe { t1_ns: now_ns } ClockProbe { t1_ns: now_ns }
} }
@@ -129,11 +140,12 @@ impl ClockResync {
} }
self.samples self.samples
.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns)); .push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
if self.samples.len() < Self::ROUNDS { // No probe in flight until the driver arms the next round (or a batch restarts) — a
self.pending_t1 = Some(now_ns); // duplicate of this round's echo must not double-record.
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
}
self.pending_t1 = None; self.pending_t1 = None;
if self.samples.len() < Self::ROUNDS {
return ResyncStep::MoreRounds;
}
match clock_offset_ns(&self.samples) { match clock_offset_ns(&self.samples) {
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns }, Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
@@ -147,12 +159,97 @@ impl Default for ClockResync {
} }
} }
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is /// Acceptance predicate for a re-sync batch: its min RTT must be comparable to the best RTT
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window /// this session has evidenced — `≤ max(2 ms, 1.5 × floor)`. A congested window biases the
/// biases the offset by its queueing delay, and frames already read late exactly then; better /// offset by its queueing delay, and frames already read late exactly then; better to keep the
/// to keep the old estimate and let the next batch try again. /// old estimate and let the next batch try again.
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool { pub fn accept_resync(batch_rtt_ns: u64, floor_rtt_ns: u64) -> bool {
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000) batch_rtt_ns <= (floor_rtt_ns + floor_rtt_ns / 2).max(2_000_000)
}
/// Admission decision for a completed re-sync batch (see [`ResyncGuard::admit`]).
#[derive(Debug, PartialEq, Eq)]
pub enum ResyncAdmit {
/// Batch RTT is within the guard band of the session floor: apply this batch's offset.
Fresh,
/// Batch rejected (congested window) — keep the previous offset; `streak` counts the
/// consecutive rejections since the last applied batch.
Rejected { streak: u32 },
/// The rejection streak hit [`ResyncGuard::MAX_REJECTED_STREAK`]: apply the best (min-RTT)
/// batch of the streak instead of drifting further. Carries that batch's estimate.
BestOfStreak { offset_ns: i64, rtt_ns: u64 },
}
/// Admission control for mid-stream re-sync batches. Two fixes over the original static
/// `≤ max(2 ms, 1.5 × connect RTT)` guard (2026-07 PyroWave-sawtooth field report):
///
/// - **The baseline is the session floor, not the connect-time RTT.** The connect handshake
/// runs before the video data plane exists; comparing loaded mid-stream batches against that
/// idle figure rejected essentially every batch of a high-bitrate LAN session, and the
/// offset went stale while the wall clocks drifted apart — the OSD latency ramped for
/// minutes and snapped back only when a lucky batch landed. The floor now folds in every
/// completed batch's min RTT (rejected ones included: their min-RTT round is still floor
/// evidence), so the baseline tracks what this path can actually do under load.
/// - **Staleness is bounded.** After [`Self::MAX_REJECTED_STREAK`] consecutive rejections the
/// best batch of the streak is applied anyway: its queueing bias is at most ~half its RTT
/// (a few ms), while unbounded wall-clock drift is worth that many ms *per minute* on a
/// slewing clock. A bounded bias beats an unbounded drift.
pub struct ResyncGuard {
/// Best RTT this session has evidenced: connect-time RTT, then min over every batch.
floor_rtt_ns: u64,
rejected_streak: u32,
/// Min-RTT batch among the current rejection streak.
best_pending: Option<(i64, u64)>,
}
impl ResyncGuard {
/// Consecutive rejected batches tolerated before the best of them is applied anyway.
pub const MAX_REJECTED_STREAK: u32 = 3;
pub fn new(connect_rtt_ns: u64) -> ResyncGuard {
ResyncGuard {
floor_rtt_ns: connect_rtt_ns,
rejected_streak: 0,
best_pending: None,
}
}
/// The current baseline the guard compares batches against (log/debug surface).
pub fn floor_rtt_ns(&self) -> u64 {
self.floor_rtt_ns
}
/// Judge a completed batch. The caller applies the offset on [`ResyncAdmit::Fresh`] (this
/// batch's) or [`ResyncAdmit::BestOfStreak`] (the carried one) and keeps the old offset on
/// [`ResyncAdmit::Rejected`].
pub fn admit(&mut self, offset_ns: i64, rtt_ns: u64) -> ResyncAdmit {
// Judge against the floor as evidenced BEFORE this batch, then fold this batch in —
// comparing a batch against a floor that already includes it would accept everything.
let fresh = accept_resync(rtt_ns, self.floor_rtt_ns);
self.floor_rtt_ns = self.floor_rtt_ns.min(rtt_ns);
if fresh {
self.rejected_streak = 0;
self.best_pending = None;
return ResyncAdmit::Fresh;
}
let best = match self.best_pending {
Some((o, r)) if r <= rtt_ns => (o, r),
_ => (offset_ns, rtt_ns),
};
self.best_pending = Some(best);
self.rejected_streak += 1;
if self.rejected_streak >= Self::MAX_REJECTED_STREAK {
self.rejected_streak = 0;
self.best_pending = None;
return ResyncAdmit::BestOfStreak {
offset_ns: best.0,
rtt_ns: best.1,
};
}
ResyncAdmit::Rejected {
streak: self.rejected_streak,
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -218,9 +315,14 @@ mod tests {
let echo = echo_for(probe.t1_ns, one_way); let echo = echo_for(probe.t1_ns, one_way);
let t4 = t4_for(&echo, one_way); let t4 = t4_for(&echo, one_way);
match rs.on_echo(&echo, t4) { match rs.on_echo(&echo, t4) {
ResyncStep::Probe(p) => { ResyncStep::MoreRounds => {
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds"); assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
probe = p; // A duplicate of the just-consumed echo must not double-record: no probe
// is in flight until the driver arms the next round.
assert_eq!(rs.on_echo(&echo, t4), ResyncStep::Idle);
// The driver stamps the next probe at SEND time (after its inter-round
// spacing), so the spacing never lands in the measured RTT.
probe = rs.next_probe(t4 + 7_000_000);
} }
ResyncStep::Done { offset_ns, rtt_ns } => { ResyncStep::Done { offset_ns, rtt_ns } => {
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early"); assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
@@ -243,10 +345,46 @@ mod tests {
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000), rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
ResyncStep::Idle ResyncStep::Idle
); );
assert!(matches!( assert_eq!(
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000), rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
ResyncStep::Probe(_) ResyncStep::MoreRounds
)); );
}
/// The guard's two field-report fixes: the baseline tracks the SESSION floor (a batch
/// better than the stale connect figure re-anchors it), and a rejection streak is bounded
/// — the best batch of the streak is applied rather than letting the offset go stale
/// while the wall clocks drift apart.
#[test]
fn resync_guard_floor_tracking_and_bounded_streak() {
// Connect measured 400 µs idle; the 2 ms floor of accept_resync governs early on.
let mut g = ResyncGuard::new(400_000);
assert_eq!(g.admit(10, 1_500_000), ResyncAdmit::Fresh);
// A better batch lowers the floor evidence.
assert_eq!(g.admit(11, 300_000), ResyncAdmit::Fresh);
assert_eq!(g.floor_rtt_ns(), 300_000);
// Loaded stretch: batches at 46 ms all exceed max(2 ms, 1.5 × 300 µs).
assert_eq!(g.admit(100, 6_000_000), ResyncAdmit::Rejected { streak: 1 });
// The best (min-RTT) batch of the streak is remembered…
assert_eq!(g.admit(200, 4_000_000), ResyncAdmit::Rejected { streak: 2 });
// …and applied when the streak hits the cap — offset 200 (the 4 ms batch), not 300.
assert_eq!(
g.admit(300, 5_000_000),
ResyncAdmit::BestOfStreak {
offset_ns: 200,
rtt_ns: 4_000_000
}
);
// The streak reset: the next congested batch starts a new one.
assert_eq!(g.admit(400, 5_000_000), ResyncAdmit::Rejected { streak: 1 });
// A quiet batch clears it and applies normally.
assert_eq!(g.admit(500, 350_000), ResyncAdmit::Fresh);
// A batch that IS the new floor is always fresh (compared against the pre-batch floor).
let mut g2 = ResyncGuard::new(10_000_000);
assert_eq!(g2.admit(1, 8_000_000), ResyncAdmit::Fresh);
assert_eq!(g2.floor_rtt_ns(), 8_000_000);
} }
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not /// The acceptance guard: a batch measured through a congested window (fat RTT) must not
+252
View File
@@ -475,3 +475,255 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
println!("dualsense-windows-test: done (devnode removed)"); println!("dualsense-windows-test: done (devnode removed)");
Ok(()) Ok(())
} }
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
///
/// Opens the display backend exactly as a session would (so a `PUNKTFUNK_CAPTURE_MONITOR` pin
/// routes to the mirror backend), attaches a capturer to whatever PipeWire node comes back, and
/// reports the frames it actually receives. What it proves that a unit test cannot: the compositor
/// accepted the record request for a NAMED head, and that head is producing pixels at its own size.
///
/// `--monitor <CONNECTOR>` pins for this run (else `PUNKTFUNK_CAPTURE_MONITOR`); `--seconds N`.
#[cfg(target_os = "linux")]
pub fn mirror_test(args: &[String]) -> Result<()> {
use std::time::{Duration, Instant};
let arg = |name: &str| {
args.iter()
.skip_while(|a| a.as_str() != name)
.nth(1)
.cloned()
};
let secs: u64 = arg("--seconds").and_then(|s| s.parse().ok()).unwrap_or(5);
// `--monitor` cannot work by setting PUNKTFUNK_CAPTURE_MONITOR here: pf_host_config parses the
// environment ONCE and startup already read it, so this process would still see the old
// snapshot. An explicit connector therefore goes through `open_mirror` below; only the unset
// case falls back to the pin (and to `open`, which is the production routing).
let explicit = arg("--monitor");
let want = explicit
.clone()
.or_else(crate::vdisplay::capture_monitor)
.context(
"no monitor named — pass --monitor <CONNECTOR> or set PUNKTFUNK_CAPTURE_MONITOR",
)?;
let compositor = crate::vdisplay::detect()?;
let monitors = crate::vdisplay::monitors::list(compositor)?;
let target = crate::vdisplay::monitors::resolve(&monitors, &want)?;
println!(
"mirror-test: {compositor:?} {} ({}) at +{},+{}",
target.connector,
target.mode_label(),
target.x,
target.y
);
// No `--monitor` ⇒ exercise the PRODUCTION routing (`open` consulting the pin), which is the
// more valuable path to prove; an explicit connector takes the direct opener.
let mut vd = match &explicit {
Some(connector) => crate::vdisplay::open_mirror(compositor, connector)?,
None => crate::vdisplay::open(compositor)?,
};
// The mode is ignored by the mirror backend (a panel runs at the mode its owner set); pass the
// head's own so this also behaves if the pin is ever unset mid-test.
let mode = crate::vdisplay::Mode {
width: target.width,
height: target.height,
refresh_hz: 60,
};
let vout = vd.create(mode).context("open the mirror display")?;
println!(
"mirror-test: node_id={} preferred={:?} ownership={:?}",
vout.node_id, vout.preferred_mode, vout.ownership
);
// Default to the GPU (dmabuf zero-copy) path a real session uses; `--cpu` forces the mmap
// path, which is worth having as a switch — the two negotiate different PipeWire buffer types.
let gpu = !args.iter().any(|a| a == "--cpu");
let fmt = pf_frame::OutputFormat::resolve(false, gpu);
println!(
"mirror-test: capture path = {}",
if gpu { "gpu/dmabuf" } else { "cpu/mmap" }
);
let mut cap = crate::capture::capture_virtual_output(
vout,
fmt,
crate::session_plan::CaptureBackend::resolve(),
)
.context("attach a capturer to the mirrored monitor")?;
cap.set_active(true);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut frames, mut first) = (0u32, None);
let mut idle = 0u32;
let mut dims = (0u32, 0u32);
while Instant::now() < deadline {
match cap.next_frame_within(Duration::from_secs(5)) {
Ok(f) => {
if first.is_none() {
first = Some(Instant::now());
println!(
"mirror-test: FIRST FRAME {}x{} {:?}",
f.width, f.height, f.format
);
}
dims = (f.width, f.height);
frames += 1;
}
// A timeout is NOT fatal here: compositor screencast is damage-driven, so a static
// desktop legitimately produces nothing for seconds at a time (the host's own capture
// diag logs `new_fps=0` for virtual outputs on an idle desktop for the same reason).
// Keep waiting until the deadline instead of ending the measurement on the first gap.
Err(e) => {
idle += 1;
if idle == 1 {
println!("mirror-test: (idle — no damage yet: {e:#})");
}
}
}
}
match first {
Some(_) => println!(
"mirror-test: OK — {frames} frames in {secs}s at {}x{} ({:.1} fps over the whole run, \
{idle} idle gaps). Compositor capture is damage-driven: a static desktop produces \
nothing, so judge this by whether frames track what is happening on screen.",
dims.0,
dims.1,
frames as f64 / secs as f64
),
None => {
anyhow::bail!("no frames arrived in {secs}s — the cast started but produced nothing")
}
}
Ok(())
}
/// Aim absolute input at a named monitor and prove where it landed — the on-glass gate for the
/// input-region ladder (`design/per-monitor-portal-capture.md` §7.2), without needing a client.
///
/// The ladder exists for one case a unit test can only simulate: **two heads of the same size**,
/// where matching a libei region by the streamed mode is a coin flip and the pointer silently ends
/// up on the wrong screen. This drives the real thing — the compositor's own EIS regions, the real
/// anchor, the real resolver — and prints the region absolute coordinates actually mapped into, so
/// "it went to the right monitor" is something you read rather than infer.
///
/// `--monitor <CONNECTOR>` anchors at that head's origin (default: the `PUNKTFUNK_CAPTURE_MONITOR` /
/// policy pin); `--none` deliberately runs UNANCHORED, which is the A/B that makes the anchored run
/// mean something on a same-size pair. It then walks the corners and centre of a `--width`×`--height`
/// client surface so an observer can watch the pointer.
///
/// Read the answer from the log line `libei: absolute input maps into this output`.
#[cfg(target_os = "linux")]
pub fn anchor_test(args: &[String]) -> Result<()> {
use punktfunk_core::input::{InputEvent, InputKind};
use std::time::Duration;
let arg = |name: &str| {
args.iter()
.skip_while(|a| a.as_str() != name)
.nth(1)
.cloned()
};
let unanchored = args.iter().any(|a| a == "--none");
let w: u32 = arg("--width").and_then(|s| s.parse().ok()).unwrap_or(1920);
let h: u32 = arg("--height").and_then(|s| s.parse().ok()).unwrap_or(1080);
let compositor = crate::vdisplay::detect()?;
let monitors = crate::vdisplay::monitors::list(compositor)?;
println!(
"anchor-test: {compositor:?} has {} monitor(s):",
monitors.len()
);
for m in &monitors {
println!(
" {:<12} {:>13} at +{},+{}",
m.connector,
m.mode_label(),
m.x,
m.y
);
}
// Two heads at the same size is the case the ladder exists for; say so when the rig is right,
// and say so when it is NOT — a green run on a single-head box proves nothing about it.
let same_size = monitors.iter().enumerate().any(|(i, a)| {
monitors
.iter()
.skip(i + 1)
.any(|b| a.width == b.width && a.height == b.height)
});
println!(
"anchor-test: two same-size heads present: {} {}",
same_size,
if same_size {
"— this run exercises the case the ladder exists for"
} else {
"— WEAK RIG: size matching would have picked correctly anyway"
}
);
if unanchored {
crate::inject::set_absolute_anchor(None);
println!("anchor-test: UNANCHORED (--none) — the size/first rungs decide");
} else {
let want = arg("--monitor")
.or_else(crate::vdisplay::capture_monitor)
.context("no monitor named — pass --monitor <CONNECTOR>, or --none for the A/B")?;
let m = crate::vdisplay::monitors::resolve(&monitors, &want)?;
crate::inject::set_absolute_anchor(Some(crate::inject::AbsoluteAnchor {
origin: Some((m.x, m.y)),
mapping_id: None,
}));
println!(
"anchor-test: anchored at {} +{},+{} ({})",
m.connector,
m.x,
m.y,
m.mode_label()
);
}
let backend = crate::inject::default_backend();
if backend != crate::inject::Backend::Libei {
// The ladder is libei's; on any other backend this command would report nothing about it.
// Say so rather than emitting a green run that means nothing (sway injects via WlrVirtual,
// which is why the sway box cannot serve as this rig — set PUNKTFUNK_INPUT_BACKEND=libei on
// a compositor that speaks EI).
anyhow::bail!(
"input backend is {backend:?}, not libei — the absolute-region ladder only exists on \
the libei backend; set PUNKTFUNK_INPUT_BACKEND=libei"
);
}
let mut inj = crate::inject::open(backend)?;
// libei establishes its portal/EIS session + device resume asynchronously; events before then
// are dropped (and it is the resume that publishes the regions we are testing).
std::thread::sleep(Duration::from_secs(4));
let flags = (w << 16) | (h & 0xffff);
let pts = [
(w as i32 / 2, h as i32 / 2),
(60, 60),
(w as i32 - 60, 60),
(w as i32 - 60, h as i32 - 60),
(60, h as i32 - 60),
(w as i32 / 2, h as i32 / 2),
];
println!("anchor-test: walking {w}x{h} — centre, four corners, centre (1s apart)");
for (x, y) in pts {
let e = InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x,
y,
flags,
};
if let Err(err) = inj.inject(&e) {
tracing::warn!(error = %format!("{err:#}"), "anchor-test: inject failed");
}
std::thread::sleep(Duration::from_secs(1));
}
println!(
"anchor-test: done — read the `libei: absolute input maps into this output` line above \
for the region that was chosen"
);
Ok(())
}
+97 -13
View File
@@ -260,14 +260,13 @@ pub struct LeaseRequest {
/// The reference instant for adopting this launch's processes, in seconds since boot. Call it /// The reference instant for adopting this launch's processes, in seconds since boot. Call it
/// **before** anything spawns; see [`LeaseRequest::launch_stamp`]. /// **before** anything spawns; see [`LeaseRequest::launch_stamp`].
pub fn launch_clock() -> Option<f64> { pub fn launch_clock() -> Option<f64> {
#[cfg(target_os = "linux")] // 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() crate::procscan::launch_stamp()
}
#[cfg(not(target_os = "linux"))]
{
None
}
} }
/// What the watcher does when it concludes the game has exited. /// 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 // The child being alive counts as the game running for a `Child` lease, so a title with no
// detect signals is still fully tracked. // 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); let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive { if !live.is_empty() || child_alive {
known = live.clone(); known = live.clone();
@@ -1136,6 +1147,59 @@ mod tests {
assert_eq!(readopt(Some("fp-151"), Some(b)), 1); 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, /// 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. /// notices when it exits, and reports that exit exactly once.
/// ///
@@ -1182,13 +1246,19 @@ mod tests {
let shared = lease.shared(); let shared = lease.shared();
assert!(matches!(shared.kind(), LeaseKind::Child)); assert!(matches!(shared.kind(), LeaseKind::Child));
// Seen running on the first poll, because the host holds the child. // This script `exec`s a binary OUTSIDE the install dir, so neither the image path nor the
std::thread::sleep(Duration::from_millis(1_500)); // command line carries the directory: the host's own child is the only signal there is.
assert_eq!(shared.state(), GameState::Running, "should be running"); // 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"); 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"); terminate(shared.clone(), "test asked");
// The ladder asks politely first; `sleep` dies on SIGTERM, so this resolves well inside the // 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] #[test]
fn state_strings_are_stable() { fn state_strings_are_stable() {
// These reach the API and the console; renaming one is a wire change. // 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. // One-shot latch for the HDR-mode control message (0x010e); re-armed on Disconnect.
let mut hdr_sent = false; let mut hdr_sent = false;
let mut peer: Option<PeerID> = None; 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 {
loop { loop {
match host.service() { 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 // Service the pads' force-feedback protocol every tick (games block inside
// EVIOCSFF until answered) and relay mixed rumble levels to the client. // 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). // use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD).
if let (Some(pid), Some(scheme)) = (peer, detected) { if let (Some(pid), Some(scheme)) = (peer, detected) {
let key = state.launch.lock().unwrap().map(|s| s.gcm_key); 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 { if let Some(key) = key {
let mut out: Vec<Vec<u8>> = Vec::new(); let mut out: Vec<Vec<u8>> = Vec::new();
// One-shot HDR-mode signal (type 0x010e / Sunshine `IDX_HDR_MODE`) once the // 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 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 /// 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 /// 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 /// 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 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] /// 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. /// [u16 length=27][u8 enable][SS_HDR_METADATA]`, 31 bytes, all little-endian, primaries R,G,B.
#[test] #[test]
+91 -11
View File
@@ -33,10 +33,16 @@ pub struct StreamConfig {
pub hdr: bool, pub hdr: bool,
} }
/// A pooled capturer plus the two PipeWire-negotiation-time properties reuse must match on — /// A pooled capturer plus the three properties reuse must match on — its HDR-ness, its
/// its HDR-ness and its metadata-cursor mode; a mismatch on either needs a fresh screencast /// metadata-cursor mode (both fixed at PipeWire-negotiation time) and **which screen it is
/// session (see `AppState::video_cap`). /// actually capturing**: the `capture_monitor` pin, or `None` for the portal's own pick. A
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool); /// mismatch on any of them needs a fresh screencast session (see `AppState::video_cap`).
///
/// The pin belongs in the key because it is a *live* setting — the console can re-aim the host
/// between two GameStream connects (`design/per-monitor-portal-capture.md` §7.3). Without it the
/// second connect would silently keep streaming the previous screen, which is the exact failure
/// the pin exists to prevent.
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool, Option<String>);
/// Slot for the persistent screen capturer, shared with the control plane and reused across /// Slot for the persistent screen capturer, shared with the control plane and reused across
/// streams so a reconnect doesn't open a second (conflicting) screencast session. /// streams so a reconnect doesn't open a second (conflicting) screencast session.
@@ -404,18 +410,31 @@ fn run(
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
false false
}; };
// Which screen this stream must show. The host-wide pin (§5.3) applies to the compat plane too:
// the portal chooser cannot name a head, so a pinned host MIRRORS it here the same way the
// virtual source does via `vdisplay::open`. Without this a Moonlight client on a pinned host
// would silently get whichever monitor the portal handed back — "showing the wrong monitor is
// worse than showing none" is the rule the whole feature is built on.
#[cfg(target_os = "linux")]
let pinned = crate::vdisplay::capture_monitor();
#[cfg(not(target_os = "linux"))]
let pinned: Option<String> = None;
let pooled = match video_cap.lock().unwrap().take() { let pooled = match video_cap.lock().unwrap().take() {
Some((c, was_hdr, was_meta)) if was_hdr == cfg.hdr && was_meta == metadata_cursor => { Some((c, was_hdr, was_meta, ref was_pin))
if was_hdr == cfg.hdr && was_meta == metadata_cursor && *was_pin == pinned =>
{
Some(c) Some(c)
} }
Some((c, was_hdr, was_meta)) => { Some((c, was_hdr, was_meta, was_pin)) => {
tracing::info!( tracing::info!(
was_hdr, was_hdr,
want_hdr = cfg.hdr, want_hdr = cfg.hdr,
was_metadata_cursor = was_meta, was_metadata_cursor = was_meta,
want_metadata_cursor = metadata_cursor, want_metadata_cursor = metadata_cursor,
"video source: pooled capturer depth/cursor-mode mismatch — opening a fresh \ was_monitor = was_pin.as_deref().unwrap_or("<portal's pick>"),
screencast session" want_monitor = pinned.as_deref().unwrap_or("<portal's pick>"),
"video source: pooled capturer depth/cursor-mode/monitor mismatch — opening a \
fresh screencast session"
); );
drop(c); drop(c);
None None
@@ -427,6 +446,20 @@ fn run(
tracing::info!("video source: reusing capturer"); tracing::info!("video source: reusing capturer");
c c
} }
#[cfg(target_os = "linux")]
None if pf_host_config::config().video_source.as_deref() == Some("portal")
&& pinned.is_some() =>
{
let connector = pinned.as_deref().expect("guarded by the match arm");
tracing::info!(
hdr = cfg.hdr,
metadata_cursor,
monitor = connector,
"video source: mirroring the pinned monitor (portal source, host pin)"
);
open_gs_mirror_source(connector, cfg, metadata_cursor)
.with_context(|| format!("mirror the pinned monitor {connector:?}"))?
}
None if pf_host_config::config().video_source.as_deref() == Some("portal") => { None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
tracing::info!( tracing::info!(
hdr = cfg.hdr, hdr = cfg.hdr,
@@ -463,10 +496,10 @@ fn run(
// point — and this path has no rebuild closure (unlike the virtual-output path above), so a // point — and this path has no rebuild closure (unlike the virtual-output path above), so a
// re-admitted dead capturer wedged GameStream portal video permanently, at 10 s per reconnect // re-admitted dead capturer wedged GameStream portal video permanently, at 10 s per reconnect
// attempt. Dropping it instead costs one fresh screencast session on the next connect. Note // attempt. Dropping it instead costs one fresh screencast session on the next connect. Note
// `result` may already be `Err` here, which is itself that signal. (`metadata_cursor` rides // `result` may already be `Err` here, which is itself that signal. (`metadata_cursor` and the
// along as the second reuse key, beside HDR-ness — see `PooledCapturer`.) // monitor pin ride along as the other two reuse keys, beside HDR-ness — see `PooledCapturer`.)
if result.is_ok() && capturer.is_alive() { if result.is_ok() && capturer.is_alive() {
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor)); *video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor, pinned));
} else { } else {
tracing::info!( tracing::info!(
stream_failed = result.is_err(), stream_failed = result.is_err(),
@@ -478,6 +511,53 @@ fn run(
result result
} }
/// Open a capturer on the **pinned physical monitor** for the compat plane's portal source
/// (`design/per-monitor-portal-capture.md` §5.3). The pin is host-wide, so it has to be honored on
/// every plane that captures a screen — and the portal source is the one that otherwise takes
/// "whichever head the portal hands back".
///
/// Deliberately *not* the `open_gs_virtual_source` path: this source launches nothing and creates no
/// virtual output, so it needs neither the game-lifetime machinery nor the registry (a mirror is
/// [`DisplayOwnership::External`](crate::vdisplay::DisplayOwnership) and would pass straight through
/// it anyway). A missing monitor fails the stream loudly rather than falling back to another screen.
#[cfg(target_os = "linux")]
fn open_gs_mirror_source(
connector: &str,
cfg: StreamConfig,
metadata_cursor: bool,
) -> Result<Box<dyn Capturer>> {
// Follow the live session first, exactly as the virtual source does — a mirror host that
// switched Desktop↔Game since startup must be enumerated against the compositor that is up now.
let active = crate::vdisplay::detect_active_session();
crate::vdisplay::observe_session_instance(&active);
crate::vdisplay::apply_session_env(&active);
let compositor = crate::vdisplay::compositor_for_kind(active.kind)
.map(Ok)
.unwrap_or_else(crate::vdisplay::detect)
.context("detect compositor")?;
crate::vdisplay::apply_input_env(compositor, false);
let mut vd = crate::vdisplay::open_mirror(compositor, connector)?;
// Cursor mode is the session's negotiated one: metadata where this encode path composites
// `frame.cursor`, otherwise let the compositor embed it (§7.5 — one resolver, per-backend
// expression).
vd.set_hw_cursor(metadata_cursor);
// The mirror backend ignores the requested mode by design (§7.3 — a panel runs at the mode its
// owner set, and the client scales); pass the client's anyway so the argument stays honest.
let vout = vd
.create(punktfunk_core::Mode {
width: cfg.width,
height: cfg.height,
refresh_hz: cfg.fps,
})
.context("start mirroring the pinned monitor")?;
crate::capture::capture_virtual_output(
vout,
pf_frame::OutputFormat::resolve(cfg.hdr, crate::zerocopy::enabled()),
crate::session_plan::CaptureBackend::resolve(),
)
.context("attach a capturer to the mirrored monitor")
}
/// What the compat plane resolved about the app a client launched: identity for the lease, the status /// What the compat plane resolved about the app a client launched: identity for the lease, the status
/// surface and the `game.*` events; the signals that recognize the running game; and the command to /// surface and the `game.*` events; the signals that recognize the running game; and the command to
/// run it. /// run it.
+54
View File
@@ -85,6 +85,57 @@ pub struct LaunchSpec {
pub value: String, 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. /// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)] #[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry { pub struct GameEntry {
@@ -113,6 +164,9 @@ pub struct GameEntry {
#[serde(skip)] #[serde(skip)]
#[schema(ignore)] #[schema(ignore)]
pub detect: DetectSpec, 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 /// 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. /// the host would otherwise lose the game the moment the shim returns.
#[serde(default, skip_serializing_if = "DetectHint::is_empty")] #[serde(default, skip_serializing_if = "DetectHint::is_empty")]
pub detect: DetectHint, 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). /// 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`]. /// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)] #[serde(default)]
pub detect: DetectHint, 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 /// 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. /// through the provider's own client still end its session when the player quits.
#[serde(default)] #[serde(default)]
pub detect: DetectHint, pub detect: DetectHint,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
} }
impl From<CustomEntry> for GameEntry { impl From<CustomEntry> for GameEntry {
@@ -98,6 +108,7 @@ impl From<CustomEntry> for GameEntry {
launch: c.launch, launch: c.launch,
provider: c.provider, provider: c.provider,
detect, detect,
meta: c.meta,
} }
} }
} }
@@ -188,6 +199,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
provider: None, provider: None,
external_id: None, external_id: None,
detect: input.detect, detect: input.detect,
meta: input.meta,
}; };
entries.push(entry.clone()); entries.push(entry.clone());
save_custom(&entries)?; save_custom(&entries)?;
@@ -210,6 +222,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
slot.launch = input.launch; slot.launch = input.launch;
slot.prep = input.prep; slot.prep = input.prep;
slot.detect = input.detect; slot.detect = input.detect;
slot.meta = input.meta;
let updated = slot.clone(); let updated = slot.clone();
save_custom(&entries)?; save_custom(&entries)?;
emit_changed("manual"); emit_changed("manual");
@@ -305,6 +318,7 @@ fn reconcile_entries(
provider: Some(provider.to_string()), provider: Some(provider.to_string()),
external_id: Some(input.external_id), external_id: Some(input.external_id),
detect: input.detect, detect: input.detect,
meta: input.meta,
}); });
} }
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile). // `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
@@ -383,6 +397,7 @@ mod tests {
provider: None, provider: None,
external_id: None, external_id: None,
detect: DetectHint::default(), detect: DetectHint::default(),
meta: GameMeta::default(),
} }
} }
@@ -394,6 +409,7 @@ mod tests {
launch: None, launch: None,
prep: Vec::new(), prep: Vec::new(),
detect: DetectHint::default(), detect: DetectHint::default(),
meta: GameMeta::default(),
} }
} }
@@ -407,8 +423,43 @@ mod tests {
let mut e = manual("def456", "Synced"); let mut e = manual("def456", "Synced");
e.provider = Some("romm".into()); e.provider = Some("romm".into());
e.external_id = Some("rom-1".into()); e.external_id = Some("rom-1".into());
e.meta.platform = Some("PS2".into());
let g: GameEntry = e.into(); let g: GameEntry = e.into();
assert_eq!(g.provider.as_deref(), Some("romm")); 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, /// 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 { Some(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("epic:{app_name}"), id: format!("epic:{app_name}"),
store: "epic".into(), store: "epic".into(),
title, title,
+1
View File
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
let detect = DetectSpec::exe(&exe).with_dir(&path); let detect = DetectSpec::exe(&exe).with_dir(&path);
out.push(GameEntry { out.push(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id, id,
store: "gog".into(), store: "gog".into(),
title, title,
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
}; };
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"), id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(), store: "heroic".into(),
title, title,
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
for (id, slug, name, directory) in rows.flatten() { for (id, slug, name, directory) in rows.flatten() {
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("lutris:{id}"), id: format!("lutris:{id}"),
store: "lutris".into(), store: "lutris".into(),
title: name, title: name,
@@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider {
.filter(|app| !is_steam_tool(app.appid, &app.name)) .filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry { .map(|app| GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("steam:{}", app.appid), id: format!("steam:{}", app.appid),
store: "steam".into(), store: "steam".into(),
art: steam_art(app.appid), art: steam_art(app.appid),
@@ -382,6 +383,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
} }
Some(GameEntry { Some(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("steam:{}", sc.appid), id: format!("steam:{}", sc.appid),
store: "steam".into(), store: "steam".into(),
title: sc.name, title: sc.name,
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
let art = cached_art(&id).unwrap_or_default(); let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id, id,
store: "xbox".into(), store: "xbox".into(),
title, title,
+142
View File
@@ -191,6 +191,77 @@ fn main() {
/// prints an alarming `SetProcessDpiAwarenessContext … "access denied"` WARN on a plain /// prints an alarming `SetProcessDpiAwarenessContext … "access denied"` WARN on a plain
/// `plugins add`. `service run` is the SCM-launched host itself, so it is explicitly NOT lightweight /// `plugins add`. `service run` is the SCM-launched host itself, so it is explicitly NOT lightweight
/// (it must keep the hook — the hybrid-GPU ACCESS_LOST fix depends on it). /// (it must keep the hook — the hybrid-GPU ACCESS_LOST fix depends on it).
/// Resolve the effective monitor pin (env, else the stored policy) and aim absolute input at that
/// head — then report it. Called at startup (an operator sets the pin in a `host.env` and then has
/// no session to watch, so this is where a typo has to surface) and again whenever the console
/// writes the policy, so a picker change re-aims input without a host restart.
///
/// The anchor lives HERE rather than in the mirror backend for two reasons: pf-vdisplay must not
/// depend on pf-inject (its crate doc), and the anchor is a host-level pin anyway — the injector is
/// host-lifetime and shared by every concurrent session, so there is nothing per-session to track
/// (`design/per-monitor-portal-capture.md` §7.2).
#[cfg(target_os = "linux")]
pub(crate) fn refresh_capture_monitor_anchor(context: &str) {
let Some(want) = pf_vdisplay::capture_monitor() else {
// No pin (or the console just cleared one): stop aiming input at a monitor we are no
// longer mirroring, or a later virtual-display session inherits a stale anchor.
// Setting a pin says so in the log; clearing one is the same size of change to what the
// host streams, so say that too — but only when there WAS one, or every unpinned host
// logs this line at startup for no reason.
if pf_inject::absolute_anchor().is_some() {
tracing::info!(
context,
"capture monitor: cleared — sessions create a virtual display again and absolute \
input is no longer anchored"
);
}
pf_inject::set_absolute_anchor(None);
return;
};
match pf_vdisplay::detect().and_then(pf_vdisplay::monitors::list) {
Ok(ms) => match pf_vdisplay::monitors::resolve(&ms, &want) {
Ok(m) => {
// Match the libei region by the head's ORIGIN: two monitors can share a size — and
// a mirrored head's region is not the client's size at all — so size matching would
// put the pointer on the wrong screen.
pf_inject::set_absolute_anchor(Some(pf_inject::AbsoluteAnchor {
origin: Some((m.x, m.y)),
mapping_id: None,
}));
tracing::info!(
context,
connector = %m.connector,
description = %m.description,
mode = %m.mode_label(),
at = %format!("+{}+{}", m.x, m.y),
"capture monitor: sessions will mirror this monitor (no virtual display) and \
absolute input is anchored to it"
);
}
// Left unanchored on purpose: a pin that resolves to nothing must not aim input at a
// guess. The session's own `create` fails with the same reason.
Err(e) => {
pf_inject::set_absolute_anchor(None);
tracing::warn!(
context,
error = %e,
"capture monitor: the pinned monitor is not on this host — sessions will fail \
to start until it is corrected or cleared"
);
}
},
Err(e) => {
pf_inject::set_absolute_anchor(None);
tracing::warn!(
context,
error = %format!("{e:#}"),
monitor = %want,
"capture monitor: a monitor is pinned but the monitors could not be enumerated"
);
}
}
}
fn is_management_cli(args: &[String]) -> bool { fn is_management_cli(args: &[String]) -> bool {
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
Some("plugins") Some("plugins")
@@ -199,6 +270,9 @@ fn is_management_cli(args: &[String]) -> bool {
| Some("openapi") | Some("openapi")
| Some("library") | Some("library")
| Some("detect-conflicts") | Some("detect-conflicts")
// Reads the compositor's output list and exits — none of the host-startup work applies,
// and it must not re-run the pin's own startup report while printing that same list.
| Some("list-monitors")
| Some("-h") | Some("-h")
| Some("--help") | Some("--help")
| Some("help") | Some("help")
@@ -231,6 +305,11 @@ fn real_main() -> Result<()> {
); );
} }
#[cfg(target_os = "linux")]
if !management_cli {
refresh_capture_monitor_anchor("startup");
}
// Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a // Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a
// neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set. // neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set.
let _ = pf_vdisplay::DISPLAY_EVENT_SINK.set(Box::new(|ev| match ev { let _ = pf_vdisplay::DISPLAY_EVENT_SINK.set(Box::new(|ev| match ev {
@@ -333,6 +412,11 @@ fn real_main() -> Result<()> {
// names the inherited socketpair end. // names the inherited socketpair end.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]), Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
// Hidden: the splash client every bare gamescope spawn backgrounds beside its nested app,
// so a fresh headless gamescope composites (and its PipeWire node delivers frames) from
// the first second — never run by hand; it needs a gamescope session's DISPLAY.
#[cfg(target_os = "linux")]
Some("gamescope-splash") => vdisplay::gamescope_splash_client(),
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12 // NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
// on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A // on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error. // `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
@@ -406,6 +490,62 @@ fn real_main() -> Result<()> {
println!("{compositor:?} ready"); println!("{compositor:?} ready");
Ok(()) Ok(())
} }
// List the host's physical monitors — the connector names `PUNKTFUNK_CAPTURE_MONITOR`
// takes. An operator configuring an unattended host has to learn those names from
// somewhere, and "curl the management API before the host is configured" is not it.
#[cfg(target_os = "linux")]
Some("list-monitors") => {
let compositor = vdisplay::detect()?;
let monitors = vdisplay::monitors::list(compositor)
.with_context(|| format!("enumerate monitors on {compositor:?}"))?;
if monitors.is_empty() {
println!("{compositor:?}: no monitors");
return Ok(());
}
let pinned = vdisplay::capture_monitor();
println!("{compositor:?}:");
for m in &monitors {
let mut tags = Vec::new();
if m.primary {
tags.push("primary");
}
if !m.enabled {
tags.push("disabled");
}
if m.managed {
tags.push("punktfunk virtual display");
}
if pinned
.as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector))
{
tags.push("PINNED");
}
println!(
" {:<12} {:>13} at +{},+{} scale {} {}{}",
m.connector,
m.mode_label(),
m.x,
m.y,
m.scale,
m.description,
if tags.is_empty() {
String::new()
} else {
format!(" [{}]", tags.join(", "))
}
);
}
Ok(())
}
// Mirror a pinned physical monitor and pull frames from it — the per-monitor capture
// on-glass gate, with no client involved.
#[cfg(target_os = "linux")]
Some("mirror-test") => devtest::mirror_test(&args),
// Aim absolute input at a named monitor and report which libei region it landed in — the
// on-glass gate for the input-region ladder, with no client involved.
#[cfg(target_os = "linux")]
Some("anchor-test") => devtest::anchor_test(&args),
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session). // Create a virtual DualSense via UHID and exercise it (validation, no streaming session).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Some("dualsense-test") => devtest::dualsense_test(&args), Some("dualsense-test") => devtest::dualsense_test(&args),
@@ -728,6 +868,8 @@ USAGE:
punktfunk-host openapi print the management API's OpenAPI document (codegen) punktfunk-host openapi print the management API's OpenAPI document (codegen)
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane) punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate) punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
punktfunk-host list-monitors list the host's physical monitors (Linux) the
connector names PUNKTFUNK_CAPTURE_MONITOR takes
punktfunk-host spike [OPTIONS] captureencodefile pipeline spike (dev tool) punktfunk-host spike [OPTIONS] captureencodefile pipeline spike (dev tool)
SERVE OPTIONS: SERVE OPTIONS:
+1
View File
@@ -188,6 +188,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(display::get_display_settings)) .routes(routes!(display::get_display_settings))
.routes(routes!(display::set_display_settings)) .routes(routes!(display::set_display_settings))
.routes(routes!(display::get_display_state)) .routes(routes!(display::get_display_state))
.routes(routes!(display::get_display_monitors))
.routes(routes!(display::release_display)) .routes(routes!(display::release_display))
.routes(routes!(display::set_display_layout)) .routes(routes!(display::set_display_layout))
.routes(routes!( .routes(routes!(
+107
View File
@@ -88,6 +88,9 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere. // (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(), "ddc_power_off".into(),
"pnp_disable_monitors".into(), "pnp_disable_monitors".into(),
// Linux-only in effect: routes every session to the mirror backend
// (design/per-monitor-portal-capture.md).
"capture_monitor".into(),
], ],
} }
} }
@@ -141,6 +144,10 @@ pub(crate) async fn set_display_settings(
); );
} }
tracing::info!("management API: display policy updated"); tracing::info!("management API: display policy updated");
// The policy carries the capture-monitor pin, so a picker change must re-aim absolute input now
// rather than at the next host restart — and must clear the anchor when the pin is cleared.
#[cfg(target_os = "linux")]
crate::refresh_capture_monitor_anchor("display policy updated");
Json(display_settings_state()).into_response() Json(display_settings_state()).into_response()
} }
@@ -181,6 +188,105 @@ pub(crate) struct DisplayStateResponse {
displays: Vec<ApiDisplayInfo>, displays: Vec<ApiDisplayInfo>,
} }
/// One physical monitor this host has, as the compositor reports it.
#[derive(Serialize, ToSchema)]
pub(crate) struct ApiMonitorInfo {
/// Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes.
connector: String,
/// Human label for a picker (`make model`, else the connector).
description: String,
/// `WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown).
mode: String,
/// Desktop-space top-left — what makes a head identifiable when two share a size.
x: i32,
y: i32,
/// Logical scale factor.
scale: f64,
/// The compositor's primary/focused head.
primary: bool,
/// Driven right now. A disabled head is still listed, so it can be explained rather than missing.
enabled: bool,
/// Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only).
managed: bool,
/// True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor.
selected: bool,
}
/// The host's physical monitors + which one capture is pinned to.
#[derive(Serialize, ToSchema)]
pub(crate) struct MonitorsResponse {
/// Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved.
compositor: Option<String>,
/// The heads, ordered left-to-right by desktop position.
monitors: Vec<ApiMonitorInfo>,
/// The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,
/// so the console can show "pinned to DP-2, which this host doesn't have".
pinned: Option<String>,
/// Why the list is empty, when enumeration failed (compositor unreachable, unsupported
/// platform). `None` with an empty list means "asked, and there are none".
error: Option<String>,
}
/// Physical monitors
///
/// The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and
/// for rendering a picker. Read-only: this never creates, moves or disables anything. Note these
/// are *not* the managed virtual displays — those are `/display/state`. See
/// `design/per-monitor-portal-capture.md` §5.1.
#[utoipa::path(
get,
path = "/display/monitors",
tag = "display",
operation_id = "getDisplayMonitors",
responses(
(status = OK, description = "The host's physical monitors", body = MonitorsResponse),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
// The EFFECTIVE pin (env override, else the stored policy) — so the picker highlights what
// sessions will actually mirror, not just what the console last wrote.
#[cfg(target_os = "linux")]
let pinned = crate::vdisplay::capture_monitor();
#[cfg(not(target_os = "linux"))]
let pinned: Option<String> = None;
// Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker.
let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() {
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
Err(e) => (None, Err(e)),
})
.await
.unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}"))));
let (monitors, error) = match listed {
Ok(ms) => (
ms.into_iter()
.map(|m| ApiMonitorInfo {
mode: m.mode_label(),
selected: pinned
.as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector)),
connector: m.connector,
description: m.description,
x: m.x,
y: m.y,
scale: m.scale,
primary: m.primary,
enabled: m.enabled,
managed: m.managed,
})
.collect(),
None,
),
Err(e) => (Vec::new(), Some(format!("{e:#}"))),
};
Json(MonitorsResponse {
compositor,
monitors,
pinned,
error,
})
}
/// Request body for `releaseDisplay`. /// Request body for `releaseDisplay`.
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub(crate) struct ReleaseDisplayRequest { pub(crate) struct ReleaseDisplayRequest {
@@ -298,6 +404,7 @@ pub(crate) async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutReques
store.game_session(), store.game_session(),
store.ddc_power_off(), store.ddc_power_off(),
store.pnp_disable_monitors(), store.pnp_disable_monitors(),
store.get().capture_monitor,
); );
if let Err(e) = store.set(policy) { if let Err(e) = store.set(policy) {
return api_error( return api_error(
+13 -1
View File
@@ -8,6 +8,8 @@ use axum::http::header;
pub(crate) struct LibraryQuery { pub(crate) struct LibraryQuery {
/// Only entries owned by this external provider (RFC §8). /// Only entries owned by this external provider (RFC §8).
provider: Option<String>, provider: Option<String>,
/// Only entries on this platform (case-insensitive).
platform: Option<String>,
} }
/// List the game library /// 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) /// 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 /// 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 /// 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( #[utoipa::path(
get, get,
path = "/library", path = "/library",
@@ -23,6 +26,7 @@ pub(crate) struct LibraryQuery {
operation_id = "getLibrary", operation_id = "getLibrary",
params( params(
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"), ("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( responses(
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]), (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()) { if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
games.retain(|g| g.provider.as_deref() == Some(provider.as_str())); 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 // 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 // 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:\…`). // library size, and the client never sees an unreachable `C:\…`).
+28
View File
@@ -1056,6 +1056,34 @@ async fn display_state_and_release_empty() {
assert_eq!(body["released"], 0); assert_eq!(body["released"], 0);
} }
/// `/display/monitors` is wired, auth-gated, and — the point of the test — **always answers 200
/// with a well-formed envelope**, including on a test host with no compositor to enumerate. The
/// console renders a picker from this; an enumeration failure has to arrive as an `error` string
/// next to an empty list, never as a 5xx that reads to the UI as "the host is broken".
#[tokio::test]
async fn display_monitors_answers_even_with_no_compositor() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/monitors")).await;
assert_eq!(status, StatusCode::OK);
assert!(body["monitors"].is_array(), "monitors is always an array");
// No compositor on the test host ⇒ either a clean empty list or an explained failure, never
// both empty AND silent about why.
let listed = body["monitors"].as_array().map(|a| a.len()).unwrap_or(0);
// gamescope is nested: it owns no physical heads by construction, so "empty and silent" is the
// correct answer there, not an unexplained one. A dev box that has ever been in game mode keeps
// `gamescope-0` sockets in its runtime dir, so `detect()` resolves gamescope and this test would
// otherwise fail on the machine rather than on the code (found running the suite on .136).
let nested = body["compositor"] == "gamescope";
assert!(
listed > 0 || !body["error"].is_null() || body["compositor"].is_null() || nested,
"an empty list must carry an error, an absent compositor, or a nested one: {body}"
);
// The pin is reported verbatim so the console can flag "pinned to a monitor you don't have";
// unset on the test host.
assert!(body["pinned"].is_null(), "no PUNKTFUNK_CAPTURE_MONITOR set");
}
#[tokio::test] #[tokio::test]
async fn native_pairing_arm_show_and_unpair() { async fn native_pairing_arm_show_and_unpair() {
let np = Arc::new( let np = Arc::new(
+67 -1
View File
@@ -350,6 +350,8 @@ pub(crate) async fn serve(
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to // A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start. // $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
crate::vdisplay::restore_takeover_on_startup(); crate::vdisplay::restore_takeover_on_startup();
// …and the other end of that: give the box its session back when WE are the ones going away.
install_shutdown_restore();
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com / // Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never // displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
// blocks on the network. A no-op on a host whose stores all carry their own art. // blocks on the network. A no-op on a host whose stores all carry their own art.
@@ -486,6 +488,58 @@ pub(crate) async fn serve(
Ok(()) Ok(())
} }
/// How long a shutdown waits for the box's session to be handed back before exiting anyway. The
/// restore is a couple of `systemctl` calls (or one `pkexec` helper run); this only bounds a
/// genuine wedge, well inside systemd's 90 s `TimeoutStopSec`.
const SHUTDOWN_RESTORE_GRACE: std::time::Duration = std::time::Duration::from_secs(20);
/// Hand the box's own session back on the way out. Until this existed the host had NO signal
/// handling at all: `SIGTERM` killed it outright, which is fine for a host that owns nothing — but
/// a managed gamescope takeover owns the box's session, and on a mask-fragile display manager
/// (Nobara's plasmalogin) it has STOPPED that display manager for the length of the stream. Killed
/// there, the host leaves a box with no graphical session and nothing left to restart it: the
/// crash-restore state lives in `$XDG_RUNTIME_DIR`, which logind removes along with the user
/// manager, so even the next host start can't heal it. `systemctl --user restart punktfunk-host`
/// mid-stream — or a package update doing it for you — was enough.
///
/// So: catch `SIGTERM`/`SIGINT`, restore, then exit. Restoring runs on a blocking thread (it shells
/// out) under [`SHUTDOWN_RESTORE_GRACE`], and a host that took nothing over exits immediately.
fn install_shutdown_restore() {
#[cfg(unix)]
tokio::spawn(async {
use tokio::signal::unix::{signal, SignalKind};
let (Ok(mut term), Ok(mut int)) = (
signal(SignalKind::terminate()),
signal(SignalKind::interrupt()),
) else {
tracing::warn!(
"could not install shutdown signal handlers — a host stopped mid-takeover will \
leave the box's own session down until it is restarted"
);
return;
};
let sig = tokio::select! {
_ = term.recv() => "SIGTERM",
_ = int.recv() => "SIGINT",
};
tracing::info!(
signal = sig,
"host stopping — handing the box's session back"
);
let restore = tokio::task::spawn_blocking(crate::vdisplay::restore_takeover_now);
if tokio::time::timeout(SHUTDOWN_RESTORE_GRACE, restore)
.await
.is_err()
{
tracing::warn!(
secs = SHUTDOWN_RESTORE_GRACE.as_secs(),
"the session restore did not finish in time — exiting anyway"
);
}
std::process::exit(0);
});
}
/// The accept loop is sequential, so the control phase must be bounded — a client that /// The accept loop is sequential, so the control phase must be bounded — a client that
/// connects and never finishes the handshake would otherwise wedge the host for everyone. /// connects and never finishes the handshake would otherwise wedge the host for everyone.
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
@@ -949,6 +1003,9 @@ async fn serve_session(
// so a resize would resolve a DIFFERENT slot — on Windows a fresh monitor ADD instead of the // so a resize would resolve a DIFFERENT slot — on Windows a fresh monitor ADD instead of the
// in-place reconfigure, on KWin a differently-named output — defeating the policy's // in-place reconfigure, on KWin a differently-named output — defeating the policy's
// per-resolution identity. Honest downgrade: reject, client scales (H5). // per-resolution identity. Honest downgrade: reject, client scales (H5).
// * a monitor MIRROR (a `capture_monitor` pin): a physical head runs at the mode its owner set
// and the mirror backend ignores the requested one, so a resize would restart the identical
// cast at the identical size (design/per-monitor-portal-capture.md §7.3).
// The SYNTHETIC source stays reconfigurable on purpose (nothing to rebuild — the ack round-trip // The SYNTHETIC source stays reconfigurable on purpose (nothing to rebuild — the ack round-trip
// is the whole effect): it is the compositor-free protocol test source, and the C-ABI roundtrip // is the whole effect): it is the compositor-free protocol test source, and the C-ABI roundtrip
// test + client harnesses exercise the Reconfigure/Reconfigured plumbing through it. // test + client harnesses exercise the Reconfigure/Reconfigured plumbing through it.
@@ -957,7 +1014,16 @@ async fn serve_session(
let per_client_mode_identity = crate::vdisplay::policy::prefs() let per_client_mode_identity = crate::vdisplay::policy::prefs()
.configured_effective() .configured_effective()
.is_some_and(|e| e.identity == crate::vdisplay::policy::Identity::PerClientMode); .is_some_and(|e| e.identity == crate::vdisplay::policy::Identity::PerClientMode);
reconfig_allowed(compositor, per_client_mode_identity) // Read once here, like the identity above: this session opened its display under whatever
// the pin said at bring-up, so a console change mid-session must not retroactively change
// what THIS session answers a Reconfigure with. Linux-only because `vdisplay::open` only
// routes to the mirror there — a pin left in a Windows host's settings streams nothing
// different, and must not silently disable resize as a side effect.
#[cfg(target_os = "linux")]
let mirrored = crate::vdisplay::capture_monitor().is_some();
#[cfg(not(target_os = "linux"))]
let mirrored = false;
reconfig_allowed(compositor, per_client_mode_identity, mirrored)
}; };
// Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's // Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's
// `async move` captures a copy and it stays usable for the data-plane SessionContext below. // `async move` captures a copy and it stays usable for the data-plane SessionContext below.
+43 -10
View File
@@ -335,6 +335,11 @@ struct FrameMsg {
/// nor a stats capture is armed). The send thread accumulates them for the web-console sample: /// nor a stats capture is armed). The send thread accumulates them for the web-console sample:
/// `cap_us` = `try_latest` (ring read + colour convert), `submit_us` = NVENC `encode_picture` /// `cap_us` = `try_latest` (ring read + colour convert), `submit_us` = NVENC `encode_picture`
/// launch, `wait_us` = `lock_bitstream` (the scheduling wait + ASIC encode = the "encode" stage). /// launch, `wait_us` = `lock_bitstream` (the scheduling wait + ASIC encode = the "encode" stage).
/// SYNCHRONOUS backends (PyroWave: the whole GPU encode + fence wait runs inside `submit`)
/// carry their real encode time in `submit_us`, and the "encode" stage reads ~0 by
/// construction — read the pair together (the 2026-07 field triage read "encode 0.00" as an
/// instrumentation hole; it's the stage split's shape). The client-facing 0xCF `encode_us`
/// is unaffected: its anchor is stamped before `submit`, so it spans both.
cap_us: u32, cap_us: u32,
submit_us: u32, submit_us: u32,
wait_us: u32, wait_us: u32,
@@ -491,13 +496,20 @@ struct SendStats {
/// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a /// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a
/// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output), /// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output),
/// defeating the policy — honest downgrade is to reject and let the client scale. /// defeating the policy — honest downgrade is to reject and let the client scale.
/// * a **monitor mirror** (`mirrored`): the source is a physical head running at the mode its owner
/// set, and `MirrorDisplay::create` ignores the requested one by design
/// (design/per-monitor-portal-capture.md §7.3). A resize would tear the cast down and re-`create`
/// the *same* head at the *same* size — a visible hitch that changes nothing — or, worse, invite
/// the reflex of reconfiguring the display someone is sitting in front of. Reject; the client
/// scales, exactly as it already does for gamescope.
/// ///
/// Every other compositor (and the synthetic protocol-test source) with the default identity accepts. /// Every other compositor (and the synthetic protocol-test source) with the default identity accepts.
pub(super) fn reconfig_allowed( pub(super) fn reconfig_allowed(
compositor: Option<crate::vdisplay::Compositor>, compositor: Option<crate::vdisplay::Compositor>,
per_client_mode: bool, per_client_mode: bool,
mirrored: bool,
) -> bool { ) -> bool {
compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode && !mirrored
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -1118,9 +1130,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video"); tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
} }
if streamed_wire { if streamed_wire {
// Client capability only — whether AUs actually stream per-slice depends on the encoder
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
// exist yet at this point. The old wording ("chunked encoder output will stream
// per-slice") sent a 2026-07 field triage chasing a streaming path AMF doesn't have.
tracing::info!( tracing::info!(
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \ "client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — used if this session's \
will stream per-slice" encoder supports chunked output"
); );
} }
tracing::info!( tracing::info!(
@@ -3515,22 +3531,39 @@ mod tests {
use crate::vdisplay::Compositor::{Gamescope, Hyprland, Kwin, Mutter, Wlroots}; use crate::vdisplay::Compositor::{Gamescope, Hyprland, Kwin, Mutter, Wlroots};
// gamescope ALWAYS rejects — a resize would respawn the nested game (H1/D3), regardless of // gamescope ALWAYS rejects — a resize would respawn the nested game (H1/D3), regardless of
// the identity policy. // the identity policy.
assert!(!reconfig_allowed(Some(Gamescope), false)); assert!(!reconfig_allowed(Some(Gamescope), false, false));
assert!(!reconfig_allowed(Some(Gamescope), true)); assert!(!reconfig_allowed(Some(Gamescope), true, false));
// A per-client-mode identity policy rejects on every backend — the resize resolves a // A per-client-mode identity policy rejects on every backend — the resize resolves a
// different display-identity slot (H5). // different display-identity slot (H5).
assert!(!reconfig_allowed(Some(Kwin), true)); assert!(!reconfig_allowed(Some(Kwin), true, false));
assert!(!reconfig_allowed(Some(Mutter), true)); assert!(!reconfig_allowed(Some(Mutter), true, false));
assert!(!reconfig_allowed(None, true)); assert!(!reconfig_allowed(None, true, false));
// Every other compositor with the default identity ACCEPTS (recreate / re-arrival / in-place). // Every other compositor with the default identity ACCEPTS (recreate / re-arrival / in-place).
for c in [Kwin, Mutter, Wlroots, Hyprland] { for c in [Kwin, Mutter, Wlroots, Hyprland] {
assert!( assert!(
reconfig_allowed(Some(c), false), reconfig_allowed(Some(c), false, false),
"{c:?} should allow live reconfigure" "{c:?} should allow live reconfigure"
); );
} }
// The synthetic source (no compositor) is the protocol-test path — always reconfigurable. // The synthetic source (no compositor) is the protocol-test path — always reconfigurable.
assert!(reconfig_allowed(None, false)); assert!(reconfig_allowed(None, false, false));
}
/// A mirrored physical head has a fixed mode (§7.3): every backend that would otherwise accept
/// a live reconfigure must reject one while the session is streaming someone's real monitor.
#[test]
fn reconfig_allowed_rejects_a_monitor_mirror_on_every_backend() {
use crate::vdisplay::Compositor::{Hyprland, Kwin, Mutter, Wlroots};
for c in [Kwin, Mutter, Wlroots, Hyprland] {
assert!(
reconfig_allowed(Some(c), false, false),
"{c:?} without a pin should still allow live reconfigure"
);
assert!(
!reconfig_allowed(Some(c), false, true),
"{c:?} mirroring a physical head must reject a resize"
);
}
} }
#[test] #[test]
+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 // 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 // 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.) // 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 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"); std::fs::copy("/bin/sleep", &game).expect("copy a stand-in game binary");
let s = Scanner::system(); let s = Scanner::system();
let before = s.now_stamp().expect("real /proc/uptime is readable"); let before = s.now_stamp().expect("real /proc/uptime is readable");
+3 -1
View File
@@ -45,7 +45,9 @@ pub struct StatsSample {
pub fps: f32, pub fps: f32,
/// Re-encoded holds/s (source-starvation indicator). /// Re-encoded holds/s (source-starvation indicator).
pub repeat_fps: f32, pub repeat_fps: f32,
/// Transmit goodput (Mb/s). /// Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes
/// plus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned
/// mode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops.
pub mbps: f32, pub mbps: f32,
/// Configured target bitrate. /// Configured target bitrate.
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
+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 // 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. // instead of stacking a second (possibly all-profiles) rule behind the new one.
let fw_profile = 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( run_quiet(
"netsh", "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. // 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 — // 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 // remove any prior rules first so an upgrade tightens the scope instead of leaving a stale
// all-profiles rule behind the new one. // all-profiles rule behind the new one. With the flag absent (every upgrade) this resolves to
let allow_public = allow_public_network(args); // 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); set_fw_public_marker(allow_public);
remove_firewall_rules(); remove_firewall_rules();
add_firewall_rules(allow_public); 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 /// Resolve the Public-network firewall scope for this install/upgrade (the installer's "Allow
/// networks" task forwards it). Absent = the secure default (Domain + Private only). /// connections on Public networks" task forwards the flag).
pub(crate) fn allow_public_network(args: &[String]) -> bool { ///
args.iter().any(|a| a == "--allow-public-network") /// 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 /// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
+1
View File
@@ -36,6 +36,7 @@ redundant or stale.
|---|---|---| |---|---|---|
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` · `hyprland` (aliases: `kde`/`plasma`, `gnome`, `sway`/`wlr`) | Which backend creates the virtual display. `wlroots` is sway/River; `hyprland` is its own backend. **Leave unset.** Setting it **pins** the backend and turns session-following **off** — per connect *and* mid-stream, so a Desktop ↔ Gaming switch kills the stream instead of being followed. For CI/tests and dedicated single-session appliances only. | | `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` · `hyprland` (aliases: `kde`/`plasma`, `gnome`, `sway`/`wlr`) | Which backend creates the virtual display. `wlroots` is sway/River; `hyprland` is its own backend. **Leave unset.** Setting it **pins** the backend and turns session-following **off** — per connect *and* mid-stream, so a Desktop ↔ Gaming switch kills the stream instead of being followed. For CI/tests and dedicated single-session appliances only. |
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. | | `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
| `PUNKTFUNK_CAPTURE_MONITOR` | a connector name (`HDMI-A-1`, `DP-2`, …) | Stream a **physical** monitor this host already has instead of creating a virtual display — see [Streamed screen](/docs/virtual-displays#stream-a-real-monitor-instead). List the names with `punktfunk-host list-monitors`. Setting it here **outranks the web console's** choice, so an appliance stays aimed where its operator pointed it; leave it unset to steer from the console. A name that matches no monitor fails the session loudly rather than streaming a different screen. Linux only. |
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. | | `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. | | `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). On a multi-GPU Windows box a forced hardware backend whose vendor contradicts the selected GPU (web-console preference) is **overridden** — the adapter wins and the host logs a warning; remove the stale pin. | | `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). On a multi-GPU Windows box a forced hardware backend whose vendor contradicts the selected GPU (web-console preference) is **overridden** — the adapter wins and the host logs a warning; remove the stale pin. |
+8
View File
@@ -69,6 +69,14 @@ depends on the display manager driving the autologin:
restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
[Configuration](/docs/configuration)) is fired as the fallback. [Configuration](/docs/configuration)) is fired as the fallback.
**Lingering is required here**, and the host turns it on for you the first time it takes the box
over. Stopping the display manager ends your last login session, and without
`loginctl enable-linger` logind stops your `systemd --user` manager about ten seconds later —
taking the host with it, mid-stream, with the display manager down and nothing left to bring it
back. If lingering can't be enabled the host refuses the takeover and degrades to attach instead
(above) rather than risk that. Run `sudo loginctl enable-linger "$USER"` once, as the setup guides
ask; `loginctl disable-linger "$USER"` reverts it.
With the takeover authorized the **in-stream session switch round-trips** in managed mode: With the takeover authorized the **in-stream session switch round-trips** in managed mode:
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again. and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
+27
View File
@@ -86,6 +86,33 @@ hosts from another machine with `punktfunk-probe --discover`. Where multicast do
Docker/VLAN setups), pass `--no-mdns` (or set `PUNKTFUNK_MDNS=0`) and add the host in the client by Docker/VLAN setups), pass `--no-mdns` (or set `PUNKTFUNK_MDNS=0`) and add the host in the client by
address instead. address instead.
## `list-monitors`
`punktfunk-host list-monitors` prints the **physical** monitors this host's compositor has, by
connector name — which is how you name one for [Streamed
screen](/docs/virtual-displays#stream-a-real-monitor-instead) (in the console, or as
`PUNKTFUNK_CAPTURE_MONITOR`).
```sh
punktfunk-host list-monitors
```
```
Kwin:
HDMI-A-1 1920x1080@60 at +0,+0 scale 1 Dell U2412M [primary]
DP-2 2560x1440@144 at +1920,+0 scale 1 ACME 27 [PINNED]
```
Tags flag what's worth knowing before you pick: `primary`, `disabled` (nothing to stream), `punktfunk
virtual display` (one of ours, not a real head), and `PINNED` for the one currently selected. Linux
only — it reads the live compositor, so run it in (or with the environment of) the session you want
to stream.
`punktfunk-host mirror-test --monitor <CONNECTOR> [--seconds N] [--cpu]` then proves the whole path —
mirror, capture, frames — with no client involved. It reports the first frame, the frame count and
the negotiated size. Screen recording is damage-driven, so move the mouse on the host while it runs;
an idle desktop legitimately yields almost nothing.
## `detect-conflicts` ## `detect-conflicts`
`punktfunk-host detect-conflicts` reports other Moonlight-compatible hosts (Sunshine, Apollo, and `punktfunk-host detect-conflicts` reports other Moonlight-compatible hosts (Sunshine, Apollo, and
@@ -26,6 +26,35 @@ systemctl --user enable --now punktfunk-host
The host now starts whenever you log in. Check it with `systemctl --user status punktfunk-host`. The host now starts whenever you log in. Check it with `systemctl --user status punktfunk-host`.
**You don't need to export anything for it.** The host finds the live compositor session itself on
every connect and works out where to reach it (`WAYLAND_DISPLAY`, `XDG_RUNTIME_DIR`, the session bus,
sway's `SWAYSOCK`, Hyprland's instance signature) from the running compositor — so `host.env` is for
policy, not session plumbing, and `systemctl --user import-environment` is not a prerequisite.
### Restart the host with your desktop
Add one drop-in so the host follows your session's lifetime:
```sh
mkdir -p ~/.config/systemd/user/punktfunk-host.service.d
# /usr/share/punktfunk/ on Fedora/Arch, /usr/share/punktfunk-host/ on Debian/Ubuntu,
# scripts/ in a source checkout
cp /usr/share/punktfunk/punktfunk-host-desktop-session.conf \
~/.config/systemd/user/punktfunk-host.service.d/desktop-session.conf
systemctl --user daemon-reload
systemctl --user reenable punktfunk-host
systemctl --user restart punktfunk-host
```
Without it, restarting Plasma or GNOME — a crash, a log out and back in, "restart the shell" — leaves
the host running against a compositor that no longer exists. It keeps listening and answering, and
every session after that fails at capture, which is a confusing way to find out. The drop-in makes a
compositor restart a host restart.
Skip it on the headless/appliance route below (which has its own session unit), and on Sway or
Hyprland, which don't hand their session to systemd — start the host from the compositor's config
there instead, so it comes and goes with the session.
## B. A headless, always-on host ## B. A headless, always-on host
To run with **no monitor and no login** — a machine in a closet that's always ready — you need two To run with **no monitor and no login** — a machine in a closet that's always ready — you need two
+100
View File
@@ -18,6 +18,10 @@ opened on.
> Reach for a preset when you want a specific experience — a dedicated box you only stream from, a > Reach for a preset when you want a specific experience — a dedicated box you only stream from, a
> desktop you also use in person, or a multi-monitor workstation. > desktop you also use in person, or a multi-monitor workstation.
If you'd rather stream a monitor the host **already has** than have punktfunk make one, that's a
different setting and it turns most of this page off — see
[Stream a real monitor instead](#stream-a-real-monitor-instead) right below.
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary / > **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin > exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
> *and* GNOME/Mutter), and **multi-monitor layout** (several clients as monitors of one desktop) are > *and* GNOME/Mutter), and **multi-monitor layout** (several clients as monitors of one desktop) are
@@ -25,6 +29,71 @@ opened on.
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway > second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway
> `exclusive`, and multi-display for a *single* client (that last is the next stage). > `exclusive`, and multi-display for a *single* client (that last is the next stage).
## Stream a real monitor instead
Sometimes you don't want a new screen — you want *that* screen. A shop-floor PC on a wall mount, a
lab bench machine, a media box whose TV output you'd like to watch from the couch: what matters is
seeing the monitor that's already there, exactly as the person in front of it sees it.
Set **Host → *Virtual displays* → Streamed screen** to one of the listed monitors and punktfunk stops
creating a virtual display altogether. It streams that physical monitor instead, and every client
sees it at *its* resolution — you're a viewer of a screen, not the owner of your own.
- The monitor is **never touched**. punktfunk doesn't resize it, move it, disable it or restore it —
it only watches. Keep-alive, topology and the multi-monitor layout options above simply don't
apply, because there's no display of ours to apply them to.
- **The resolution is the monitor's**, not yours. A client asking for a different one is politely
told no and scales its own picture — reconfiguring the display someone is sitting at would be
rude, and the mid-stream resize machinery is switched off for this reason.
- **Every client sees the same screen.** Two clients means two viewers of one monitor, not two
desktops.
- Naming a monitor that isn't there is a **hard error**, not a fallback: the session fails with
`no monitor named "DP-9" — this host has: HDMI-1`. Showing you the wrong screen would be worse
than showing you none.
- Choosing **Virtual screen (default)** in the same card puts you back on the normal path.
Supported on **KDE/KWin**, **GNOME/Mutter**, **Sway/wlroots** and **Hyprland** — each through the
compositor's own screen-recording API, so there is **no chooser dialog** and nothing to click. That
matters most for a host running unattended as a [service](/docs/running-as-a-service): a background
`systemd --user` daemon has nobody to answer a permission prompt, so the monitor has to be
configuration rather than a question. (A nested gamescope session has no physical monitors of its
own, so the setting doesn't appear there.)
### Naming the monitor from the host
Monitors are named by **connector**`HDMI-A-1`, `DP-2`, `eDP-1`. List what this host has:
```sh
punktfunk-host list-monitors
```
```
Kwin:
HDMI-A-1 1920x1080@60 at +0,+0 scale 1 Dell U2412M [primary]
DP-2 2560x1440@144 at +1920,+0 scale 1 ACME 27
```
To pin it **from the host's configuration** instead of the console — the appliance route — set it in
[`host.env`](/docs/configuration):
```sh
PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
```
The environment variable **wins over the console setting**, deliberately: an operator who declared
the answer in the unit's environment shouldn't have it re-aimed by a click. The console shows the
Streamed screen card as locked while it's set. Leave it unset on a machine you want to steer from the
console.
Check the whole path — mirror, capture, frames — without a client involved:
```sh
punktfunk-host mirror-test --monitor HDMI-A-1 --seconds 20
```
Compositor screen recording is **damage-driven**: an idle desktop legitimately produces almost no
frames, so move the mouse on the host while it runs or a working mirror reads as a stall.
## Pick a preset ## Pick a preset
A preset is the easy way in — select one in the console and you're done. Each expands to a bundle of A preset is the easy way in — select one in the console and you're done. Each expands to a bundle of
@@ -179,6 +248,23 @@ shutdown — and only forces the issue after ten seconds of being ignored.
> (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a > (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a
> pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it. > pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it.
### On a gamescope session, the display has the final say
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam Deck
or a Bazzite couch box — the game runs *inside* the streamed display. So it lives exactly as long as
that display does, and **Keep alive decides that, not the setting above**:
| you disconnect by | what happens to the game |
|---|---|
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is deliberately skipped for a real stop — and the game goes with it, even on *Leave it running* |
| dropping out (network, sleep) | the display lingers for your keep-alive window, then tears down; the game ends with it |
| dropping out, keep-alive **Forever** | the display is pinned, so the game genuinely survives — and *Always close it* still ends it when the reconnect window closes |
So on a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
window long enough to come back in), not just this setting. On a desktop session — KWin, GNOME, Sway —
the game is an ordinary process next to your desktop and none of this applies; the setting above is
the whole story.
### Automation ### Automation
The host publishes `game.running` and `game.exited` events (the latter says whether the player quit The host publishes `game.running` and `game.exited` events (the latter says whether the player quit
@@ -235,6 +321,20 @@ landing on a dead stream — and switching between game mode and the KDE / GNOME
follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your
library; a game mode / desktop session keeps streaming. library; a game mode / desktop session keeps streaming.
**My keep-alive / topology / layout settings do nothing.** Check whether **Streamed screen** is set
to a real monitor. Those options are all about a display punktfunk created; when it's mirroring one
of yours there is nothing of ours to keep alive or rearrange. Switch the card back to *Virtual screen
(default)* to get them back.
**The console won't let me change Streamed screen.** `PUNKTFUNK_CAPTURE_MONITOR` is set in this
host's [`host.env`](/docs/configuration) and deliberately outranks the console. Unset it (and restart
the host) to choose from the console instead.
**My session fails with "no monitor named …".** The pinned connector isn't among this host's
monitors — it was renamed, unplugged, or the host is now in a different session. Run
`punktfunk-host list-monitors` on the host to see the real names. punktfunk will not quietly stream a
different screen instead.
**My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless box** **My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless box**
preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes
instantly — return to game mode on the box (or restart the host) to hand the TV back. instantly — return to game mode on the box (or restart the host) to hand the TV back.
+212
View File
@@ -0,0 +1,212 @@
Update whenever it suits you — the app and the machine you stream from can be updated one at a time, old and new work together in either combination, and everything you've already paired stays paired.
The headline: **Punktfunk now knows when your game ends.** Quit a game and the stream ends with it — on Windows and Linux, in the Punktfunk app and in Moonlight — instead of dropping you onto someone else's desktop. Turn it around if you want, and ending the stream can close the game too. The console and the tray show what is running and who is about to close it. Underneath that sits the largest sweep of the video encoder and screen capture Punktfunk has had: machines that could never stream at all now do, an idle stream on an AMD or Intel PC has stopped sending megabytes of padding every second, 4K at 120 fps holds its frame rate, HDR from an Intel PC is finally the right brightness, and a stack of pointer, controller and reconnect bugs are gone. Windows also gets its first real update path — install and upgrade the host with winget — and there is now a client for 64-bit ARM Linux machines, which you can pair over SSH without installing a desktop on them.
## New: the stream ends when the game does
Until now this only worked in one narrow case — a Steam game on a Linux machine running Steam's own Game Mode. Everywhere else the host launched your game and forgot about it, so finishing a game left you looking at a desktop until you disconnected by hand. It now works on **Windows and Linux**, for games launched through **Steam, Epic, GOG, Xbox, Lutris, Heroic** or added by hand, and in **Moonlight** as well as the Punktfunk app.
Punktfunk waits out the usual hand-off, so a launcher that starts your game and exits is no longer mistaken for the game itself — `steam://`, `epic://` and Playnite launches don't end the session the moment they begin. Where nothing can identify what was launched, the behaviour stays off and the host says so once, rather than guessing.
## New: and optionally, the game ends when the stream does
Off by default, because closing someone's game is destructive. Switch it on and disconnecting closes the game you launched — useful for a shared machine or a games box you stream to from the sofa.
It is careful about it:
- **A dropped connection is not a decision.** Wi-Fi cutting out gives you a **five-minute** window to come back; reconnecting reclaims your game and cancels the countdown.
- **Only ever the game this session started.** A copy you already had open is never touched.
- **It asks before it insists** — the game is asked to close normally, and only forced if it refuses.
- **A display you asked Punktfunk to keep open stays open**, regardless of this setting.
On macOS, which has no game-launching path at all, both controls are shown but disabled — "does nothing here" is worth knowing.
## New: see what's running, and end it from anywhere
- **The console dashboard has a running-game card** with box art, above the session card. "End now" ends a live game by stopping its session, or ends one already counting down after a disconnect.
- **The tray shows the running game too**, including the countdown for a game whose client walked away — visible at the machine without opening the console.
- **Automation gets an event when a game starts and when it stops**, filterable like every other event, so scripts can react to a game rather than to a stream. If you have been polling the host to find out when a game finished, you can stop.
- **The settings for both behaviours** sit next to the display keep-alive policy in the console, which is the same question one step out.
## New: richer library entries
Every library entry can now carry **platform, description, developer, publisher, release year, genres, tags, region and player count** — the things an emulation or retro library needs and a title-and-poster shape could not hold. The console's add/edit form gains a Details section, poster tiles show a platform badge and the year, and the library can be filtered by platform. Existing libraries, provider plugins and clients keep working untouched; every field is optional.
**Plugin authors:** a provider entry can now also say how to recognise its games — install directory, executable, or process name — so titles launched through a provider's own client get the same start-and-end tracking as a Steam or Epic title.
## New: install and update the Windows host with winget
Windows had **no update path at all** — no self-update, no package manager — so keeping a host current meant noticing that a release had happened and re-running an installer by hand. Now it is two commands.
Punktfunk lives in **its own package source**, not the public winget catalogue, so add that first. Once per machine, from an **Administrator** terminal:
```
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
```
Then install, and from then on upgrade:
```
winget install unom.PunktfunkHost
winget upgrade unom.PunktfunkHost
```
**If you skip the first command, `winget install unom.PunktfunkHost` will tell you no package was found** — winget only searches the sources it knows about, and Punktfunk is not in the public one. The source knows about every release, so you can also pin an older version with `--version` or upgrade from one.
A silent install shows you the same disclosures the wizard does — including where the bundled virtual audio device comes from — and takes the same defaults the wizard offers, so installing without a screen doesn't quietly get you a different machine.
## New: a client for 64-bit ARM Linux machines
Punktfunk now has a client build for **64-bit ARM Linux** — the class of small board and mini-PC people put behind a TV — published to the apt repository alongside the Intel/AMD one. It is the full client, on-screen overlay included; your box picks it up automatically with no extra setup. The machine you stream *from* is still Intel/AMD only, because the hardware video encoders it uses are.
## New: pair a machine that has no desktop, over SSH
A box that only has a terminal can now enrol itself:
```
punktfunk-session --pair <PIN> --connect <host>
```
It runs the same pairing handshake as the app and prints the same confirmation. Until now the pairing screen only existed inside the desktop app, so setting up a kiosk or a headless media box meant installing a whole desktop on it first — or copying credentials across by hand.
## New: pick which graphics card drives the screen on a display-only box
On a machine with two graphics cards and no desktop running, Punktfunk's own display client used to pick a card and hope — regularly choosing the one already in use and failing to start, while the card with the actual monitor attached sat idle. You can now pin the card explicitly. And if starting the display still fails, the error names the card it tried and lists what to check, instead of a one-line internal failure.
## Fixed: computers that could never stream now can
Six separate reasons a machine would connect and then die on its first frame — or refuse to stream at all — each with its own kind of host behind it:
- **Laptops with both Intel and NVIDIA graphics.** Punktfunk assumed the first graphics device it found was the NVIDIA one. On these laptops it is the Intel chip, so setup failed with a message about graphics configuration on a machine whose NVIDIA drivers were perfectly healthy. It now looks for the NVIDIA card by identity, and every message on this path names the card it used.
- **Machines whose driver refuses the fast frame handoff now fall back by themselves.** Previously that host lost every session on the first frame, and every reconnect repeated it — while the very same machine streamed fine with the fast path switched off by hand. Punktfunk now notices after three refusals and switches that machine to the slower-but-working path on its own, with a log line saying so. One bad session, then a working computer.
- **Older Intel graphics.** The shared frame was handed to the driver declared as **empty** — most drivers work the real size out, older Intel ones answer "out of memory" on every frame instead. It now declares the true size — it is the first thing the driver reads, and it was wrong for every host we have ever handed a frame to; whether it is the whole story on the machine that reported this is not yet confirmed.
- **Linux desktops running gamescope** whose graphics drivers are missing the newer built-in video encoder. Punktfunk committed the capture to a format only that encoder can take, and there is deliberately no going back once it has — so the session died at its first frame. It now checks the encoder is really there *before* committing.
- **Wayland desktops that hand over 24-bit frames.** A common frame layout had no handling at all in one of the Linux encoders, so those sessions connected and immediately failed. They are now supported outright rather than refused.
- **PCs with no graphics card.** A host encoding in software advertised HEVC and AV1 to Moonlight — which its software encoder cannot produce — so Moonlight picked one and the session died at start-up. It now offers H.264 only, honestly. In the same vein, a resolution beyond what the software encoder supports is now refused at connect, with the reason, instead of connecting successfully and never delivering a single frame.
## Fixed: an idle stream was sending megabytes of nothing
On an **AMD or Intel PC streaming from Linux** — a Steam Deck included — a calm picture did not use less bandwidth. It used all of it. Once the stream settled, the driver padded every single frame back up to the full target bitrate with zeros, forever: measured on an AMD laptop chip, 300 frames of a calm desktop at 10 Mbps came to **5.63 MB, of which 98.5% was padding**. The same 300 frames now come to **83 KB with no padding at all**; on AV1 the padding went from 99.6% to zero.
That padding was also convincing the automatic quality control that the connection was comfortably full, so it never eased off. If you stream over anything metered, capped, or shared, this is the single biggest change in the release.
## Fixed: 4K at 120 fps holds its frame rate
A 4K120 stream on a fast local network climbed its way into a hole: the quality control kept raising the bitrate, the encoder quietly ran at a lower rate than it reported, and the stream kept rebuilding itself to chase a number it was never going to hit — landing at around 107 of 120 frames per second with visible stutter.
- **What the encoder actually delivers is now what gets reported** — to the pacing, to the web console, and back to your device. The bitrate control no longer climbs away from a phantom number.
- **Punktfunk learns the ceiling of your hardware** and stops re-discovering it the hard way — each discovery used to cost a full encoder rebuild mid-stream.
- **At 4K120 both encoder engines on the graphics card are used**, instead of the second one sitting idle in exactly the mode it exists for.
- **Your device notices when the host is struggling to encode**, not just when the network is, and eases off — on a fast LAN nothing else would ever have brought the rate back down.
## Improved: a stream that stumbles once no longer stays degraded
When a stream hits sustained trouble, Punktfunk trades a little latency for stability — buffering more, and switching the encoder into a mode that keeps up under load. That trade used to be **permanent**: one rough patch, however brief, and the session ran at higher latency until you reconnected.
It now winds back. After a sustained stretch of clean, on-time frames the stream steps back down — first to the low-latency encoder mode, then to minimum buffering — and it retries progressively less often, so a machine that genuinely needs the trade settles into keeping it rather than flapping.
## Fixed: garbled picture on 10-bit PyroWave streams
Streaming in **PyroWave at 10-bit** produced complete corruption on the client — scrambled tiles and saturated green, at any bitrate or refresh rate, with no error anywhere and clean network counters. Reported on an AMD host into an AMD laptop client; milder versions of the same fault were possible on NVIDIA clients. The client was reading the decoded picture through the wrong description of its own buffers, which most drivers execute anyway rather than refusing. 8-bit sessions were never affected.
## Fixed: HDR
- **From an Intel PC, HDR is the right brightness.** The video was tagged with a peak brightness **ten thousand times too low** — 0.1 nits where 1000 was meant. TVs and monitors tone-map from exactly that number, so the picture arrived wrong at the far end. Confirmed fixed by reading the tags back out of a real Intel-encoded stream.
- **Moonlight no longer gets an HDR label on an SDR picture.** If HDR capture failed to start — a timed-out negotiation, a monitor that left HDR mode at the wrong moment — the host went on telling the client the stream was HDR while capturing and encoding ordinary colour. The client then renders it as HDR: washed out, wrong colours, no error, and every reconnect repeated it until the host was restarted.
- **A graphics card that cannot encode 10-bit no longer rebuilds the encoder on every single frame** when HDR content is streamed to it — a full teardown and rebuild per frame.
- **When switching the streamed display into HDR fails, the session survives.** The capture correctly falls back to ordinary colour, but the encoder kept insisting on HDR — one of the three Windows encoders then failed every frame forever, and the session ended after burning through its recovery budget. The encoder now follows the pixels it is actually handed, and labels the stream honestly.
- **On a Windows machine with its own dedicated streaming display**, a mismatch between what the display was composing and what the encoder expected could leave the session in a permanent three-second reconnect loop.
## Fixed: the mouse pointer
- **On iPad, iPhone and Mac, the text cursor stopped disappearing.** A pointer whose new picture had not arrived yet was hidden outright rather than left as it was — routine, because the host announces a new pointer shape the moment it sends it. Reported on glass as "the I-beam never appears over text fields, every other cursor is fine".
- **On Windows, a pointer three times too large is fixed.** Windows quietly re-draws its cursors at a new size when display scaling changes, behind an unchanged internal handle — so Punktfunk latched whatever size the pointer had a second after the streamed display appeared, which is exactly when the scale is still settling, and streamed that size for the whole session.
- **Sessions that streamed with no pointer at all now have one.** Some encoders cannot draw the pointer into the video; the host now works that out *before* capture starts and asks the desktop to draw it instead, rather than warning and streaming a pointerless picture.
- **A stray space in a configuration file could make the pointer vanish.** A setting written as `=0 ` — with a trailing space, as an editor or shell script leaves behind — was read as the *opposite* of what it said, switching on a mode that cannot draw the pointer at all.
- **In Steam's Game Mode, the pointer lands where it should**, no longer drawn at a fraction of its real position when the game runs at a different resolution than the stream — and it no longer disappears for the rest of a game session when the game opens its own window.
- **AV1 at 1080p is now genuinely 1080p.** It was shipping eight rows of duplicated pixels along the bottom edge; playback now reports the true size and the padding is gone rather than cropped back off.
- **A visible pointer no longer costs you frame rate.** On a Linux machine with an NVIDIA card running Steam's Game Mode — where the pointer is drawn into every frame, unlike a normal game — a 120 fps session was capped at around 80, with the graphics card barely working. Drawing the pointer was making the encoder wait for the game's own work to finish, on every frame. It doesn't any more.
## Fixed: your controller type setting is honoured again
Choosing "emulate my controller as a DualShock 4" reached the host and then stopped working the instant the controller actually connected — every pad re-declared itself as what it physically was, so the setting appeared to do nothing. This affected the Punktfunk apps on Apple platforms, Windows, Linux and macOS (Android already did this correctly), and it has been broken since multi-controller support shipped in 0.19.x. Your explicit choice now applies to every controller slot; **Automatic** still detects each pad individually so a mixed session stays honest. Your controller keeps its own local features either way — a DualSense emulated as a DualShock 4 keeps its lightbar.
**And Windows Device Manager now names each emulated pad correctly** — all four types previously showed up as "punktfunk Virtual DualSense", which reads exactly like the setting being ignored.
## Fixed: Windows machines with a dedicated streaming display
- **The dedicated display stays dedicated.** Punktfunk switched the physical monitors off, verified it, and never looked again — and on laptops with two graphics chips the internal panel came back moments later on its own. The host still believed it had the screen to itself, so windows and the mouse could land off-stream and the lock screen could appear on the physical panel. It is now re-checked every couple of seconds while the session runs, and put back if something re-lights a display.
- **Choosing a graphics card in the console no longer wedges the host.** If your host configuration file pinned an encoder belonging to a different make of graphics card than the one you picked, every session failed at the same point and the client reconnected into the identical wall roughly every ten seconds, with no visible reason. The card you pick now wins, and the console shows the pin with a warning when the two disagree.
## Fixed: streaming from a Linux desktop, session after session
Four faults that all shared a shape — the first session worked, and something never came back afterwards:
- **Each Moonlight reconnect leaked a screen-recording session** with your desktop, until the compositor started refusing new ones — the very "another app is already recording" clash the pooling was there to prevent.
- **One compositor restart could wedge Moonlight video permanently**, ten seconds per reconnect attempt, until the host was restarted: a dead capture was put back in the pool and handed out again forever.
- **One failed negotiation switched the whole machine off the fast capture path** — every later session, on every encoder, until restart. It is now scoped to the one thing that actually failed.
- **A stalled moment cost you the wrong frames.** Under load the capture kept the *oldest* eight frames and discarded everything newer, so the picture caught up through stale frames instead of jumping to the current one. It now always hands over the freshest frame. A reused capture also no longer opens the next session showing the last frame of the previous one.
## Fixed: Moonlight
- **Moonlight returns to its app list when the host ends the session.** Whatever ended it — your game exiting, someone pressing stop in the console, another client taking over — the client simply froze on its last frame and eventually reported the session as a connection failure, because the host tore everything down without ever saying it had. It now says so, and Moonlight lands you back in the app list exactly as quitting does.
- **A wedged encoder no longer ends the stream.** If the graphics card's encoder locked up mid-stream, Moonlight clients got a full disconnect and had to reconnect. The Punktfunk app has recovered in place from this for a while; the Moonlight path now does the same, with the same bounded retries and backoff.
## Fixed: launching a game into a fresh Steam session streams from the first second
On a Linux machine where Punktfunk starts a **dedicated Steam session** for you — rather than attaching to one already running — launching a game showed you nothing at all, and the attempt was retried from scratch and eventually given up on. The cause was a chicken-and-egg: that display only produces a picture when something on it is drawing, and Steam draws nothing at all until its interface first appears, which takes far longer than the time allowed for a first frame. Sessions that reused an already-running display worked, which pointed away from the real cause for a long time.
Punktfunk now puts a quiet loading screen up from the first second, so there is always something to stream while Steam boots. The dedicated session also comes up in **Big Picture** now, instead of the desktop Steam window flashing through your stream on the way to the game.
## Fixed: an idle computer pinned a whole CPU core
Punktfunk's script and plugin runner burned **100% of one CPU core doing nothing** — in exactly the state its own documentation calls inert, with no scripts and no plugins installed. One report had it at 99.9% for two hours on a small laptop, starving the desktop badly enough to blank the screen. Three seconds idle used to cost three seconds of CPU; it now costs a tenth of that, all of it start-up.
## Fixed: Steam Game Mode takeover on Nobara and KDE machines
The 0.19.2 fix for switching a Linux machine into Steam's Game Mode needed a permission that was only ever a manual, documented step — so on a normal install it always fell back to mirroring, and with a monitor attached you got the desktop's own resolution mirrored instead of a proper takeover. The permission now ships **with the packages**, so entering Game Mode mid-stream takes the screen over as intended, and everything it changed is put back when you disconnect.
Punktfunk also now warns in its log when the installed gamescope is too old to support properly, instead of failing in ways that look unrelated.
## Fixed: the Windows installer stops undoing your choices
- **Re-installing or upgrading keeps your settings.** Both the Moonlight-compatibility option and the "allow Punktfunk through the firewall on Public networks" option were re-applied from scratch on every run — so an upgrade turned Moonlight support back off for anyone who had switched it on, and quietly closed the Public-network firewall rule for anyone who had opened it. Both are now decided on a fresh install only.
- **An unattended install can't stall on an invisible dialog.** The warning about another streaming host being present (Sunshine, Apollo) was a message box that appeared even in silent mode, so an install nobody was watching sat on a dialog nobody could click. It now declines and stops, which is the honest answer for a combination that warning already calls unsupported.
- **It calls itself "Punktfunk Host"** in Add/Remove Programs, the Start Menu group and the wizard, instead of the internal name. Install locations are untouched.
## Fixed: the tray icon, and the Windows web console after a fresh install
- **The tray said "Idle" through an entire stream.** It only counted Moonlight-style sessions as streaming, and ordinary Punktfunk sessions — the default — never registered. The icon and tooltip now track any live session.
- **"Open web console" no longer disappears from the tray menu.** It was built only if a live check of the console happened to answer within two seconds, so a console still starting up simply had no menu entry — with no discoverable way in. It is always there now, and says "(not responding)" when it genuinely is not.
- **No more false "conflicting host" alarm.** The tray warned about Sunshine being *installed* — not running, not listening — on every poll, and on Linux flagged the icon for attention over it. The host still reports genuine conflicts where that belongs.
- **New "Release kept display…" entry** when Punktfunk is holding a display open, and the menu entries deep-link to the pairing and display pages instead of all landing on the dashboard.
- **A fresh Windows install left the web console down until the next reboot.** The installer started it before the files it needs existed, the launcher exited, nothing retried — and the installer still reported success. It now waits for what it needs, verifies the console really came up, and says so honestly if it did not. The Linux service had the same flaw and would give up permanently after five quick restarts.
## Under the hood (for developers)
- **Nothing changed on the wire or at the API boundary.** The streaming protocol stays at version **2**, the embeddable core library stays at C ABI **13**, and the Windows virtual-display driver protocol stays at **6** — no embedder rebuild, and 0.18/0.19/0.20 hosts and clients mix freely. The library's new `GameMeta` fields are optional and flat on the wire, so existing `library.json` files, provider plugins and clients are unaffected; `GET /library` gains a case-insensitive `?platform=` filter beside `?provider=`, and the SDK and OpenAPI spec are regenerated. New `game.running` / `game.exited` events are filterable like every other kind, and `/status` reports `games[]` including a game whose session has gone and which is waiting out its window, with `POST /game/end` to end it.
- **Session⇄game lifetime.** `DetectSpec` (install dir / exe / process name / appid / env marker) comes from each store's scanner and from an optional provider `detect` hint; `procscan` turns it into live pids per OS; `gamelease` turns pids into a lifetime with four kinds — `nested` (gamescope owns it), `child` (host-spawned, own process group), `matched` (a launcher owns it, recognised by store signals) and `untracked`. A child exiting successfully within 5 s is a launcher handing off, and re-resolves to `matched` rather than reporting an exit — that single rule is what keeps `steam://`, `epic://` and `playnite://` from ending a session at launch. A pid is adopted only if it started after the launch, and re-verified against its start time immediately before being signalled, so a recycled pid is never touched. Provider-supplied hints never win over the host's own findings, and a blank field is absent rather than match-everything (an empty install dir would prefix-match every process on the box, and this feature ends processes). On Windows: Toolhelp + `QueryFullProcessImageNameW` + `GetProcessTimes`, `\\?\`-normalised case-insensitive path matching, Steam's per-app `Running` flag used only as a **veto** (it cannot be trusted to say a game *is* running, but it is exactly right for refusing to declare one gone), and the terminating thread binds to the input desktop before `EnumWindows`/`WM_CLOSE` — the host's own desktop is session 0 and holds none of the user's windows, so without the bind the polite pass finds nothing and every game dies unsaved. Job Objects are deferred with reasoning in the design doc. GameStream gained a session quit flag (RTSP carries no close code, so `/cancel`, a management stop and a game exit were indistinguishable from a client vanishing), and both planes now resolve a launch through one `resolve_launch`. Two on-glass runs each found a defect the fixtures could not: on Linux (.41) a `Child` lease left its waiting phase on the FIRST poll, because a live child counts as the game running — and the shim reclassification lives in that phase, so every Steam launch over the compat plane ended the session ~7 s later when `steam` handed off and exited; a bare live child now only counts once the shim window has passed, where the store gave us signals to recognise the real game by. On Windows (.173) `procscan::launch_stamp`'s wrapper was gated `#[cfg(target_os = "linux")]` and answered `None` everywhere else — and `None` doesn't fail, it turns the filter OFF, so every process under the install dir became adoptable and a deliberate stop closed a pre-existing copy of the game. The guard added there asserts the reference *exists* wherever processes can be matched, which is the shape of that failure: silent, and invisible downstream.
- **Vulkan encode moves from CBR to VBR**, which is the whole filler story: Vulkan exposes no filler-suppression control (no equivalent of AMF's `filler_data=false` or NVENC's default-off), so the rate-control *mode* is the only lever, and CBR under the shipped 1000 ms window overflows the CPB once the initial fill drains (~30 frames at 10 Mbps/60 fps) and pads every frame to the exact rate share thereafter. `VkVideoEncodeCapabilitiesKHR::rateControlModes` was previously ignored — CBR was hardcoded with no capability check; VBR now installs with average == max plus the house ~1-frame window when advertised, CBR-only drivers keep the loose window (tightening it just starts the stuffing earlier), and drivers advertising neither (ANV per current Mesa) keep the old install with a WARN. `PUNKTFUNK_VULKAN_RC=cbr|vbr` is the escape hatch and the on-box A/B control. No pacing claim: a burst A/B on the 780M is byte-identical between 1000 ms CBR and 17 ms VBR, so this firmware ignores the window for QP decisions entirely — the payload is filler elimination. ⚠ The first attempt at this was withdrawn after measuring a "36× bandwidth regression"; that measurement's baseline row was an 8-frame artifact, and the stuffing it feared was already live in shipped code. `maxBitrate` is now read from the same caps struct and clamps open + retarget, and `applied_bitrate_bps()` reports encoder-side truth pending-first.
- **Phases 38 of the pf-encode audit landed here.** Highlights not in the body: `ensure_cpu_rgb` cached its staging image on format alone while sizing it to the source (8× `VUID-vkCmdCopyBufferToImage-imageSubresource-07971` with `submit` returning `Ok` throughout); `reset()` re-armed `first_frame`, which also gated the begin-video-coding rate-control declaration (`VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08253`); AV1 at unaligned modes violated `VUID-vkCmdEncodeVideoKHR-flags-10324`/`-10325` on every frame under RGB-direct, fixed by making the sequence header, DPB setup and reference slots agree at the render size while keeping the EFC fast path; `VK_EXT_queue_family_foreign` was named as the dmabuf acquires' source family on four sites without ever being enabled (spec-invalid everywhere, tolerated by RADV) and is now enabled when advertised with a core-1.1 substitute otherwise; and a one-frame `VUID-...-08254` where a pending retarget was promoted into the session rate before recording, which correlated with the two triggers that fire together (ABR retarget + stall watchdog).
- **NVENC split-encode vs sub-frame is now arbitrated** against `nvEncodeAPI.h`'s own doc rather than the audit's one-liner: H.264 hard-disables split (it "is not applicable", so the rejection-retry used to re-open a byte-identical session), HEVC yields sub-frame when *we* force split for 4K120 throughput — keyed on the FORCED modes only, never `!= DISABLE`, since AUTO is the resolver's fallthrough for every sub-950 Mpix session and the wider key would have disarmed chunked polling fleet-wide — and AV1 is untouched. libav-NVENC open failures are classified by typed errno rather than by an English `strerror` substring over the whole context chain.
- **The pf-capture sweep (Phases 06).** Phase 0 put the crate under CI at all (`-p pf-capture --all-targets` on Windows: its `#[cfg(test)]` modules were compiled by nothing) and added `deny(unsafe_op_in_unsafe_fn)` — 119 operations across 16 functions gained SAFETY proofs, because in edition 2021 the existing `undocumented_unsafe_blocks` deny has nothing to fire on inside an `unsafe fn`, exempting the hardest FFI in the crate from its own program. Then a truth pass over comments and log strings, then the defect fixes: the portal thread parked on `future::pending()` so every dropped capturer leaked a thread, a 2-worker tokio runtime, a zbus connection *and* the compositor-side cast; `Capturer::is_alive()` now gates re-pooling; the "VAAPI" downgrade latch was really a global zero-copy kill switch fed by a hand-mirrored copy of `spawn_pipewire`'s negotiation decision, now one `negotiation_plan` resolver consumed by both; the frame channel was `sync_channel(8)` + `try_send` (drop-*newest*) against a trait documenting drop-oldest, replaced by a one-deep overwriting mailbox; four Linux buffer-geometry defects including a self-mmap that ignored `spa_data.mapoffset` and read the wrong buffer out of a pooled memfd; and on Windows the HDR pin wrote the *desired* state in place of the observed one (a permanent 3 s reconnect loop in one direction, a silently mismatched ring in the other), plus cursor rect staleness, recreate ordering, `LocalAlloc`/NT-handle leaks, and an `f32_to_f16` that swallowed the rounding carry (`1.9998779 → 1.0`) and so failed a correct shader. Phase 5 split `linux/mod.rs` 2,778 → 770 lines; Phase 6 took the suite from 6 to **38 Linux + 19 Windows tests**.
- **Cursor blending is negotiated ahead of capture.** `EncoderCaps::blends_cursor`'s contract said the host must fall back to capturer-side compositing, and that host half was never built — `open_video` warned and streamed pointerless (confirmed on the VAAPI dmabuf and libav-NVENC CUDA paths). `cursor_blend_capable()` is the pre-open dispatch mirror; the native plane grants the cursor channel only where the resolved backend composites, `SessionPlan::output_format` keeps cursor-blend sessions off producer-native NV12, Vulkan RGB-direct yields even when pinned, and the GameStream portal source asks for cursor-as-metadata only when the backend blends (the capturer pool keys on that mode). Zero-copy is preserved throughout — every fallback is a capture-negotiation change, never a readback.
- **The GameStream plane can now say a session ended.** `end_session` stops the media threads, and stopping a UDP sender is silence, not a signal — Moonlight holds the ENet control stream for the whole session and sat on its last frame until its own timeout fired. The control loop now watches for its session being cleared out from under it, sends the TERMINATION control message with `NVST_DISCONN_SERVER_TERMINATED_CLOSED`, then `disconnect_later`s the peer so the queued message reaches the wire instead of racing teardown. Two details that are silent when wrong, both verified against moonlight-common-c's `ControlStream.c` rather than recalled: the reason is BIG-endian on the ≥6-byte branch (the short branch is a little-endian u16, GFE's older shape), and the packet type comes from the table the CLIENT chose — `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and we advertise 7.1.431, so it is `0x0109`, not `0x0100`. Deriving that from `NonceKind` was the error; the nonce scheme describes how the GCM nonce is built and nothing else, and the HDR message could never have caught it because `0x010e` is identical in both tables. A test pins `APP_VERSION` above 7.1.431, since dropping below it silently restores the wrong type. The session's GCM key is cached per tick because ending a session clears the launch state the key lives in.
- **The direct-NVENC cursor blend is stream-ordered.** A cursor-bearing frame forced the CPU-synced submit path — a blocking CUDA copy plus a fence-waited Vulkan blend, both exposed to the running game's GPU load — on the assumption that games hide the pointer, which does not hold under gamescope, where the host composites the live pointer into every frame. Reported as an iPad on a 120 fps NVIDIA/gamescope session capped at ~80 fps with `repeat_fps` 0, zero loss, capture 0 µs, ASIC 15 µs and submit p50 at 10.2 ms — 81% of the loop period. `VkSlotBlend` now exports a timeline semaphore (`VK_KHR_timeline_semaphore` + `external_semaphore_fd`) into CUDA via `cuImportExternalSemaphore`: the enqueued copy signals it on the encode thread's copy stream, the blend waits and advances it on the Vulkan queue, and a CUDA-side wait orders the encode after the blend on the session's bound IO stream. Per-slot command buffers and descriptor sets keep several ordered blends in flight; drivers without the timeline export keep the CPU-synced path, and any failure degrades to "no cursor", never a dropped frame. The blocking multi-plane copies also pay one stream sync instead of one per plane (NV12 2→1, YUV444 3→1). Verified on an RTX 5070 Ti (driver 610.43.03): 12 on-hardware smokes green.
- **The GameStream HDR SDR-downgrade latch had exactly one consumer** (`open_portal_monitor`, dropping the HDR offer) while the RTSP negotiation consulted only `gnome_hdr_monitor_active()` — so a set latch meant advertising HDR while capturing and encoding SDR, which the client renders as PQ. Consulted at RTSP honor time rather than folded into `host_hdr_capable()`, which is the static serverinfo capability.
- **Phase 7 consolidations.** One Linux backend resolver (`resolve_linux_backend`) consumed by dispatch *and* the five partial hand-copies, matching what Windows has had; the slot-family and range-family RFI recovery policies extracted from three and two hand-copied twins respectively (the taint sweep had already reached AMF/QSV a commit before Vulkan was carved out, so Vulkan shipped without it); the AMF C-ABI mirror moved to `amf_sys.rs`; `vulkan_video.rs`'s ~820-line construction tail split into `vk_build.rs` (5,292 → 4,489 lines) — ⚠ with a trap recorded for future splits: an inline `use super::X` inside a moved fn body silently changes meaning. `TrackedEncoder`'s forwarding completeness is now guarded by a source-text set-equality test, a trap that has bitten three times (`set_wire_chunking`, `set_pipelined`, `applied_bitrate_bps` — a defaulted method the wrapper doesn't forward silently no-ops for every session).
- **PyroWave's GPU selection stays first-usable, by decision.** Two selection designs died in adversarial review — matching `pf_gpu::selected_gpu()` moves the encoder off the iGPU that can import the compositor's dmabufs on an Intel-compositor + NVIDIA-present laptop, and anchoring on the render node picks the idle iGPU on the common AMD-iGPU + NVIDIA-display desktop, because render minors are driver-bind-order artifacts. The correct oracle is which device *allocated* the capture buffers, which needs per-session producer identity threaded through; until then the open logs one greppable line (picked vendor/device, anchor node and its owner, and the console's selected GPU) with no WARN arm, since the wrong-pick direction inverts between topologies.
- **New tooling.** `scripts/wincheck.sh` type-checks and lints `#[cfg(target_os = "windows")]` Rust from a Linux box — a generated workspace whose members symlink `src` at the real crates plus a ~30-line stub `punktfunk-core`, keeping rustls/ring/opus out of the graph (the in-tree `--target x86_64-pc-windows-msvc` command dies in `audiopus_sys` and `ring` build scripts). Verified non-vacuous against a planted type error. Windows CI now *runs* pf-capture's tests rather than only type-checking them (18 of 19; the crate has no encoder dependency, so its test binary links against nothing the runner lacks). Also: `hdr-p010-selftest` silently ignored its size argument (`skip(2)` where the arm's own optionals start at index 1), so it had only ever validated 64×64 while printing PASS for `1920x1080`.
- **aarch64 Linux client.** `deb.yml` cross-builds it on the ordinary amd64 runner via a new `punktfunk-rust-ci-arm64cross` image; the job hard-fails on a non-AArch64 session binary. skia's `aarch64-linux-gnu` textlayout+vulkan prebuilt resolves, so ARM ships the full client, OSD included. `ci.yml` gains an aarch64 clippy leg plus a `--no-default-features` session build — `c_char` signedness cuts both ways and neither direction is visible from an x86-only CI (hardcoding `i8` fails to compile on ARM; `as *mut u8` fails the lint there), and it has already caught two defects. The RPM spec gained `%bcond_without host` (verified by a real `rpmbuild` in a native aarch64 Fedora 43 container), the Arch PKGBUILD drops `punktfunk-host` from `pkgname` on aarch64, and the flatpak manifest is architecture-generic — but no aarch64 rpm/Arch/flatpak is published yet, and those builds are not cross-compiles.
- **Encode CI coverage.** `nvenc` and `vulkan-encode` were previously linted by nothing — `enc/linux/nvenc_cuda.rs`, `enc/linux/vulkan_video.rs` and the vendored `vk_av1_encode` / `vk_valve_rgb` bindings, ~8,150 lines carrying ~70 `unsafe` blocks, so the crate's own `#![deny(clippy::undocumented_unsafe_blocks)]` was never enforced on them. Linux now lints and tests at the shipped feature set and Windows gets a `-p pf-encode --all-targets` leg (clippy rather than tests: MSVC link-imports the NVENC entry points). `--all-targets` is load-bearing — without it the feature-gated `#[cfg(test)]` modules are never compiled, which is how ten `E0061`s rotted undetected in the direct-NVENC test module. VAAPI and the Windows ffmpeg fallback gained unit-pinned decision logic (1,300 and 1,400 lines, 26 and 23 `unsafe`, zero tests before).
- **The 19 vendored `#[repr(C)]` Vulkan structs gained const size/alignment/per-field-offset assertions**, plus three tests pinning bitfield member order (where a wrong index means the driver reads `use_superres` where we meant `render_and_frame_size_different`). Field order was diffed against `vulkan_core.h` and `vulkan_video_codec_av1std_encode.h` from Vulkan-Headers `main` as of 2026-07-25 — no drift.
- **Windows NVENC teardown and session accounting.** Completion events are pushed to the teardown list before registration; a retrieve thread that has already blown one full 5 s completion budget drops later drains to 250 ms slices (routine teardown byte-identical — nothing is ever abandoned); and `LIVE_SESSION_UNITS` is refunded only on proof the driver released the session, with ambiguous failures parking the handle and `init_session` retrying the destroy under a gate that serialises against session opens.
- **Resource-lifetime work across the Linux encode paths.** Every dmabuf-import failure after `create_image` leaked a `VkImage` and a dup'd fd — with the sharp edge that a successful `vkAllocateMemory` transfers fd ownership, so the naive close-on-error is a double close. PyroWave's `open_inner` had ~20 fallible steps each leaking everything before it, now constructed early with null resources so `Drop` is the single unwind path. `sws_getContext` leaked on both of `open`'s early returns — up to ~10 per session on the EINVAL bitrate ladder. The PyroWave NT-handle contract is pinned callee-side (patch 0006), so `import_plane`'s close-on-failure is correct on every path.
- **Measured performance work.** The RGB-direct CPU upload builds the padded frame straight into mapped staging memory: 1080p `submit` p50 2315 → **1725 µs**, p99 2819 → **1972 µs** on a 780M, with an aligned-mode control run showing no delta. The packed 24-bpp expand moved from a `w*h` per-pixel loop to swscale's SIMD expanders (8.3M iterations per 4K frame, on the encode thread). Three per-frame costs came off the Windows HDR path, two of them inside the ring slot's keyed-mutex hold — plane RTVs and an immutable constant buffer are lifetime-of-mode facts, and a CCD `sdr_white_level_scale` query has no business contending the display-config lock while the driver's publisher is blocked. `std::env::var` came off three per-frame paths — worth recording that the audit ranked this as a hot-path defect and it measured **~122 ns/frame**, i.e. 0.003% of a frame budget; the fix is right, the severity was not.
- **Bitrate honesty end to end.** `Encoder::applied_bitrate_bps()` exposes the post-clamp rate; the codec-level ceiling is cached per GPU/config so an overshoot opens at the ceiling instead of re-running a ~6-open binary search plus a rebuild + IDR; `SPLIT_FORCE_PIXEL_RATE` moved to 950 Mpix/s because 4K120 (995,328,000 px/s) missed the old `> 1e9` gate by 0.47% and stayed on AUTO, which never engages at 2160 px height. Client-side, two consecutive identical short acks latch a host rate cap (mode-scoped, re-probed after ~60 s parked clean), and the per-AU `0xCF` `encode_us` feeds the controller through its own window accumulator, baseline-relative. The latency escalation de-escalates on a ~5 s clean run at 120 fps with 1 → 5 → 25 min backoff; `PUNKTFUNK_NVENC_ASYNC=1` refuses the wind-back.
- **`NV_ENC_ERR_INVALID_VERSION` is split on a `SESSION_OPENED` latch.** A version skew is static and cannot come and go inside one process, so a host that streams once per boot and then fails every later session is told to restart the host service, not to reboot. Diagnosis only — that field bug's root cause is still open.
- **A bare gamescope spawn now backgrounds a splash client.** gamescope composites — and only on a composite pushes a capture buffer — when a client paints, and a nested Steam bootstrap paints nothing until its UI's first frame, far past the 10 s first-frame budget; the native plane's retry ladder then killed the half-booted Steam on every attempt and the GameStream plane died on its single wait. Root-caused on .41 with a raw `pw_stream` probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately. The new hidden `gamescope-splash` subcommand paints a breathing bar at ~2.5 Hz, so damage arrives from the first second on both planes. In `--steam` mode gamescope composites only windows whose appid is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a painting vkcube gets zero composites without it), so the splash declares `STEAM_GAME=769` and seeds the baselayer iff unset — Steam's own rewrite at game launch hands composite focus over with no action on our side. `PUNKTFUNK_GAMESCOPE_SPLASH=0` opts out. The dedicated launch also uses `-gamepadui` rather than `-silent`, since the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built around — and gamescope's focus rules (game outranks the Steam UI appid) cover what `-silent` was working around.
- **The DM-stop takeover ships its privilege.** `libexec/punktfunk/pf-dm-helper` (verbs `stop`|`restore`) behind `io.unom.punktfunk.dm-helper` (`allow_any`, the same mechanism Nobara's `os-session-select` uses); the helper derives the DM unit from the `display-manager.service` symlink so callers never name a unit across the privilege boundary. Packaged in rpm/deb/arch; Nix keeps the manual polkit rule because store paths cannot match the probe.
- **Other knobs and internals:** `PUNKTFUNK_DRM_CARD=<n>` pins SDL's KMSDRM device index; `PUNKTFUNK_ZEROCOPY_RENDER_NODE` overrides the NVIDIA render-node scan; `PUNKTFUNK_EXCLUSIVE_REASSERT_MS` (default 2 s, 0 disables) paces the Windows exclusive-topology watchdog; `PUNKTFUNK_VULKAN_RGB_DIRECT` and `PUNKTFUNK_PIPEWIRE_NV12` now share pf-host-config's `env_on()` grammar with four in-crate copies (both previously read a trailing space as force-ON); the libav log-level save/restore around the capability probes is one RAII guard over a shared mutex (four overlapping sites — interleaved save/restore pinned the process at `AV_LOG_FATAL` permanently); PyroWave's RDO block-index cap moved into `validate_dimensions` checked against 4:2:0 (8192×8192 = 98304 blocks overflows the 16-bit packing, and the host's only use of the helper actively routed oversized modes into the unguarded branch); the write-only `EncoderCaps::supports_hdr_metadata` is deleted (both planes send the static HDR grade out-of-band unconditionally and no first-party client parses in-band SEI); and `Encoder::flush` is documented as deliberately not wired into the production loops. Also, `@punktfunk/host`'s publish workflow is unblocked twice over: bun 1.3 installs a `file:` dependency by symlinking each top-level *file* to itself, so `package.json` arrived as a dangling self-reference and resolution died at the front door — and the step added to repair that was itself named `Repair the file: dependency (…)`, where the unquoted `file: ` reads as a second YAML mapping key, so the workflow had been unparseable (and therefore silently never running) since it landed.
- **winget packaging.** Manifests ship in winget-pkgs' own format so upstream submission is later a copy rather than a rewrite, with a release-time generator substituting only version, URL, hash and notes link. The source is self-hosted — the community repo gates on Defender/SmartScreen validation the self-signed installer cert would not clear today — and implements three endpoints (`/information`, `/manifestSearch`, `/packageManifests/{id}`); the reference implementation's other twenty are its admin API for mutating a CosmosDB, and a catalogue generated at release time has nothing to mutate. It runs on unom-1 as a stock bun image with two bind-mounted `.mjs` files, the same shape as the flatpak server. The catalogue is derived from the RELEASES, not local files, because winget resolves `--version` and upgrade against the version list — a source that knew only the newest release could neither pin an older one nor show an upgrade path from it. `NormalizedPackageNameAndPublisher` is declared unsupported on purpose (winget derives it client-side with its own normalization, and a near-miss silently mis-correlates an installed host; `ProductCode` is exact and Inno gives us one). 28 checks drive the handler directly and CI gates on them, because a wrong response shape does not fail loudly — it just makes winget report "no package found".
- **Installer idempotence.** `--gamestream` and `--allow-public-network` become fresh-install-only, keyed off whether `host.env` already exists; `service install` already read an absent `--gamestream` as "keep `host.env` as-is", and the public-network flag becomes tri-state the same way, resolved from the marker the previous install recorded. It is strict on the value: a typo'd `=of` must not fall through to a marker that may say true, which would turn a mistyped opt-OUT into leaving Public open. The conflicting-host warning moves from `MsgBox` (which ignores `/SUPPRESSMSGBOXES` and displays under `/VERYSILENT`) to `SuppressibleMsgBox` with an `IDNO` default.
+53
View File
@@ -0,0 +1,53 @@
Update whenever it suits you — the app and the machine you stream from can still be updated one at a time, and everything already paired keeps working. This release is entirely fixes and hardening on top of last release's session⇄game work — nothing new to opt into.
## Fixed: installing or updating the Windows host
Three separate reports came in within hours of the last release, all blocking install:
- **`winget install` could fail outright if you'd ever had another streaming host on the machine** — even one you had since disabled or mostly uninstalled. A leftover, disabled service or an empty folder from an old install was enough to trip the "conflicting host" check and abort. Only a host actually set to start on its own counts now.
- **Installing through a package-manager app (like UniGetUI) could fail with a file-path error** before the install wizard ever appeared, because of a broken setting the installer passed itself. Fixed.
- **The package source instructions were incomplete.** `winget install unom.PunktfunkHost` says "no package found" until you've pointed winget at Punktfunk's own source, once per machine — the command for that is now spelled out.
## Fixed: Moonlight compatibility no longer turns on without asking
A fresh Windows install used to enable Moonlight-app compatibility silently, with no checkbox ever shown — even though that mode pairs over a less secure connection and the host warns about it on every start. It's opt-in now. Existing installs are untouched either way; Punktfunk's own clients were never affected.
## Fixed: switching into Steam's Game Mode on Linux could black out the machine
Last release shipped the permission needed to properly take over the screen for Steam's Game Mode — and surfaced a few sharp edges that only show up once that takeover is actually live:
- **The takeover could kill Punktfunk's own background service a few seconds later**, leaving the display dark with nothing left to bring it back except a physical console or SSH.
- **It could also hang waiting on a permission prompt nobody could see**, delaying recovery until well after the stream had already dropped — or act on a leftover marker file from months ago and jump the machine to the desktop for no reason.
- **Restarting the host mid-stream** — including a routine update — **could leave the display stuck off**, since nothing told the screen to come back.
All three are fixed. Recognizing the machine's built-in Steam Game Mode may now need a one-time permission on some distributions; installing or updating normally takes care of it.
## Fixed: a laptop with its built-in screen turned off stuttered constantly
On a laptop with two graphics chips and the internal screen switched off — a common setup for streaming from a laptop docked to a TV or monitor — streaming stuttered roughly every two seconds, for the whole session. Punktfunk now recognizes a dark-but-connected laptop screen as the cause and points at the actual fix, instead of pointing at an external monitor that isn't the problem.
## Fixed: latency creeping up over the course of a fast, high-bitrate stream
On a fast local network, especially at higher bitrates, latency could climb steadily over a long session instead of staying flat. Several separate causes are fixed at once: the stream's internal clock sync could get starved by a busy connection and let the two ends' clocks drift; a stream mode could put more data on the wire than its bitrate setting actually allowed; a slow-to-decode moment could build up a backlog that took a while to work through instead of catching up immediately; and the one-time startup speed test could be mistaken for real network congestion and permanently cap the stream below what the connection could actually handle.
## Fixed: missing cover art looked broken
A library entry with no cover art showed as an empty grey box, which read as something that failed to load. It now shows a controller icon instead, so "no art yet" looks intentional rather than broken.
## Improved: the Windows app now keeps its own log file
The desktop app writes its own log to `%LOCALAPPDATA%\punktfunk\logs\client.log` now, instead of only printing to a console window that a normal launch never shows. If you're ever asked to send logs for a problem, this is where they are.
## Under the hood (for developers)
- **Nothing changed on the wire or at the API boundary.** Streaming protocol stays at version **2**, the embeddable C ABI at **13**, the Windows virtual-display driver protocol at **6** — 0.180.20 hosts and clients keep mixing freely.
- **Windows conflict detection** now keys on the competing service's `Start` registry value (02 = boot/system/auto only), matching the tray's own dormant-is-harmless logic from last release; install directories no longer count.
- **The winget `Log` switch used the literal string `|LOGPATH|`** instead of winget's actual `<LOGPATH>` substitution token, so Inno received an illegal filename on any install that requested a log (UniGetUI does by default). Guarded by a new manifest check.
- **`gamestream` installer task lost its default-checked flag**; a silent/unattended install now lands `PUNKTFUNK_HOST_CMD=serve`. `/MERGETASKS=gamestream` opts in. Fresh installs only — `GamestreamParam` already omitted the flag on upgrades.
- **The gamescope display-manager takeover** now calls privileged `systemctl` verbs non-interactively (`--no-ask-password`) so a headless polkit prompt can no longer block the stream's own recovery thread; `session_select_requested`'s sentinel baseline is now recorded at takeover time as well as at successful launch, closing a window where a months-old `~/.config/steamos-session-select` write could false-positive a session-select honor; and `punktfunk-host` now handles SIGTERM/SIGINT by restoring the display manager it stopped (20 s grace) before exiting, so a `systemctl --user restart` or package upgrade mid-takeover can't strand the box. The takeover also now ensures the invoking user has systemd lingering enabled before touching the display manager — without it, stopping the DM stops the user's own service manager ~10 s later and takes the host down with it.
- **A new `internal_panel` target class** lets the exclusive-topology stall detector name a deactivated eDP/LVDS laptop panel as a disturbance suspect; previously the suspect list was filtered to external physicals only, so the one display responsible on a hybrid laptop reported as none. `EvtIddCxMonitorI2CTransmit/Receive` now answer every DDC/CI probe against the virtual monitor with an immediate `STATUS_NOT_SUPPORTED`, so third-party brightness/monitor tools stop occupying win32k's serialized physical-monitor path on it. The swap-chain drain thread now falls back to `TIME_CRITICAL` priority if MMCSS registration is refused, and raises `IddMinimumVersionRequired` 4 → 10 to use `IddCxSetRealtimeGPUPriority`.
- **Clock re-sync** now spaces its probe rounds ~7 ms apart, tracks a running best-RTT guard band instead of a static connect-time floor, and applies the best batch of a rejection streak after three consecutive rejections rather than drifting unbounded.
- **A new per-frame `WireBudget`** (shared between encoder backends) tracks the real bitstream-to-wire inflation ratio for datagram-aligned PyroWave sessions and deflates the rate-control target by it, so the configured bitrate pin holds on the wire rather than on the raw codec bitstream.
- **The pre-decode frame channel now drains to the newest AU for all-intra (PyroWave) streams**, since those have no reference chain forbidding a mid-stream drop — capping any standing backlog at roughly one frame instead of ratcheting between the coarse jump-to-live thresholds.
- **The ABR capacity probe's first post-probe report window is now discarded outright**, rather than measured, so the probe's own deliberate overload can no longer read as session congestion and permanently end slow-start early.
- New `display-disturb` dev tool for reproducing DDC/CI and modeset-class display disturbances on demand during stall-immunity testing; not shipped to end users.
+74
View File
@@ -0,0 +1,74 @@
Update whenever it suits you — the app and the machine you stream from can be updated one at a time, and everything already paired keeps working.
## New: stream one of your actual monitors, not just a virtual screen
Until now, Punktfunk's Linux host always created its own virtual display to stream from — even on a machine you're sitting in front of, with real monitors already showing something. You can now point it at one of your machine's real monitors instead: pick it from a new **Streamed screen** card in the console, or pin it once in the host's configuration for an unattended box.
The monitor keeps working exactly as it always did — Punktfunk mirrors it rather than taking it over, so nothing about your own setup changes. It streams at that monitor's own resolution and refresh rate; your device scales to fit. Works with **KWin, GNOME, sway and Hyprland**, in both the Punktfunk app and Moonlight, and applies whether you connect straight away or pick up a saved display preset later.
A machine you pin from the host's own configuration shows the choice in the console as locked, with the reason, rather than offering a control that would silently lose to it. And on a machine you actually log into day to day (rather than a dedicated streaming box), the host now restarts itself when your desktop session does, so it recovers on its own instead of quietly going stale until the next stream fails to start.
For headless and unattended setups, two new commands help you find and verify the right monitor without a screen attached: `punktfunk-host list-monitors` lists what the machine has (names, geometry, which one is pinned), and `punktfunk-host mirror-test` confirms frames are actually flowing from the one you picked.
## Fixed: newer KDE Plasma silently fell back to a slower, less reliable way to arrange displays
On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the desktop about display layout stopped seeing any displays at all, with no visible error — every session quietly fell back to an external helper tool that's known to hang under load, exactly the thing the previous release's Plasma fix was meant to stop relying on. Fixed: Punktfunk now recognizes both the way older and newer Plasma releases announce their displays.
## Fixed: installing or updating the Windows host
Three separate reports came in within hours of the last release, all blocking install:
- **`winget install` could fail outright if you'd ever had another streaming host on the machine** — even one you had since disabled or mostly uninstalled. A leftover, disabled service or an empty folder from an old install was enough to trip the "conflicting host" check and abort. Only a host actually set to start on its own counts now.
- **Installing through a package-manager app (like UniGetUI) could fail with a file-path error** before the install wizard ever appeared, because of a broken setting the installer passed itself. Fixed.
- **The package source instructions were incomplete.** `winget install unom.PunktfunkHost` says "no package found" until you've pointed winget at Punktfunk's own source, once per machine — the command for that is now spelled out.
## Fixed: Moonlight compatibility no longer turns on without asking
A fresh Windows install used to enable Moonlight-app compatibility silently, with no checkbox ever shown — even though that mode pairs over a less secure connection and the host warns about it on every start. It's opt-in now. Existing installs are untouched either way; Punktfunk's own clients were never affected.
## Fixed: switching into Steam's Game Mode on Linux could black out the machine
The previous release shipped the permission needed to properly take over the screen for Steam's Game Mode — and surfaced a few sharp edges that only show up once that takeover is actually live:
- **The takeover could kill Punktfunk's own background service a few seconds later**, leaving the display dark with nothing left to bring it back except a physical console or SSH.
- **It could also hang waiting on a permission prompt nobody could see**, delaying recovery until well after the stream had already dropped — or act on a leftover marker file from months ago and jump the machine to the desktop for no reason.
- **Restarting the host mid-stream** — including a routine update — **could leave the display stuck off**, since nothing told the screen to come back.
All three are fixed. Recognizing the machine's built-in Steam Game Mode may now need a one-time permission on some distributions; installing or updating normally takes care of it.
## Fixed: a laptop with its built-in screen turned off stuttered constantly
On a laptop with two graphics chips and the internal screen switched off — a common setup for streaming from a laptop docked to a TV or monitor — streaming stuttered roughly every two seconds, for the whole session. Punktfunk now recognizes a dark-but-connected laptop screen as the cause and points at the actual fix, instead of pointing at an external monitor that isn't the problem.
## Fixed: latency creeping up over the course of a fast, high-bitrate stream
On a fast local network, especially at higher bitrates, latency could climb steadily over a long session instead of staying flat. Several separate causes are fixed at once: the stream's internal clock sync could get starved by a busy connection and let the two ends' clocks drift; a stream mode could put more data on the wire than its bitrate setting actually allowed; a slow-to-decode moment could build up a backlog that took a while to work through instead of catching up immediately; and the one-time startup speed test could be mistaken for real network congestion and permanently cap the stream below what the connection could actually handle.
## Fixed: missing cover art looked broken
A library entry with no cover art showed as an empty grey box, which read as something that failed to load. It now shows a controller icon instead, so "no art yet" looks intentional rather than broken.
## Improved: the Windows app now keeps its own log file
The desktop app writes its own log to `%LOCALAPPDATA%\punktfunk\logs\client.log` now, instead of only printing to a console window that a normal launch never shows. If you're ever asked to send logs for a problem, this is where they are.
## Under the hood (for developers)
- **Nothing changed on the wire.** Streaming protocol stays at version **2**, the embeddable C ABI at **13**, the Windows virtual-display driver protocol at **6** — 0.180.21 hosts and clients keep mixing freely. `GET /display/monitors` is a new endpoint (always 200, an explained empty list on a compositor-less host or a nested gamescope session); the display policy gains an optional `capture_monitor` field, resolved env-over-policy so a host.env pin outranks a console change. Both are additive.
- **Monitor enumeration is one read per compositor**, beside the code that already speaks its dialect: KWin's `kde_output_device_v2`, Mutter's `DisplayConfig.GetCurrentState`, `swaymsg get_outputs` (through a `swaymsg_query` helper after the bare command form mis-parsed `-t get_outputs` as an unknown command), `hyprctl monitors`. Logical geometry throughout — origin, not size, is what identifies a head.
- **Mirroring routes through `vdisplay::open` on all four backends**, arriving as a `VirtualDisplay` with `DisplayOwnership::External`, which by construction gets no keep-alive, no topology participation and no reuse, and ignores the client's requested mode — a real monitor runs at the mode its owner set. KWin uses `zkde_screencast`'s `stream_output` (the connector name is the selection, no dialog); Mutter uses the same private ScreenCast session the virtual path drives, `RecordMonitor` instead of `RecordVirtual`, deliberately outside the topology lock since mirroring adds or removes nothing; sway and Hyprland reuse their existing portal-chooser config (`xdpw`'s `Monitor: NAME`, `xdph`'s `[SELECTION]screen:NAME`), now serialized against the handshake that reads it. A mirrored display refuses a mid-stream reconfigure (Linux only — a pin on Windows streams nothing different).
- **The GameStream/Moonlight portal source now honors the pin too** — it previously always went straight to the portal chooser regardless — reusing the same `MirrorDisplay` path, with which screen a pooled capturer is showing added as a reuse key beside HDR-ness and cursor mode, so a live pin change between two connects can't leave a client streaming the previous screen.
- **libei absolute-coordinate resolution changed from matching by mode size to `mapping_id` → origin → size → first**, since two outputs can share a size but never a top-left, and a mirrored head's region is not the client's stream size at all. A named anchor that matches nothing falls back down the ladder rather than stranding input, and both a miss and a match now log once per distinct answer. `punktfunk-host anchor-test` exercises the ladder against a live compositor with two same-sized outputs — the one case a unit test can only simulate.
- **`SWAYSOCK` is now derived rather than required to be inherited** — by the compositor's known PID, then the newest socket owned by the host's user — closing the last session variable a `systemd --user` host lacked for sway. A new drop-in (`PartOf`/`WantedBy` on `graphical-session.target`, opt-in, shipped under `/usr/share`) restarts a desktop-login host with its desktop, so a Wayland socket and portal connection that die with a Plasma/GNOME restart don't leave the daemon silently unable to recover.
- **KWin's in-process output-management path now also binds `kde_output_device_registry_v2`**, not just the per-output `kde_output_device_v2` globals it originally shipped with — KWin 6.7 stopped advertising the latter, so the module written specifically to avoid shelling out to `kscreen-doctor` (0.19.x) saw zero devices and silently degraded to it on every session. Both models are supported now; registry-sourced devices arrive one Wayland round-trip later, so the handshake gains one conditional extra barrier.
- **Windows conflict detection** now keys on the competing service's `Start` registry value (02 = boot/system/auto only), matching the tray's own dormant-is-harmless logic from last release; install directories no longer count.
- **The winget `Log` switch used the literal string `|LOGPATH|`** instead of winget's actual `<LOGPATH>` substitution token, so Inno received an illegal filename on any install that requested a log (UniGetUI does by default). Guarded by a new manifest check.
- **`gamestream` installer task lost its default-checked flag**; a silent/unattended install now lands `PUNKTFUNK_HOST_CMD=serve`. `/MERGETASKS=gamestream` opts in. Fresh installs only — `GamestreamParam` already omitted the flag on upgrades.
- **The gamescope display-manager takeover** now calls privileged `systemctl` verbs non-interactively (`--no-ask-password`) so a headless polkit prompt can no longer block the stream's own recovery thread; `session_select_requested`'s sentinel baseline is now recorded at takeover time as well as at successful launch, closing a window where a months-old `~/.config/steamos-session-select` write could false-positive a session-select honor; and `punktfunk-host` now handles SIGTERM/SIGINT by restoring the display manager it stopped (20 s grace) before exiting, so a `systemctl --user restart` or package upgrade mid-takeover can't strand the box. The takeover also now ensures the invoking user has systemd lingering enabled before touching the display manager — without it, stopping the DM stops the user's own service manager ~10 s later and takes the host down with it.
- **A new `internal_panel` target class** lets the exclusive-topology stall detector name a deactivated eDP/LVDS laptop panel as a disturbance suspect; previously the suspect list was filtered to external physicals only, so the one display responsible on a hybrid laptop reported as none. `EvtIddCxMonitorI2CTransmit/Receive` now answer every DDC/CI probe against the virtual monitor with an immediate `STATUS_NOT_SUPPORTED`, so third-party brightness/monitor tools stop occupying win32k's serialized physical-monitor path on it. The swap-chain drain thread now falls back to `TIME_CRITICAL` priority if MMCSS registration is refused, and raises `IddMinimumVersionRequired` 4 → 10 to use `IddCxSetRealtimeGPUPriority`.
- **Clock re-sync** now spaces its probe rounds ~7 ms apart, tracks a running best-RTT guard band instead of a static connect-time floor, and applies the best batch of a rejection streak after three consecutive rejections rather than drifting unbounded.
- **A new per-frame `WireBudget`** (shared between encoder backends) tracks the real bitstream-to-wire inflation ratio for datagram-aligned PyroWave sessions and deflates the rate-control target by it, so the configured bitrate pin holds on the wire rather than on the raw codec bitstream.
- **The pre-decode frame channel now drains to the newest AU for all-intra (PyroWave) streams**, since those have no reference chain forbidding a mid-stream drop — capping any standing backlog at roughly one frame instead of ratcheting between the coarse jump-to-live thresholds.
- **The ABR capacity probe's first post-probe report window is now discarded outright**, rather than measured, so the probe's own deliberate overload can no longer read as session congestion and permanently end slow-start early.
- **New CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere.
- **New dev tooling:** `display-disturb` reproduces DDC/CI and modeset-class display disturbances on demand for stall-immunity testing; `punktfunk-host anchor-test`/`mirror-test` are described above. None of this ships to end users.
+5
View File
@@ -680,6 +680,11 @@
#define ClockResync_ROUNDS 8 #define ClockResync_ROUNDS 8
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Consecutive rejected batches tolerated before the best of them is applied anyway.
#define ResyncGuard_MAX_REJECTED_STREAK 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`Reconfigure`] (first byte after the magic). // Type byte of [`Reconfigure`] (first byte after the magic).
#define MSG_RECONFIGURE 1 #define MSG_RECONFIGURE 1
+6
View File
@@ -161,6 +161,12 @@ package_punktfunk-host() {
install -Dm0644 "$R/scripts/punktfunk-host.service" "$pkgdir/usr/lib/systemd/user/punktfunk-host.service" install -Dm0644 "$R/scripts/punktfunk-host.service" "$pkgdir/usr/lib/systemd/user/punktfunk-host.service"
sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \ sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
"$pkgdir/usr/lib/systemd/user/punktfunk-host.service" "$pkgdir/usr/lib/systemd/user/punktfunk-host.service"
# Optional drop-in for a DESKTOP-LOGIN host: binds the host to graphical-session.target so a
# Plasma/GNOME restart restarts it instead of leaving it on a dead compositor connection. Shipped
# under /usr/share (NOT as an active drop-in) because it is wrong for the appliance route — the
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \
"$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf"
install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service" install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \
"$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
+6
View File
@@ -67,6 +67,12 @@ install -Dm0644 scripts/punktfunk-host.service "$STAGE/usr/lib/systemd/user/
# /usr/bin. Rewrite it so a fresh apt install (no hand-rolled unit) starts the installed binary. # /usr/bin. Rewrite it so a fresh apt install (no hand-rolled unit) starts the installed binary.
sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \ sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
"$STAGE/usr/lib/systemd/user/punktfunk-host.service" "$STAGE/usr/lib/systemd/user/punktfunk-host.service"
# Optional drop-in for a DESKTOP-LOGIN host: binds the host to graphical-session.target so a
# Plasma/GNOME restart restarts it instead of leaving it on a dead compositor connection. Shipped
# under /usr/share (NOT as an active drop-in) because it is wrong for the appliance route — the
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
install -Dm0644 scripts/punktfunk-host-desktop-session.conf \
"$STAGE/usr/share/punktfunk-host/punktfunk-host-desktop-session.conf"
# Optional headless KWin session unit (the kwin --virtual appliance), as the RPM/Arch ship. # Optional headless KWin session unit (the kwin --virtual appliance), as the RPM/Arch ship.
# Repoint its ExecStart from the dev source tree to the packaged script. NOT enabled by default. # Repoint its ExecStart from the dev source tree to the packaged script. NOT enabled by default.
install -Dm0644 scripts/punktfunk-kde-session.service "$STAGE/usr/lib/systemd/user/punktfunk-kde-session.service" install -Dm0644 scripts/punktfunk-kde-session.service "$STAGE/usr/lib/systemd/user/punktfunk-kde-session.service"
+5
View File
@@ -284,6 +284,11 @@ install -Dm0644 scripts/punktfunk-host.service %{buildroot}%{_userunitdir}/punkt
# The source unit's ExecStart points at the dev source tree; a packaged install has the binary at # The source unit's ExecStart points at the dev source tree; a packaged install has the binary at
# %{_bindir}. Rewrite it so a fresh install (no hand-rolled unit) starts the installed binary. # %{_bindir}. Rewrite it so a fresh install (no hand-rolled unit) starts the installed binary.
sed -i 's#%h/punktfunk/target/release/punktfunk-host#%{_bindir}/punktfunk-host#' %{buildroot}%{_userunitdir}/punktfunk-host.service sed -i 's#%h/punktfunk/target/release/punktfunk-host#%{_bindir}/punktfunk-host#' %{buildroot}%{_userunitdir}/punktfunk-host.service
# Optional drop-in for a DESKTOP-LOGIN host: binds the host to graphical-session.target so a
# Plasma/GNOME restart restarts it instead of leaving it on a dead compositor connection. Shipped
# under %{_datadir}/%{name} (NOT as an active drop-in) because it is wrong for the appliance route —
# the operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
install -Dm0644 scripts/punktfunk-host-desktop-session.conf %{buildroot}%{_datadir}/%{name}/punktfunk-host-desktop-session.conf
# Optional headless KDE session unit (the kwin streaming appliance): brings up `kwin --virtual` on # Optional headless KDE session unit (the kwin streaming appliance): brings up `kwin --virtual` on
# wayland-kde via the packaged run-headless-kde.sh, so the host's kwin backend has a session whose # wayland-kde via the packaged run-headless-kde.sh, so the host's kwin backend has a session whose
+8 -4
View File
@@ -60,10 +60,14 @@ parse breakage that silently failed installs on non-English boxes.
**`PunktfunkWeb`** scheduled task (boot, SYSTEM, restart-on-failure → `web-run.cmd``bun` on **`PunktfunkWeb`** scheduled task (boot, SYSTEM, restart-on-failure → `web-run.cmd``bun` on
`:47992`), opens TCP 47992, and starts it. It proxies the host's loopback mgmt API with the host's `:47992`), opens TCP 47992, and starts it. It proxies the host's loopback mgmt API with the host's
own `%ProgramData%\punktfunk\mgmt-token`. own `%ProgramData%\punktfunk\mgmt-token`.
- **GameStream (Moonlight) compatibility is a wizard task** (checked by default): the choice is passed - **GameStream (Moonlight) compatibility is a wizard task** (**unchecked** by default — it pairs over
to `service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or plain HTTP, so it is opt-in like the Public-firewall task): the choice is passed to
`serve`, the secure native-only host) into `host.env`. Upgrade-safe: a hand-customized `service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or
`PUNKTFUNK_HOST_CMD` is never overwritten. `serve`, the secure native-only host) into `host.env`. Unattended, add it with
`/MERGETASKS=gamestream`. Upgrade-safe: a hand-customized `PUNKTFUNK_HOST_CMD` is never
overwritten, and on an upgrade the task is inert entirely (the flag is omitted, so `host.env`
keeps whatever it already says) — change an existing host with
`punktfunk-host service install --gamestream=on|off` plus a service restart.
- **Branded, modern wizard**: `WizardStyle=modern dynamic windows11` (Inno ≥ 6.6 — Windows-11-style - **Branded, modern wizard**: `WizardStyle=modern dynamic windows11` (Inno ≥ 6.6 — Windows-11-style
controls following the system light/dark theme; pre-6.6 compilers fall back to plain `modern`), with controls following the system light/dark theme; pre-6.6 compilers fall back to plain `modern`), with
the punktfunk lens mark on the side panel / header tile and a multi-size `punktfunk.ico` the punktfunk lens mark on the side panel / header tile and a multi-size `punktfunk.ico`
@@ -375,3 +375,30 @@ pub unsafe extern "C" fn device_io_control(
// SAFETY: `request` is the framework-provided WDFREQUEST; `control::dispatch` completes it exactly once. // SAFETY: `request` is the framework-provided WDFREQUEST; `control::dispatch` completes it exactly once.
unsafe { crate::control::dispatch(request, ioctl_code) }; unsafe { crate::control::dispatch(request, ioctl_code) };
} }
/// DDC/CI transmit toward the virtual monitor — refuse INSTANTLY (stall-immunity: DDC fail-fast).
///
/// In exclusive topology the virtual display is the ONLY monitor on the desktop, so
/// monitor-control software (the Twinkle Tray / PowerToys PowerDisplay / Monitorian class) aims
/// its entire DDC traffic — brightness polls, capabilities-string requests — at THIS monitor.
/// There is no bus and no sink here; the only wrong answer is a slow one (a timeout-shaped
/// failure occupies win32k's physical-monitor path, serialized per monitor, for its full
/// duration). Registering the pair pins every probe's cost to one immediate
/// `STATUS_NOT_SUPPORTED`. EDID needs no equivalent: the OS serves descriptor queries from the
/// blob supplied at monitor creation without calling the driver.
pub unsafe extern "C" fn monitor_i2c_transmit(
_monitor: iddcx::IDDCX_MONITOR,
_p_in: *const iddcx::IDARG_IN_I2C_TRANSMIT,
) -> NTSTATUS {
crate::STATUS_NOT_SUPPORTED
}
/// DDC/CI receive from the virtual monitor — same contract as [`monitor_i2c_transmit`]. (The
/// receive DDI has no out-arg at the callback — data would flow back through an async completion;
/// a synchronous failure return refuses the whole transaction, which is exactly the point.)
pub unsafe extern "C" fn monitor_i2c_receive(
_monitor: iddcx::IDDCX_MONITOR,
_p_in: *const iddcx::IDARG_IN_I2C_RECEIVE,
) -> NTSTATUS {
crate::STATUS_NOT_SUPPORTED
}
@@ -89,6 +89,11 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
cfg.EvtIddCxMonitorSetGammaRamp = Some(callbacks::set_gamma_ramp); cfg.EvtIddCxMonitorSetGammaRamp = Some(callbacks::set_gamma_ramp);
cfg.EvtIddCxMonitorAssignSwapChain = Some(callbacks::assign_swap_chain); cfg.EvtIddCxMonitorAssignSwapChain = Some(callbacks::assign_swap_chain);
cfg.EvtIddCxMonitorUnassignSwapChain = Some(callbacks::unassign_swap_chain); cfg.EvtIddCxMonitorUnassignSwapChain = Some(callbacks::unassign_swap_chain);
// DDC fail-fast (stall-immunity): monitor-control software polls the virtual monitor's DDC —
// in exclusive topology it is the ONLY monitor. These answer every probe with an immediate
// STATUS_NOT_SUPPORTED instead of whatever slow path an unregistered interface takes.
cfg.EvtIddCxMonitorI2CTransmit = Some(callbacks::monitor_i2c_transmit);
cfg.EvtIddCxMonitorI2CReceive = Some(callbacks::monitor_i2c_receive);
cfg.EvtIddCxDeviceIoControl = Some(callbacks::device_io_control); cfg.EvtIddCxDeviceIoControl = Some(callbacks::device_io_control);
// SAFETY: init is the framework device-init; cfg is fully populated + sized. (Links IddCxStub.) // SAFETY: init is the framework device-init; cfg is fully populated + sized. (Links IddCxStub.)
@@ -35,12 +35,22 @@ use wdk_sys::NTSTATUS;
// NTSTATUS codes the driver returns (wdk-sys doesn't surface all of these as constants). // NTSTATUS codes the driver returns (wdk-sys doesn't surface all of these as constants).
pub(crate) const STATUS_SUCCESS: NTSTATUS = 0; pub(crate) const STATUS_SUCCESS: NTSTATUS = 0;
pub(crate) const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS; pub(crate) const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
pub(crate) const STATUS_NOT_SUPPORTED: NTSTATUS = 0xC000_00BBu32 as NTSTATUS;
pub(crate) const STATUS_NOT_FOUND: NTSTATUS = 0xC000_0225u32 as NTSTATUS; pub(crate) const STATUS_NOT_FOUND: NTSTATUS = 0xC000_0225u32 as NTSTATUS;
pub(crate) const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS; pub(crate) const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
pub(crate) const STATUS_BUFFER_TOO_SMALL: NTSTATUS = 0xC000_0023u32 as NTSTATUS; pub(crate) const STATUS_BUFFER_TOO_SMALL: NTSTATUS = 0xC000_0023u32 as NTSTATUS;
/// IddCx (stub mode) requires the driver to export the minimum IddCx framework version it needs — the /// IddCx (stub mode) requires the driver to export the minimum IddCx framework version it needs — the
/// `#ifndef IDD_STUB` branch of `IddCxFuncEnum.h` that normally emits it is compiled out under /// `#ifndef IDD_STUB` branch of `IddCxFuncEnum.h` that normally emits it is compiled out under
/// `IDD_STUB`. `4` matches the proven `wdf-umdf` oracle. /// `IDD_STUB`.
///
/// `10` = IddCx 1.10, the TRUTHFUL floor: the drain loop calls
/// `IddCxSwapChainReleaseAndAcquireBuffer2` (1.10) unconditionally and the swap-chain worker uses
/// `IddCxSetRealtimeGPUPriority` (1.9); the product floor is already Windows 11 22H2 / build 22621,
/// whose framework is 1.10 (the installer gate — `MinVersion=10.0.22621` in punktfunk-host.iss —
/// exists precisely for this driver). The oracle's historical `4` predated that floor and would let
/// the driver load against a SHORT `IddFunctions` table, where dispatching any post-1.4 DDI reads
/// past the populated entries. With `10`, an older framework fails the bind cleanly (driver doesn't
/// load) instead of limping into undefined dispatch.
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub static IddMinimumVersionRequired: wdk_sys::ULONG = 4; pub static IddMinimumVersionRequired: wdk_sys::ULONG = 10;
@@ -29,8 +29,8 @@ use std::{
}; };
use wdk_sys::iddcx::{ use wdk_sys::iddcx::{
IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SWAPCHAINSETDEVICE, IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SETREALTIMEGPUPRIORITY,
IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN, IDARG_IN_SWAPCHAINSETDEVICE, IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN,
}; };
// `HANDLE` is the shared wdk-sys typedef (`crate::types`) re-used by the iddcx bindings — take it from // `HANDLE` is the shared wdk-sys typedef (`crate::types`) re-used by the iddcx bindings — take it from
// the crate root, which is guaranteed to export it (the iddcx module only re-exports it if bindgen // the crate root, which is guaranteed to export it (the iddcx module only re-exports it if bindgen
@@ -44,7 +44,8 @@ use windows::{
Dxgi::{IDXGIDevice, IDXGIResource}, Dxgi::{IDXGIDevice, IDXGIResource},
}, },
System::Threading::{ System::Threading::{
AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, WaitForSingleObject, AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, GetCurrentThread,
SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL, WaitForSingleObject,
}, },
}, },
core::{Interface, w}, core::{Interface, w},
@@ -125,12 +126,25 @@ impl SwapChainProcessor {
// MMCSS can fail under the restricted WUDFHost token ('Distribution' task unregistered / // MMCSS can fail under the restricted WUDFHost token ('Distribution' task unregistered /
// service unavailable). The MS sample CONTINUES unprioritized — never abort: returning // service unavailable). The MS sample CONTINUES unprioritized — never abort: returning
// here would leave the assigned swap-chain undrained (the monitor stalls, DWM blocks on // here would leave the assigned swap-chain undrained (the monitor stalls, DWM blocks on
// it) and leak the WDF swap-chain object until device teardown. // it) and leak the WDF swap-chain object until device teardown. But "unprioritized" is
// not acceptable either: this thread is the whole display's frame pump, and at normal
// priority a display-stack disturbance (DDC/HPD servicing DPC pressure, poller-software
// storms) can starve it into multi-hundred-ms delivery holes. Fall back to
// TIME_CRITICAL — the highest band available without the realtime priority class, and
// the closest to what MMCSS 'Distribution' would have granted. The thread spends its
// life blocked on the surface-available event / keyed mutex, so it cannot starve others.
let av_handle = match res { let av_handle = match res {
Ok(h) => Some(h), Ok(h) => Some(h),
Err(e) => { Err(e) => {
// SAFETY: plain FFI; GetCurrentThread returns a pseudo-handle (never fails,
// nothing to close), SetThreadPriority on it affects only this thread.
let fallback = unsafe {
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL)
};
dbglog!( dbglog!(
"[pf-vd] swap-chain: MMCSS prioritization failed ({e:?}) — continuing unprioritized" "[pf-vd] swap-chain: MMCSS prioritization failed ({e:?}) — fell back to \
TIME_CRITICAL thread priority (ok={})",
fallback.is_ok()
); );
None None
} }
@@ -229,6 +243,29 @@ impl SwapChainProcessor {
} }
thread::sleep(Duration::from_millis(50)); thread::sleep(Duration::from_millis(50));
} }
// IddCx 1.9 realtime GPU scheduling priority for the processing device (stall-immunity
// program, branch-2 hardening): swap-chain buffer processing outruns ordinary GPU
// contention — "higher priority than any regular application can set". The slot is
// guaranteed populated (`IddMinimumVersionRequired = 10`, lib.rs); the DDI itself may
// still decline (e.g. E_NOTIMPL on pre-WDDM-3.0 hardware) — best-effort, never fatal.
// Called while our borrowed device reference is still alive; IddCx uses it synchronously.
if set_ok {
let mut rt = pod_init!(IDARG_IN_SETREALTIMEGPUPRIORITY);
rt.pDevice = dxgi_device.as_raw().cast();
// SAFETY: driver is loaded; `swap_chain` is the live assigned swap-chain whose device
// bind just succeeded; `rt.pDevice` is that same bound DXGI device, alive across the
// synchronous call; `rt` points to valid local storage.
let hr = unsafe { wdk_iddcx::IddCxSetRealtimeGPUPriority(swap_chain, &rt) };
if hr_success(hr) {
dbglog!(
"[pf-vd] swap-chain: processing device raised to REALTIME GPU priority (target={target_id})"
);
} else {
dbglog!(
"[pf-vd] swap-chain: realtime GPU priority declined ({hr:#x}) — normal scheduling (target={target_id})"
);
}
}
// Release our borrowed device reference — IddCx holds its own now, or we gave up. (Explicit drop // Release our borrowed device reference — IddCx holds its own now, or we gave up. (Explicit drop
// so NLL can't release it mid-loop while the swap-chain still references the raw ptr.) // so NLL can't release it mid-loop while the swap-chain still references the raw ptr.)
drop(dxgi_device); drop(dxgi_device);
@@ -200,3 +200,20 @@ iddcx_ddi!(
IddCxSwapChainFinishedProcessingFrame(swap_chain: iddcx::IDDCX_SWAPCHAIN) IddCxSwapChainFinishedProcessingFrame(swap_chain: iddcx::IDDCX_SWAPCHAIN)
@ IddCxSwapChainFinishedProcessingFrameTableIndex as PFN_IDDCXSWAPCHAINFINISHEDPROCESSINGFRAME @ IddCxSwapChainFinishedProcessingFrameTableIndex as PFN_IDDCXSWAPCHAINFINISHEDPROCESSINGFRAME
); );
iddcx_ddi!(
/// Raise the swap-chain's processing D3D device to realtime GPU scheduling priority — "higher
/// than any regular application can set" (IddCx 1.9) — so buffer processing outruns ordinary
/// GPU contention. It does NOT help against adapter-wide display servicing (modeset-class DDIs
/// idle the hardware outright); it defends the contention case only. Best-effort at the call
/// site: the DDI itself may decline (e.g. E_NOTIMPL on WDDM < 3.0 hardware).
///
/// Table-slot availability rests on the driver's `IddMinimumVersionRequired = 10` export
/// (pf-vdisplay lib.rs): the loader refuses to bind a framework older than IddCx 1.10, so
/// every slot of our compiled 1.10 surface — this 1.9 one included — is populated wherever the
/// driver runs at all.
IddCxSetRealtimeGPUPriority(
swap_chain: iddcx::IDDCX_SWAPCHAIN,
in_args: *const iddcx::IDARG_IN_SETREALTIMEGPUPRIORITY,
) @ IddCxSetRealtimeGPUPriorityTableIndex as PFN_IDDCXSETREALTIMEGPUPRIORITY
);
+104 -36
View File
@@ -88,12 +88,12 @@
[Setup] [Setup]
AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93} AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}
AppName=punktfunk host AppName=Punktfunk Host
AppVersion={#MyAppVersion} AppVersion={#MyAppVersion}
AppPublisher=unom AppPublisher=unom
AppPublisherURL=https://git.unom.io/unom/punktfunk AppPublisherURL=https://git.unom.io/unom/punktfunk
DefaultDirName={autopf}\punktfunk DefaultDirName={autopf}\punktfunk
DefaultGroupName=punktfunk DefaultGroupName=Punktfunk
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
UsePreviousAppDir=yes UsePreviousAppDir=yes
PrivilegesRequired=admin PrivilegesRequired=admin
@@ -123,7 +123,7 @@ WizardStyle=modern
SetupIconFile={#BrandingDir}\punktfunk.ico SetupIconFile={#BrandingDir}\punktfunk.ico
WizardImageFile={#BrandingDir}\wizard-image-*.bmp WizardImageFile={#BrandingDir}\wizard-image-*.bmp
WizardSmallImageFile={#BrandingDir}\wizard-small-*.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 ; 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 ; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
; copy stays as the uninstall-entry icon. ; 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 ; 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 ; "requires Windows version 10.0.22621" (users on Windows 10 LTSC hit this; see the pf-vdisplay
; IddCx 1.10 note at MinVersion above). ; 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] [Tasks]
#ifdef WithDriver #ifdef WithDriver
@@ -160,18 +160,30 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
#endif #endif
; Host-config choice, applied via `service install --gamestream=on|off` (writes PUNKTFUNK_HOST_CMD ; 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 ; 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; unchecked (DEFAULT) = 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)" ;
; OPT-IN, like allowpublicfw below and for the same reason: the host itself WARNs on every start
; that this plane pairs over plain HTTP and its legacy control encryption can reuse GCM nonces
; (security-review #5/#9), so an on-path LAN attacker could MITM pairing or recover input. A
; default-on security downgrade cannot be squared with that warning - least of all on the silent
; path, where the wizard never appears and 1839d756 makes an unattended install take these very
; defaults. Reported by a user who found the warning in their log and had never been shown a
; choice, because they installed through winget.
;
; Turning it on unattended is `/MERGETASKS="gamestream"`; on an UPGRADE this task is inert either
; way (GamestreamParam omits the flag unless FreshHostInstall), so an existing host keeps whatever
; host.env already says - changing it afterwards is `service install --gamestream=on|off`.
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"; Flags: unchecked
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked ; 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 ; (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. ; 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. ; 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: "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 ; 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 ; 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). ; 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] [Files]
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
@@ -280,15 +292,15 @@ Filename: "powershell.exe"; \
; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}. ; 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. ; --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}"; \ 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}"; \ 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 #ifdef WithWeb
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd ; 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), ; 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. ; 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}"; \ 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 #endif
#ifdef WithScripting #ifdef WithScripting
; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it ; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it
@@ -302,7 +314,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
; in the command, so no Inno {{ }} escaping needed. ; in the command, so no Inno {{ }} escaping needed.
Filename: "powershell.exe"; \ 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"""; \ 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 #endif
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the ; 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. ; icon appears without waiting for the next sign-in.
@@ -341,15 +353,44 @@ Filename: "powershell.exe"; \
#endif #endif
[Code] [Code]
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its { Captured in InitializeSetup - BEFORE [Run] calls `service install`, which creates host.env. True on
Program Files directory. Sunshine and its forks all register a "<Name>Service" and install under a first-ever install, False on an upgrade. Gates the install-time-only CONFIG choices (see
Program Files, so a registry + directory probe catches them without enumerating processes (which GamestreamParam): a silent run has no wizard on screen, so every task checkbox falls back to its
pure Pascal can't do cleanly). } script default. Re-applying those defaults on an upgrade would overwrite a setting the user chose
function StreamHostPresent(SvcKey, DirName: String): Boolean; on an earlier run, with nothing shown. `winget upgrade` runs us exactly that way. }
var
FreshHostInstall: Boolean;
function HostEnvPath: String;
begin begin
Result := RegKeyExists(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey) Result := ExpandConstant('{commonappdata}\punktfunk\host.env');
or DirExists(ExpandConstant('{commonpf}\' + DirName)) end;
or DirExists(ExpandConstant('{commonpf32}\' + DirName));
{ True if another Moonlight-compatible streaming host is not merely present but will actually RUN.
Sunshine and its forks register a "<Name>Service"; `Start` is that service's start type
(REG_DWORD): 0 boot, 1 system, 2 automatic, 3 manual, 4 disabled. Only 0-2 come up on their own,
and only a host that comes up can take the GameStream ports or load a second virtual-display
driver - which is the entire content of the warning below.
DELIBERATELY NARROWER than it was. The old probe also counted the service key existing at ANY
start type, plus a bare Program Files\<Name> directory - so a disabled service, or a leftover folder
from an uninstall, read as a live conflict. Combined with the silent-install default of IDNO that
aborted setup, and a field report followed within hours of 0.20.0: `winget install
unom.PunktfunkHost` failed with exit code 1 (0x8A150006) on a box whose Sunshine was not running.
Nothing about a dormant install can clash, and the tray reached the same conclusion in this same
release (3e782852 dropped its always-on warning over a merely-INSTALLED Sunshine as a false
alarm) - the two surfaces now agree.
A host running as a plain user process rather than a service is not detected here, by choice:
that cannot be seen from pure Pascal, it is a runtime condition rather than an install-time one,
and the host already reports it where it belongs (the `punktfunk::detect` startup warning, the
`detect-conflicts` subcommand, and /api/v1/local/summary). }
function StreamHostEnabled(SvcKey: String): Boolean;
var
StartType: Cardinal;
begin
Result := RegQueryDWordValue(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey, 'Start', StartType)
and (StartType <= 2);
end; end;
{ Runs before any wizard page - the earliest point we can warn. Detect a conflicting host and let { Runs before any wizard page - the earliest point we can warn. Detect a conflicting host and let
@@ -359,30 +400,49 @@ var
Found: String; Found: String;
begin begin
Result := True; Result := True;
{ Record the fresh-vs-upgrade verdict while host.env still reflects the PREVIOUS run. }
FreshHostInstall := not FileExists(HostEnvPath);
Found := ''; Found := '';
if StreamHostPresent('SunshineService', 'Sunshine') then Found := Found + ' - Sunshine' + #13#10; if StreamHostEnabled('SunshineService') then Found := Found + ' - Sunshine' + #13#10;
if StreamHostPresent('ApolloService', 'Apollo') then Found := Found + ' - Apollo' + #13#10; if StreamHostEnabled('ApolloService') then Found := Found + ' - Apollo' + #13#10;
if StreamHostPresent('VibeshineService', 'Vibeshine') then Found := Found + ' - Vibeshine' + #13#10; if StreamHostEnabled('VibeshineService') then Found := Found + ' - Vibeshine' + #13#10;
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10; if StreamHostEnabled('VibepolloService') then Found := Found + ' - Vibepollo' + #13#10;
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10; if StreamHostEnabled('LuminalShineService') then Found := Found + ' - LuminalShine' + #13#10;
if Found <> '' then if Found <> '' then
Result := MsgBox( { SuppressibleMsgBox, NOT MsgBox: a plain MsgBox ignores /SUPPRESSMSGBOXES and displays even
'Another game-streaming host is already installed on this PC:' + #13#10#13#10 + Found + #13#10 + 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(
{ NB: keep #13#10 off the START of a line - ISPP reads a leading '#' as a preprocessor
directive and aborts the compile with "Unknown preprocessor directive". }
'Another game-streaming host is installed on this PC and set to start automatically:' + #13#10#13#10 + Found + #13#10 +
'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' + 'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' +
'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' + 'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' +
'install a conflicting virtual-display driver, which causes pairing failures, "address ' + 'install a conflicting virtual-display driver, which causes pairing failures, "address ' +
'already in use" errors and capture glitches.' + #13#10#13#10 + 'already in use" errors and capture glitches.' + #13#10#13#10 +
'Stop and uninstall the other host before using Punktfunk.' + #13#10#13#10 + 'Stop and disable its service (or uninstall it) before using Punktfunk. A host that is ' +
'installed but disabled does not clash and is not reported here.' + #13#10#13#10 +
'Continue with the installation anyway?', 'Continue with the installation anyway?',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES; mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES;
end; end;
{ The GameStream task choice, forwarded to `service install` (which writes host.env's { 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 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; function GamestreamParam(Param: String): String;
begin begin
if WizardIsTaskSelected('gamestream') then if not FreshHostInstall then
Result := ''
else if WizardIsTaskSelected('gamestream') then
Result := '--gamestream=on' Result := '--gamestream=on'
else else
Result := '--gamestream=off'; Result := '--gamestream=off';
@@ -392,13 +452,21 @@ end;
(default = Private/Domain only). Forwarded to both `service install` and `web setup`. Returns a (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. 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, (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; function PublicFwParam(Param: String): String;
begin begin
if WizardIsTaskSelected('allowpublicfw') then if not FreshHostInstall then
Result := ' --allow-public-network' Result := ''
else if WizardIsTaskSelected('allowpublicfw') then
Result := ' --allow-public-network=on'
else else
Result := ''; Result := ' --allow-public-network=off';
end; end;
#ifdef WithWeb #ifdef WithWeb
@@ -443,7 +511,7 @@ var
begin begin
FreshWebInstall := not FileExists(WebPasswordPath); FreshWebInstall := not FileExists(WebPasswordPath);
WebPwPage := CreateInputQueryPage(wpSelectTasks, 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 ' + '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 ' + '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.'); '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 };

Some files were not shown because too many files have changed in this diff Show More