31 Commits

Author SHA1 Message Date
enricobuehler a5dc3134de docs(display-management): handoff — mark Stages 0-4 done, Stage 5 started
Update the design doc for handoff: top-of-doc status, a Status/handoff block in §11
(per-stage state, validation boxes, key decisions), and per-stage [DONE]/[STARTED]
markers. Records the decisions that diverged from the plan as written — the Windows
admission default is reject (single-capturer IDD-push), reject is typed (QUIC 0x42),
Stage 5's group-aware exclusive fixes a Stage-3 latent bug — and what's left in
Stage 5 (Mutter/wlroots analogues, layout, /display/layout, per-group restore).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:50:50 +00:00
enricobuehler eddcd91f48 feat(vdisplay/kwin): group-aware exclusive — never disable a sibling output (Stage 5 §6.1)
The critical latent bug Stage 3 introduced: per-slot output names mean a 2nd
exclusive session's other_enabled_outputs() (which disabled 'everything not named
Virtual-punktfunk') would black out the 1st session's Virtual-punktfunk-<id>
output. Fix: recognise the whole managed group by the shared Virtual-punktfunk
prefix — exclusive now disables only NON-managed outputs (bootstrap/physical),
never a group sibling. Plus first-slot-wins for the group primary
(a_managed_output_is_primary): a later session joins as a secondary monitor of the
shared desktop instead of stealing the shell off the first. Unit-tested.

Start of Stage 5 (§6A many-clients-one-desktop). Remaining: Mutter/wlroots
group-aware analogues, layout (auto-row/manual + /display/layout + console),
per-group topology restore, gamescope groups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:44:41 +00:00
enricobuehler 23446fa177 fix(vdisplay): Windows admission default is reject, not join (single-capturer limit)
Two concurrent Windows sessions both drive the same pf-vdisplay monitor's
single-capturer IDD-push channel (newest-delivery-wins), which freezes the live
client and can wedge the driver (observed live: a concurrent-session test wedged
.173 → Moonlight 'no video'; needed a reboot). True multi-session capture is §6.6/
Stage 7. So on Windows 'separate' (incl. the unconfigured default) now resolves to
REJECT — a 2nd client gets a clean 503 and the live session is protected — instead
of join (which would freeze it). join/steal stay explicit opt-ins; Linux keeps
separate (real multi-view). Centralized as admission::effective_conflict(), shared
by the native handshake + GameStream h_launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:32:52 +00:00
enricobuehler 980939ed6b refactor(gamestream): extract + unit-test gamestream_admission (Stage 4)
Pull the GameStream mode-conflict decision out of h_launch into a pure
gamestream_admission(live, req_fp, policy) -> GsDecision so the 503/join/take-over
logic is unit-tested (no live session / same-client → Serve; different client →
Reject/Join/Serve per policy; anonymous requester treated as different) — the
GameStream path can't be driven without a Moonlight client, so this covers the logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:43:08 +00:00
enricobuehler cfad0cf7ee feat(vdisplay): finish Stage 4 — typed reject, Windows join-default, GameStream 503
Completes the mode-conflict admission surface deferred from the initial Stage 4:

- REJECT now delivers the reason to the client: punktfunk/1 closes the QUIC
  connection with a distinct BUSY code (0x42) + the 'host busy: streaming WxH@Hz to
  <client>' string, which the client reads from ApplicationClosed (validated on
  loopback: the probe logs 'closed by peer: host busy … (code 66)').
- Windows default: separate (incl. the unconfigured default) resolves to JOIN — the
  Windows native host admits a second client at the live mode instead of the old
  silent last-wins reconfigure of the shared monitor (release-note behavior fix; the
  reconfigure is now opt-in as steal). separate stays multi-view on Linux.
- GameStream 503: h_launch tracks the session owner fp (LaunchSession.owner_fp, kept
  [u8;32] for Copy) and applies the policy when a DIFFERENT paired client launches —
  reject → 503 (Moonlight 'host busy'), join → serve the live mode, steal/separate →
  take over. Same-client re-launch is never a conflict.

Native reject-reason loopback-validated; Windows join-default pending .173 rebuild;
GameStream 503 pending a Moonlight client (can't drive /launch autonomously).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:34:49 +00:00
enricobuehler 42b1158ea7 feat(vdisplay): mode-conflict admission — separate/join/steal/reject (Stage 4)
The mode_conflict policy is now enforced at ADMISSION, before the punktfunk/1
Welcome, when a DIFFERENT client connects while another client's session is live:
- separate (default, unconfigured → no change): each client its own display.
- join: admit at the live display's mode (honest-downgrade — the Welcome carries it).
- steal: signal the victim session(s)' stop flags, wait the release grace, serve.
- reject: refuse the handshake with a busy reason (live mode + client label).

New vdisplay/admission.rs: the pure decide() (unit-tested — same-client never
conflicts, anonymous clients each distinct, join targets the oldest session) + a
live-session registry (identity + mode + stop flag) sessions register in once up.
Wired into punktfunk1 serve_session: admit() before validate_dimensions, register
after the data plane binds. A same-client reconnect never conflicts.

Validated on loopback (two probes, distinct identities, differing modes) across all
four policies: separate→own mode, join→live mode, steal→victim interrupted,
reject→handshake refused.

Remaining Stage-4 surface (deferred): GameStream 503 path, Windows-specific
defaults (separate→join map, silent-reconfigure→steal), reject reason delivered to
the client as a typed message (currently host-side log + connection close).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:21:28 +00:00
enricobuehler 029d1134a9 harden(vdisplay/windows): verify+retry exclusive isolation; pack primary layout
Exclusive (topology=exclusive) was fire-and-forget — a field-reported bug had a
physical monitor STAY ACTIVE. isolate_displays_ccd now re-queries after each apply
and RETRIES (up to 4x) until count_other_active()==0, never trusting rc alone;
logs SOLE-active on success, an error if a display survives all attempts. Secure
desktop correctness depends on the lock screen not landing on a stray panel.

Primary: drop the temporary per-path diagnostic; pack the kept displays left-to-
right from the virtual's right edge instead of blindly shifting each by virt_width
(which left a dead gap when extend already placed them right).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:55:13 +00:00
enricobuehler e35b6991e2 fix(vdisplay/windows): topology=primary force-extends to reactivate the physical
Root cause: on a headless box the IDD auto-activates as the SOLE display, so
QueryDisplayConfig sees only the virtual — the physical is already deactivated
before set_virtual_primary_ccd runs (no physical to keep). Force EXTEND first to
reactivate every connected display alongside the virtual, then reposition to make
the virtual primary, keeping the physical active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:41:20 +00:00
enricobuehler 913f6ce659 diag(vdisplay/windows): log active paths in set_virtual_primary_ccd
Temporary diagnostic — the physical monitor goes black in topology=primary
despite rc=0; the SSH/session-0 view can't see the real interactive-session
topology, so log the active paths the host actually operates on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:36:53 +00:00
enricobuehler d23bd9b0cf fix(vdisplay/windows): DISPLAYCONFIG_PATH_SOURCE_INFO union field access
modeInfoIdx lives in the Anonymous union (windows-rs), not directly on
sourceInfo — set_virtual_primary_ccd now reads .Anonymous.modeInfoIdx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:27:07 +00:00
enricobuehler eda7cac78e feat(vdisplay/windows): topology=primary — keep physicals active, virtual primary
Implements the deferred Windows primary-only CCD (Stage 2). set_virtual_primary_ccd
repositions the virtual output's source to (0,0) = primary and shifts the physical
display(s) to its right, ALL kept active — one atomic CCD SetDisplayConfig (not GDI
CDS_SET_PRIMARY, which storms MODE_CHANGE_IN_PROGRESS with another display live).
The manager's should_isolate() becomes topology_action() (3-way): extend (skip),
primary (set_virtual_primary_ccd), exclusive (isolate_displays_ccd). Restore-on-teardown
covers both. Validates the user's two scenarios on a physical-monitor .173.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:17:41 +00:00
enricobuehler d73951414c feat(vdisplay): KWin per-slot output naming for persistent scaling (Stage 3)
The KWin backend names its output Virtual-punktfunk-<id> from the client's
stable identity slot, so KWin persists per-output config (scale/mode) by name in
kwinoutputconfig.json and reapplies that client's scaling on reconnect — the KDE
scaling ask. Also fixes the latent clash where two concurrent sessions both used
Virtual-punktfunk (topology name-matching now uses the per-slot name).

- identity::global() + resolve_slot(fp, mode, default) — the shared persisted map
  (Windows manager dropped its own field; both use the global — never same-process).
  Default identity is per-platform: PerClient on Windows, Shared on Linux, so
  unconfigured hosts keep today's behavior (Linux = single 'punktfunk' name).
- KwinDisplay carries the client fp (set_client_identity), computes the per-slot
  name, threads it through the stream_virtual_output name + the topology helpers
  (set_custom_refresh / apply_virtual_primary[_only] / other_enabled_outputs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:54:39 +00:00
enricobuehler b150d79626 feat(vdisplay): platform-neutral identity map + per-client-mode (Stage 3)
Generalize the Windows-only per-client stable-id map into vdisplay/identity.rs:
- DisplayIdentityMap keyed on a composable string (identity_key: fingerprint,
  or fingerprint+resolution under per-client-mode); LRU at 15, persisted to
  display-identity.json (migrated from the legacy pf-vdisplay-identity.json).
- Windows manager wired to it, picking the key from the identity policy.
- Foundation for KWin per-slot output naming (persistent KDE scaling) — the
  KWin wiring is the next Stage-3 step (needs a KWin box).
- Unit-tested (stable, per-client-mode split, LRU, key composition).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:40:18 +00:00
enricobuehler cb7ddc0411 feat(vdisplay): topology decoupling — distinct primary level (Stage 2)
The three topology levels become distinct behaviors (Stage 0 only did
extend-vs-exclusive, faking primary):
- vdisplay::effective_topology() -> the concrete level (console policy > legacy
  *_VIRTUAL_PRIMARY env > Auto default). Backends read it directly at create
  time; apply_session_env no longer writes the boolean env (one fewer connect-
  path env mutation).
- Mutter: extend (no config), primary (virtual primary + physicals kept as
  secondaries — build_primary_keeping_physicals), exclusive (sole, physicals
  disabled). KWin: extend (no-op), primary (kscreen primary only), exclusive
  (primary + disable others).
- Windows should_isolate treats primary as isolate (the primary-only CCD variant
  is a follow-up); wlroots exclusive + the physical-keep effect need a
  display-attached box (headless lab boxes can't observe primary vs exclusive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:18:46 +00:00
enricobuehler 60816709c4 fix(vdisplay): call life.acquire() outside debug_assert (release no-op)
The pooled entry's lifecycle transition was inside debug_assert_eq!, whose
arguments don't evaluate in release builds — so acquire() never ran, the entry
stayed Idle, and release saw Noop → immediate teardown (no keep-alive). Caught
on-glass on the CachyOS box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:45:36 +00:00
enricobuehler 783c52dfad feat(vdisplay): Linux keep-alive pool — registry-owned display lifecycle (Stage 1b)
The ownership split (design/display-management.md §3): the registry owns the
per-session virtual-display lifecycle on Linux, so a display can outlive its
session (keep-alive) and be reused on reconnect.

- registry.rs: a Linux pool driven by the pure lifecycle machine. acquire()
  reuses a kept (lingering/pinned) display of the same backend+mode, else
  creates one and keeps the backend's keepalive so the compositor output (and
  its PipeWire node_id) survives the session. The session's capturer holds a
  gen-stamped DisplayLease instead of the real keepalive; its drop drives
  linger/teardown. Enabling fact: KWin/Mutter/gamescope put their node on the
  DEFAULT PipeWire daemon (remote_fd=None) — reconnect re-attaches by node_id,
  no fd re-open. wlroots (remote_fd=Some, xdpw portal) passes through unchanged
  (teardown-on-drop) pending the fresh-portal-capture re-attach.
- Default (unconfigured) linger = Immediate → today's teardown-on-disconnect,
  so no behavior change without a keep-alive policy; concurrent sessions still
  each create their own output (reuse only matches LINGERING entries).
- Wired build_pipeline (punktfunk1) + gamestream through registry::acquire;
  capture_virtual_output signature unchanged. Windows delegates to vd.create
  (the manager already leases) — unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:37:21 +00:00
enricobuehler 2dd17dda80 test(mgmt): display state/release endpoint smoke test
Covers the idle path (empty /display/state + released:0 /display/release) on a
unit-test host, exercising the wiring + auth without touching any global owner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:27:52 +00:00
enricobuehler 87f0ce7997 feat(vdisplay): lifecycle state machine + display state/release API (Stage 1)
Stage 1 of design/display-management.md — the lifecycle core + the display
management surface:

- vdisplay/lifecycle.rs: pure per-slot state machine (Idle/Active{refs}/
  Lingering{until}/Pinned) with acquire/release/expiry/force-release
  transitions. No I/O, no OS types — the platform-neutral distillation of the
  Windows manager's model. Unit + a 200k-iteration seeded property walk
  (no leaks / double-frees / refcount underflow across arbitrary interleavings).
- vdisplay/registry.rs: neutral snapshot/release facade over the per-OS
  lifecycle owners. Windows reads/controls the VirtualDisplayManager; Linux
  keep-alive (a per-session pool) lands in a following increment (needs GPU-box
  validation).
- windows/manager.rs: additive snapshot() + force_release() (no behavior change
  to the on-glass-validated path).
- mgmt: GET /api/v1/display/state (live/kept displays) + POST /api/v1/display/release
  (tear down lingering/pinned now; refuses active). OpenAPI regenerated.
- web console: Virtual displays card gains a live-display list (polled) with
  per-row + release-all buttons and a linger countdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:32:03 +00:00
enricobuehler bbd98241e4 feat(vdisplay): display-management policy surface (Stage 0)
A user-configurable policy layer above the per-compositor VirtualDisplay
backends: keep-alive, topology, conflict, identity, layout, max-displays —
persisted to display-settings.json, editable from the web console, applied
per connect. Design: design/display-management.md.

Stage 0 stands up the surface and wires the two behaviors the existing code
can already express — the Windows monitor linger duration and the
"make the streamed output the sole desktop" topology — through it; every
other option is stored + echoed but not yet enforced (later stages). An
unconfigured host (no display-settings.json) keeps today's exact behavior.

- vdisplay/policy.rs: pure DisplayPolicy + 5 presets + JSON store (gpu-settings
  pattern) + EffectivePolicy; 9 unit tests.
- vdisplay.rs: resolve_topology(Auto); apply_session_env drives *_VIRTUAL_PRIMARY
  from the policy only when a settings file exists.
- windows/manager.rs: linger_ms() + should_isolate() read the policy when configured.
- mgmt: GET/PUT /api/v1/display/settings (bearer-only); PUT rejects keep_alive
  forever until the lifecycle stage. OpenAPI regenerated.
- web console: Host → Virtual displays card (preset picker + custom fields); en+de.
- docs-site: virtual-displays.md + configuration.md cross-links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:44:18 +00:00
enricobuehler 202f40fd4e chore(release): bump workspace version to 0.7.4
apple / swift (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 1m33s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 51s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 55s
ci / rust (push) Successful in 7m8s
android-screenshots / screenshots (push) Successful in 46s
ci / bench (push) Successful in 4m49s
android / android (push) Successful in 3m20s
release / apple (push) Successful in 9m30s
windows-host / package (push) Successful in 7m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
arch / build-publish (push) Successful in 8m8s
apple / screenshots (push) Successful in 5m47s
deb / build-publish (push) Successful in 2m56s
decky / build-publish (push) Successful in 14s
flatpak / build-publish (push) Successful in 4m53s
linux-client-screenshots / screenshots (push) Successful in 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 9m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m36s
docker / deploy-docs (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
web-screenshots / screenshots (push) Successful in 2m40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:46:34 +00:00
enricobuehler 8f90563ffd docs: dedicated Arch Linux host+client guide
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Every other distro has a full Host Setup page; Arch only had table rows. Add
docs/arch.md (signed pacman binary repo: key import + repo + install, GPU
prereqs, service/linger, web console, client, PKGBUILD appendix), slot it into
the nav after fedora-kde, and point the install/client tables at it. Update the
client-install rows from 'from the PKGBUILD' to the binary repo now that it exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:37:01 +00:00
enricobuehler 2e6b822fd6 docs(ci/arch): correct the header's pacman setup (key import, not TrustAll) + note the trust root
android / android (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:19:28 +00:00
enricobuehler f7c5314b5e fix(packaging/arch): correct pacman setup — import the registry key, cache cargo git
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 3m18s
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The Gitea Arch registry signs its DB + packages, so 'SigLevel = Optional TrustAll' fails
non-interactively (pacman still needs the key to verify). Document the one-time
pacman-key import instead; install is then signature-validated under pacman's default
SigLevel (verified end-to-end: clean archlinux container -> repo sync -> install,
'Validated By: Signature').

Also cache /usr/local/cargo/git in arch.yml: the workspace pulls clients/windows'
git-pinned windows-reactor/windows deps to resolve, cloning windows-rs (huge) every run
otherwise — same registry+git cache deb.yml uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:16:24 +00:00
enricobuehler d6669fc3fb fix(ci/arch): create CARGO_HOME before chown — actions/cache doesn't on a miss
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
arch / build-publish (push) Successful in 7m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:03:46 +00:00
enricobuehler e292084225 fix(ci/arch): install nodejs before actions/checkout — act_runner doesn't inject node
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
android / android (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
arch / build-publish (push) Failing after 43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:02:17 +00:00
enricobuehler c758b0393a docs: sysext + pacman repo are the Bazzite/Arch install paths
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m37s
ci / web (push) Successful in 53s
android / android (push) Successful in 3m37s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m23s
ci / bench (push) Successful in 4m52s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
deb / build-publish (push) Successful in 4m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 9m57s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m40s
arch / build-publish (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler d6a659a1ee feat(packaging/arch): distribute binary packages via the Gitea Arch registry
New arch.yml builds the split PKGBUILD (host/client/web, PF_WITH_WEB=1) in an
archlinux:base-devel container on every push and publishes to the pacman repos
'punktfunk' (tags) / 'punktfunk-canary' (main, X.Y.Z-0.<run#> — pkgrel allows
only digits+dots, so the run number carries the ordering). Consumers add one
pacman.conf section; no more build-it-yourself as the only Arch path.

PKGBUILD: pkgver/pkgrel env-driven (PF_PKGVER/PF_PKGREL), source=() when
PF_SRCDIR is set (a canary version has no tag to clone), stale NVENC-only
header fixed, and options=('!lto' '!debug') — makepkg's lto option injects
-flto=auto into CFLAGS, aws-lc-sys compiles its C with it, and rust's lld
cannot read GCC LTO bitcode: 'undefined symbol: aws_lc_*' at link (reproduced
minimally on Arch + rust 1.90). Full build + clean-container install
smoke-tested locally (binaries run, payload + scriptlets intact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler 2190dad2ad feat(packaging/bazzite): systemd-sysext replaces rpm-ostree layering as the primary install path
Layering is a last resort per the Bazzite docs (slows every OS update, can
block upgrades until removed); a sysext never enters an rpm-ostree
transaction, survives OS updates, and installs/updates with no reboot —
the mechanism Fedora Atomic ships via fedora-sysexts.

- build-sysext.sh wraps the built host+web RPMs into punktfunk-<V-R>-x86-64.raw:
  /etc payload relocated to /usr/share/punktfunk/etc (a sysext carries only
  /usr), the punktfunk-sysext helper embedded, ID=fedora + VERSION_ID pinned
  (merges on Bazzite via ID_LIKE; REFUSED after a major rebase instead of
  running soname-broken binaries — both behaviors validated live on Bazzite 43).
  SELinux labels are baked in as squashfs pseudo-xattrs from matchpathcon:
  unlabeled files run fine for user units but system daemons are DENIED
  (udev couldn't read the gamepad rule under enforcing) — validated on-glass.
  Refuses duplicate input package names (a stale noarch punktfunk-web next to
  the x86_64 one built a chimera image with the dead node launcher once).
- punktfunk-sysext.sh: install/update/status/remove against per-Fedora-major
  feeds (…/generic/punktfunk-sysext/f43[-canary]), SHA-256-verified, applies
  the udev/sysctl scriptlet work + /etc copies, prints the layering-migration
  hint. Live-validated on the .41 Bazzite box incl. service restart + web console.
- publish-sysext-feed.sh + rpm.yml: build + publish the image per matrix leg
  (fedver 43/44), canary feeds pruned to 6, stable release assets attached.
- update-punktfunk.sh warns when the sysext shadows a layered install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler 5b5ec15ead fix(client-linux): GL presenter — eglCreateImageKHR takes EGLint attribs, not EGLAttrib
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 5m47s
android / android (push) Successful in 3m18s
ci / rust (push) Successful in 1m35s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m35s
ci / bench (push) Successful in 4m57s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
deb / build-publish (push) Successful in 4m37s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
flatpak / build-publish (push) Successful in 4m18s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m57s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m14s
The KHR variant reads 32-bit attrib pairs; the pointer-sized array fed it
garbage and every plane import came back rejected (observed on-Deck; the
new fallback ladder caught it and demoted to software exactly as designed).
Also print the real EGL error enum instead of its discriminant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:32:06 +00:00
enricobuehler c9ff144492 Merge branch 'main' of git.unom.io:unom/punktfunk
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 1m49s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m51s
windows-host / package (push) Successful in 6m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m22s
deb / build-publish (push) Successful in 4m40s
ci / bench (push) Successful in 4m48s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
release / apple (push) Successful in 7m51s
decky / build-publish (push) Successful in 24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 58s
flatpak / build-publish (push) Successful in 4m12s
apple / screenshots (push) Successful in 5m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m41s
docker / deploy-docs (push) Successful in 20s
2026-07-04 14:29:40 +00:00
enricobuehler 7930d2f0f4 fix(core): split WIRE_VERSION from ABI_VERSION — new clients locked out of every deployed host
ABI_VERSION was doing double duty: the embeddable C surface AND the punktfunk/1
Hello/Welcome version that hosts equality-check. The WoL feature's v3 bump added
a client-local FFI function without changing a single wire byte — and every new
client started refusing against every deployed host ("ABI mismatch: client 3
host 2", observed live Deck → Bazzite). The wire now carries its own
WIRE_VERSION (still 2); ABI_VERSION stays 3 for the C header and the mgmt API's
informational field. Bump WIRE_VERSION only when the handshake/planes actually
change incompatibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:29:33 +00:00
56 changed files with 6080 additions and 594 deletions
+142
View File
@@ -0,0 +1,142 @@
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
# resolves today — the same sonames an up-to-date Arch box runs.
#
# Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry
# SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add +
# --lsign-key), then no SigLevel line is needed (pacman's default Required verifies):
# [punktfunk] # or [punktfunk-canary] for main-push builds
# Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
# NOTE: this token + the registry-held private key are the trust root — a token holder can
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
name: arch
on:
push:
branches: [main]
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: git.unom.io
OWNER: unom
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
image: docker.io/library/archlinux:base-devel
timeout-minutes: 90
env:
CARGO_HOME: /usr/local/cargo
steps:
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
- name: Install build + runtime-dev deps
run: |
pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake nasm pkgconf python \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
# it's AUR-only on Arch, so bootstrap the official binary.
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
}
bun --version
- uses: actions/checkout@v4
# Cache cargo's git dir too, not just the registry: the workspace includes
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
# is ever compiled here. Cached, that cost is paid once per runner.
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-arch-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-arch-
- name: Version + channel
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
esac
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
echo "REPO=$REPO" >> "$GITHUB_ENV"
echo "pacman $V-$R -> repo '$REPO'"
- name: Build packages (makepkg)
run: |
git config --global --add safe.directory "$PWD"
# libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI
# links -lcuda but the builder has no GPU; synthesize every cu* symbol the source
# references so a newly-added call can't silently break the link.
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then
STUB_C="$(mktemp --suffix=.c)"
for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C"
ln -sf libcuda.so.1 /usr/lib/libcuda.so
rm -f "$STUB_C"; ldconfig
echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols =="
fi
# makepkg refuses to run as root; deps are already installed above (-d skips the
# RPM-level check that can't see the script-installed bun anyway).
useradd -m builder
mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss
chown -R builder: "$PWD" "$CARGO_HOME"
sudo -u builder git config --global --add safe.directory "$PWD"
mkdir -p dist && chown builder: dist
cd packaging/arch
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
- name: Publish to the Gitea Arch registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for pkg in dist/*.pkg.tar.zst; do
echo "uploading $pkg"
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p')
ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p')
# A re-tagged release re-fires this workflow and the registry 409s on duplicate
# package versions — delete any prior copy first (404 on the first publish is fine).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO"
done
echo "published to $OWNER/arch/$REPO"
# On a real release, also attach the packages to the unified Gitea Release.
- name: Attach packages to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for pkg in dist/*.pkg.tar.zst; do
upsert_asset "$RID" "$pkg"
done
+28
View File
@@ -35,8 +35,10 @@ jobs:
include:
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
group: bazzite
fedver: 43
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
group: fedora-44
fedver: 44
container:
image: git.unom.io/unom/${{ matrix.image }}:latest
timeout-minutes: 90
@@ -53,6 +55,8 @@ jobs:
run: |
git config --global --add safe.directory "$PWD"
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
command -v bun >/dev/null || {
@@ -117,6 +121,27 @@ jobs:
done
echo "published to $OWNER/rpm/$GROUP"
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
- name: Build the sysext image
run: |
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
- name: Publish the sysext feed
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
case "$GROUP" in
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
esac
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
@@ -132,3 +157,6 @@ jobs:
base="$(basename "$rpm" .rpm)"
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
done
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
Generated
+9 -9
View File
@@ -2129,7 +2129,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.7.2"
version = "0.7.4"
[[package]]
name = "lazy_static"
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.7.2"
version = "0.7.4"
dependencies = [
"punktfunk-core",
]
@@ -2908,7 +2908,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.7.2"
version = "0.7.4"
dependencies = [
"android_logger",
"jni",
@@ -2922,7 +2922,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.7.2"
version = "0.7.4"
dependencies = [
"anyhow",
"async-channel",
@@ -2945,7 +2945,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.7.2"
version = "0.7.4"
dependencies = [
"anyhow",
"async-channel",
@@ -2968,7 +2968,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.7.2"
version = "0.7.4"
dependencies = [
"aes-gcm",
"bytes",
@@ -2999,7 +2999,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.7.2"
version = "0.7.4"
dependencies = [
"aes",
"aes-gcm",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.7.2"
version = "0.7.4"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3085,7 +3085,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.7.2"
version = "0.7.4"
dependencies = [
"anyhow",
"ksni",
+1 -1
View File
@@ -17,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package]
version = "0.7.2"
version = "0.7.4"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+3 -2
View File
@@ -83,8 +83,9 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| Platform | Install | Guide |
|--------|---------|-------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
+542 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.6.0"
"version": "0.7.4"
},
"paths": {
"/api/v1/clients": {
@@ -138,6 +138,172 @@
}
}
},
"/api/v1/display/release": {
"post": {
"tags": [
"display"
],
"summary": "Release kept virtual displays",
"description": "Tear down lingering/pinned displays now — so a physical-screen user gets their screen back\nwithout waiting out the linger. `slot` releases one; omit it to release all kept displays.\nActive (streaming) displays are never torn down here (that is session control).",
"operationId": "releaseDisplay",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseDisplayRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The number of kept displays released",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseDisplayResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/settings": {
"get": {
"tags": [
"display"
],
"summary": "Display-management policy",
"description": "The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),\nevery preset's expansion, and which options this build enforces yet. See\n`design/display-management.md`.",
"operationId": "getDisplaySettings",
"responses": {
"200": {
"description": "Stored policy + preset expansions + enforced options",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplaySettingsState"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"put": {
"tags": [
"display"
],
"summary": "Set the display-management policy",
"description": "Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a\nrunning session keeps the display it opened on. `keep_alive: forever` is rejected until the\ndisplay-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release\npath yet).",
"operationId": "setDisplaySettings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplayPolicy"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Policy stored; the new state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplaySettingsState"
}
}
}
},
"400": {
"description": "An option value is not yet supported (e.g. keep_alive forever)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Policy could not be persisted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/state": {
"get": {
"tags": [
"display"
],
"summary": "Live virtual displays",
"description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.",
"operationId": "getDisplayState",
"responses": {
"200": {
"description": "The live/kept virtual displays",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplayStateResponse"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/gpus": {
"get": {
"tags": [
@@ -1601,6 +1767,59 @@
"av1"
]
},
"ApiDisplayInfo": {
"type": "object",
"description": "One live or kept virtual display.",
"required": [
"slot",
"backend",
"mode",
"state",
"sessions"
],
"properties": {
"backend": {
"type": "string",
"description": "Backend name (`pf-vdisplay`, `kwin`, …)."
},
"client": {
"type": [
"string",
"null"
],
"description": "Short client label, when the owner tracks it."
},
"expires_in_ms": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Milliseconds until a lingering display is torn down (absent when active/pinned).",
"minimum": 0
},
"mode": {
"type": "string",
"description": "`WIDTHxHEIGHT@HZ`."
},
"sessions": {
"type": "integer",
"format": "int32",
"description": "Live sessions holding the display.",
"minimum": 0
},
"slot": {
"type": "integer",
"format": "int64",
"description": "Stable-enough id for the `/display/release` `slot` argument.",
"minimum": 0
},
"state": {
"type": "string",
"description": "`active` | `lingering` | `pinned`."
}
}
},
"ApiError": {
"type": "object",
"description": "Error envelope for every non-2xx response.",
@@ -1909,6 +2128,130 @@
}
}
},
"DisplayPolicy": {
"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`].",
"properties": {
"identity": {
"$ref": "#/components/schemas/Identity"
},
"keep_alive": {
"$ref": "#/components/schemas/KeepAlive"
},
"layout": {
"$ref": "#/components/schemas/Layout"
},
"max_displays": {
"type": "integer",
"format": "int32",
"description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).",
"minimum": 0
},
"mode_conflict": {
"$ref": "#/components/schemas/ModeConflict"
},
"preset": {
"$ref": "#/components/schemas/Preset"
},
"topology": {
"$ref": "#/components/schemas/Topology"
},
"version": {
"type": "integer",
"format": "int32",
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
"minimum": 0
}
}
},
"DisplaySettingsState": {
"type": "object",
"description": "Full display-management state for the console: the stored policy, every preset's expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).",
"required": [
"settings",
"configured",
"effective",
"presets",
"enforced"
],
"properties": {
"configured": {
"type": "boolean",
"description": "True once a `display-settings.json` exists (the console has configured this host)."
},
"effective": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective (preset-expanded) policy currently in force."
},
"enforced": {
"type": "array",
"items": {
"type": "string"
},
"description": "Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining\nstored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the\nconsole can mark them \"coming soon\" instead of implying they already take effect."
},
"presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PresetInfo"
},
"description": "Every named preset and what it expands to (for the picker's preview)."
},
"settings": {
"$ref": "#/components/schemas/DisplayPolicy",
"description": "The stored policy (preset + custom fields), or the built-in default when unconfigured."
}
}
},
"DisplayStateResponse": {
"type": "object",
"description": "The host's managed virtual displays right now.",
"required": [
"displays"
],
"properties": {
"displays": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiDisplayInfo"
}
}
}
},
"EffectivePolicy": {
"type": "object",
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
"required": [
"keep_alive",
"topology",
"mode_conflict",
"identity",
"layout",
"max_displays"
],
"properties": {
"identity": {
"$ref": "#/components/schemas/Identity"
},
"keep_alive": {
"$ref": "#/components/schemas/KeepAlive"
},
"layout": {
"$ref": "#/components/schemas/Layout"
},
"max_displays": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"mode_conflict": {
"$ref": "#/components/schemas/ModeConflict"
},
"topology": {
"$ref": "#/components/schemas/Topology"
}
}
},
"GameEntry": {
"type": "object",
"description": "One title in the unified library, regardless of which store it came from.",
@@ -2099,6 +2442,72 @@
}
}
},
"Identity": {
"type": "string",
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
"enum": [
"shared",
"per-client",
"per-client-mode"
]
},
"KeepAlive": {
"oneOf": [
{
"type": "object",
"description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s).",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"off"
]
}
}
},
{
"type": "object",
"description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.",
"required": [
"seconds",
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"duration"
]
},
"seconds": {
"type": "integer",
"format": "int32",
"description": "Linger window in seconds.",
"minimum": 0
}
}
},
{
"type": "object",
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"forever"
]
}
}
}
],
"description": "How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)\nsurvives after the last client session detaches. Serialized as an object tagged on `mode`\n(`{\"mode\":\"off\"}` / `{\"mode\":\"duration\",\"seconds\":300}` / `{\"mode\":\"forever\"}`) so the web form\nand the OpenAPI schema stay simple."
},
"LaunchSpec": {
"type": "object",
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
@@ -2118,6 +2527,32 @@
}
}
},
"Layout": {
"type": "object",
"description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).",
"properties": {
"mode": {
"$ref": "#/components/schemas/LayoutMode"
},
"positions": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Position"
},
"propertyNames": {
"type": "string"
}
}
}
},
"LayoutMode": {
"type": "string",
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
"enum": [
"auto-row",
"manual"
]
},
"LocalSummary": {
"type": "object",
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
@@ -2242,6 +2677,16 @@
}
}
},
"ModeConflict": {
"type": "string",
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
"enum": [
"separate",
"steal",
"join",
"reject"
]
},
"NativeClient": {
"type": "object",
"description": "A paired native (punktfunk/1) client.",
@@ -2439,6 +2884,88 @@
}
}
},
"Position": {
"type": "object",
"description": "A desktop-space offset for a display (top-left origin).",
"required": [
"x",
"y"
],
"properties": {
"x": {
"type": "integer",
"format": "int32"
},
"y": {
"type": "integer",
"format": "int32"
}
}
},
"Preset": {
"type": "string",
"description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).",
"enum": [
"custom",
"default",
"gaming-rig",
"shared-desktop",
"hotdesk",
"workstation"
]
},
"PresetInfo": {
"type": "object",
"description": "One preset's human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.",
"required": [
"id",
"summary",
"fields"
],
"properties": {
"fields": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective policy this preset expands to (the same fields a `custom` policy carries)."
},
"id": {
"type": "string",
"description": "The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`)."
},
"summary": {
"type": "string",
"description": "One-line story shown next to the option."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
"properties": {
"slot": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Slot to release (see `state`); omit to release **all** kept displays.",
"minimum": 0
}
}
},
"ReleaseDisplayResult": {
"type": "object",
"description": "Result of a `/display/release`.",
"required": [
"released"
],
"properties": {
"released": {
"type": "integer",
"description": "Number of kept displays torn down.",
"minimum": 0
}
}
},
"RuntimeStatus": {
"type": "object",
"description": "Live host status (changes as clients launch/end sessions).",
@@ -2740,6 +3267,16 @@
"example": "1234"
}
}
},
"Topology": {
"type": "string",
"description": "What the host does to the box's display topology while managed virtual displays are up.",
"enum": [
"auto",
"extend",
"primary",
"exclusive"
]
}
},
"securitySchemes": {
@@ -2763,6 +3300,10 @@
"name": "gpu",
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
},
{
"name": "display",
"description": "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"
},
{
"name": "clients",
"description": "Paired Moonlight client management"
+1 -1
View File
@@ -80,7 +80,7 @@ const QamPanel: FC = () => {
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page → host row → games button). */}
{pins.pins.length > 0 && (
<PanelSection title="Games">
<PanelSection title="Pinned Games">
{pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts);
return (
+25 -26
View File
@@ -3,13 +3,14 @@
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
// library (`--browse`).
import { DialogButton, Field, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react";
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
import { FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair";
import { RowActions, actionButton } from "./ui";
/** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string {
@@ -58,12 +59,6 @@ export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
void startStream(host, { launchId: pin.game_id }, pin.title);
}
const pickButton: CSSProperties = {
width: "fit-content",
minWidth: "5em",
flexShrink: 0,
};
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
function errorCopy(res: LibraryResult): string {
switch (res.error) {
@@ -143,16 +138,18 @@ export const GamePickerModal: FC<{
description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max"
>
<DialogButton
style={pickButton}
onClick={() => {
closeModal?.();
void startBrowse(host);
}}
>
<FaTv style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
void startBrowse(host);
}}
>
<FaTv style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</RowActions>
</Field>
{clientUpdatePending && (
@@ -177,10 +174,10 @@ export const GamePickerModal: FC<{
{result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<RowActions>
{result.error === "not-paired" && (
<DialogButton
style={pickButton}
style={actionButton}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
}
@@ -188,10 +185,10 @@ export const GamePickerModal: FC<{
Pair
</DialogButton>
)}
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}>
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</DialogButton>
</Focusable>
</RowActions>
</Field>
)}
@@ -217,10 +214,12 @@ export const GamePickerModal: FC<{
}
childrenContainerWidth="max"
>
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
<FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"}
</DialogButton>
<RowActions>
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
<FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"}
</DialogButton>
</RowActions>
</Field>
);
})}
+58 -66
View File
@@ -10,6 +10,7 @@ import {
showModal,
staticClasses,
} from "@decky/ui";
import { RowActions, actionButton, iconButton } from "./ui";
import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react";
import {
@@ -58,27 +59,6 @@ const tabScroll: CSSProperties = {
boxSizing: "border-box",
};
// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a
// screen-wide button. Size action buttons to their content instead (right-aligned by the
// Field's children container).
const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "6em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or
// the zero padding collapses it to the icon's line height.
const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
// ----------------------------------------------------------------------------------------
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
// against the host's own log / web console before trusting it.
@@ -144,7 +124,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<RowActions>
<DialogButton
style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} />)}
@@ -153,13 +133,13 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
</DialogButton>
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}>
<DialogButton style={actionButton} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} />
Games
</DialogButton>
{needsPair && (
<DialogButton
style={{ ...actionButton, minWidth: "5em" }}
style={actionButton}
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
>
Pair
@@ -178,7 +158,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</RowActions>
</Field>
);
};
@@ -201,14 +181,16 @@ const HostsTab: FC<{
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
<RowActions>
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</RowActions>
</Field>
{hosts.length === 0 && !scanning && (
@@ -251,18 +233,18 @@ const HostsTab: FC<{
}${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<RowActions>
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Play
</DialogButton>
<DialogButton
style={{ ...actionButton, minWidth: "5em" }}
style={actionButton}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
>
Remove
</DialogButton>
</Focusable>
</RowActions>
</Field>
);
})}
@@ -306,13 +288,15 @@ const AboutTab: FC<{
}
childrenContainerWidth="max"
>
<DialogButton
style={{ ...actionButton, minWidth: "11em" }}
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
<RowActions>
<DialogButton
style={actionButton}
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</RowActions>
</Field>
{hasUpdate(update) && (
<Field
@@ -326,13 +310,12 @@ const AboutTab: FC<{
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
childrenContainerWidth="max"
>
<DialogButton
style={{ ...actionButton, minWidth: "9em" }}
onClick={() => applyUpdate(update!, check)}
>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
<RowActions>
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</RowActions>
</Field>
)}
<Field
@@ -340,13 +323,15 @@ const AboutTab: FC<{
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max"
>
<DialogButton
style={{ ...actionButton, minWidth: "8em" }}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
>
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
>
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</RowActions>
</Field>
<Field
focusable={false}
@@ -358,9 +343,11 @@ const AboutTab: FC<{
description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max"
>
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}>
Force-stop
</DialogButton>
<RowActions>
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
Force-stop
</DialogButton>
</RowActions>
</Field>
</div>
);
@@ -399,16 +386,21 @@ const PunktfunkPage: FC = () => {
</div>
</Focusable>
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the
right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that
still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole
Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always
live in a clipped flex box; match that. */}
{/* Two things fight each other on an L1/R1 tab switch:
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
up and pans the whole page — the "screen jumps right, then animates back" glitch.
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
strip and Down enters the content, which is how Steam's own tabbed pages behave.
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[
{
id: "hosts",
+52 -24
View File
@@ -2,8 +2,20 @@
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { FC, useEffect, useState } from "react";
import { CSSProperties, FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend";
import { RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
// edge. Matches the right-aligned, content-sized buttons everywhere else.
const selectShell: CSSProperties = {
width: "fit-content",
minWidth: "10em",
maxWidth: "24em",
};
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
@@ -61,21 +73,29 @@ export const SettingsSection: FC = () => {
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</div>
</RowActions>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</div>
</RowActions>
</Field>
<SliderField
label="Bitrate"
@@ -93,11 +113,15 @@ export const SettingsSection: FC = () => {
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</div>
</RowActions>
</Field>
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
@@ -110,11 +134,15 @@ export const SettingsSection: FC = () => {
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</div>
</RowActions>
</Field>
<ToggleField
label="Stream microphone"
+46
View File
@@ -0,0 +1,46 @@
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
// them and the label text — never hugging it.
//
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
import { Focusable } from "@decky/ui";
import { CSSProperties, FC, ReactNode } from "react";
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
<Focusable
style={{
display: "flex",
gap: "0.5em",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{children}
</Focusable>
);
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
// reads at the same weight.
export const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "7em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
// padding collapses it to the icon's line height.
export const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
+22 -20
View File
@@ -30,15 +30,17 @@ use std::sync::{Arc, Mutex};
// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------
const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270;
const EGL_LINUX_DRM_FOURCC_EXT: usize = 0x3271;
const EGL_DMA_BUF_PLANE0_FD_EXT: usize = 0x3272;
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: usize = 0x3273;
const EGL_DMA_BUF_PLANE0_PITCH_EXT: usize = 0x3274;
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: usize = 0x3443;
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: usize = 0x3444;
const EGL_WIDTH: usize = 0x3057;
const EGL_HEIGHT: usize = 0x3056;
const EGL_NONE: usize = 0x3038;
// eglCreateImageKHR takes 32-bit EGLint attribs (the core-1.5 eglCreateImage variant is the
// one with pointer-sized EGLAttrib) — using the wrong width feeds the driver garbage pairs.
const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271;
const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272;
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273;
const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274;
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443;
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444;
const EGL_WIDTH: i32 = 0x3057;
const EGL_HEIGHT: i32 = 0x3056;
const EGL_NONE: i32 = 0x3038;
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when
@@ -140,7 +142,7 @@ type EglCreateImageKhr = unsafe extern "C" fn(
*mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf)
egl::Enum,
*mut c_void, // EGLClientBuffer (null for dmabuf)
*const usize,
*const i32, // EGLint attrib list (KHR variant — NOT pointer-sized EGLAttrib)
) -> *const c_void;
type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean;
@@ -464,24 +466,24 @@ impl GlConverter {
) -> Result<*const c_void> {
let mut attribs = vec![
EGL_WIDTH,
width as usize,
width as i32,
EGL_HEIGHT,
height as usize,
height as i32,
EGL_LINUX_DRM_FOURCC_EXT,
fourcc as usize,
fourcc as i32,
EGL_DMA_BUF_PLANE0_FD_EXT,
plane.fd as usize,
plane.fd,
EGL_DMA_BUF_PLANE0_OFFSET_EXT,
plane.offset as usize,
plane.offset as i32,
EGL_DMA_BUF_PLANE0_PITCH_EXT,
plane.stride as usize,
plane.stride as i32,
];
if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 {
attribs.extend_from_slice(&[
EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT,
(modifier & 0xffff_ffff) as usize,
(modifier & 0xffff_ffff) as u32 as i32,
EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT,
(modifier >> 32) as usize,
(modifier >> 32) as u32 as i32,
]);
}
attribs.push(EGL_NONE);
@@ -497,12 +499,12 @@ impl GlConverter {
};
if img.is_null() {
bail!(
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:#x}",
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:?}",
width,
height,
fourcc,
modifier,
self.egl.get_error().map(|e| e as u32).unwrap_or(0)
self.egl.get_error()
);
}
Ok(img)
+1 -1
View File
@@ -412,7 +412,7 @@ async fn session(args: Args) -> Result<()> {
io::write_msg(
&mut send,
&Hello {
abi_version: punktfunk_core::ABI_VERSION,
abi_version: punktfunk_core::WIRE_VERSION,
mode: args.mode,
compositor: args.compositor,
gamepad: args.gamepad,
+1 -1
View File
@@ -876,7 +876,7 @@ async fn worker_main(args: WorkerArgs) {
io::write_msg(
&mut send,
&Hello {
abi_version: crate::ABI_VERSION,
abi_version: crate::WIRE_VERSION,
mode,
compositor,
gamepad,
+8
View File
@@ -54,3 +54,11 @@ pub use stats::Stats;
/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
pub const ABI_VERSION: u32 = 3;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
/// (functions a client links), which can grow without changing a single wire byte — v3's
/// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
/// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
/// live). Bump this ONLY when the handshake/planes actually change incompatibly.
pub const WIRE_VERSION: u32 = 2;
+34 -3
View File
@@ -170,18 +170,26 @@ pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
/// true, Moonlight offers its per-app HDR toggle.
///
/// The document is emitted **COMPACT — no whitespace between elements** — deliberately, to match
/// Sunshine/GFE. Moonlight-Android's `getAppListByReader` calls `appList.getLast()` on *every* XML
/// text node before it checks the current tag, and only fills `appList` on an `<App>` start tag. A
/// pretty-print newline between `<root>` and the first `<App>` is a whitespace text node while
/// `appList` is still empty → `NoSuchElementException` → the Android app hard-crashes on host click.
/// (iOS/macOS parse via moonlight-common-c/expat and are unaffected; `serverinfo`/pairing use the
/// named-tag `getXmlString` scan, so their whitespace is harmless.) Keep this whitespace-free.
pub fn applist_xml() -> String {
let hdr = u8::from(crate::gamestream::host_hdr_capable());
let mut xml =
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?><root status_code=\"200\">");
for app in catalog() {
xml.push_str(&format!(
"<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
"<App><IsHdrSupported>{hdr}</IsHdrSupported><AppTitle>{}</AppTitle><ID>{}</ID></App>",
xml_escape(&app.title),
app.id
));
}
xml.push_str("</root>\n");
xml.push_str("</root>");
xml
}
@@ -249,4 +257,27 @@ mod tests {
assert!(xml.starts_with("<?xml"));
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
}
/// Regression: the applist MUST be whitespace-free between elements. Moonlight-Android's
/// `getAppListByReader` calls `appList.getLast()` on every text node before an `<App>` has been
/// pushed, so a pretty-print newline between `<root>` and the first `<App>` crashes the app
/// (`NoSuchElementException`). Reproduced on 2 Android phones; iOS/macOS (moonlight-common-c)
/// were unaffected. Keep `applist_xml` compact like Sunshine/GFE.
#[test]
fn applist_xml_has_no_interelement_whitespace() {
let xml = applist_xml();
// <root> is immediately followed by the first <App> — no whitespace text node while the
// parser's app list is still empty.
assert!(
xml.contains("status_code=\"200\"><App>"),
"no whitespace between <root> and the first <App>: {xml}"
);
// No pretty-print newlines anywhere in the element stream, and no whitespace-only text
// nodes between any adjacent tags.
assert!(!xml.contains('\n'), "applist must contain no newlines: {xml}");
assert!(
!xml.contains("> <"),
"applist must contain no inter-element spaces: {xml}"
);
}
}
@@ -108,6 +108,11 @@ pub struct LaunchSession {
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
pub peer_ip: Option<std::net::IpAddr>,
/// SHA-256 cert fingerprint of the paired client that owns this session — mode-conflict admission
/// (Stage 4) compares it against a launching client to tell a same-client re-launch (always
/// allowed) from a DIFFERENT client (subject to the `mode_conflict` policy). `[u8; 32]` keeps
/// [`LaunchSession`] `Copy`; `None` when the peer cert couldn't be read.
pub owner_fp: Option<[u8; 32]>,
}
/// Shared control-plane state used as the axum app state.
+129 -5
View File
@@ -126,15 +126,57 @@ async fn h_launch(
peer: Option<Extension<PeerCertFingerprint>>,
addr: Option<Extension<PeerAddr>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
) -> Response {
if !peer_is_paired(&peer, &st) {
tracing::warn!("launch rejected — client is not paired");
return xml(error_xml());
return xml(error_xml()).into_response();
}
let req_fp: Option<[u8; 32]> = match &peer {
Some(Extension(PeerCertFingerprint(Some(fp)))) => {
hex::decode(fp).ok().and_then(|v| <[u8; 32]>::try_from(v).ok())
}
_ => None,
};
// Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT
// paired client launching while a session is live is governed by `mode_conflict` (see
// [`gamestream_admission`]). Snapshot the live owner + mode (Copy) so the lock isn't held over it.
let mut forced_mode: Option<(u32, u32, u32)> = None;
{
let live = st
.launch
.lock()
.unwrap()
.as_ref()
.map(|s| (s.owner_fp, (s.width, s.height, s.fps)));
// Same Windows default as the native path (separate → reject; see `effective_conflict`) so a
// 2nd Moonlight client gets a clean 503 rather than wedging the shared monitor's capture.
let conflict = crate::vdisplay::admission::effective_conflict();
match gamestream_admission(live, req_fp, conflict) {
GsDecision::Serve => {}
GsDecision::Join((w, h, f)) => {
forced_mode = Some((w, h, f));
tracing::info!("GameStream launch JOIN — admitting at the live session's mode {w}x{h}@{f}");
}
GsDecision::Reject => {
tracing::warn!(
"GameStream launch REJECTED — host busy (mode_conflict=reject, session owned by another client)"
);
return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response();
}
}
}
match launch(&st, &q) {
Ok(mut session) => {
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
session.owner_fp = req_fp;
if let Some((w, h, f)) = forced_mode {
session.width = w;
session.height = h;
session.fps = f;
}
*st.launch.lock().unwrap() = Some(session);
tracing::info!(
w = session.width,
@@ -144,11 +186,11 @@ async fn h_launch(
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
);
xml(session_url_xml(&st, "gamesession"))
xml(session_url_xml(&st, "gamesession")).into_response()
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "launch failed");
xml(error_xml())
xml(error_xml()).into_response()
}
}
}
@@ -210,7 +252,8 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
height,
fps,
appid,
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
owner_fp: None, // set by `h_launch` from the verified HTTPS peer cert fingerprint
})
}
@@ -223,6 +266,48 @@ fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> {
Some((w, h, fps))
}
/// A live GameStream session's `(owner cert fingerprint, mode)` snapshot for [`gamestream_admission`].
type LiveGs = (Option<[u8; 32]>, (u32, u32, u32));
/// The outcome of [`gamestream_admission`].
enum GsDecision {
/// Proceed with the launch (no live session, a same-client re-launch, or `steal`/`separate`
/// taking over the single session).
Serve,
/// Serve at the live session's mode (`join` — honest-downgrade).
Join((u32, u32, u32)),
/// Refuse with a 503 (`reject`).
Reject,
}
/// The GameStream single-session mode-conflict decision (Stage 4, pure so it's unit-tested). `live`
/// is the currently-live session's `(owner_fp, mode)` (`None` ⇒ no session live). No session or a
/// same-client re-launch ⇒ `Serve`; a DIFFERENT client launching applies `policy` — `reject` ⇒
/// `Reject`, `join` ⇒ `Join` the live mode, `steal`/`separate` (GameStream has no separate) ⇒ `Serve`
/// (take over the one session).
fn gamestream_admission(
live: Option<LiveGs>,
req_fp: Option<[u8; 32]>,
policy: crate::vdisplay::policy::ModeConflict,
) -> GsDecision {
use crate::vdisplay::policy::ModeConflict;
let Some((owner, mode)) = live else {
return GsDecision::Serve;
};
let different = match (owner, req_fp) {
(Some(o), Some(r)) => o != r,
_ => true, // unknown owner or anonymous requester → treat as a different client
};
if !different {
return GsDecision::Serve;
}
match policy {
ModeConflict::Reject => GsDecision::Reject,
ModeConflict::Join => GsDecision::Join(mode),
ModeConflict::Steal | ModeConflict::Separate => GsDecision::Serve,
}
}
fn session_url_xml(st: &AppState, tag: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
@@ -349,4 +434,43 @@ mod tests {
"a non-pinned cert stays rejected"
);
}
#[test]
fn gamestream_admission_policy_matrix() {
use crate::vdisplay::policy::ModeConflict;
let (a, b) = ([1u8; 32], [2u8; 32]);
let live = Some((Some(a), (2560, 1440, 120)));
// No live session → always Serve.
assert!(matches!(
gamestream_admission(None, Some(b), ModeConflict::Reject),
GsDecision::Serve
));
// Same-client re-launch → Serve regardless of policy.
assert!(matches!(
gamestream_admission(live, Some(a), ModeConflict::Reject),
GsDecision::Serve
));
// A DIFFERENT client applies the policy.
assert!(matches!(
gamestream_admission(live, Some(b), ModeConflict::Reject),
GsDecision::Reject
));
assert!(matches!(
gamestream_admission(live, Some(b), ModeConflict::Join),
GsDecision::Join((2560, 1440, 120))
));
assert!(matches!(
gamestream_admission(live, Some(b), ModeConflict::Steal),
GsDecision::Serve
));
assert!(matches!(
gamestream_admission(live, Some(b), ModeConflict::Separate),
GsDecision::Serve
));
// Anonymous requester (no cert presented) is treated as a different client.
assert!(matches!(
gamestream_admission(live, None, ModeConflict::Reject),
GsDecision::Reject
));
}
}
+86 -14
View File
@@ -286,13 +286,15 @@ fn open_gs_virtual_source(
std::sync::atomic::AtomicBool::new(false),
))
});
let vout = vd
.create(punktfunk_core::Mode {
let vout = crate::vdisplay::registry::acquire(
&mut vd,
punktfunk_core::Mode {
width: cfg.width,
height: cfg.height,
refresh_hz: cfg.fps,
})
.context("create virtual output at client resolution")?;
},
)
.context("create virtual output at client resolution")?;
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
// Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
// PQ stream flows even from an SDR desktop; an already-HDR desktop streams PQ regardless (the
@@ -397,6 +399,20 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
Ok(())
}
/// Pacing layout for one frame's `n` packets (`n >= 1`): `(chunk_size, steps)`. The chunk grows
/// with the frame so the number of paced bursts — each ending in a `thread::sleep` — never exceeds
/// `MAX_PACE_STEPS`. A fixed 16-packet chunk let the step count scale with bitrate (~38 for a
/// 4K/250Mbps frame's ~600 packets); the accumulated sub-ms sleep overshoot on the non-RT send
/// thread then blew the per-frame budget and backed the handoff queue up. Bounding the steps keeps
/// microburst shaping at low bitrate while making overshoot negligible and bitrate-independent.
fn pace_layout(n: usize) -> (usize, usize) {
const MIN_PACE_CHUNK: usize = 16;
const MAX_PACE_STEPS: usize = 12;
let chunk_sz = MIN_PACE_CHUNK.max(n.div_ceil(MAX_PACE_STEPS));
let steps = n.div_ceil(chunk_sz); // ≤ MAX_PACE_STEPS
(chunk_sz, steps)
}
/// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in
/// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval
/// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode
@@ -414,8 +430,14 @@ fn spawn_sender(
// Transmit thread: above-normal, matching the native path's send thread (includes the
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
crate::punktfunk1::boost_thread_priority(false);
// Chunk pacing: 16 packets per burst, bursts spread across the send budget.
const PACE_CHUNK: usize = 16;
// Chunk pacing: spread the frame's packets across the send budget in a BOUNDED number
// of bursts. A fixed 16-packet chunk made the burst count scale with bitrate (~38 for a
// 4K/250Mbps frame's ~600 packets), and each burst ends in a `thread::sleep`; on this
// non-RT send thread those sub-ms sleeps overshoot, and ~38 per frame blew the 12.5ms
// budget past the 16.67ms frame interval — backing the depth-2 handoff queue up and
// dropping ~half the frames ("send queue full"). Capping the step count keeps the
// microburst shaping (a real link drops line-rate bursts) while making per-frame sleep
// overshoot negligible and independent of bitrate.
let budget = frame_interval.mul_f32(0.75);
let mut rng = rand::thread_rng();
let mut sent: u64 = 0;
@@ -434,17 +456,21 @@ fn spawn_sender(
if n == 0 {
continue;
}
let per_chunk = budget.mul_f64((PACE_CHUNK as f64 / n as f64).min(1.0));
// Chunk size + step count, bounded so a high-bitrate frame doesn't fan out into
// dozens of sleeps. Each step gets an equal slice of the budget (total pacing time
// == budget regardless of n).
let (chunk_sz, steps) = pace_layout(n);
let per_step = budget.mul_f64(1.0 / steps as f64);
let start = Instant::now();
for (i, chunk) in batch.chunks(PACE_CHUNK).enumerate() {
for (i, chunk) in batch.chunks(chunk_sz).enumerate() {
if let Err(e) = sendmmsg_all(&sock, chunk) {
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
running.store(false, Ordering::SeqCst);
return;
}
sent += chunk.len() as u64;
// Sleep toward the next chunk's deadline; skip sub-500µs sleeps (jitter).
let target = start + per_chunk.mul_f64((i + 1) as f64);
// Sleep toward the next step's deadline; skip sub-500µs sleeps (jitter).
let target = start + per_step.mul_f64((i + 1) as f64);
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
if ahead >= Duration::from_micros(500) {
std::thread::sleep(ahead);
@@ -580,6 +606,15 @@ fn stream_body(
const MAX_REBUILDS: u32 = 5;
let mut rebuilds: u32 = 0;
// Coalesce forced keyframes. Under loss Moonlight spams IDR/RFI requests; on an encoder without
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
let keyframe_coalesce = frame_interval * 2;
let mut last_keyframe: Option<Instant> = None;
while running.load(Ordering::SeqCst) {
let tick = Instant::now();
// Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is
@@ -645,6 +680,7 @@ fn stream_body(
.context("reopen encoder after rebuild")?;
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
next_frame = Instant::now();
tracing::info!("gamestream: source rebuilt — stream continues");
continue;
@@ -654,17 +690,33 @@ fn stream_body(
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
let mut want_keyframe = false;
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
// spike); otherwise — or if the range is too old to invalidate — force a keyframe.
// spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe.
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
enc.request_keyframe();
want_keyframe = true;
}
}
// An explicit IDR request (or a rangeless RFI) forces a keyframe so the client resyncs
// An explicit IDR request (or a rangeless RFI) asks for a keyframe so the client resyncs
// immediately instead of waiting for the next GOP boundary.
if force_idr.swap(false, Ordering::SeqCst) {
enc.request_keyframe();
want_keyframe = true;
}
// Coalesce: emit at most one forced keyframe per in-flight window, so a burst of recovery
// requests during one loss event doesn't turn every frame into a full IDR (see above).
if want_keyframe {
let now = Instant::now();
let emit = match last_keyframe {
Some(t) => now.duration_since(t) >= keyframe_coalesce,
None => true,
};
if emit {
enc.request_keyframe();
last_keyframe = Some(now);
} else {
tracing::debug!("video: keyframe request coalesced (IDR still in flight)");
}
}
enc.submit(&frame).context("encoder submit")?;
let t_enc = tick.elapsed();
@@ -889,4 +941,24 @@ mod tests {
assert_eq!(got, 3 * PER_FRAME);
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
}
/// The pacing layout bounds the paced-burst (and thus sleep) count regardless of frame size,
/// while always covering every packet and keeping small frames on the 16-packet floor. Guards
/// the 4K/high-bitrate "send queue full" regression (a fixed 16-packet chunk fanned a ~600
/// packet frame into ~38 sleeps, whose overshoot blew the per-frame send budget).
#[test]
fn pace_layout_bounds_step_count() {
for &n in &[1usize, 16, 146, 610, 1024, 5000, 50_000] {
let (chunk, steps) = pace_layout(n);
assert!(steps >= 1, "n={n}: at least one step");
assert!(steps <= 12, "n={n}: step count {steps} exceeded the cap");
assert!(chunk >= 16, "n={n}: chunk {chunk} below the 16-packet floor");
assert!(chunk * steps >= n, "n={n}: {chunk}×{steps} must cover all packets");
}
// Small frames stay on the floor: one 16-packet burst.
assert_eq!(pace_layout(1), (16, 1));
assert_eq!(pace_layout(16), (16, 1));
// A 4K/250Mbps frame (~600 packets) was ~38 bursts at a fixed 16 — now bounded.
assert!(pace_layout(610).1 <= 12);
}
}
+311
View File
@@ -156,6 +156,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(list_compositors))
.routes(routes!(list_gpus))
.routes(routes!(set_gpu_preference))
.routes(routes!(get_display_settings))
.routes(routes!(set_display_settings))
.routes(routes!(get_display_state))
.routes(routes!(release_display))
.routes(routes!(get_status))
.routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients))
@@ -210,6 +214,7 @@ pub fn openapi_json() -> String {
tags(
(name = "host", description = "Host identity, capabilities, and liveness"),
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
(name = "display", description = "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"),
(name = "clients", description = "Paired Moonlight client management"),
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
@@ -954,6 +959,242 @@ async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> Response
Json(gpu_state()).into_response()
}
// ---------------------------------------------------------------------------------------
// Display management (design/display-management.md)
// ---------------------------------------------------------------------------------------
/// One preset's human-facing description + the fields it expands to, so the console can render a
/// preset picker with an accurate "what this does" preview without hardcoding the expansion.
#[derive(Serialize, ToSchema)]
struct PresetInfo {
/// The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).
id: String,
/// One-line story shown next to the option.
summary: String,
/// The effective policy this preset expands to (the same fields a `custom` policy carries).
fields: crate::vdisplay::policy::EffectivePolicy,
}
/// Full display-management state for the console: the stored policy, every preset's expansion, the
/// resolved effective policy, and which options this build actually enforces yet (Stage 0 wires
/// keep-alive linger + topology; the rest are stored but not yet acted on).
#[derive(Serialize, ToSchema)]
struct DisplaySettingsState {
/// The stored policy (preset + custom fields), or the built-in default when unconfigured.
settings: crate::vdisplay::policy::DisplayPolicy,
/// True once a `display-settings.json` exists (the console has configured this host).
configured: bool,
/// The effective (preset-expanded) policy currently in force.
effective: crate::vdisplay::policy::EffectivePolicy,
/// Every named preset and what it expands to (for the picker's preview).
presets: Vec<PresetInfo>,
/// Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining
/// stored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the
/// console can mark them "coming soon" instead of implying they already take effect.
enforced: Vec<String>,
}
fn preset_summary(id: &str) -> &'static str {
match id {
"default" => "Today's behavior: a short linger absorbs reconnects, the streamed output is the sole desktop, extra clients get their own view.",
"gaming-rig" => "Dedicated couch/headless box: the game and its display survive disconnects; whoever connects takes the box over.",
"shared-desktop" => "A desktop you also use in person: never blank the real monitors, never keep ghost displays, concurrent viewers each get a view.",
"hotdesk" => "One user at a time with fast reattach; a second user is told the box is busy; each device+resolution keeps its own scaling.",
"workstation" => "Multi-monitor daily driver: your displays come back exactly where you arranged them, per-client identity, exclusive.",
_ => "",
}
}
fn display_settings_state() -> DisplaySettingsState {
use crate::vdisplay::policy::{self, Preset};
let store = policy::prefs();
let settings = store.get();
let configured = store.configured().is_some();
let presets = [
("default", Preset::Default),
("gaming-rig", Preset::GamingRig),
("shared-desktop", Preset::SharedDesktop),
("hotdesk", Preset::Hotdesk),
("workstation", Preset::Workstation),
]
.into_iter()
.filter_map(|(id, p)| {
policy::preset_fields(p).map(|e| PresetInfo {
id: id.to_string(),
summary: preset_summary(id).to_string(),
fields: e,
})
})
.collect();
DisplaySettingsState {
effective: settings.effective(),
settings,
configured,
presets,
enforced: vec!["keep_alive".into(), "topology".into()],
}
}
/// Display-management policy
///
/// The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),
/// every preset's expansion, and which options this build enforces yet. See
/// `design/display-management.md`.
#[utoipa::path(
get,
path = "/display/settings",
tag = "display",
operation_id = "getDisplaySettings",
responses(
(status = OK, description = "Stored policy + preset expansions + enforced options", body = DisplaySettingsState),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn get_display_settings() -> Json<DisplaySettingsState> {
Json(display_settings_state())
}
/// Set the display-management policy
///
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
/// running session keeps the display it opened on. `keep_alive: forever` is rejected until the
/// display-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release
/// path yet).
#[utoipa::path(
put,
path = "/display/settings",
tag = "display",
operation_id = "setDisplaySettings",
request_body = crate::vdisplay::policy::DisplayPolicy,
responses(
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
(status = BAD_REQUEST, description = "An option value is not yet supported (e.g. keep_alive forever)", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn set_display_settings(
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
) -> Response {
use crate::vdisplay::policy::KeepAlive;
// Reject options this build can't honor yet, so the console can't promise a behavior that won't
// happen. `keep_alive: forever` (directly or via the `gaming-rig` preset) needs the Pinned
// lifecycle + a release path; until then it would strand physical monitors dark.
if policy.effective().keep_alive == KeepAlive::Forever {
return api_error(
StatusCode::BAD_REQUEST,
"keep_alive `forever` (and the `gaming-rig` preset) is not available yet — it arrives \
with the display-lifecycle stage. Use a fixed duration for now.",
);
}
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist display policy: {e:#}"),
);
}
tracing::info!("management API: display policy updated");
Json(display_settings_state()).into_response()
}
/// One live or kept virtual display.
#[derive(Serialize, ToSchema)]
struct ApiDisplayInfo {
/// Stable-enough id for the `/display/release` `slot` argument.
slot: u64,
/// Backend name (`pf-vdisplay`, `kwin`, …).
backend: String,
/// `WIDTHxHEIGHT@HZ`.
mode: String,
/// `active` | `lingering` | `pinned`.
state: String,
/// Milliseconds until a lingering display is torn down (absent when active/pinned).
expires_in_ms: Option<u64>,
/// Live sessions holding the display.
sessions: u32,
/// Short client label, when the owner tracks it.
client: Option<String>,
}
/// The host's managed virtual displays right now.
#[derive(Serialize, ToSchema)]
struct DisplayStateResponse {
displays: Vec<ApiDisplayInfo>,
}
/// Request body for `releaseDisplay`.
#[derive(Deserialize, ToSchema)]
struct ReleaseDisplayRequest {
/// Slot to release (see `state`); omit to release **all** kept displays.
#[serde(default)]
slot: Option<u64>,
}
/// Result of a `/display/release`.
#[derive(Serialize, ToSchema)]
struct ReleaseDisplayResult {
/// Number of kept displays torn down.
released: usize,
}
/// Live virtual displays
///
/// The host's managed virtual displays right now — active (streaming), lingering (kept after
/// disconnect, counting down to teardown), or pinned (kept indefinitely). See
/// `design/display-management.md`.
#[utoipa::path(
get,
path = "/display/state",
tag = "display",
operation_id = "getDisplayState",
responses(
(status = OK, description = "The live/kept virtual displays", body = DisplayStateResponse),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn get_display_state() -> Json<DisplayStateResponse> {
let snap = crate::vdisplay::registry::snapshot();
Json(DisplayStateResponse {
displays: snap
.displays
.into_iter()
.map(|d| ApiDisplayInfo {
slot: d.slot,
backend: d.backend,
mode: format!("{}x{}@{}", d.mode.0, d.mode.1, d.mode.2),
state: d.state,
expires_in_ms: d.expires_in_ms,
sessions: d.sessions,
client: d.client,
})
.collect(),
})
}
/// Release kept virtual displays
///
/// Tear down lingering/pinned displays now — so a physical-screen user gets their screen back
/// without waiting out the linger. `slot` releases one; omit it to release all kept displays.
/// Active (streaming) displays are never torn down here (that is session control).
#[utoipa::path(
post,
path = "/display/release",
tag = "display",
operation_id = "releaseDisplay",
request_body = ReleaseDisplayRequest,
responses(
(status = OK, description = "The number of kept displays released", body = ReleaseDisplayResult),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn release_display(
ApiJson(req): ApiJson<ReleaseDisplayRequest>,
) -> Json<ReleaseDisplayResult> {
let released = crate::vdisplay::registry::release(req.slot);
tracing::info!(slot = ?req.slot, released, "management API: display release");
Json(ReleaseDisplayResult { released })
}
/// Live host status
#[utoipa::path(
get,
@@ -2257,6 +2498,7 @@ mod tests {
fps: 120,
appid: 1,
peer_ip: None,
owner_fp: None,
});
state.streaming.store(true, Ordering::SeqCst);
@@ -2383,6 +2625,7 @@ mod tests {
fps: 60,
appid: 1,
peer_ip: None,
owner_fp: None,
});
let del = axum::http::Request::delete("/api/v1/session")
@@ -2473,6 +2716,74 @@ mod tests {
.unwrap()
}
/// The display-management endpoints: GET returns the policy surface (presets + effective +
/// the Stage-0 enforced list); PUT rejects `keep_alive: forever` (the `gaming-rig` preset)
/// *before* persisting, so this stays read-only against the global policy store.
#[tokio::test]
async fn display_settings_surface_and_forever_rejected() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/settings")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["presets"].as_array().map(|a| a.len()),
Some(5),
"all five named presets are surfaced for the console picker"
);
assert!(
body["effective"]["keep_alive"].is_object(),
"the effective policy is echoed"
);
let enforced: Vec<&str> = body["enforced"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(enforced.contains(&"keep_alive") && enforced.contains(&"topology"));
// `gaming-rig` expands to keep_alive: forever → rejected at Stage 0 (before any write).
let put = axum::http::Request::put("/api/v1/display/settings")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({ "preset": "gaming-rig" }).to_string(),
))
.unwrap();
let (status, body) = send(&app, put).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("forever"),
"the rejection names the unsupported option"
);
}
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
/// created a display (and non-Windows reports none), so `/state` is empty and `/release` is a
/// no-op — the shapes + the "nothing to release" path, without touching any global owner.
#[tokio::test]
async fn display_state_and_release_empty() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/state")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["displays"].as_array().map(|a| a.len()),
Some(0),
"no managed displays on an idle test host"
);
let (status, body) = send(
&app,
post_json("/api/v1/display/release", serde_json::json!({})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["released"], 0);
}
#[tokio::test]
async fn native_pairing_arm_show_and_unpair() {
let np = Arc::new(
+78 -9
View File
@@ -341,6 +341,11 @@ pub(crate) async fn serve(
/// 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);
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
/// code lets a client tell "host busy" apart from a transport failure.
const REJECT_BUSY_CODE: u32 = 0x42;
/// Encoder bitrate (kbps) the host falls back to when the client expresses no preference
/// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its
/// link (e.g. after a speed test) requests an explicit rate instead.
@@ -585,10 +590,10 @@ async fn serve_session(
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!(
gate_hello.abi_version == punktfunk_core::ABI_VERSION,
"ABI mismatch: client {} host {}",
gate_hello.abi_version == punktfunk_core::WIRE_VERSION,
"wire version mismatch: client {} host {}",
gate_hello.abi_version,
punktfunk_core::ABI_VERSION
punktfunk_core::WIRE_VERSION
);
let fp = endpoint::peer_fingerprint(&conn);
let known = fp
@@ -652,12 +657,12 @@ async fn serve_session(
let source = opts.source;
let frames = opts.frames;
let handshake = async {
let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!(
hello.abi_version == punktfunk_core::ABI_VERSION,
"ABI mismatch: client {} host {}",
hello.abi_version == punktfunk_core::WIRE_VERSION,
"wire version mismatch: client {} host {}",
hello.abi_version,
punktfunk_core::ABI_VERSION
punktfunk_core::WIRE_VERSION
);
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
// before this future, so a client reaching here is paired (or the host is `--open`).
@@ -684,6 +689,50 @@ async fn serve_session(
"video codec negotiated"
);
// Mode-conflict ADMISSION (Stage 4): a DIFFERENT client connecting while another client's
// session is live is resolved by the `mode_conflict` policy BEFORE the Welcome — `separate`
// (default, no change), `join` (serve at the live mode — an honest downgrade the client
// renders from the Welcome), `steal` (preempt the victim), or `reject` (refuse the handshake).
// A same-client reconnect never conflicts. THIS session registers in the live set once its
// data plane is up (below the handshake), so a later client can see + steal it.
{
use crate::vdisplay::admission::{admit, Admission};
match admit(endpoint::peer_fingerprint(&conn)) {
Admission::Separate => {}
Admission::Join(m) => {
tracing::info!(
requested =
%format_args!("{}x{}@{}", hello.mode.width, hello.mode.height, hello.mode.refresh_hz),
live = %format_args!("{}x{}@{}", m.0, m.1, m.2),
"mode-conflict: JOIN — admitting at the live display's mode"
);
hello.mode.width = m.0;
hello.mode.height = m.1;
hello.mode.refresh_hz = m.2;
}
Admission::Steal(victims) => {
tracing::info!(
victims = victims.len(),
"mode-conflict: STEAL — preempting the live session(s)"
);
for v in &victims {
v.store(true, Ordering::SeqCst);
}
// Give the victims the release grace to tear their display down before we acquire.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
Admission::Reject(reason) => {
tracing::warn!("mode-conflict: REJECT — {reason}");
// Deliver the reason to the client as a TYPED refusal: close the QUIC connection
// with the BUSY application code + the reason bytes, which the client reads from
// the `ApplicationClosed` error (so its UI can say "host is streaming X to <name>")
// instead of seeing a bare connection drop. Then end the handshake.
conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes());
anyhow::bail!("{reason}");
}
}
}
crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
.context("client-requested mode")?;
@@ -805,7 +854,7 @@ async fn serve_session(
let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key);
let welcome = Welcome {
abi_version: punktfunk_core::ABI_VERSION,
abi_version: punktfunk_core::WIRE_VERSION,
udp_port,
mode: hello.mode,
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
@@ -1055,6 +1104,22 @@ async fn serve_session(
});
}
// Register this now-live session for mode-conflict admission (Stage 4): carry its identity, the
// negotiated mode, and its stop flag so a LATER connecting client's admission can see it and
// (under `steal`) signal it. The guard removes the entry when this session ends.
let _live_guard = {
let id = endpoint::peer_fingerprint(&conn);
let label = id
.map(|fp| fp.iter().take(4).map(|b| format!("{b:02x}")).collect::<String>())
.unwrap_or_else(|| "client".to_string());
crate::vdisplay::admission::register(
id,
(welcome.mode.width, welcome.mode.height, welcome.mode.refresh_hz),
stop.clone(),
label,
)
};
// Audio plane (virtual source only — synthetic runs are protocol tests): desktop Opus
// → host→client QUIC datagrams, on its own native thread. Best-effort on every failure
// (no PipeWire audio, spawn error): the session continues without audio — and a spawn
@@ -3508,7 +3573,11 @@ fn build_pipeline(
bit_depth: u8,
plan: crate::session_plan::SessionPlan,
) -> Result<Pipeline> {
let vout = vd.create(mode).context("create virtual output")?;
// Acquire through the registry (design/display-management.md): on Linux this pools the display
// for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the
// session per policy); on Windows it delegates to `vd.create` (the manager already leases). The
// returned `VirtualOutput`'s keepalive is a registry lease — the capturer holds it as before.
let vout = crate::vdisplay::registry::acquire(vd, mode).context("create virtual output")?;
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
// virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame
// clock to that, not the requested rate, so we don't emit phantom duplicate frames over a
+75 -17
View File
@@ -403,21 +403,11 @@ pub fn apply_session_env(active: &ActiveSession) {
if active.kind == ActiveKind::DesktopGnome {
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
}
// Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so
// the panels + windows land on the streamed surface, not an unstreamed real output (the
// auto-detected desktop path *is* "stream this desktop"). Default-on for the auto path; an
// explicit `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` still wins.
match active.kind {
ActiveKind::DesktopKde if std::env::var_os("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY").is_none() => {
std::env::set_var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY", "1");
}
ActiveKind::DesktopGnome
if std::env::var_os("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY").is_none() =>
{
std::env::set_var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY", "1");
}
_ => {}
}
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// [`effective_topology`] directly at create time — the console policy, else the legacy
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
// auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
// mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
}
#[cfg(not(target_os = "linux"))]
pub fn apply_session_env(_active: &ActiveSession) {}
@@ -723,14 +713,82 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(())
}
// The user-configurable management policy (keep-alive / topology / conflict / identity / layout),
// layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read
// it), so no cfg gate. See `design/display-management.md`.
#[path = "vdisplay/policy.rs"]
pub(crate) mod policy;
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
// property-tested; the registry executes the side effects its transitions dictate.
#[path = "vdisplay/lifecycle.rs"]
pub(crate) mod lifecycle;
// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool
// later), for the management API's /display/state + /display/release.
#[path = "vdisplay/registry.rs"]
pub(crate) mod registry;
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
/// Linux desktop path, where "stream this desktop" means promoting the virtual output to sole).
pub fn resolve_topology(t: policy::Topology) -> policy::Topology {
match t {
policy::Topology::Auto => {
if crate::config::config().compositor.is_some() {
policy::Topology::Extend
} else {
policy::Topology::Exclusive
}
}
concrete => concrete,
}
}
/// The concrete display topology for the current session — what the per-compositor backends (and the
/// Windows isolate gate) apply at create time. Precedence, mirroring the rest of the policy surface:
/// the **console policy** when configured, else the legacy **`PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`**
/// env (an operator's explicit choice — `1`→exclusive, `0`→extend), else the **Auto** default
/// ([`resolve_topology`]: exclusive on the auto-detected desktop / Windows, extend under a compositor
/// pin). Always resolved (never [`policy::Topology::Auto`]). This is the Stage-2 replacement for the
/// `apply_session_env` boolean write — the backends read policy directly, so the `primary` level
/// (distinct from `exclusive`) becomes expressible and one process-env mutation drops off the connect
/// path.
pub fn effective_topology() -> policy::Topology {
if let Some(e) = policy::prefs().configured_effective() {
return resolve_topology(e.topology);
}
// Unconfigured: honor a legacy operator env if present (a host runs one desktop backend, so at
// most one of these is set), else the Auto default.
let legacy = [
"PUNKTFUNK_KWIN_VIRTUAL_PRIMARY",
"PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY",
]
.iter()
.find_map(|k| std::env::var(k).ok());
match legacy.as_deref().map(str::trim) {
Some("1" | "true" | "yes" | "on") => policy::Topology::Exclusive,
Some("0" | "false" | "no" | "off") => policy::Topology::Extend,
_ => resolve_topology(policy::Topology::Auto),
}
}
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/gamescope.rs"]
mod gamescope;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/identity.rs"]
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
#[allow(dead_code)]
#[path = "vdisplay/identity.rs"]
pub(crate) mod identity;
// Platform-neutral mode-conflict admission (Stage 4): the separate/join/steal/reject decision + the
// live-session registry, wired into the punktfunk/1 handshake.
#[path = "vdisplay/admission.rs"]
pub(crate) mod admission;
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin.rs"]
mod kwin;
@@ -0,0 +1,239 @@
//! Mode-conflict **admission** (design: `design/display-management.md` §5.3, Stage 4). When a
//! *different* client connects while another client's session is already live, the `mode_conflict`
//! policy decides what happens — BEFORE the Welcome / RTSP launch, so the client gets an honest answer
//! instead of a mid-build failure:
//!
//! * `separate` — proceed on a fresh display at the requested mode (today's Linux multi-view / the
//! default; no behavior change unconfigured).
//! * `join` — admit at the live display's mode (honest-downgrade: the Welcome carries the real mode).
//! * `steal` — signal the victim session(s)' stop flag(s), wait the release grace, then serve.
//! * `reject` — refuse with a typed handshake error naming the live mode + client.
//!
//! A **live-session registry** ([`register`]) lets the decision see the current sessions (identity +
//! mode + stop flag); each session registers once admitted and drops its [`LiveGuard`] on end. The
//! decision itself ([`decide`]) is pure over a session slice, so it is unit-tested exhaustively.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use crate::vdisplay::policy::{self, ModeConflict};
/// A currently-live session, as admission sees it.
#[derive(Clone)]
pub struct LiveSession {
id: u64,
/// The owning client's cert fingerprint (`None` = anonymous / no client cert presented).
pub identity: Option<[u8; 32]>,
pub mode: (u32, u32, u32),
/// The session's stop flag — signaled to preempt it on `steal`.
pub stop: Arc<AtomicBool>,
/// Short client label for `reject` messages.
pub label: String,
}
/// The admission outcome for a connecting session.
#[derive(Debug)]
pub enum Admission {
/// No conflict / `separate`: proceed on a fresh display at the requested mode.
Separate,
/// `join`: admit at this (live) mode — share the existing display (honest-downgrade).
Join((u32, u32, u32)),
/// `steal`: signal these victim stop flags, wait the release grace, then proceed at the requested mode.
Steal(Vec<Arc<AtomicBool>>),
/// `reject`: refuse with this reason (host-busy + live mode + client label).
Reject(String),
}
fn table() -> &'static Mutex<Vec<LiveSession>> {
static T: OnceLock<Mutex<Vec<LiveSession>>> = OnceLock::new();
T.get_or_init(|| Mutex::new(Vec::new()))
}
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
/// Two identities are the same client iff both are present and equal. Anonymous (`None`) never
/// matches — we can't prove it's the same client, so two anonymous clients are treated as distinct
/// (each conflicts), which is the safe side for `steal`/`reject`.
fn same_client(a: Option<[u8; 32]>, b: Option<[u8; 32]>) -> bool {
matches!((a, b), (Some(x), Some(y)) if x == y)
}
/// The mode-conflict decision, pure over the live-session slice (so it's unit-testable). A conflict is
/// a live session owned by a DIFFERENT client — a same-client reconnect adopts / reconfigures its own
/// display and never conflicts (so it always resolves to `Separate` here and preempts downstream).
pub fn decide(
conflict: ModeConflict,
req_identity: Option<[u8; 32]>,
live: &[LiveSession],
) -> Admission {
let others: Vec<&LiveSession> = live
.iter()
.filter(|s| !same_client(s.identity, req_identity))
.collect();
if others.is_empty() {
return Admission::Separate; // no other client is live → no conflict
}
match conflict {
ModeConflict::Separate => Admission::Separate,
// Join at the OLDEST other session's mode (the established "primary" the desktop is built on).
ModeConflict::Join => Admission::Join(others[0].mode),
ModeConflict::Steal => {
Admission::Steal(others.iter().map(|s| Arc::clone(&s.stop)).collect())
}
ModeConflict::Reject => {
let v = others[0];
Admission::Reject(format!(
"host busy: streaming {}x{}@{} to {}",
v.mode.0, v.mode.1, v.mode.2, v.label
))
}
}
}
/// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when
/// unconfigured), with the **Windows default applied**. On Windows `separate` — including the
/// unconfigured default — resolves to **`reject`**: two concurrent Windows sessions would both drive the
/// SAME pf-vdisplay monitor's single-capturer IDD-push channel ("newest-delivery-wins"), which freezes
/// the live client and can wedge the driver (true multi-session capture is §6.6 / Stage 7). So a 2nd
/// client gets a clean 503 and the live session is protected; `join`/`steal` stay as explicit opt-ins.
/// Linux keeps `separate` (real multi-view). Shared by the native + GameStream admission paths.
pub fn effective_conflict() -> ModeConflict {
let conflict = policy::prefs()
.configured_effective()
.map(|e| e.mode_conflict)
.unwrap_or(ModeConflict::Separate);
#[cfg(windows)]
if matches!(conflict, ModeConflict::Separate) {
return ModeConflict::Reject;
}
conflict
}
/// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`]
/// against the live set.
pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
decide(effective_conflict(), req_identity, &table().lock().unwrap())
}
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call
/// AFTER [`admit`] (so a session never conflicts with itself) and once the mode + stop flag are known.
pub fn register(
identity: Option<[u8; 32]>,
mode: (u32, u32, u32),
stop: Arc<AtomicBool>,
label: String,
) -> LiveGuard {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
table().lock().unwrap().push(LiveSession {
id,
identity,
mode,
stop,
label,
});
LiveGuard { id }
}
/// RAII handle: removes its live-session entry from the registry on drop (session end).
pub struct LiveGuard {
id: u64,
}
impl Drop for LiveGuard {
fn drop(&mut self) {
table().lock().unwrap().retain(|s| s.id != self.id);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sess(identity: Option<u8>, mode: (u32, u32, u32)) -> LiveSession {
LiveSession {
id: 0,
identity: identity.map(|n| {
let mut f = [0u8; 32];
f[0] = n;
f
}),
mode,
stop: Arc::new(AtomicBool::new(false)),
label: "peer".into(),
}
}
fn fp(n: u8) -> Option<[u8; 32]> {
let mut f = [0u8; 32];
f[0] = n;
Some(f)
}
#[test]
fn no_live_session_is_always_separate() {
for c in [
ModeConflict::Separate,
ModeConflict::Join,
ModeConflict::Steal,
ModeConflict::Reject,
] {
assert!(matches!(decide(c, fp(1), &[]), Admission::Separate));
}
}
#[test]
fn same_client_never_conflicts() {
let live = [sess(Some(1), (2560, 1440, 60))];
// Even under reject/steal, the SAME client (fp 1) reconnecting is not a conflict.
assert!(matches!(
decide(ModeConflict::Reject, fp(1), &live),
Admission::Separate
));
assert!(matches!(
decide(ModeConflict::Steal, fp(1), &live),
Admission::Separate
));
}
#[test]
fn different_client_applies_policy() {
let live = [sess(Some(1), (2560, 1440, 60))];
assert!(matches!(
decide(ModeConflict::Separate, fp(2), &live),
Admission::Separate
));
assert!(matches!(
decide(ModeConflict::Join, fp(2), &live),
Admission::Join((2560, 1440, 60))
));
assert!(matches!(
decide(ModeConflict::Steal, fp(2), &live),
Admission::Steal(v) if v.len() == 1
));
assert!(matches!(
decide(ModeConflict::Reject, fp(2), &live),
Admission::Reject(r) if r.contains("2560x1440@60")
));
}
#[test]
fn two_anonymous_clients_conflict() {
// Anonymous (None) can't be proven same-client, so a second anon client DOES conflict.
let live = [sess(None, (1920, 1080, 60))];
assert!(matches!(
decide(ModeConflict::Reject, None, &live),
Admission::Reject(_)
));
}
#[test]
fn join_targets_the_oldest_other_session() {
let live = [
sess(Some(1), (3840, 2160, 60)), // oldest
sess(Some(2), (1280, 720, 120)),
];
assert!(matches!(
decide(ModeConflict::Join, fp(3), &live),
Admission::Join((3840, 2160, 60))
));
}
}
@@ -0,0 +1,246 @@
//! Platform-neutral **per-client → stable display-id map** (design: `design/display-management.md`
//! §5.4 — identity). A client that reconnects gets the SAME small stable id every time, so the
//! desktop environment can key its per-display config (notably **DPI scaling**) to it and reapply it:
//!
//! * **Windows** seeds the pf-vdisplay monitor's EDID serial + IddCx `ConnectorIndex` from the id, so
//! Windows reapplies the client's saved `PerMonitorSettings` scaling. The id must stay `1..=15`
//! (`ConnectorIndex < MaxMonitorsSupported = 16`).
//! * **KWin** names the streamed output `Virtual-punktfunk-<id>`; KWin persists per-output scale/mode
//! in `kwinoutputconfig.json` matched by name, so a stable per-client name makes KDE reapply that
//! client's scaling. (Generalised here from the Windows-only map; the KWin wiring is Stage 3.)
//!
//! The map key is a composable string ([`identity_key`]): the client cert fingerprint alone
//! (`per-client`), or fingerprint + resolution (`per-client-mode` — distinct scaling per resolution).
//! Anonymous/TOFU/GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream,
//! never reaching this map.
//!
//! Persisted to `<config>/display-identity.json` (migrated from the legacy Windows
//! `pf-vdisplay-identity.json`) so ids — and the client→config association — survive host restarts.
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
/// Max stable id. Bounded by the Windows driver's use of the id as the IddCx `ConnectorIndex`
/// (`< MaxMonitorsSupported = 16`), so ids run `1..=15` on every platform for a single shared map.
const MAX_ID: u32 = 15;
/// The map filename (migrated from the legacy Windows-only `pf-vdisplay-identity.json`).
const FILE: &str = "display-identity.json";
const LEGACY_FILE: &str = "pf-vdisplay-identity.json";
/// Compose the map key for a client. `per_client_mode` appends the resolution so a client keeps a
/// distinct id (and thus distinct persisted scaling) per resolution; otherwise the fingerprint alone.
pub(crate) fn identity_key(fp: [u8; 32], mode: (u32, u32), per_client_mode: bool) -> String {
let hex: String = fp.iter().map(|b| format!("{b:02x}")).collect();
if per_client_mode {
format!("{hex}@{}x{}", mode.0, mode.1)
} else {
hex
}
}
#[derive(Serialize, Deserialize, Default)]
struct Store {
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
/// the LRU ordering survives host restarts.
tick: u64,
entries: Vec<Entry>,
}
#[derive(Serialize, Deserialize)]
struct Entry {
/// The composed client key ([`identity_key`]) — the map key. (Serialized as `fp` for
/// back-compat with the legacy Windows `pf-vdisplay-identity.json`.)
#[serde(rename = "fp")]
key: String,
/// The client's stable display id (`1..=15`).
id: u32,
/// MRU stamp (compared against [`Store::tick`]).
seen: u64,
}
/// Persistent client-key → stable-id map (see the module docs).
pub(crate) struct DisplayIdentityMap {
path: PathBuf,
store: Store,
}
impl DisplayIdentityMap {
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
/// re-derives ids, costing a client one scaling re-set the first time). Migrates the legacy
/// Windows `pf-vdisplay-identity.json` if the new file is absent.
pub(crate) fn load() -> Self {
let dir = crate::gamestream::config_dir();
let path = dir.join(FILE);
let bytes = std::fs::read(&path)
.or_else(|_| std::fs::read(dir.join(LEGACY_FILE)))
.ok();
let mut store = bytes
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
.unwrap_or_default();
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s
// found-entry branch returns the stored id verbatim, so an out-of-range id (0 = the "auto"
// sentinel, or > MAX_ID) or a duplicate id/key would flow straight into the display identity.
// Drop out-of-range ids and dedup by BOTH key and id (keeping the most-recently-seen on a
// clash) so no two clients can map to the same id.
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
let mut seen_key = std::collections::HashSet::new();
let mut seen_id = std::collections::HashSet::new();
store.entries.retain(|e| {
(1..=MAX_ID).contains(&e.id) && seen_key.insert(e.key.clone()) && seen_id.insert(e.id)
});
Self { path, store }
}
/// The stable id (`1..=15`) for the client `key` ([`identity_key`]): its remembered id, or a
/// freshly assigned one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
pub(crate) fn resolve(&mut self, key: &str) -> u32 {
self.store.tick = self.store.tick.wrapping_add(1);
let now = self.store.tick;
if let Some(e) = self.store.entries.iter_mut().find(|e| e.key == key) {
e.seen = now;
let id = e.id;
self.persist();
return id;
}
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
let id = (1..=MAX_ID)
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
.unwrap_or_else(|| {
let lru = self
.store
.entries
.iter()
.enumerate()
.min_by_key(|(_, e)| e.seen)
.map(|(i, _)| i)
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
let evicted = self.store.entries.remove(lru);
evicted.id
});
self.store.entries.push(Entry {
key: key.to_string(),
id,
seen: now,
});
self.persist();
id
}
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
fn persist(&self) {
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
return;
};
if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, &self.path);
}
}
}
/// The process-wide identity map (persisted, loaded once). Shared by the Windows manager and the
/// Linux KWin backend — never in the same process (a host runs one platform), so one instance ⇒ no
/// clobbering of the shared `display-identity.json`.
pub(crate) fn global() -> &'static Mutex<DisplayIdentityMap> {
static MAP: OnceLock<Mutex<DisplayIdentityMap>> = OnceLock::new();
MAP.get_or_init(|| Mutex::new(DisplayIdentityMap::load()))
}
/// Resolve the connecting client's stable slot id per the `identity` policy. When no policy is
/// configured, `default` applies — **PerClient on Windows / Shared on Linux**, preserving each
/// platform's historical behavior (Windows always keyed monitors per-client; Linux used one shared
/// output name). `None` ⇒ shared / anonymous → the backend uses its base name / auto slot.
pub(crate) fn resolve_slot(
fp: Option<[u8; 32]>,
mode: (u32, u32),
default: crate::vdisplay::policy::Identity,
) -> Option<u32> {
use crate::vdisplay::policy::Identity;
let id_policy = crate::vdisplay::policy::prefs()
.configured_effective()
.map(|e| e.identity)
.unwrap_or(default);
let per_client_mode = match id_policy {
Identity::Shared => return None,
Identity::PerClient => false,
Identity::PerClientMode => true,
};
let fp = fp?;
Some(
global()
.lock()
.unwrap()
.resolve(&identity_key(fp, mode, per_client_mode)),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(n: u8) -> [u8; 32] {
let mut f = [0u8; 32];
f[0] = n;
f
}
fn temp_map(tag: &str) -> DisplayIdentityMap {
DisplayIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-{tag}-{}.json", std::process::id())),
store: Store::default(),
}
}
#[test]
fn stable_across_calls_and_distinct_per_client() {
let mut m = temp_map("stable");
let a1 = m.resolve(&identity_key(fp(1), (1920, 1080), false));
let b = m.resolve(&identity_key(fp(2), (1920, 1080), false));
let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false)); // per-client: mode ignored
assert_eq!(a1, a2, "same client → same id (per-client ignores mode)");
assert_ne!(a1, b, "distinct clients → distinct ids");
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn per_client_mode_splits_by_resolution() {
let mut m = temp_map("permode");
let hd = m.resolve(&identity_key(fp(1), (1920, 1080), true));
let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true));
let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true));
assert_ne!(hd, uhd, "same client, different resolution → different id");
assert_eq!(hd, hd2, "same client + resolution → same id");
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn lru_eviction_reuses_an_id_at_the_cap() {
let mut m = temp_map("lru");
for n in 1..=15u8 {
m.resolve(&identity_key(fp(n), (1920, 1080), false));
}
let _ = m.resolve(&identity_key(fp(2), (1920, 1080), false)); // touch 2 so 1 is LRU
let id16 = m.resolve(&identity_key(fp(16), (1920, 1080), false));
assert!((1..=MAX_ID).contains(&id16));
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn key_composition() {
assert_eq!(identity_key(fp(0xab), (1920, 1080), false).len(), 64); // hex fp only
assert!(identity_key(fp(0xab), (1920, 1080), true).ends_with("@1920x1080"));
}
}
@@ -0,0 +1,338 @@
//! Pure per-display **lifecycle state machine** (design: `design/display-management.md` §3).
//!
//! One virtual display's earned refcount + linger + pin state, with **no I/O and no OS-specific
//! types** — the registry ([`super::registry`]) executes the side effects (backend create /
//! teardown / linger timer) that this machine's transitions dictate. Extracted so the lifecycle
//! logic is unit- and property-testable in isolation, and so the Linux registry and (later) the
//! Windows manager share one audited machine instead of each re-deriving refcount+linger by hand.
//!
//! It is the platform-neutral distillation of the model the Windows `VirtualDisplayManager` already
//! runs on glass: `Idle → Active{refs} → Lingering{until} → Idle`, plus a `Pinned` state for
//! keep-alive-forever. The registry pairs one [`State`] with the owned backend resource; the machine
//! only tracks the discriminant + refcount + deadline and reports what to do.
use std::time::Instant;
use super::policy::Linger;
/// The lifecycle state of one virtual-display slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum State {
/// No display exists.
#[default]
Idle,
/// A display exists with `refs` live sessions holding it.
Active { refs: u32 },
/// The last session left; the display is kept until `until`, then torn down.
Lingering { until: Instant },
/// The last session left; the display is kept indefinitely (keep-alive forever), until an
/// explicit release.
Pinned,
}
/// What acquiring a slot means for the backend.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Acquire {
/// The slot was empty — the backend must CREATE a fresh display.
Create,
/// The slot was already Active — another session JOINS the live display (refcount++).
Join,
/// The slot was kept alive (Lingering/Pinned) — REUSE the existing display (re-attach capture).
Reuse,
}
/// What releasing a hold on a slot means for the backend.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Release {
/// Another session still holds the display — nothing to do.
Decref,
/// The last session left; keep the display until its deadline ([`State::Lingering`]), then tear down.
Linger,
/// The last session left; keep the display indefinitely ([`State::Pinned`]).
Pin,
/// The last session left and keep-alive is off — tear the display down now.
Teardown,
/// A release with no live hold (stale/duplicate) — no-op.
Noop,
}
impl State {
/// True while a backend display resource exists (Active/Lingering/Pinned) — the registry holds
/// the keepalive in exactly these states, and `Idle` means it has been dropped.
pub fn has_display(self) -> bool {
!matches!(self, State::Idle)
}
/// Number of live sessions holding the display (0 unless Active).
pub fn refs(self) -> u32 {
match self {
State::Active { refs } => refs,
_ => 0,
}
}
/// A session acquires the slot. Transitions the state and reports whether the backend must
/// create a fresh display, join the live one, or reuse the kept one.
pub fn acquire(&mut self) -> Acquire {
match *self {
State::Idle => {
*self = State::Active { refs: 1 };
Acquire::Create
}
State::Active { refs } => {
*self = State::Active { refs: refs + 1 };
Acquire::Join
}
State::Lingering { .. } | State::Pinned => {
*self = State::Active { refs: 1 };
Acquire::Reuse
}
}
}
/// A session releases the slot. When the LAST session leaves, `now` + the resolved `linger`
/// decide the kept state. Returns what the registry should do.
pub fn release(&mut self, now: Instant, linger: Linger) -> Release {
match *self {
State::Active { refs } if refs > 1 => {
*self = State::Active { refs: refs - 1 };
Release::Decref
}
State::Active { .. } => match linger {
Linger::Immediate => {
*self = State::Idle;
Release::Teardown
}
Linger::For(d) => {
*self = State::Lingering { until: now + d };
Release::Linger
}
Linger::Forever => {
*self = State::Pinned;
Release::Pin
}
},
// Releasing a slot with no live hold is a stale/duplicate release. The registry's
// gen-stamped leases already make a stale lease's drop a no-op before it reaches here;
// this is the defensive backstop.
State::Idle | State::Lingering { .. } | State::Pinned => Release::Noop,
}
}
/// The registry's linger-timer tick: a Lingering slot past its deadline goes Idle and returns
/// `true` (the registry tears the display down). Pinned and every other state are untouched.
pub fn poll_expiry(&mut self, now: Instant) -> bool {
match *self {
State::Lingering { until } if now >= until => {
*self = State::Idle;
true
}
_ => false,
}
}
/// Force-release a kept display (the `/display/release` endpoint): a Lingering/Pinned slot goes
/// Idle and the registry tears it down (`true`). An Active slot is refused (`false`) — releasing
/// a display that still has live sessions is session management, not display management. Idle → `false`.
pub fn force_release(&mut self) -> bool {
match *self {
State::Lingering { .. } | State::Pinned => {
*self = State::Idle;
true
}
State::Active { .. } | State::Idle => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn create_join_reuse_and_teardown() {
let mut s = State::default();
assert_eq!(s.acquire(), Acquire::Create);
assert_eq!(s, State::Active { refs: 1 });
// A concurrent session joins.
assert_eq!(s.acquire(), Acquire::Join);
assert_eq!(s.refs(), 2);
// One leaves — still active.
let now = Instant::now();
assert_eq!(s.release(now, Linger::Immediate), Release::Decref);
assert_eq!(s.refs(), 1);
// The last leaves with keep-alive off — teardown.
assert_eq!(s.release(now, Linger::Immediate), Release::Teardown);
assert_eq!(s, State::Idle);
assert!(!s.has_display());
}
#[test]
fn linger_then_reuse_within_window() {
let mut s = State::default();
let t0 = Instant::now();
s.acquire();
assert_eq!(
s.release(t0, Linger::For(Duration::from_secs(10))),
Release::Linger
);
assert!(s.has_display());
// A tick before the deadline does nothing.
assert!(!s.poll_expiry(t0 + Duration::from_secs(5)));
// A reconnect inside the window reuses the kept display.
assert_eq!(s.acquire(), Acquire::Reuse);
assert_eq!(s, State::Active { refs: 1 });
}
#[test]
fn linger_expires_to_teardown() {
let mut s = State::default();
let t0 = Instant::now();
s.acquire();
s.release(t0, Linger::For(Duration::from_secs(10)));
// Past the deadline → teardown.
assert!(s.poll_expiry(t0 + Duration::from_secs(11)));
assert_eq!(s, State::Idle);
// A second tick is idempotent (nothing to tear down).
assert!(!s.poll_expiry(t0 + Duration::from_secs(12)));
}
#[test]
fn pinned_never_expires_but_force_releases() {
let mut s = State::default();
let t0 = Instant::now();
s.acquire();
assert_eq!(s.release(t0, Linger::Forever), Release::Pin);
assert_eq!(s, State::Pinned);
// No amount of ticking tears a pinned display down.
assert!(!s.poll_expiry(t0 + Duration::from_secs(86_400)));
assert!(s.has_display());
// Only an explicit release does.
assert!(s.force_release());
assert_eq!(s, State::Idle);
}
#[test]
fn force_release_refuses_active() {
let mut s = State::default();
s.acquire();
assert!(
!s.force_release(),
"an active display can't be force-released"
);
assert_eq!(s.refs(), 1);
// Idle also can't.
let mut idle = State::default();
assert!(!idle.force_release());
}
#[test]
fn stale_release_is_noop() {
let mut s = State::default();
assert_eq!(s.release(Instant::now(), Linger::Immediate), Release::Noop);
assert_eq!(s, State::Idle);
}
/// Property test (deterministic seeded walk): across an arbitrary interleaving of acquire /
/// release / expiry-tick / force-release, the machine must never (a) leak or double-free the
/// backend resource — `has_display()` must exactly track a shadow "resource alive" flag, with
/// every Create preceded by no live resource and every teardown preceded by one — nor (b)
/// underflow the refcount, nor (c) tear a display down while a session still holds it.
#[test]
fn property_no_leaks_no_double_free_no_underflow() {
// Tiny deterministic LCG (Numerical Recipes) — reproducible, no dependency.
let mut rng: u64 = 0x1234_5678_9abc_def0;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(rng >> 33) as u32
};
let base = Instant::now();
let mut logical_ms: u64 = 0;
let mut s = State::default();
// Shadow model.
let mut resource_alive = false;
let mut live_holds: u32 = 0;
for _ in 0..200_000 {
// Advance logical time by 0..2000 ms each step so lingers cross their deadlines.
logical_ms += (next() % 2000) as u64;
let now = base + Duration::from_millis(logical_ms);
match next() % 5 {
0 => {
// acquire
let before_alive = resource_alive;
let a = s.acquire();
match a {
Acquire::Create => {
assert!(!before_alive, "Create while a resource was alive")
}
Acquire::Join | Acquire::Reuse => {
assert!(before_alive, "Join/Reuse with no live resource")
}
}
resource_alive = true;
live_holds += 1;
}
1 | 2 => {
// release (weighted 2/5 so refs actually drain)
let linger = match next() % 3 {
0 => Linger::Immediate,
1 => Linger::For(Duration::from_millis((next() % 3000) as u64 + 1)),
_ => Linger::Forever,
};
let held_before = live_holds;
let r = s.release(now, linger);
match r {
Release::Noop => assert_eq!(held_before, 0, "Noop only with no live hold"),
Release::Decref => {
assert!(held_before >= 2, "Decref must leave the display held");
live_holds -= 1;
}
Release::Teardown => {
assert_eq!(held_before, 1, "Teardown only on the last hold");
live_holds = 0;
resource_alive = false;
}
Release::Linger | Release::Pin => {
assert_eq!(held_before, 1, "Linger/Pin only on the last hold");
live_holds = 0;
// resource stays alive (kept)
}
}
}
3 => {
// expiry tick
if s.poll_expiry(now) {
assert_eq!(live_holds, 0, "expiry tore down a held display");
resource_alive = false;
}
}
_ => {
// force release
if s.force_release() {
assert_eq!(live_holds, 0, "force-release tore down a held display");
resource_alive = false;
}
}
}
// Invariant after every step: the machine's own view of "a display exists" matches the
// shadow, and the refcount matches the live-hold count.
assert_eq!(
s.has_display(),
resource_alive,
"has_display drifted from the shadow model"
);
assert_eq!(
s.refs(),
live_holds,
"refs drifted from the live-hold count"
);
}
}
}
+149 -54
View File
@@ -67,13 +67,19 @@ const VOUT_NAME: &str = "punktfunk";
/// event (deprecated only since v6) for the node id, so cap the bind at 5.
const MAX_VERSION: u32 = 5;
/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up
/// its own Wayland connection/thread that owns the resulting output.
pub struct KwinDisplay;
/// The KWin virtual-display driver. Carries the connecting client's cert fingerprint (set before
/// [`create`](VirtualDisplay::create)) so a paired client gets a STABLE per-slot output NAME
/// (`Virtual-punktfunk-<id>`) — KWin persists per-output config (scale/mode) keyed by name in
/// `kwinoutputconfig.json`, so a stable name makes KDE reapply that client's scaling on reconnect
/// (Stage 3). Each `create` spins up its own Wayland connection/thread that owns the output.
#[derive(Default)]
pub struct KwinDisplay {
client_fp: Option<[u8; 32]>,
}
impl KwinDisplay {
pub fn new() -> Result<Self> {
Ok(KwinDisplay)
Ok(KwinDisplay::default())
}
}
@@ -82,14 +88,32 @@ impl VirtualDisplay for KwinDisplay {
"kwin"
}
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
self.client_fp = fingerprint;
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id →
// `punktfunk-<id>` (KWin exposes `Virtual-punktfunk-<id>`, whose per-output config KWin
// persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux
// defaults to Shared when unconfigured, so this is a no-op change until a policy opts in — AND
// it fixes the latent clash where two concurrent sessions both used `Virtual-punktfunk`.
let name = match crate::vdisplay::identity::resolve_slot(
self.client_fp,
(mode.width, mode.height),
crate::vdisplay::policy::Identity::Shared,
) {
Some(id) => format!("{VOUT_NAME}-{id}"),
None => VOUT_NAME.to_string(),
};
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 (width, height) = (mode.width, mode.height);
let name_thread = name.clone();
thread::Builder::new()
.name("punktfunk-kwin-vout".into())
.spawn(move || virtual_output_thread(width, height, setup_tx, stop_thread))
.spawn(move || virtual_output_thread(width, height, name_thread, setup_tx, stop_thread))
.context("spawn KWin virtual-output thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
@@ -107,18 +131,23 @@ impl VirtualDisplay for KwinDisplay {
// rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install —
// the source runs 60 Hz and the encoder downsamples — so carry the requested rate through.
let achieved_hz = if mode.refresh_hz > 60 {
set_custom_refresh(width, height, mode.refresh_hz)
set_custom_refresh(width, height, mode.refresh_hz, &name)
} else {
mode.refresh_hz
};
// Make our streamed output the SOLE desktop: plasmashell + windows land on the surface we
// stream, not on the headless session's `kwin --virtual` bootstrap output (otherwise the
// client sees only the wallpaper of an empty extended output). Opt-in
// (PUNKTFUNK_KWIN_VIRTUAL_PRIMARY), mirroring the Mutter backend's PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY.
let restore = if virtual_primary_enabled() {
apply_virtual_primary()
} else {
Vec::new()
// Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
use crate::vdisplay::policy::Topology;
let restore = match crate::vdisplay::effective_topology() {
Topology::Exclusive => apply_virtual_primary(&name),
Topology::Primary => {
apply_virtual_primary_only(&name);
Vec::new() // nothing disabled → nothing to restore
}
Topology::Extend | Topology::Auto => Vec::new(),
};
Ok(VirtualOutput {
node_id,
@@ -135,8 +164,8 @@ impl VirtualDisplay for KwinDisplay {
/// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected),
/// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller
/// paces the encoder to the *achieved* rate, not the requested one.
fn set_custom_refresh(width: u32, height: u32, hz: u32) -> u32 {
let output = format!("Virtual-{VOUT_NAME}");
fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 {
let output = format!("Virtual-{name}");
let mhz = hz.saturating_mul(1000);
let run = |arg: String| {
std::process::Command::new("kscreen-doctor")
@@ -213,26 +242,17 @@ fn read_active_refresh(output: &str) -> Option<u32> {
Some(hz.round() as u32)
}
/// Opt-in: make the per-session virtual output the sole desktop. Off by default — a host with no
/// competing output (or one that wants the bootstrap kept) is unaffected; the headless KDE appliance
/// (run-headless-kde.sh's `kwin --virtual` bootstrap + our streamed output) sets it so the desktop
/// renders on the streamed surface, not the bootstrap. Mirrors the Mutter backend's gate.
fn virtual_primary_enabled() -> bool {
std::env::var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
/// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`,
/// which KWin exposes as `Virtual-punktfunk` / `Virtual-punktfunk-<id>`. Group membership (§6.1) is
/// recognised by this prefix, so we never have to thread the live set through the backend.
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless
/// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j`
/// (same source as [`read_active_refresh`]).
/// Names of currently-ENABLED outputs that are **not managed by us** — the headless session's
/// bootstrap output(s) + any physical monitor, i.e. exactly what `exclusive` must disable.
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]).
fn other_enabled_outputs() -> Vec<String> {
let ours = format!("Virtual-{VOUT_NAME}");
let out = match std::process::Command::new("kscreen-doctor")
.arg("-j")
.output()
@@ -248,22 +268,49 @@ fn other_enabled_outputs() -> Vec<String> {
.and_then(|o| o.as_array())
.map(|outs| {
outs.iter()
.filter(|o| {
o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false)
&& o.get("name").and_then(|n| n.as_str()) != Some(ours.as_str())
})
.filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from))
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.filter(|n| !n.starts_with(MANAGED_PREFIX))
.map(String::from)
.collect()
})
.unwrap_or_default()
}
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so it becomes the sole
/// desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for the
/// keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
/// True if any managed group member (the [`MANAGED_PREFIX`] family) is ALREADY the KWin primary —
/// first-slot-wins support (§6.1) so a later exclusive session doesn't steal primary from the group's
/// first member. Best-effort: if kscreen reports no primary flag we treat it as "none" (the session
/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with
/// `"priority": 1`; older builds used a `"primary": true` bool — accept either.
fn a_managed_output_is_primary() -> bool {
let Ok(out) = std::process::Command::new("kscreen-doctor").arg("-j").output() else {
return false;
};
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
return false;
};
doc.get("outputs")
.and_then(|o| o.as_array())
.map(|outs| {
outs.iter().any(|o| {
let managed = o
.get("name")
.and_then(|n| n.as_str())
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
let primary = o.get("primary").and_then(|p| p.as_bool()).unwrap_or(false)
|| o.get("priority").and_then(|p| p.as_u64()) == Some(1);
managed && primary
})
})
.unwrap_or(false)
}
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so the managed group becomes
/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
/// showing only the wallpaper) rather than failing the session.
fn apply_virtual_primary() -> Vec<String> {
let ours = format!("Virtual-{VOUT_NAME}");
fn apply_virtual_primary(name: &str) -> Vec<String> {
let ours = format!("Virtual-{name}");
let kscreen = |args: &[String]| {
std::process::Command::new("kscreen-doctor")
.args(args)
@@ -271,15 +318,20 @@ fn apply_virtual_primary() -> Vec<String> {
.map(|s| s.success())
.unwrap_or(false)
};
// Make ours primary — KWin usually then re-homes the desktop and disables the bootstrap on its
// own. Let that settle, then belt-and-suspenders: disable anything still enabled besides ours so
// the streamed output is unambiguously the sole desktop regardless of KWin's implicit behaviour.
if !kscreen(&[format!("output.{ours}.primary")]) {
tracing::warn!(
"KWin: could not set the virtual output primary; client may see only the wallpaper"
);
// First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
// exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
// shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
// bootstrap on its own; the belt-and-suspenders disable below covers the rest.
if !a_managed_output_is_primary() {
if !kscreen(&[format!("output.{ours}.primary")]) {
tracing::warn!(
"KWin: could not set the virtual output primary; client may see only the wallpaper"
);
}
std::thread::sleep(Duration::from_millis(200));
}
std::thread::sleep(Duration::from_millis(200));
// Disable everything still enabled that ISN'T a managed group member (bootstrap / physical), so
// the group is unambiguously the desktop — never a sibling session's output (group-aware filter).
let others = other_enabled_outputs();
if !others.is_empty() {
let args: Vec<String> = others
@@ -292,6 +344,23 @@ fn apply_virtual_primary() -> Vec<String> {
others
}
/// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
fn apply_virtual_primary_only(name: &str) {
let ours = format!("Virtual-{name}");
let ok = std::process::Command::new("kscreen-doctor")
.arg(format!("output.{ours}.primary"))
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
} else {
tracing::warn!("KWin: could not set the virtual output primary");
}
}
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
/// drops the Wayland connection and makes KWin reclaim the output.
struct StopGuard {
@@ -388,10 +457,11 @@ impl Dispatch<ScreencastStream, ()> for State {
fn virtual_output_thread(
width: u32,
height: u32,
name: String,
setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>,
) {
if let Err(e) = run(width, height, &setup_tx, &stop) {
if let Err(e) = run(width, height, &name, &setup_tx, &stop) {
// If we never delivered a node id, report the failure to the waiting opener.
let _ = setup_tx.send(Err(format!("{e:#}")));
}
@@ -431,6 +501,7 @@ pub fn is_available() -> bool {
fn run(
width: u32,
height: u32,
name: &str,
setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool,
) -> Result<()> {
@@ -453,7 +524,7 @@ fn run(
// Create the virtual output sized to the client, cursor composited into the stream.
let stream = screencast.stream_virtual_output(
VOUT_NAME.to_string(),
name.to_string(),
width as i32,
height as i32,
1.0, // scale (logical == physical)
@@ -522,3 +593,27 @@ fn run(
let _ = conn.flush();
Ok(())
}
#[cfg(test)]
mod tests {
use super::MANAGED_PREFIX;
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
/// (the Stage-3 naming would otherwise make a 2nd exclusive session black out the 1st).
#[test]
fn exclusive_disables_only_non_managed() {
let enabled = [
"Virtual-punktfunk", // base name (shared identity)
"Virtual-punktfunk-1", // client A's per-slot output
"Virtual-punktfunk-7", // client B's per-slot output
"eDP-1", // a physical panel
];
let to_disable: Vec<&str> = enabled
.iter()
.copied()
.filter(|n| !n.starts_with(MANAGED_PREFIX))
.collect();
assert_eq!(to_disable, vec!["eDP-1"]);
}
}
@@ -118,9 +118,19 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
}
};
rt.block_on(async move {
// Opt-in: snapshot the monitor layout BEFORE the virtual output exists, so we can tell the
// new (virtual) connector apart and restore the layout on teardown. Best-effort.
let dc_pre = if virtual_primary_enabled() {
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
// SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream.
let topo = crate::vdisplay::effective_topology();
let want_config = matches!(
topo,
crate::vdisplay::policy::Topology::Primary | crate::vdisplay::policy::Topology::Exclusive
);
let exclusive = matches!(topo, crate::vdisplay::policy::Topology::Exclusive);
// Snapshot the monitor layout BEFORE the virtual output exists (so we can tell the new
// connector apart and restore on teardown) whenever we're going to touch the topology.
let dc_pre = if want_config {
match display_config().await {
Ok(dc) => match get_state(&dc).await {
Ok(state) => Some((dc, state)),
@@ -152,8 +162,12 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
// monitor attached, the virtual output is an empty extended desktop — you stream only the
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
if let Some((dc, pre)) = &dc_pre {
match make_virtual_primary(dc, mode, pre).await {
Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"),
match make_virtual_primary(dc, mode, pre, exclusive).await {
Ok(()) => tracing::info!(
exclusive,
"mutter: virtual output set as the primary monitor (physicals {})",
if exclusive { "disabled" } else { "kept" }
),
Err(e) => tracing::warn!(
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
),
@@ -338,17 +352,6 @@ type CurrentState = (
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
fn virtual_primary_enabled() -> bool {
std::env::var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
/// Opt-in: pin the virtual output to the client's exact refresh via RecordVirtual "modes" (true
/// above-60 Hz). Off by default — Mutter-derived 60 Hz is safe on every host; high-refresh virtual
/// CRTCs are validated on Mutter 50 + NVIDIA but behaviour can vary, so it stays opt-in. (The
@@ -411,7 +414,12 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
/// which lands shortly after the node id), then make it the SOLE primary output (physicals
/// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed
/// surface. Restored on teardown.
async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentState) -> Result<()> {
async fn make_virtual_primary(
dc: &zbus::Proxy<'_>,
mode: Mode,
pre: &CurrentState,
exclusive: bool,
) -> Result<()> {
let pre_conns = connectors(pre);
let deadline = Instant::now() + Duration::from_secs(6);
loop {
@@ -437,7 +445,14 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
let Some(vmode) = vmode else {
bail!("virtual monitor {vconn} has no usable mode yet");
};
let config = build_primary_config(&vconn, &vmode);
// Exclusive: the virtual output alone (physicals omitted → Mutter disables them).
// Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries.
// (On a headless host with no physicals the two are identical.)
let config = if exclusive {
build_exclusive_config(&vconn, &vmode)
} else {
build_primary_keeping_physicals(&state, &vconn, &vmode, mode.width as i32)
};
let _: () = dc
.call(
"ApplyMonitorsConfig",
@@ -459,12 +474,12 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
}
}
/// The virtual output as the SOLE, primary monitor physical outputs are omitted, so Mutter
/// disables them for the session. This confines the cursor, windows, and keyboard focus to the
/// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so
/// Mutter disables them for the session. This confines the cursor, windows, and keyboard focus to the
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
/// vanish). The physical layout is restored on teardown.
fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
vec![(
0,
0,
@@ -474,3 +489,47 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
)]
}
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its current mode). So
/// the shell + new windows land on the streamed surface, but the operator's physical screen stays
/// on. On a headless host (no physicals) this is identical to [`build_exclusive_config`].
///
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
fn build_primary_keeping_physicals(
state: &CurrentState,
vconn: &str,
vmode: &str,
virt_width: i32,
) -> Vec<ApplyLogical> {
let mut logicals: Vec<ApplyLogical> = vec![(
0,
0,
1.0,
0,
true,
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
)];
// Append each physical (non-virtual) connector that has a usable current mode, to the right of
// the virtual output, as a non-primary secondary.
let mut x = virt_width.max(0);
for mon in &state.1 {
let conn = &mon.0 .0;
if conn == vconn {
continue;
}
if let Some((mode_id, w, _h)) = current_mode(state, conn) {
logicals.push((
x,
0,
1.0,
0,
false,
vec![(conn.clone(), mode_id, HashMap::new())],
));
x += w.max(0);
}
}
logicals
}
@@ -0,0 +1,573 @@
//! Virtual-display **management policy** — the user-configurable behavior surface for how virtual
//! displays are created, kept alive, and arranged (design: `design/display-management.md`).
//!
//! This is the pure config layer that sits **above** the per-compositor [`VirtualDisplay`](super)
//! backends: a small set of orthogonal options ([`DisplayPolicy`]) with safe defaults and named
//! [`Preset`]s, persisted to `<config>/display-settings.json` and editable from the web console.
//! The lifecycle/registry that *acts* on this policy lands in later stages; **Stage 0** (this file
//! plus the mgmt endpoints) stands up the surface and wires the two behaviors the existing code can
//! already express — the Windows monitor linger duration and the Linux "make the streamed output
//! the sole desktop" topology — through it.
//!
//! Precedence, mirroring the GPU preference (`console preference > env pin > default`): a present,
//! valid `display-settings.json` (console-written) **wins**; when it is absent the host keeps its
//! historical env-knob / default behavior untouched ([`DisplayPolicyStore::configured`] returns
//! `None`, and every Stage-0 call site falls back to exactly what it did before). The policy is
//! read at each acquire/teardown (file state, not a startup-frozen env var), so a console change
//! applies to the next connect without a host restart.
//!
//! The pure logic here — preset expansion, [`DisplayPolicy::effective`], the [`KeepAlive`] linger
//! resolution — is unit-tested; the store adds file I/O around it (the `gpu.rs` discipline:
//! private dir, temp-write + atomic rename, in-memory rollback on a failed write).
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
/// survives after the last client session detaches. Serialized as an object tagged on `mode`
/// (`{"mode":"off"}` / `{"mode":"duration","seconds":300}` / `{"mode":"forever"}`) so the web form
/// and the OpenAPI schema stay simple.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum KeepAlive {
/// Tear the display down at session end (today's default on every backend but Windows, which
/// lingers 10 s).
Off,
/// Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect
/// inside the window reuses it.
Duration {
/// Linger window in seconds.
seconds: u32,
},
/// Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).
/// **Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.
Forever,
}
impl Default for KeepAlive {
fn default() -> Self {
// The historical Windows behavior, made explicit; the Linux backends had no linger and map
// `Off`/short-duration onto their (nonexistent) keep-alive as a no-op until the lifecycle stage.
KeepAlive::Duration { seconds: 10 }
}
}
/// Resolved linger for the display lifecycle: teardown immediately, after a fixed window, or never.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Linger {
/// Tear down as soon as the last session leaves.
Immediate,
/// Linger for this window, then tear down.
For(Duration),
/// Never auto-tear-down (Pinned).
Forever,
}
impl KeepAlive {
/// The [`Linger`] this keep-alive resolves to.
pub fn linger(self) -> Linger {
match self {
KeepAlive::Off => Linger::Immediate,
KeepAlive::Duration { seconds } => Linger::For(Duration::from_secs(seconds as u64)),
KeepAlive::Forever => Linger::Forever,
}
}
}
/// What the host does to the box's display topology while managed virtual displays are up.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Topology {
/// Today's behavior, resolved per host at acquire time (see [`super::effective_topology`]):
/// exclusive on Windows and the auto-detected Linux desktop path, extend under an explicit
/// `PUNKTFUNK_COMPOSITOR` pin.
#[default]
Auto,
/// Add the virtual display(s); touch nothing else.
Extend,
/// Make the group's primary virtual display the OS primary; physical outputs stay enabled.
Primary,
/// The managed virtual displays become the only enabled outputs (physical outputs disabled,
/// restored on teardown).
Exclusive,
}
/// Admission when a *different* client connects while a display/session is already live and asks for
/// a different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ModeConflict {
/// Give the new client its own virtual display on the same desktop (today's Linux multi-view).
#[default]
Separate,
/// Stop the existing session(s), tear down / reconfigure, serve the new client.
Steal,
/// Admit the new client at the live display's mode (the honest-downgrade convention).
Join,
/// Refuse the new client with a clear handshake error.
Reject,
}
/// Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored
/// at Stage 0; carriers wired from the identity stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Identity {
/// One identity for everything (today's Linux behavior).
Shared,
/// One identity per paired client cert fingerprint (today's Windows behavior).
#[default]
PerClient,
/// One identity per (client, resolution) — distinct scaling per resolution, at the cost of
/// identity slots.
PerClientMode,
}
/// How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from
/// the multi-monitor stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutMode {
/// Left-to-right in acquire order, top-aligned (deterministic default).
#[default]
AutoRow,
/// Per-identity-slot offsets from [`Layout::positions`] (console-arranged).
Manual,
}
/// A desktop-space offset for a display (top-left origin).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Position {
pub x: i32,
pub y: i32,
}
/// Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by
/// identity-slot id (string keys for stable JSON).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Layout {
#[serde(default)]
pub mode: LayoutMode,
#[serde(default)]
pub positions: BTreeMap<String, Position>,
}
/// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any
/// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Preset {
/// The explicit fields below define the policy.
#[default]
Custom,
/// Today's behavior, made explicit.
Default,
/// Dedicated headless/couch box: displays + game survive disconnects; whoever connects takes over.
GamingRig,
/// A desktop someone also uses physically: never blank the real monitors, never keep ghosts.
SharedDesktop,
/// One user at a time with fast reattach; a second user is told the box is busy.
Hotdesk,
/// The multi-monitor daily driver: manual arrangement, per-client identity, exclusive.
Workstation,
}
/// The user-facing display-management policy — what `display-settings.json` holds and what the mgmt
/// API GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are
/// ignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a
/// single [`EffectivePolicy`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct DisplayPolicy {
/// Schema version (currently 1) — lets a future field addition migrate rather than reject.
#[serde(default = "one")]
pub version: u32,
#[serde(default)]
pub preset: Preset,
#[serde(default)]
pub keep_alive: KeepAlive,
#[serde(default)]
pub topology: Topology,
#[serde(default)]
pub mode_conflict: ModeConflict,
#[serde(default)]
pub identity: Identity,
#[serde(default)]
pub layout: Layout,
/// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).
#[serde(default = "default_max_displays")]
pub max_displays: u32,
}
fn one() -> u32 {
1
}
fn default_max_displays() -> u32 {
4
}
impl Default for DisplayPolicy {
fn default() -> Self {
// Bit-for-bit today's behavior (the `default` preset expanded), so an unconfigured host reads
// the same policy the Stage-0 call sites already produce.
DisplayPolicy {
version: 1,
preset: Preset::Custom,
keep_alive: KeepAlive::default(),
topology: Topology::Auto,
mode_conflict: ModeConflict::default(),
identity: Identity::default(),
layout: Layout::default(),
max_displays: 4,
}
}
}
/// The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call
/// sites read, and what the mgmt API echoes as the "currently in force" policy. Pure output of
/// [`DisplayPolicy::effective`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct EffectivePolicy {
pub keep_alive: KeepAlive,
pub topology: Topology,
pub mode_conflict: ModeConflict,
pub identity: Identity,
pub layout: Layout,
pub max_displays: u32,
}
impl DisplayPolicy {
/// Resolve to the [`EffectivePolicy`]: a named preset expands to its bundle; `Custom` uses the
/// explicit fields. Pure — the single source of truth shared by the preset docs and the runtime.
pub fn effective(&self) -> EffectivePolicy {
if let Some(mut e) = preset_fields(self.preset) {
// A preset fixes the six behavior fields but honors an explicit manual layout table
// (positions are data, not behavior — the `workstation` preset only sets the *mode*).
if self.preset == Preset::Workstation && !self.layout.positions.is_empty() {
e.layout.positions = self.layout.positions.clone();
}
e
} else {
EffectivePolicy {
keep_alive: self.keep_alive,
topology: self.topology,
mode_conflict: self.mode_conflict,
identity: self.identity,
layout: self.layout.clone(),
max_displays: self.max_displays,
}
}
}
/// Clamp fields to their valid ranges (called on write). `max_displays` to `1..=16` (the
/// pf-vdisplay connector ceiling / a sane Linux bound).
pub fn sanitized(mut self) -> Self {
self.version = 1;
self.max_displays = self.max_displays.clamp(1, 16);
self
}
}
/// The field bundle a named preset expands to; `None` for [`Preset::Custom`]. The single expansion
/// table — the docs' preset table mirrors this and the `presets_match_doc` test guards the shape.
pub fn preset_fields(preset: Preset) -> Option<EffectivePolicy> {
let base = |keep_alive, topology, mode_conflict, identity, layout_mode| EffectivePolicy {
keep_alive,
topology,
mode_conflict,
identity,
layout: Layout {
mode: layout_mode,
positions: BTreeMap::new(),
},
max_displays: 4,
};
Some(match preset {
Preset::Custom => return None,
Preset::Default => base(
KeepAlive::Duration { seconds: 10 },
Topology::Auto,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::GamingRig => base(
KeepAlive::Forever,
Topology::Exclusive,
ModeConflict::Steal,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::SharedDesktop => base(
KeepAlive::Off,
Topology::Extend,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::Hotdesk => base(
KeepAlive::Duration { seconds: 300 },
Topology::Exclusive,
ModeConflict::Reject,
Identity::PerClientMode,
LayoutMode::AutoRow,
),
Preset::Workstation => base(
KeepAlive::Duration { seconds: 300 },
Topology::Exclusive,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::Manual,
),
})
}
/// The persisted policy store: the loaded file value (or `None` when no file exists) behind its
/// JSON path. Mirrors [`crate::gpu::GpuPrefStore`] — private dir, temp-write + atomic rename,
/// in-memory rollback if the disk write fails.
pub struct DisplayPolicyStore {
path: PathBuf,
/// `Some` only when a valid `display-settings.json` was loaded / written — the "console has
/// configured this host" signal that gates whether Stage-0 call sites override their historical
/// env/default behavior.
cur: Mutex<Option<DisplayPolicy>>,
}
impl DisplayPolicyStore {
/// Load from `path`. A missing file ⇒ unconfigured (`None`); a corrupt file ⇒ unconfigured with a
/// warning (never fail host startup over a settings file).
pub fn load_from(path: PathBuf) -> Self {
let cur = match std::fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<DisplayPolicy>(&bytes) {
Ok(p) => Some(p),
Err(e) => {
tracing::warn!(path = %path.display(),
"display-settings.json unreadable — using built-in defaults: {e}");
None
}
},
Err(_) => None,
};
DisplayPolicyStore {
path,
cur: Mutex::new(cur),
}
}
/// The stored policy, or [`DisplayPolicy::default`] when unconfigured (for the mgmt GET).
pub fn get(&self) -> DisplayPolicy {
self.cur.lock().unwrap().clone().unwrap_or_default()
}
/// The console-configured policy, or `None` when no settings file exists. Stage-0 call sites use
/// this to decide whether to override their historical behavior (`None` ⇒ leave it untouched).
pub fn configured(&self) -> Option<DisplayPolicy> {
self.cur.lock().unwrap().clone()
}
/// The effective (preset-expanded) policy the console configured, or `None` when unconfigured.
pub fn configured_effective(&self) -> Option<EffectivePolicy> {
self.configured().map(|p| p.effective())
}
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
/// write succeeds, so a full disk can't leave memory and file disagreeing.
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
let policy = policy.sanitized();
if let Some(dir) = self.path.parent() {
crate::gamestream::create_private_dir(dir)?;
}
let tmp = self.path.with_extension("json.tmp");
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&policy)?)?;
std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = Some(policy);
Ok(())
}
}
/// The process-wide display-policy store (config-dir file), loaded once on first access — the same
/// global-accessor shape as [`crate::gpu::prefs`], because display setup happens deep in the
/// capture/vdisplay path where no app state is threaded.
pub fn prefs() -> &'static DisplayPolicyStore {
static STORE: OnceLock<DisplayPolicyStore> = OnceLock::new();
STORE.get_or_init(|| {
DisplayPolicyStore::load_from(crate::gamestream::config_dir().join("display-settings.json"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keep_alive_serializes_tagged_on_mode() {
assert_eq!(
serde_json::to_value(KeepAlive::Duration { seconds: 300 }).unwrap(),
serde_json::json!({ "mode": "duration", "seconds": 300 })
);
assert_eq!(
serde_json::to_value(KeepAlive::Off).unwrap(),
serde_json::json!({ "mode": "off" })
);
assert_eq!(
serde_json::to_value(KeepAlive::Forever).unwrap(),
serde_json::json!({ "mode": "forever" })
);
// Round-trips.
for k in [
KeepAlive::Off,
KeepAlive::Duration { seconds: 42 },
KeepAlive::Forever,
] {
let s = serde_json::to_string(&k).unwrap();
assert_eq!(serde_json::from_str::<KeepAlive>(&s).unwrap(), k);
}
}
#[test]
fn keep_alive_linger_resolution() {
assert_eq!(KeepAlive::Off.linger(), Linger::Immediate);
assert_eq!(
KeepAlive::Duration { seconds: 30 }.linger(),
Linger::For(Duration::from_secs(30))
);
assert_eq!(KeepAlive::Forever.linger(), Linger::Forever);
}
#[test]
fn default_policy_is_todays_behavior() {
let e = DisplayPolicy::default().effective();
assert_eq!(e.keep_alive, KeepAlive::Duration { seconds: 10 });
assert_eq!(e.topology, Topology::Auto);
assert_eq!(e.mode_conflict, ModeConflict::Separate);
assert_eq!(e.identity, Identity::PerClient);
assert_eq!(e.layout.mode, LayoutMode::AutoRow);
}
#[test]
fn custom_uses_explicit_fields_presets_override_them() {
// Custom: explicit fields flow through.
let p = DisplayPolicy {
preset: Preset::Custom,
keep_alive: KeepAlive::Off,
topology: Topology::Extend,
..DisplayPolicy::default()
};
assert_eq!(p.effective().keep_alive, KeepAlive::Off);
assert_eq!(p.effective().topology, Topology::Extend);
// A named preset ignores the explicit fields.
let p = DisplayPolicy {
preset: Preset::GamingRig,
keep_alive: KeepAlive::Off, // ignored
topology: Topology::Extend, // ignored
..DisplayPolicy::default()
};
let e = p.effective();
assert_eq!(e.keep_alive, KeepAlive::Forever);
assert_eq!(e.topology, Topology::Exclusive);
assert_eq!(e.mode_conflict, ModeConflict::Steal);
}
#[test]
fn workstation_preset_keeps_manual_layout_positions() {
let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 2560, y: 0 });
let p = DisplayPolicy {
preset: Preset::Workstation,
layout: Layout {
mode: LayoutMode::AutoRow, // preset forces Manual regardless
positions,
},
..DisplayPolicy::default()
};
let e = p.effective();
assert_eq!(e.layout.mode, LayoutMode::Manual);
assert_eq!(
e.layout.positions.get("1"),
Some(&Position { x: 2560, y: 0 })
);
}
#[test]
fn every_preset_expands() {
for preset in [
Preset::Default,
Preset::GamingRig,
Preset::SharedDesktop,
Preset::Hotdesk,
Preset::Workstation,
] {
assert!(preset_fields(preset).is_some(), "{preset:?} must expand");
}
assert!(preset_fields(Preset::Custom).is_none());
}
#[test]
fn sanitize_clamps_max_displays_and_pins_version() {
let p = DisplayPolicy {
version: 99,
max_displays: 0,
..DisplayPolicy::default()
}
.sanitized();
assert_eq!(p.version, 1);
assert_eq!(p.max_displays, 1);
let p = DisplayPolicy {
max_displays: 999,
..DisplayPolicy::default()
}
.sanitized();
assert_eq!(p.max_displays, 16);
}
#[test]
fn partial_json_fills_defaults() {
// A hand-written file with only a couple of fields loads, the rest defaulting.
let p: DisplayPolicy =
serde_json::from_str(r#"{ "preset": "custom", "max_displays": 2 }"#).unwrap();
assert_eq!(p.max_displays, 2);
assert_eq!(p.keep_alive, KeepAlive::default());
assert_eq!(p.topology, Topology::Auto);
assert_eq!(p.version, 1);
}
#[test]
fn store_roundtrips_and_gates_on_file_presence() {
let dir = std::env::temp_dir().join(format!("pf-disp-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("display-settings.json");
let _ = std::fs::remove_file(&path);
let store = DisplayPolicyStore::load_from(path.clone());
// Unconfigured: get() yields defaults, configured() is None.
assert!(store.configured().is_none());
assert_eq!(store.get(), DisplayPolicy::default());
// After a write the file gates flip to configured.
let want = DisplayPolicy {
preset: Preset::SharedDesktop,
..DisplayPolicy::default()
};
store.set(want.clone()).unwrap();
assert_eq!(
store.configured().as_ref().map(|p| p.preset),
Some(Preset::SharedDesktop)
);
assert_eq!(
store.configured_effective().unwrap().keep_alive,
KeepAlive::Off
);
// A fresh store reading the same path sees the persisted value.
let reopened = DisplayPolicyStore::load_from(path.clone());
assert_eq!(reopened.configured().unwrap().preset, Preset::SharedDesktop);
let _ = std::fs::remove_file(&path);
}
}
@@ -0,0 +1,418 @@
//! Host-lifetime **virtual-display registry** (design: `design/display-management.md` §3/§7): the
//! owner of the display lifecycle, so a display can outlive the session that created it (keep-alive)
//! and the management API can list + release kept displays.
//!
//! **Windows** already owns its lifecycle in [`super::manager::VirtualDisplayManager`] (one shared
//! IddCx monitor, refcounted, lingering); [`acquire`] there is a pass-through to `vd.create` (the
//! manager does the leasing), and [`snapshot`]/[`release`] read/control it.
//!
//! **Linux** gains a per-session **pool** here, driven by the pure [`super::lifecycle`] machine. The
//! key enabling fact: KWin / Mutter / gamescope put their capture node on the *default* PipeWire
//! daemon (`VirtualOutput::remote_fd == None`), reachable by `node_id` alone — so keeping the
//! backend's keepalive alive keeps the node alive, and a reconnect just re-attaches a fresh PipeWire
//! consumer to the same `node_id`. No fd dup / re-open needed. wlroots (`remote_fd == Some`, the
//! sandboxed xdpw portal) can't be kept without re-opening the portal fd per attach, so it is passed
//! through unchanged (teardown-on-drop, today's behavior) until that fresh-portal-capture re-attach
//! lands — a runtime gate on `remote_fd.is_some()`.
//!
//! The ownership split: the session's capturer no longer owns the real keepalive — the registry does.
//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, gen-stamped
//! `DisplayLease` (mirrors the Windows `MonitorLease`); dropping it releases the registry refcount,
//! and the lifecycle machine decides linger / teardown. `capture_virtual_output`'s signature is
//! unchanged — it just holds a lease instead of the real keepalive.
use anyhow::Result;
/// One live or kept virtual display, for the mgmt snapshot.
#[derive(Clone, Debug)]
pub struct DisplayInfo {
/// A stable-enough id for the `/display/release` slot argument (the owner's generation stamp).
pub slot: u64,
/// Backend name (`"pf-vdisplay"`, `"kwin"`, `"mutter"`, …).
pub backend: String,
/// `(width, height, refresh_hz)`.
pub mode: (u32, u32, u32),
/// `"active"` | `"lingering"` | `"pinned"`.
pub state: String,
/// Milliseconds until a lingering display is torn down (`None` when active/pinned).
pub expires_in_ms: Option<u64>,
/// Live sessions holding the display.
pub sessions: u32,
/// Short client label (cert-fp prefix / peer), when the owner tracks it.
pub client: Option<String>,
}
/// The live display set for the mgmt `/display/state` endpoint.
#[derive(Clone, Debug, Default)]
pub struct Snapshot {
pub displays: Vec<DisplayInfo>,
}
/// Acquire a virtual display for a session: reuse a kept (lingering/pinned) display of the same
/// backend + mode if one exists, else create a fresh one. Returns a [`VirtualOutput`](super::VirtualOutput)
/// the capturer consumes as before — but its `keepalive` is a registry lease, so the *display*
/// outlives the capturer per the keep-alive policy.
///
/// Windows delegates to the [`manager`](super::manager) via `vd.create` (unchanged); Linux uses the
/// pool below; other platforms pass through.
pub fn acquire(
vd: &mut Box<dyn super::VirtualDisplay>,
mode: super::Mode,
) -> Result<super::VirtualOutput> {
#[cfg(target_os = "linux")]
{
linux::acquire(vd, mode)
}
#[cfg(not(target_os = "linux"))]
{
vd.create(mode)
}
}
/// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read);
/// safe per management request.
pub fn snapshot() -> Snapshot {
#[cfg(target_os = "windows")]
{
let displays = super::manager::snapshot()
.map(|i| DisplayInfo {
slot: i.gen,
backend: i.backend.to_string(),
mode: i.mode,
state: i.state.to_string(),
expires_in_ms: i.expires_in_ms,
sessions: i.sessions,
client: None,
})
.into_iter()
.collect();
Snapshot { displays }
}
#[cfg(target_os = "linux")]
{
Snapshot {
displays: linux::snapshot(),
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
Snapshot::default()
}
}
/// Force-release kept (lingering/pinned) displays now — the `/display/release` endpoint. `slot`
/// selects one by [`DisplayInfo::slot`]; `None` releases every kept display. Active displays are
/// refused (releasing a display with live sessions is session management). Returns the number
/// released.
pub fn release(slot: Option<u64>) -> usize {
#[cfg(target_os = "windows")]
{
// Windows manages a single shared monitor at Stage 1, so `slot` is moot — release the one
// lingering monitor if present. (Multi-monitor gives `slot` meaning later.)
let _ = slot;
usize::from(super::manager::force_release())
}
#[cfg(target_os = "linux")]
{
linux::force_release(slot)
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
let _ = slot;
0
}
}
// ---------------------------------------------------------------------------------------------
// Linux keep-alive pool
// ---------------------------------------------------------------------------------------------
#[cfg(target_os = "linux")]
mod linux {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, Once, OnceLock};
use std::time::{Duration, Instant};
use anyhow::Result;
use super::DisplayInfo;
use crate::vdisplay::lifecycle::{self, Release};
use crate::vdisplay::policy::{self, Linger};
use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput};
/// One pooled display: the lifecycle state + the backend's REAL keepalive (kept alive here so the
/// compositor output — and thus its PipeWire `node_id` — survives past the session), plus the
/// capture coordinates a reconnecting session needs.
struct Entry {
life: lifecycle::State,
/// The backend's keepalive (KWin Wayland conn / Mutter D-Bus session / gamescope child). Its
/// `Drop` releases the compositor output — so it is dropped only on teardown/expiry.
keepalive: Box<dyn Send>,
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
mode: Mode,
backend: &'static str,
/// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease
/// — its entry was reused + re-stamped — is a no-op).
gen: u64,
}
struct Reg {
entries: Mutex<Vec<Entry>>,
gen: AtomicU64,
}
static REG: OnceLock<Reg> = OnceLock::new();
fn reg() -> &'static Reg {
REG.get_or_init(|| Reg {
entries: Mutex::new(Vec::new()),
gen: AtomicU64::new(1),
})
}
/// The linger resolution for Linux: the console policy's `keep_alive` when configured, else
/// **Immediate** (today's behavior — a Linux disconnect tears the output down at once).
fn linger() -> Linger {
policy::prefs()
.configured_effective()
.map(|e| e.keep_alive.linger())
.unwrap_or(Linger::Immediate)
}
/// Remove entries whose linger deadline has passed, returning them so the caller drops (tears
/// them down) *after* releasing the lock — a backend keepalive `Drop` (Mutter D-Bus Stop) can
/// block, and holding the pool lock across it would stall every other acquire/release.
fn take_expired(entries: &mut Vec<Entry>, now: Instant) -> Vec<Entry> {
let mut expired = Vec::new();
let mut i = 0;
while i < entries.len() {
if entries[i].life.poll_expiry(now) {
expired.push(entries.remove(i));
} else {
i += 1;
}
}
expired
}
/// Background thread (started once): reap lingering displays past their deadline.
fn ensure_timer() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = std::thread::Builder::new()
.name("vdisplay-linger".into())
.spawn(|| loop {
std::thread::sleep(Duration::from_millis(500));
let expired = {
let mut es = reg().entries.lock().unwrap();
take_expired(&mut es, Instant::now())
};
for e in expired {
tracing::info!(
backend = e.backend,
"virtual display: linger expired — torn down"
);
drop(e); // outside the lock
}
});
});
}
/// Build the session-facing [`VirtualOutput`]: the kept node + a fresh gen-stamped lease. Only
/// the poolable (`remote_fd == None`) backends reach here, so `remote_fd` is always `None`.
fn output_for(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
gen: u64,
) -> VirtualOutput {
VirtualOutput {
node_id,
remote_fd: None,
preferred_mode,
keepalive: Box::new(DisplayLease { gen }),
}
}
pub(super) fn acquire(vd: &mut Box<dyn VirtualDisplay>, mode: Mode) -> Result<VirtualOutput> {
ensure_timer();
let backend = vd.name();
let r = reg();
// Reap expired first (drop outside the lock).
let expired = {
let mut es = r.entries.lock().unwrap();
take_expired(&mut es, Instant::now())
};
drop(expired);
// Reuse: a kept (lingering/pinned) display of the same backend + mode. A reconnecting session
// re-attaches a fresh PipeWire consumer to the still-live `node_id`.
{
let mut es = r.entries.lock().unwrap();
if let Some(e) = es.iter_mut().find(|e| {
matches!(
e.life,
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
) && e.backend == backend
&& e.mode == mode
}) {
// Lingering/Pinned → Active (Acquire::Reuse); side effect matters, value is known.
e.life.acquire();
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
e.gen = gen;
let out = output_for(e.node_id, e.preferred_mode, gen);
tracing::info!(
backend,
node_id = e.node_id,
"virtual display reused (keep-alive reconnect)"
);
return Ok(out);
}
}
// Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads).
let real = vd.create(mode)?;
// wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the
// portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The
// poolable backends put their node on the default daemon (remote_fd = None).
if real.remote_fd.is_some() {
tracing::debug!(
backend,
"virtual display not poolable (portal fd) — keep-alive off for this backend"
);
return Ok(real);
}
let node_id = real.node_id;
let preferred_mode = real.preferred_mode;
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
let mut life = lifecycle::State::default();
life.acquire(); // Idle → Active{refs:1} (Acquire::Create)
let entry = Entry {
life,
keepalive: real.keepalive,
node_id,
preferred_mode,
mode,
backend,
gen,
};
r.entries.lock().unwrap().push(entry);
Ok(output_for(node_id, preferred_mode, gen))
}
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
/// the lock is released.
fn release(gen: u64) {
let Some(r) = REG.get() else { return };
let linger = linger();
let torn_down = {
let mut es = r.entries.lock().unwrap();
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
return; // stale lease (entry reused + re-stamped, or already gone) — no-op
};
match es[idx].life.release(Instant::now(), linger) {
Release::Teardown | Release::Noop => Some(es.remove(idx)),
Release::Linger => {
tracing::info!(
backend = es[idx].backend,
"virtual display: last session left — lingering (keep-alive)"
);
None
}
Release::Pin => {
tracing::info!(
backend = es[idx].backend,
"virtual display: last session left — pinned (keep-alive forever)"
);
None
}
// Linux entries are single-session (refs == 1), so Decref never occurs; harmless.
Release::Decref => None,
}
};
if let Some(e) = torn_down {
tracing::info!(
backend = e.backend,
"virtual display torn down (keep-alive off / released)"
);
drop(e); // outside the lock — the keepalive Drop may block
}
}
pub(super) fn snapshot() -> Vec<DisplayInfo> {
let Some(r) = REG.get() else {
return Vec::new();
};
let now = Instant::now();
r.entries
.lock()
.unwrap()
.iter()
.filter_map(|e| {
let (state, expires_in_ms, sessions) = match e.life {
lifecycle::State::Active { refs } => ("active", None, refs),
lifecycle::State::Lingering { until } => (
"lingering",
Some(until.saturating_duration_since(now).as_millis() as u64),
0,
),
lifecycle::State::Pinned => ("pinned", None, 0),
// Idle entries are never stored (removed on teardown).
lifecycle::State::Idle => return None,
};
Some(DisplayInfo {
slot: e.gen,
backend: e.backend.to_string(),
mode: (e.mode.width, e.mode.height, e.mode.refresh_hz),
state: state.to_string(),
expires_in_ms,
sessions,
client: None,
})
})
.collect()
}
pub(super) fn force_release(slot: Option<u64>) -> usize {
let Some(r) = REG.get() else { return 0 };
let released = {
let mut es = r.entries.lock().unwrap();
let mut out = Vec::new();
let mut i = 0;
while i < es.len() {
let selected = slot.is_none_or(|s| es[i].gen == s);
if selected && es[i].life.force_release() {
out.push(es.remove(i));
} else {
i += 1;
}
}
out
};
let n = released.len();
for e in released {
tracing::info!(
backend = e.backend,
"virtual display released (mgmt /display/release)"
);
drop(e);
}
n
}
/// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
struct DisplayLease {
gen: u64,
}
impl Drop for DisplayLease {
fn drop(&mut self) {
release(self.gen);
}
}
}
@@ -1,172 +0,0 @@
//! Per-client → stable monitor-id map for pf-vdisplay (Phase 2: per-client display-config persistence).
//!
//! Windows keys per-monitor config — notably DPI **scaling** (`HKCU\Control Panel\Desktop\PerMonitorSettings`)
//! — on the monitor's EDID identity AND its OS device path (whose per-connector discriminator is the IddCx
//! `ConnectorIndex` → target UID). The pf-vdisplay driver seeds BOTH the EDID serial and the `ConnectorIndex`
//! from a single monitor `id`. So for Windows to REAPPLY a given client's saved scaling on reconnect, that
//! client must get the SAME `id` every time. This map assigns each client (keyed by its cert fingerprint) a
//! STABLE id and the host passes it as [`AddRequest::preferred_monitor_id`](pf_driver_proto::control::AddRequest).
//!
//! The id space is bounded to `1..=15` because the driver uses the id as the IddCx `ConnectorIndex`, which
//! must stay `< MaxMonitorsSupported` (16). When more than 15 distinct clients are remembered, the
//! LEAST-RECENTLY-USED entry is evicted and its id reused (that evicted client simply re-establishes its
//! scaling once on its next connect). The map persists to `%ProgramData%\punktfunk\pf-vdisplay-identity.json`
//! so ids — and therefore the client→config association — survive host restarts.
//!
//! Anonymous/TOFU and GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream, never
//! reaching this map — they keep the driver's lowest-free slot behavior unchanged.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Max stable id. The driver uses the id as the IddCx `ConnectorIndex`, which must stay
/// `< MaxMonitorsSupported` (16) — so ids run `1..=15`.
const MAX_ID: u32 = 15;
#[derive(Serialize, Deserialize, Default)]
struct Store {
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
/// the LRU ordering survives host restarts.
tick: u64,
entries: Vec<Entry>,
}
#[derive(Serialize, Deserialize)]
struct Entry {
/// Lower-hex client cert fingerprint (the map key).
fp: String,
/// The client's stable monitor id (`1..=15`).
id: u32,
/// MRU stamp (compared against [`Store::tick`]).
seen: u64,
}
/// Persistent fingerprint → stable-id map (see the module docs).
pub(crate) struct MonitorIdentityMap {
path: PathBuf,
store: Store,
}
impl MonitorIdentityMap {
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
/// re-derives ids, costing a client one scaling re-set the first time).
pub(crate) fn load() -> Self {
let path = crate::gamestream::config_dir().join("pf-vdisplay-identity.json");
let mut store = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
.unwrap_or_default();
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s found-entry
// branch returns the stored id verbatim, so an out-of-range id (0 = the "auto" sentinel, or
// > MAX_ID) or a duplicate id/fp would flow straight into preferred_monitor_id. Drop out-of-range
// ids and dedup by BOTH fp and id (keeping the most-recently-seen on a clash) so no two fingerprints
// can map to the same id. (The driver also rejects a live-colliding id as a backstop.)
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
let mut seen_fp = std::collections::HashSet::new();
let mut seen_id = std::collections::HashSet::new();
store.entries.retain(|e| {
(1..=MAX_ID).contains(&e.id) && seen_fp.insert(e.fp.clone()) && seen_id.insert(e.id)
});
Self { path, store }
}
/// The stable id (`1..=15`) for the client fingerprint `fp`: its remembered id, or a freshly assigned
/// one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
pub(crate) fn resolve(&mut self, fp: [u8; 32]) -> u32 {
let key: String = fp.iter().map(|b| format!("{b:02x}")).collect();
self.store.tick = self.store.tick.wrapping_add(1);
let now = self.store.tick;
if let Some(e) = self.store.entries.iter_mut().find(|e| e.fp == key) {
e.seen = now;
let id = e.id;
self.persist();
return id;
}
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
let id = (1..=MAX_ID)
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
.unwrap_or_else(|| {
let lru = self
.store
.entries
.iter()
.enumerate()
.min_by_key(|(_, e)| e.seen)
.map(|(i, _)| i)
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
let evicted = self.store.entries.remove(lru);
evicted.id
});
self.store.entries.push(Entry {
fp: key,
id,
seen: now,
});
self.persist();
id
}
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
fn persist(&self) {
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
return;
};
if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, &self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(n: u8) -> [u8; 32] {
let mut f = [0u8; 32];
f[0] = n;
f
}
#[test]
fn stable_across_calls_and_distinct_per_client() {
let mut m = MonitorIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-test-{}.json", std::process::id())),
store: Store::default(),
};
let a1 = m.resolve(fp(1));
let b = m.resolve(fp(2));
let a2 = m.resolve(fp(1));
assert_eq!(a1, a2, "same client → same id");
assert_ne!(a1, b, "distinct clients → distinct ids");
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn lru_eviction_reuses_an_id_at_the_cap() {
let mut m = MonitorIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-lru-{}.json", std::process::id())),
store: Store::default(),
};
// Fill all 15 ids (clients 1..=15), then touch client 2 so client 1 is the LRU.
for n in 1..=15u8 {
m.resolve(fp(n));
}
let _ = m.resolve(fp(2));
// A 16th client evicts the LRU (client 1) and reuses its id; ids stay bounded.
let id16 = m.resolve(fp(16));
assert!((1..=MAX_ID).contains(&id16));
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
let _ = std::fs::remove_file(&m.path);
}
}
@@ -34,7 +34,7 @@ use windows::Win32::System::Threading::{
use super::{Mode, VirtualOutput};
use crate::win_display::{
force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd,
set_active_mode, SavedConfig,
set_active_mode, set_virtual_primary_ccd, SavedConfig,
};
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
@@ -169,10 +169,10 @@ pub(crate) struct VirtualDisplayManager {
/// The current IDD-push session's stop flag; a new connection signals the prior one to release its
/// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
/// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the
/// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across
/// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`].
identity_map: Mutex<super::identity::MonitorIdentityMap>,
// The per-client stable monitor-id map is now the process-wide `super::identity::global()`
// (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
// resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
// ConnectorIndex across reconnects and Windows reapplies its saved per-monitor DPI scaling.
}
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
@@ -188,7 +188,6 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
state: Mutex::new(MgrState::Idle),
setup_lock: Mutex::new(()),
idd_session_stop: Mutex::new(None),
identity_map: Mutex::new(super::identity::MonitorIdentityMap::load()),
})
}
@@ -527,10 +526,14 @@ impl VirtualDisplayManager {
) -> Result<Monitor> {
// Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved
// per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver
// auto-allocates the lowest-free id (the original slot-based behavior).
let preferred_id = client_fp
.map(|fp| self.identity_map.lock().unwrap().resolve(fp))
.unwrap_or(0);
// auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy
// picks per-client vs per-client-mode; Windows defaults to PerClient (its historical behavior).
let preferred_id = super::identity::resolve_slot(
client_fp,
(mode.width, mode.height),
crate::vdisplay::policy::Identity::PerClient,
)
.unwrap_or(0);
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
@@ -630,17 +633,36 @@ impl VirtualDisplayManager {
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
// ADD only advertises the mode; force it active so DXGI captures the requested size.
set_active_mode(n, mode);
// Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1.
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes a
// `Copy` `u32` by value and returns an owned `SavedConfig` snapshot (no borrowed
// memory crosses). It runs under the `state` lock, the sole mutator of the topology.
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
} else {
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
// Apply the display-management topology (Stage 2). `Exclusive` (default) deactivates the
// other display(s) so the IDD is the SOLE composited primary — an EXTENDED (non-primary)
// IDD isn't DWM-composited on this box → Desktop Duplication born-losts. `Primary` keeps the
// physical display(s) ACTIVE and makes the IDD primary (repositioned to origin). `Extend`
// leaves it a plain extension. Both isolate + primary go through the atomic CCD path (no
// MODE_CHANGE storm). Opt out (extend) with PUNKTFUNK_NO_ISOLATE=1 / the console policy.
use crate::vdisplay::policy::Topology;
match topology_action() {
// SAFETY (both arms): the CCD helper is `unsafe` for its topology FFI; it takes a
// `Copy` `u32` by value and returns an owned `SavedConfig` (no borrowed memory crosses),
// and runs under the `state` lock, the sole mutator of the topology.
Topology::Exclusive => {
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
}
Topology::Primary => {
// The IDD auto-activates as the SOLE display on a headless box, so the
// physical (if present) is deactivated and QueryDisplayConfig sees only the
// virtual. Force EXTEND first to (re)activate every CONNECTED display
// alongside the virtual, THEN reposition to make the virtual primary — so the
// physical stays active. (The bring-up above only force-EXTENDs when the
// virtual FAILS to auto-resolve; here it resolved, so we do it explicitly.)
unsafe { force_extend_topology() };
thread::sleep(Duration::from_millis(300));
ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) };
}
Topology::Extend | Topology::Auto => {
tracing::info!(
"display topology=extend — IDD stays extended (no isolate / no primary)"
);
}
}
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
}
@@ -890,10 +912,124 @@ fn resolve_render_pin() -> Option<LUID> {
crate::win_adapter::resolve_render_adapter_luid()
}
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`).
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
/// maps it into the wire shape.
pub(crate) struct ManagedInfo {
pub backend: &'static str,
pub mode: (u32, u32, u32),
/// `"active"` | `"lingering"`.
pub state: &'static str,
/// Milliseconds until a lingering monitor is torn down (`None` when active).
pub expires_in_ms: Option<u64>,
/// Live sessions holding the monitor.
pub sessions: u32,
/// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg.
pub gen: u64,
}
impl VirtualDisplayManager {
/// Snapshot the current monitor for the mgmt `/display/state` endpoint. `None` when Idle.
pub(crate) fn snapshot(&self) -> Option<ManagedInfo> {
let st = self.state.lock().unwrap();
let (mon, state, sessions, expires_in_ms) = match &*st {
MgrState::Idle => return None,
MgrState::Active { mon, refs } => (mon, "active", *refs, None),
MgrState::Lingering { mon, until } => {
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64;
(mon, "lingering", 0u32, Some(ms))
}
};
Some(ManagedInfo {
backend: self.driver.name(),
mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz),
state,
expires_in_ms,
sessions,
gen: mon.gen,
})
}
/// Force-tear-down a LINGERING monitor now (the `/display/release` endpoint) — so a
/// physical-screen user gets their screen back without waiting out the linger. An Active monitor
/// is refused (stopping a live session is session management, not display management). Returns
/// `true` if a lingering monitor was released.
pub(crate) fn force_release(&self) -> bool {
let Some(dev) = self.device_handle() else {
return false;
};
let mut st = self.state.lock().unwrap();
if matches!(&*st, MgrState::Lingering { .. }) {
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *st, MgrState::Idle) {
// SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()`
// (cached handles are never closed — a dead one is retired, kept alive; see
// `DeviceSlot`). `mon` was moved out of the `Lingering` state under the `state` lock,
// so it is exclusively owned here — no aliasing.
unsafe { self.teardown(dev, mon) };
return true;
}
}
false
}
}
/// Snapshot the managed monitor, or `None` when no backend has initialised the manager yet (no
/// session has ever run) or it is Idle. Safe to call per management request.
pub(crate) fn snapshot() -> Option<ManagedInfo> {
VDM.get().and_then(VirtualDisplayManager::snapshot)
}
/// Force-release a lingering monitor now; `false` if nothing was lingering (or the manager is
/// uninitialised).
pub(crate) fn force_release() -> bool {
VDM.get()
.map(VirtualDisplayManager::force_release)
.unwrap_or(false)
}
/// Linger window before a session-less monitor is torn down. The console display-management policy
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
/// else the 10 s default.
fn linger_ms() -> u64 {
use crate::vdisplay::policy::{prefs, Linger};
if let Some(eff) = prefs().configured_effective() {
return match eff.keep_alive.linger() {
Linger::Immediate => 0,
Linger::For(d) => d.as_millis() as u64,
// Pinned (keep forever) is built in the display-lifecycle stage; until then fall back to
// the default rather than silently keeping the monitor — and thus the physical screens —
// dark indefinitely. (The mgmt PUT also rejects `forever` at Stage 0, so this is defensive.)
Linger::Forever => {
tracing::warn!(
"display policy: keep_alive=forever not yet honored — lingering 10 s \
(Pinned lands in the display-lifecycle stage)"
);
10_000
}
};
}
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
}
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
/// extended; `Primary` makes it primary while keeping the physical(s) active; `Exclusive` disables the
/// physical(s) so the IDD is the sole composited desktop.
fn topology_action() -> crate::vdisplay::policy::Topology {
use crate::vdisplay::policy::Topology;
if crate::vdisplay::policy::prefs()
.configured_effective()
.is_some()
{
return crate::vdisplay::effective_topology();
}
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() {
Topology::Extend
} else {
Topology::Exclusive
}
}
+137 -29
View File
@@ -18,11 +18,13 @@ use windows::Win32::Devices::Display::{
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO,
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO,
DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, DISPLAYCONFIG_PATH_INFO,
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION,
SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
};
use windows::Win32::Foundation::POINTL;
use windows::Win32::Graphics::Gdi::{
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
@@ -353,6 +355,48 @@ pub(crate) type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_M
/// doesn't export it, so define it here.
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
/// Query the current ACTIVE display config (paths + modes), truncated to the real counts. `None` on
/// API failure. Shared by [`isolate_displays_ccd`] (snapshot + per-attempt re-query) and
/// [`count_other_active`].
unsafe fn query_active_config() -> Option<SavedConfig> {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
return None;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
.is_err()
{
return None;
}
paths.truncate(np as usize);
modes.truncate(nm as usize);
Some((paths, modes))
}
/// Count currently-ACTIVE display paths whose target id != `keep_target_id` — i.e. displays that would
/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually took.
unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
let (paths, _) = query_active_config()?;
Some(
paths
.iter()
.filter(|p| {
p.targetInfo.id != keep_target_id && p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0
})
.count() as u32,
)
}
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
@@ -363,6 +407,61 @@ const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper
// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies).
pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> {
// Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes.
let saved = query_active_config()?;
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
// live topology each attempt and re-apply until ONLY the keep target is active. Secure-desktop
// correctness depends on this — the lock screen must not land on a stray panel while we stream.
for attempt in 1..=4u32 {
let (mut paths, mut modes) = query_active_config()?;
let mut others = 0u32;
for p in paths.iter_mut() {
if p.targetInfo.id == keep_target_id {
continue;
}
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
others += 1;
}
}
// Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does
// NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
// forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior —
// don't permanently rewrite the user's multi-display layout; the teardown restore handles it).
let mut flags = SDC_APPLY
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
| SDC_ALLOW_CHANGES
| SDC_FORCE_MODE_ENUMERATION;
if others == 0 {
flags |= SDC_SAVE_TO_DATABASE;
}
let rc = SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags);
// VERIFY the OUTCOME (rc alone lies — a "successful" apply can leave a panel active): re-query
// and confirm no non-keep display survived. Only then is the virtual truly the sole desktop.
let survivors = count_other_active(keep_target_id).unwrap_or(0);
if survivors == 0 {
tracing::info!("display isolate (CCD): target {keep_target_id} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})");
return Some(saved);
}
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
std::thread::sleep(std::time::Duration::from_millis(250));
}
tracing::error!("display isolate (CCD): FAILED to isolate target {keep_target_id} after 4 attempts — a non-virtual display stayed active (the field-reported exclusive-mode bug)");
Some(saved)
}
/// **Primary (topology=primary)** — make the virtual output the PRIMARY display while KEEPING every
/// other display ACTIVE (unlike [`isolate_displays_ccd`], which deactivates them). Windows treats the
/// display whose source sits at the desktop origin `(0,0)` as primary, so we move the virtual's source
/// to `(0,0)` and shift every other active source to its right — all paths stay active. Done as ONE
/// atomic CCD `SetDisplayConfig` (NOT GDI `CDS_SET_PRIMARY`, which storms
/// `DXGI_ERROR_MODE_CHANGE_IN_PROGRESS` when another display is live — see [`set_active_mode`]).
/// Returns the original config to restore on teardown.
pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
@@ -385,36 +484,45 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
paths.truncate(np as usize);
modes.truncate(nm as usize);
let saved = (paths.clone(), modes.clone());
let mut others = 0u32;
for p in paths.iter_mut() {
if p.targetInfo.id == keep_target_id {
// The virtual output's source width, to lay the other displays out to its right.
let virt_width = paths.iter().find_map(|p| {
if p.targetInfo.id != keep_target_id {
return None;
}
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
let m = modes.get(idx)?;
(m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE)
.then(|| m.Anonymous.sourceMode.width as i32)
})?;
let others = paths.len().saturating_sub(1);
// Reposition each active path's SOURCE once: the virtual to (0,0) (= primary), the other
// displays PACKED left-to-right from the virtual's right edge — kept active, no overlap and no
// gap (vs. blindly shifting each by virt_width, which leaves a dead gap when EXTEND already
// placed them to the right). Dedup source-mode indices (a cloned group shares one).
let mut next_x = virt_width;
let mut done = std::collections::HashSet::new();
for p in paths.iter() {
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
if !done.insert(idx) {
continue;
}
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
others += 1;
let Some(m) = modes.get_mut(idx) else {
continue;
};
if m.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE {
continue;
}
if p.targetInfo.id == keep_target_id {
m.Anonymous.sourceMode.position = POINTL { x: 0, y: 0 };
} else {
let w = m.Anonymous.sourceMode.width as i32;
m.Anonymous.sourceMode.position = POINTL { x: next_x, y: 0 };
next_x += w;
}
}
if others == 0 {
// The virtual path shows active in the CCD database (from set_active_mode's legacy
// ChangeDisplaySettingsExW), but a legacy mode-set does NOT drive the IddCx adapter's
// EVT_IDD_CX_ADAPTER_COMMIT_MODES — and without COMMIT_MODES the OS never calls
// ASSIGN_SWAPCHAIN, so the driver never receives composed frames. Force an explicit CCD
// SetDisplayConfig commit of the (sole) virtual path so the IddCx path actually activates.
// SDC_FORCE_MODE_ENUMERATION makes the OS re-enumerate + re-commit even though the CCD DB
// already lists the path active.
let rc = SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
| SDC_ALLOW_CHANGES
| SDC_SAVE_TO_DATABASE
| SDC_FORCE_MODE_ENUMERATION,
);
tracing::info!("display isolate (CCD): forced CCD re-commit of sole virtual path {keep_target_id} rc={rc:#x} (drives IddCx COMMIT_MODES → ASSIGN_SWAPCHAIN)");
return Some(saved);
}
let rc = SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
@@ -424,9 +532,9 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
| SDC_FORCE_MODE_ENUMERATION,
);
if rc == 0 {
tracing::info!("display isolate (CCD): deactivated {others} other display(s) — SudoVDA target {keep_target_id} is now the sole desktop");
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
} else {
tracing::warn!("display isolate (CCD): SetDisplayConfig failed rc={rc:#x} (tried to deactivate {others} path(s))");
tracing::warn!("display primary (CCD): SetDisplayConfig failed rc={rc:#x} (virtual {keep_target_id} primary, physicals kept)");
}
Some(saved)
}
+782
View File
@@ -0,0 +1,782 @@
# Virtual-display management & lifecycle policy — design
> **Status (2026-07-05):** **Stages 04 DONE + on-glass validated; Stage 5 STARTED** (branch
> `display-mgmt-stage0`, not yet merged). See the **Status — handoff** block under §11 for the
> per-stage state, the key decisions (notably the Windows `reject` default), and what's left.
> This doc designs a **policy layer on top of the
> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive
> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second
> client wants a different mode), stable display identity (so desktop environments remember
> per-client settings like scaling), and **multi-monitor** (several virtual displays forming one
> desktop, fed by one client or by several). The `VirtualDisplay` trait and the per-backend
> `create()` mechanics stay as they are; this layer decides *when* to create, *how many*, *how
> long* to keep, *what else* to do to the topology, and *under which identity*.
Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md`
(pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent,
not required).
## 1. Goal
Today the virtual-display behavior is hardcoded per platform and per backend:
- A session's virtual output is created at connect and torn down (RAII) at session end — a
disconnect destroys the display, reshuffles the desktop, and (on gamescope bare-spawn) **kills
the running game**.
- "Make the streamed output the sole desktop" is an env knob on Linux
(`PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY`, default-on for the
auto-detected desktop path) and default-on on Windows (`PUNKTFUNK_NO_ISOLATE` to opt out) —
and on Linux "primary" and "disable the other outputs" are conflated into one switch.
- What happens when a second client connects is an emergent property of the platform: Linux
creates a second output (multi-view), Windows **reconfigures the shared monitor under the
live session** (join-path `reconfigure` in `vdisplay/windows/manager.rs::acquire`), GameStream
preempts.
- Only Windows gives a client a stable monitor identity (`vdisplay/windows/identity.rs`), so only
Windows reapplies per-client display config (DPI scaling) across reconnects. On KDE every
session's output is `Virtual-punktfunk` at whatever mode — scaling has to be re-set per connect
and is shared across every client.
- One session = exactly one display. A client with two physical monitors can only stream one;
a tablet can't join an existing streamed desktop *as a second monitor* on purpose (the Linux
multi-view behavior half-does it by accident, with no layout control).
Goal: **one shared, documented configuration surface** — a small set of orthogonal options with
safe defaults and selectable presets, stored host-side, editable from the web console, applied
uniformly across the punktfunk/1 and GameStream paths and across all five backends (KWin,
gamescope, Mutter, wlroots, Windows pf-vdisplay), each backend implementing what it can and
**honestly declining** what it can't (the same honest-downgrade convention as 4:4:4/10-bit).
## 2. What exists today (inventory)
The asymmetry worth internalizing: **Windows already has most of the machinery; Linux has none.**
| Mechanism | Windows (pf-vdisplay) | Linux (kwin/mutter/wlroots) | gamescope |
|---|---|---|---|
| Lifecycle owner | `VirtualDisplayManager` singleton — `Idle / Active{refs} / Lingering{until}` state machine, gen-stamped `MonitorLease` | none — session owns `VirtualOutput.keepalive`, capturer drop = teardown | managed path: debounced TV-session restore (`RESTORE_DEBOUNCE` 5 s) + warm-session reuse; spawn path: child dies with the session |
| Keep-alive after disconnect | linger, default 10 s (`PUNKTFUNK_MONITOR_LINGER_MS`) | none | managed: 5 s debounce (hardcoded) |
| Reuse on reconnect | join Active (refcount++) / adopt Lingering (with a dead-swapchain preempt for IDD) | none (always create fresh) | managed: reuses the warm session |
| Primary / exclusive | `isolate_displays_ccd` (exclusive), default on, restore on teardown | `apply_virtual_primary` = primary **and** disable others, env-gated, restore on drop; Mutter `make_virtual_primary` = sole monitor (APPLY_TEMPORARY) | n/a (own nested session) |
| Mode conflict | join-path silently reconfigures the shared monitor (last-wins) | each session gets its own output (multi-view) | managed: one session; spawn: one gamescope per client |
| Stable identity | `identity.rs` — cert-fp → id 1..=15 (EDID serial + ConnectorIndex), LRU, persisted `pf-vdisplay-identity.json` | none — KWin output always named `punktfunk`, sway `HEADLESS-N`, Mutter auto-serial | n/a |
| Multi-monitor | manager is single-monitor (driver supports 16 connectors) | N outputs happen to coexist (multi-view), no layout/group semantics | single-output nested session |
Design consequence: the plan is **not** "build a manager" — it's (a) extract the state machine
Windows already proved into a platform-neutral, unit-testable core, (b) give Linux the ownership
split it's missing (manager owns the keepalive, session holds a lease), (c) put a typed policy
in front of both, (d) extend identity to Linux where the compositor allows it, and (e) grow the
slot model into display **groups** so multi-monitor is an arrangement of slots, not a new system.
## 3. Architecture
Three new pieces, layered strictly **above** the `VirtualDisplay` trait (no backend rewrite):
```
┌────────────────────────────────────────────┐
mgmt API / console │ DisplayPolicy (vdisplay/policy.rs) │ pure config: schema,
host.env compat ───▶│ presets · layout · validation · persist │ presets, env-compat
└───────────────┬────────────────────────────┘
│ read per acquire/release (live-reload)
┌───────────────▼────────────────────────────┐
punktfunk/1 session │ DisplayRegistry (vdisplay/registry.rs) │ host-lifetime singleton:
GameStream session ─▶ acquire(identity, mode) → DisplayLease │ owns ManagedDisplay slots
mgmt /display/state │ release(lease) · linger timer · groups │ grouped per desktop,
└───────┬────────────────────────┬───────────┘ drives the pure Lifecycle
│ create()/drop keepalive │ reconfigure/topology/layout ops
┌────────────▼──────────┐ ┌──────────▼───────────────┐
│ Linux backends │ │ Windows │
│ kwin · gamescope · │ │ VirtualDisplayManager │
│ mutter · wlroots │ │ (existing; delegates its │
│ (unchanged trait) │ │ state decisions upward) │
└───────────────────────┘ └──────────────────────────┘
```
- **`vdisplay/policy.rs`** — the typed config (`DisplayPolicy`), preset expansion, JSON
persistence (`<config>/display-settings.json`, the `gpu-settings.json` pattern: sanitize on
load, atomic tmp+rename write), and the deprecated-env-knob mapping. 100 % pure and
unit-tested (the `pick_gamescope_mode` / `wiring_plan.rs` discipline).
- **`vdisplay/lifecycle.rs`** — the pure state machine: per-slot
`Idle / Active{refs} / Lingering{until} / Pinned` plus the **admission decision function**
(given: policy, requesting identity, requested mode(s), current slots → `Create | Reuse |
Reconfigure | Join{at_mode} | Steal{victims} | Reject{reason}`). No I/O, no OS types — fully
proptest/unit-testable, shared verbatim by both platforms. `Pinned` is `Lingering` with no
deadline (keep-alive **forever**), releasable only via mgmt/teardown.
- **`vdisplay/registry.rs`** — the host-lifetime singleton that owns `ManagedDisplay` slots
(the backend `VirtualOutput` **including its `keepalive`**, the identity slot, current mode,
group membership, topology-restore state) and executes the lifecycle decisions: calls
`VirtualDisplay::create`, holds keepalives past session end, runs the linger timer, applies
layout, exposes the mgmt snapshot. On Windows it wraps the existing `VirtualDisplayManager`
(which keeps its driver/CCD/preempt specifics — the IDD dead-swapchain preempt, the
WUDFHost-death preempt, `begin_idd_setup` — but reads its linger duration and join/steal
behavior from the policy instead of env/hardcode).
### The ownership split (the one real refactor)
Today `capture::capture_virtual_output(vout, …)` consumes the whole `VirtualOutput` — the
capturer owns the keepalive, so capturer drop tears the display down. That coupling is exactly
what makes keep-alive impossible on Linux. Split it:
```rust
pub struct DisplayLease { /* registry handle + gen stamp; Drop = release(refcount--) */ }
pub struct CaptureSource { // what capture actually needs — Copy-ish, no ownership
pub node_id: u32,
pub remote_fd: Option<OwnedFd>, // Mutter portal daemon (dup'd per capture attach)
pub preferred_mode: Option<(u32, u32, u32)>,
#[cfg(windows)] pub win_capture: Option<WinCaptureTarget>,
}
// registry.acquire(...) -> (DisplayLease, CaptureSource)
```
The `keepalive: Box<dyn Send>` moves into `ManagedDisplay` inside the registry. The session's
pipeline holds the `DisplayLease` (mirrors the Windows `MonitorLease`, gen-stamped so a stale
lease from a preempted display is a release-no-op — the proven pattern). `build_pipeline`'s
`vd.create(mode)` call sites (`punktfunk1.rs`, `gamestream/stream.rs`, `spike.rs`) become
`registry::acquire(...)`. Every failure/retry path keeps its shape — the retry-hold lease trick
in `build_pipeline_with_retry` maps 1:1 onto a `DisplayLease`.
**Re-capture on reuse** is per-backend (see §7): wlroots re-runs portal capture of the still-
existing output; KWin/Mutter reconnect a PipeWire consumer to the kept node (validation item);
gamescope re-discovers the nested compositor's node; Windows already re-targets. If re-capture
of a kept display fails, the registry falls back to **teardown + fresh create** (bounded, inside
the existing `build_pipeline_with_retry` budget) — keep-alive is an optimization, never a new
failure mode.
## 4. The configuration surface
### 4.1 Schema (`<config>/display-settings.json`)
```json5
{
"version": 1,
// Convenience: a named preset. "custom" (or absent) = the explicit fields below rule.
// When a preset IS named, the fields below are ignored (the console writes one or the other).
"preset": "custom",
// How long a display (and, on gamescope, the nested session + game) survives after the last
// session detaches. "off" = teardown at session end. "forever" = until host stop / explicit
// release. Duration is seconds.
"keep_alive": { "mode": "duration", "seconds": 300 }, // "off" | {"duration", seconds} | "forever"
// What the host does to the box's display topology while virtual displays are up:
// "extend" add the virtual display(s), touch nothing else
// "primary" make the group's primary virtual display the OS primary; physical outputs
// stay enabled
// "exclusive" the managed virtual displays become the ONLY enabled outputs (physicals
// disabled, restored when the group's last display is torn down)
// "auto" today's behavior: exclusive on the auto-detected desktop path & Windows,
// extend when the operator pinned a compositor/env said otherwise
"topology": "auto",
// Admission when a client connects while another client's display/session is live and the
// requested mode differs (same-client reconnect ALWAYS reuses/reconfigures its own display):
// "separate" give the new client its own virtual display ON THE SAME DESKTOP (bounded by
// max_displays) — this is also the "many clients as monitors" mode, see §6A
// "steal" stop the existing session(s), tear down / reconfigure, serve the new client
// "join" admit the new client AT THE EXISTING MODE (Welcome/serverinfo reflect the
// real mode — the honest-downgrade convention); never reconfigures under a
// live session
// "reject" refuse the new client with a clear handshake error
"mode_conflict": "separate",
// Stable display identity → desktop environments persist per-display config (KDE scaling):
// "shared" one identity for everything (today's Linux behavior)
// "per-client" one identity per paired client cert fingerprint (today's Windows);
// a multi-display client (§6B) gets one identity per (client, display #)
// "per-client-mode" one identity per (client, WxH) — distinct scaling per resolution,
// at the cost of identity slots (Windows has 15; LRU eviction)
"identity": "per-client",
// How the group's displays are arranged in the desktop coordinate space (§6.2):
// "auto-row" left-to-right in acquire order, top-aligned (deterministic default);
// a §6B client's own monitor-arrangement hints override auto placement
// "manual" per-identity-slot offsets below (console-arranged); wins over client hints
"layout": { "mode": "auto-row", "positions": { /* "<slot>": {"x": 0, "y": 0} */ } },
// Upper bound on simultaneously-live virtual displays (Active + Lingering + Pinned, across
// the whole group). Admission returns Reject/Steal (per mode_conflict) when full; a §6B
// AddDisplay beyond it is declined. Windows is additionally capped by the driver (see §7).
"max_displays": 4
}
```
Deliberate non-options (rejected):
- **Per-client policy overrides** — real, but v2. One host-global policy first; the schema keys
are chosen so a later `"clients": {"<fp>": {…}}` overlay is additive.
- **Idle timeout for Pinned displays** ("forever but tear down after 24 h") — `keep_alive`
already expresses it as a long duration; don't add a second axis.
- **Choosing the linger for capture-loss separately from clean disconnect** — the registry only
sees "last lease released"; the session layer already distinguishes and (see §5.1) an explicit
client **quit** bypasses keep-alive entirely.
- **Per-display FEC/bitrate policy knobs** — bitrate stays session-negotiated per stream as
today; a multi-display session's per-display bitrates are the client's ask, not host policy.
### 4.2 Precedence & live-reload
`display-settings.json` (console-written) **>** deprecated env knobs **>** built-in defaults —
the exact precedence convention the GPU preference set (`console preference >
PUNKTFUNK_RENDER_ADAPTER > auto`). The policy is **read at each acquire/release**, not once at
startup (it's file/registry state, not env — no `HostConfig` constraint), so a console change
applies to the next connect/disconnect without a host restart, same contract as the GPU card
("applies to the next session"). Env-knob compatibility mapping (all logged as deprecated when
they take effect):
| Legacy knob | Maps to |
|---|---|
| `PUNKTFUNK_MONITOR_LINGER_MS` | `keep_alive = duration(ms/1000)` (Windows) |
| `PUNKTFUNK_NO_ISOLATE` | `topology = "extend"` (Windows) |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `topology = "exclusive"` when truthy, `"extend"` when explicitly `0` |
The `apply_session_env` default-on write of `*_VIRTUAL_PRIMARY` for the auto-desktop path is
**replaced** by `topology = "auto"` resolving to exclusive on that path — one fewer process-env
mutation on the connect path (a small win for the env-race surface `ENV_LOCK` guards).
### 4.3 Presets
Presets are the documented, supported entry point; raw fields are the escape hatch. Expansion
lives in `policy.rs` and is unit-tested so docs and code can't drift.
| Preset | keep_alive | topology | mode_conflict | identity | layout | Story |
|---|---|---|---|---|---|---|
| `default` | 10 s | auto | separate | per-client | auto-row | Today's behavior, made explicit: short linger absorbs client hiccups/reconnects, streamed output is the sole desktop on the auto path, extra clients get their own view. |
| `gaming-rig` | forever | exclusive | steal | per-client | auto-row | Dedicated headless/couch box: the game and its display survive disconnects indefinitely; whoever connects takes the box over ("the TV model"). |
| `shared-desktop` | off | extend | separate | per-client | auto-row | Streaming a desktop someone may also use physically: never blank the real monitors, never keep ghost outputs, concurrent viewers each get a view. |
| `hotdesk` | 5 min | exclusive | reject | per-client-mode | auto-row | One user at a time with fast reattach (roaming between own devices); a second user is told the box is busy; each device+resolution keeps its own scaling. |
| `workstation` | 5 min | exclusive | separate | per-client | manual | The multi-monitor daily driver: your dual-monitor client gets both displays back exactly where you arranged them (§6B), or a tablet joins as a side monitor (§6A). |
## 5. Option semantics in detail
### 5.1 `keep_alive`
**What survives.** The *display* (compositor output / IddCx monitor / spawned gamescope) and its
topology state survive; the *session* (QUIC conn, capture stream, encoder, input devices, audio
plumbing) does not. Concretely per backend, "the display survives" means:
- **kwin / mutter / wlroots**: the output stays in the layout → windows don't reshuffle, a
running game keeps rendering at the client's mode, reconnect is fast (no create/negotiate).
- **gamescope (bare spawn)**: the nested gamescope **and the game launched inside it keep
running** — this is the headline user value (Sunshine/Apollo-style detach/reattach) and the
reason `keep_alive` is worth building at all.
- **gamescope (managed)**: the policy duration replaces the hardcoded 5 s
`RESTORE_DEBOUNCE` — the warm Steam session stays up for the window; `forever` means the TV
session is never auto-restored (release via console/tray).
- **Windows**: the existing linger, plus `forever` = the new `Pinned` state.
**Rules.**
- Input devices (uinput pads, libei/EIS contexts) stay session-scoped — a disconnect reads to
the game as "controller unplugged", which games handle. (Keeping pads alive for kept sessions
is a possible later refinement; do not build it now.)
- The **launch command runs once per display creation, never per attach** — a reconnect to a
kept gamescope must not double-launch the game. Today launch already happens once per
`build_pipeline`-successful session; the invariant moves with the create into the registry.
- An explicit client **quit** (GameStream `cancel`/quit-app; a future punktfunk/1
`EndSession{quit}` control message — protocol growth, trailing-byte back-compat as usual)
bypasses keep-alive: the user said "stop the game", so tear down now. Plain disconnects and
connection losses honor the policy.
- Host shutdown tears everything down (RAII on exit, as today). A host crash leaves whatever
the OS reclaims — Wayland connections die with the process (compositor reclaims outputs),
spawned gamescopes die with the process group, the pf-vdisplay watchdog reaps monitors when
pings stop. No new orphan class.
- `keep_alive` + `topology=exclusive` means **physical monitors stay dark after disconnect**
until linger expiry / release. This is intended (gaming-rig) but must be loud in the docs, and
the release-now escape hatch (§8) must exist in the same release that ships `forever`.
### 5.2 `topology`
Splits the currently-conflated "primary" knob into three honest levels, **group-aware** (§6.1):
"exclusive" means *the managed virtual displays* are the only enabled outputs — never disable a
sibling slot; restore fires when the group's last display drops. Per-backend mapping:
| | extend | primary | exclusive |
|---|---|---|---|
| KWin | no-op | `kscreen-doctor output.X.primary` only | primary + disable non-managed others (today's `apply_virtual_primary` with a registry-driven filter, §6.1), restore-on-teardown |
| Mutter | no-op | `ApplyMonitorsConfig` incl. physicals, virtual primary | today's sole-monitor config (`make_virtual_primary`) extended to include all group members |
| wlroots | no-op | **unsupported** (no primary concept) → log + treat as extend | `swaymsg output <phys> disable` + re-enable on teardown (new, small) |
| gamescope | n/a — the nested session *is* the whole world; all three resolve to no-op | | |
| Windows | skip isolate (today's `PUNKTFUNK_NO_ISOLATE`) | CCD primary-only variant (new, small — `set_active_mode` already exists; primary without deactivation) | today's `isolate_displays_ccd`, extended to isolate to the SET of managed targets |
Restore stays bound to **display teardown** (keepalive drop / `teardown()`), not session end —
already true everywhere; keep-alive inherits it for free. The KWin restore-before-reclaim
ordering (re-enable others *first* so KWin never sees zero enabled outputs) is preserved.
`auto` resolves at acquire time: exclusive on Windows and on the Linux auto-detected-desktop
path, extend under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test posture) — bit-for-bit
today's defaults, so `default` preset = no behavior change.
### 5.3 `mode_conflict`
Enforced at **admission**, before the Welcome / RTSP launch, in the lifecycle decision function
— so the client gets an honest answer, not a mid-build failure:
- Applies only across **different clients** (identity ≠ identity). A same-client reconnect
always preempts its own zombie session / adopts its own kept display and reconfigures it to
the newly requested mode (today's behavior, now uniform on all platforms).
- `separate` — allocate another slot in the desktop group (Linux multi-view today, upgraded
with layout — §6A; Windows: **requires the multi-monitor manager, §6.6** — until that stage
lands, `separate` on Windows resolves to `join` with a startup + docs warning rather than
silently doing something else).
- `join` — the second client is admitted at the live display's mode. punktfunk/1: the Welcome's
`Config` carries the real mode (the client already renders what the Welcome says — the
4:4:4/10-bit honest-downgrade pattern). GameStream: serverinfo/RTSP negotiate the live mode.
**This replaces the Windows join-path's silent last-wins `reconfigure` under a live session**
— that current behavior becomes opt-in as `steal`.
- `steal` — signal the victim sessions' stop flags (the machinery `begin_idd_setup` already
uses), wait the release grace, tear down or reconfigure, admit. Trust note: conflict policy
runs **after** the pairing gate, so on a default host only paired clients can steal; on an
`--open`/TOFU host any accepted client can — the docs call this out and recommend `reject`
for open hosts.
- `reject` — punktfunk/1: a typed handshake refusal (extend the existing error path with a
`busy` reason string carrying the live mode + client label so the client UI can say "host is
streaming 2560×1440 to <name>"); GameStream: the 503/session-in-use answer Moonlight already
understands.
Interaction with `--max-concurrent` (session bound) is unchanged and orthogonal: sessions and
displays are different resources; `max_displays` bounds displays, the accept-loop permit bounds
in-flight sessions. `join` deliberately lets N sessions share one display (that's today's
Windows concurrency model).
### 5.4 `identity` — stable displays, persistent scaling (the KDE ask)
Two halves: an **identity map** (who gets which slot) and a **per-backend identity carrier**
(how a slot becomes something the DE keys its config on).
**Map** — generalize `vdisplay/windows/identity.rs` (it's already pure + unit-tested) into a
platform-neutral `vdisplay/identity.rs`: key = client cert fp (plus display ordinal for a §6B
multi-display client, plus WxH under `per-client-mode`), value = small stable slot id, LRU
eviction at the platform cap, persisted `<config>/display-identity.json` (Windows migrates
`pf-vdisplay-identity.json` on first load — read old path if new absent, write new).
Anonymous/unpaired clients stay slot 0 = auto/shared. **GameStream clients get identities too**
(improvement over today): the paired GameStream client cert fingerprint feeds the same map, so a
Moonlight device also keeps its scaling — today `set_client_identity` is only wired on the
punktfunk/1 path.
**Carriers per backend:**
- **Windows** — shipped: slot → EDID serial + IddCx ConnectorIndex; Windows keys
`PerMonitorSettings` (DPI scaling) on exactly that. Cap 15 (ConnectorIndex <
MaxMonitorsSupported=16). `per-client-mode` and per-display ordinals work unchanged but burn
slots faster — the LRU already handles pressure; document the trade-off.
- **KWin** — the carrier is the **output name**: `stream_virtual_output(name, …)` becomes
`punktfunk-<slot>` → output `Virtual-punktfunk-<slot>`. KWin persists per-output config
(scale, transform, mode) in `kwinoutputconfig.json`, matching EDID-less outputs **by name**
so a stable per-client name is precisely what makes KDE reapply that client's scaling.
Two validation items before relying on it (Stage 3 gate, §11):
1. confirm KWin ≥ 6.5.6 actually persists + reapplies scale for `Virtual-*` outputs;
2. confirm a *remembered mode* doesn't fight the freshly requested one (if KWin reapplies a
stale stored mode on output-added, our existing `set_custom_refresh`/mode apply must run
after and win — it already reads back the achieved mode, so a fight is at least visible).
Side effect worth having: distinct names also unclash concurrent sessions (today two
simultaneous KWin sessions both create `Virtual-punktfunk` and `set_custom_refresh` /
`other_enabled_outputs` match **by that shared name** — a latent multi-view bug this fixes).
- **wlroots** — no rename and no settable description via IPC; headless outputs are
`HEADLESS-N` by creation order. Identity is therefore **not reliably carriable** → declared
unsupported (`shared` behavior regardless of setting; capability matrix + docs say so). The
single-session case is de-facto stable (`HEADLESS-1`), which users can pin in sway config —
document that recipe instead of pretending.
- **Mutter** — `RecordVirtual` auto-generates the virtual monitor's serial; no public D-Bus
surface to control it → unsupported for now. Note for later: re-evaluate Mutter's
virtual-monitor D-Bus surface per GNOME release (tracked as an open item, not a promise).
- **gamescope** — n/a: the client streams a whole nested session; scaling inside it is per-game.
**Scale as a punktfunk-side option (small, high-value adjunct):** KWin's
`stream_virtual_output` takes a `scale` argument we currently hardcode to `1.0`. Add an optional
per-client `default_scale` (console-editable next to the device list) passed at create on KWin;
on Windows scaling stays the OS's job (identity makes it persist). This gives HiDPI phones/
tablets a correct-sized desktop on first connect, before any DE-side persistence exists. A
client-requested scale hint in the Hello (trailing-byte back-compat, like the gamepad-pref byte)
is future protocol growth — design it when a client actually wants to send it.
## 6. Multi-monitor
Two scenarios, deliberately separated because they differ ~10× in cost:
- **§6A — many clients, one desktop ("second screen")**: each client device becomes one more
monitor of the same host desktop (tablet as a side monitor next to the laptop's stream).
Structurally this already half-exists on the Linux desktop compositors (`separate` gives
every client its own output on the shared desktop); what's missing is *intent*: layout
control, group-aware topology, and honest per-backend gating. **No protocol change** — it
ships on the registry work.
- **§6B — one client, many displays**: a client with two physical monitors gets two virtual
displays, streamed as two video planes, presented one-per-monitor, arranged on the host to
mirror the client's physical arrangement. Needs protocol growth, N encoder pipelines, client
presenter work, and (on Windows) the multi-monitor manager. **punktfunk/1-native only**
GameStream/Moonlight has no multi-display vocabulary and stays single-stream.
### 6.1 Display groups (registry concept, serves both)
`ManagedDisplay` slots gain a **group**: the set of displays sharing one desktop/session.
- kwin / mutter / wlroots: one group per compositor session — every acquired slot joins it
(that *is* the shared desktop).
- gamescope spawn: one group per spawned nested session. gamescope is single-output — a §6B
client asking N displays there resolves to 1, honestly (the extra `AddDisplay`s are declined).
- Windows: one group (the desktop); slots = IddCx monitors (§6.6).
Group-aware semantics — these fix latent issues even before multi-monitor ships:
- **`exclusive` disables only non-managed (physical/bootstrap) outputs, never group members.**
Today's KWin `apply_virtual_primary` disables "everything not named `Virtual-punktfunk`" —
under Stage-3 per-slot names, a second session's exclusive would disable the *first* session's
live output. The filter must consult the registry (the set of managed output names), not one
hardcoded name. Same shape on Windows (`isolate_displays_ccd` isolates to the managed target
*set*) and Mutter (the sole-monitor config includes all group members).
- **`primary` designates one group member** — for §6B the client marks which of its displays is
primary (its OS already knows); for §6A the first slot wins unless the console re-designates.
- **Topology restore is per-group, not per-display** — the saved pre-stream config is restored
when the group's **last** member drops, never while siblings live. (Windows `SavedConfig` and
the KWin `restore` vec move from `Monitor`/`StopGuard` into the group record.)
### 6.2 Layout
The `layout` policy block (§4.1) controls where group members sit in the desktop space:
- `auto-row` (default): left-to-right in acquire order, top-aligned — what compositors mostly
do anyway, made deterministic.
- `manual`: per-identity-slot offsets, console-edited (an OS-settings-style drag mini-map is
the stretch UI; an x/y table ships first). Keyed by identity slot, so *client B's tablet
always reappears to the right of client A's monitor* — layout + identity compose.
- A §6B client sends its real monitor arrangement as per-display position hints; they override
`auto-row` (mouse crossing between streamed monitors then matches the client's physical
layout) but lose to `manual` pins.
Backend mapping — all existing tooling, no new protocols: KWin
`kscreen-doctor output.X.position.x,y` (validate syntax the way `set_custom_refresh` did);
wlroots `swaymsg output <n> position X Y`; Mutter logical-monitor positions in the same
`ApplyMonitorsConfig` we already build; Windows CCD source origins in the same
`SetDisplayConfig` path `isolate_displays_ccd` uses.
**Host-side input routing.** §6A needs nothing (N clients inject into one desktop — already
true today). §6B needs the injectors to map `(display, x, y)` → desktop coordinates using the
group layout: per-backend work items — libei absolute positioning is per-region, the wlr
virtual-pointer protocol binds to an output, Windows `SendInput` absolute is desktop-normalized
(pure math off the group layout). Wire change in §6.3.
Two realities to document, not engineer around: **cursor rendering is already correct** (every
backend embeds the cursor per-output — KWin `POINTER_EMBEDDED`, the IDD's per-monitor
composition — so it appears only on the stream it's on and "crosses" between monitors
naturally), and **a §6A desktop has one cursor shared by all member clients** — exactly right
for the one-user-two-devices case (touch the tablet, the cursor jumps there), chaotic for two
people; genuinely independent users want gamescope multi-user
(`design/gamescope-multiuser.md`), not groups.
### 6.3 Protocol growth for §6B (punktfunk/1 only)
Principle: **a display is one data-plane instance.** Don't touch the hardened core packet
format — N displays = N × (encoder + send thread + core `Session` over its own UDP flow), one
shared QUIC control connection, one set of session-scoped side planes (audio, mic, rumble,
input). And **don't grow the Hello**: the handshake's back-compat idiom is single trailing
bytes — a variable-length display list doesn't fit it, and it doesn't need to, because the
control stream stays open after `Start` (Reconfigure/ClockProbe already ride it).
- **Capability**: client advertises `VIDEO_CAP_MULTI_DISPLAY` (`video_caps` bit `0x10`); the
Welcome echoes the host's per-session display budget as one trailing byte (`max_displays`
remaining, `0`/absent = single-display host — old hosts are automatically honest).
- **Negotiation**: the Hello/Welcome pair is untouched and establishes **display 0** exactly as
today (an old host serves a multi-monitor-capable client's primary display with zero special
cases). Extra displays negotiate post-`Start` on the control stream:
`AddDisplay { mode, position_hint, primary: bool } → DisplayAdded { index, config /* the same
honest per-display Config shape the Welcome carries: mode, bit depth, chroma, codec */ }` or
`DisplayDeclined { reason }`. `RemoveDisplay { index }` and a per-display `Reconfigure`
(index as a trailing byte on the existing message) complete the set — **client monitor
hotplug maps 1:1 onto Add/Remove mid-session.**
- **Data plane**: `DisplayAdded` carries the flow binding (host UDP port / flow token) for that
display's own core `Session`. Per-flow crypto derives the AES-GCM nonce salts per
(direction, display index) — no salt reuse across flows; FEC domains are independent per flow
(loss on one display can't stall another) — this is why "one Session per display" beats
muxing display ids into the core packet format.
- **Side planes**: pointer/touch events gain a display-index byte (same trailing-byte pattern
as the gamepad pref; absent = display 0); 0xCF host-timing and 0xCE HDR-metadata datagrams
gain the index the same way (a client mixing an HDR laptop panel + SDR external monitor gets
per-display grades). Audio/mic/rumble/gamepad stay session-scoped, untouched.
- **Per-display honesty**: each display negotiates bit depth/chroma/codec independently through
the same resolve functions — a host that can afford HEVC Main10 on one head and only 4:2:0 on
the second says so in each `DisplayAdded.config`.
- **Stats**: the stats-unification vocabulary (four measurement points, p50/p95 windows) gains
a display dimension — per-display series, HUD shows the focused display's equation
(`design/stats-unification.md` gets a §6B addendum; don't invent client-local stats).
- **C ABI / connector**: `punktfunk_add_display` / per-display `next_au` routing (an index out
param on the existing call keeps the ABI additive), so PunktfunkKit/JNI stay on the shared
connector.
### 6.4 Encoder & resource budget
N displays = N encode pipelines. NVENC consumer session caps — and the existing auto 2-way
**split-encode** above ~1 Gpix/s consuming *two* NVENC sessions for one stream — mean admission
must budget: `DisplayAdded` is granted only if the encoder backend confirms capacity (extend the
existing NVENC session accounting + the AMF/QSV probes with a `can_open_another()` check), and
**split-encode is disabled for multi-display sessions** (displays win over split; a 5K@240
single head is not the multi-monitor use case). `max_displays` bounds the group. Same idle-cost
note as keep-alive: every added display composites + encodes at full rate. Bandwidth is
per-display additive (two 4K heads ≈ 2× the bitrate): the per-host speed test's recommendation
should be read **per session** and split across that session's displays — the client divides
its ask, the host doesn't second-guess it (per-display bitrate is deliberately not host policy,
§4.1).
### 6.5 Client staging for §6B
- **Linux GTK + Windows clients first** — natural multi-window presenters: one
window/fullscreen surface per display on the matching physical monitor, the existing capture
state machine extended to span them (pointer crossing between our fullscreen windows must not
release capture).
- **macOS second** (multi-NSWindow across Screens; Spaces/fullscreen interplay is the risk).
- **Android/iOS/tvOS: never advertise the capability** — single-display presenters. A phone or
tablet still participates in multi-monitor via §6A (it *is* a second monitor), which needs
nothing from those clients.
### 6.6 Windows multi-monitor manager
Previously an explicit non-goal; now a designed **final stage** — the single-monitor manager
keeps working unchanged until it lands:
- **Manager**: the singleton's `MgrState` becomes a map keyed by connector id; `lifecycle.rs`
is already written per-slot, so the Windows manager's delegation doesn't change shape. The
IDD reconnect preempts (dead-swapchain, WUDFHost-death) become per-slot.
- **Driver**: pf-vdisplay already ADDs by connector id 1..=15 (the identity map's bound). The
sealed frame channel (`IOCTL_SET_FRAME_CHANNEL`) must become **per-monitor** — channel
messages carry the monitor id, reusing the multi-pad `pad_index` pattern (driver proto v3;
`design/idd-push-security.md` addendum: same unnamed-object + handle-dup broker per ring).
Driver work + CI + on-glass validation is exactly why this stage is last.
- **Capture/encode**: one IDD-push capturer per monitor ring; budget per §6.4.
- **CCD**: isolate/primary/layout already group-aware from §6.1/6.2.
## 7. Per-backend capability matrix
What each backend supports; unsupported cells resolve to the stated fallback and are surfaced in
`GET /api/v1/display/state` per display (`"capabilities": [...]`) so the console can grey options
out per-host instead of lying:
| Capability | KWin | gamescope spawn | gamescope managed | gamescope attach | Mutter | wlroots | Windows |
|---|---|---|---|---|---|---|---|
| keep-alive (linger/forever) | ✅ hold the vout thread; re-attach PipeWire consumer to the kept node — **validate** | ✅ nested session + game survive; re-discover node | ✅ policy replaces the 5 s debounce | — (never owned it) | ✅ hold the D-Bus session; consumer re-attach — **validate** | ✅ output persists; fresh portal capture per attach (cleanest) | ✅ shipped; add `Pinned` |
| reconfigure kept display to a new mode | ✅ `set_custom_refresh` + kscreen mode | ✅ SIGKILL+respawn is the honest "reconfigure" (game restarts — docs say so) or decline → recreate | ✅ existing managed-mode set | — | ⚠ node is sized by negotiation; renegotiation unproven — fallback recreate | ✅ `output <n> mode --custom` | ✅ `reconfigure()` shipped |
| topology: primary | ✅ | n/a | n/a | n/a | ✅ | ❌ → extend | ✅ (new, small) |
| topology: exclusive | ✅ shipped (filter → group-aware) | n/a | n/a | n/a | ✅ shipped (→ group-aware) | ✅ (new, small) | ✅ shipped (→ group-aware) |
| mode_conflict: separate / §6A group | ✅ multi-output | ✅ one gamescope per client (independent sessions, no shared desktop) | ❌ single session → steal/join/reject only | — | ✅ assumed — **validate ≥2 RecordVirtual monitors** | ✅ HEADLESS-N | ⏳ §6.6 (until then → join + warning) |
| §6B multi-display for one client | ✅ N outputs + layout | ❌ single-output (extra displays declined) | ❌ | — | ⚠ gated on the ≥2-monitor validation | ✅ | ⏳ §6.6 |
| layout (position control) | ✅ kscreen position | n/a | n/a | n/a | ✅ ApplyMonitorsConfig | ✅ `output position` | ✅ CCD origins |
| stable identity | ✅ output name per slot | n/a | n/a | n/a | ❌ (API gives no serial control) | ❌ (no name control) | ✅ shipped |
The **attach** gamescope sub-mode never owns the display (it mirrors a foreign gamescope) — the
registry records it as an unmanaged pass-through slot: no keep-alive, no topology, no identity,
conflict = join-only. That's just codifying reality.
## 8. Management API, web console, tray
Endpoints (bearer-only, like `/gpus`; documented in `mgmt.rs`'s OpenAPI → regenerate
`api/openapi.json`):
- `GET /api/v1/display/settings``{ settings, preset_expansions, capabilities }` — the stored
policy plus what this host's live backend can actually do (so the console renders accurate
controls).
- `PUT /api/v1/display/settings` — validate (unknown fields rejected, ranges clamped like the
GPU PUT), persist atomically, log. Applies from the next acquire/release.
- `GET /api/v1/display/state` → live slots:
```json
{ "displays": [ { "slot": 3, "backend": "kwin", "output": "Virtual-punktfunk-3",
"mode": "2560x1440@120", "state": "lingering", "expires_in_s": 240,
"client": "a1b2c3…(label)", "display_index": 0, "sessions": 0,
"group": 1, "position": {"x": 0, "y": 0}, "topology": "exclusive" } ] }
```
- `POST /api/v1/display/release` `{ "slot": 3 }` or `{}` (all) — immediately tear down
Lingering/Pinned displays. **Refuses Active** (stopping a live session is session management,
not display management — don't blur it).
- `PUT /api/v1/display/layout` `{ "positions": { "<slot>": {"x":…, "y":…} } }` — the manual
arrangement (applies live to affected groups; persisted into the policy's layout block).
Web console (Host page, next to the GPU card): a **Virtual displays** card — preset selector
(radio + one-line story each, `custom` unlocking the advanced fields), the live display list from
`/state` with per-row "Release" buttons and a linger countdown, the arrangement editor (x/y
table first, drag mini-map stretch), capability-aware disabled states. The loopback
`local/summary` gains a `displays_live` count (counts only — the established no-secrets rule) so
the **tray** tooltip can show "1 display kept alive" and offer a release-all action through the
same elevation path as start/stop (Windows) / `systemctl --user` (Linux) — tray work is a
stretch stage, not core.
## 9. Enforcement points (exact code paths)
1. **punktfunk/1 handshake** (`punktfunk1.rs`, where the Hello is resolved into the Welcome):
call `registry::admit(identity, requested_mode)` → on `Reject` answer the typed refusal; on
`Join` the Welcome's `Config` carries the live mode; on `Steal` signal victims + wait release
(bounded) before proceeding. This runs **before** `SessionContext` is built.
2. **`virtual_stream` / `build_pipeline`** (`punktfunk1.rs:3511`, `build_pipeline_with_retry`):
`vd.create(mode)` → `registry::acquire(...) -> (DisplayLease, CaptureSource)`; the retry-hold
lease keeps its exact semantics. The mid-stream **Reconfigure**, **session-switch**, and
**capture-loss rebuild** paths re-acquire through the registry so a compositor switch
correctly releases the old backend's slot and the new mode updates the slot's record.
3. **Control stream, post-Start** (§6B): `AddDisplay`/`RemoveDisplay` handlers spawn/stop a
per-display pipeline (its own `registry::acquire`, encoder, send thread, UDP flow) inside the
same `SessionContext` lifetime; `--max-concurrent` counts sessions, not displays.
4. **GameStream** (`gamestream/stream.rs::open_gs_virtual_source`): same acquire; identity from
the paired client cert fp (new); quit-app → `release(quit=true)` which bypasses keep-alive.
5. **Session end**: capturer drop (releases the PipeWire consumer / ring) then `DisplayLease`
drop → lifecycle decides Linger/Pinned/teardown. On Linux the keepalive no longer rides the
capturer (§3 ownership split).
6. **`serve` startup/shutdown**: registry constructed once (like `start_restore_worker`), all
slots torn down on graceful exit.
## 10. Documentation plan
A dedicated docs-site page **`docs-site/content/docs/virtual-displays.md`** (+ `meta.json`
entry), cross-linked from `configuration.md`, `host-cli.md`, `steamos-host.md`, and
`troubleshooting.md`. Structure — written for the operator, presets first:
1. **What punktfunk does with displays** — 5 lines: per-client-sized virtual output, created on
connect, what "keep alive"/"exclusive" mean physically.
2. **Pick a preset** — the §4.3 table verbatim, each with a one-paragraph story and the JSON it
expands to ("copy this into display-settings.json, or click it in the console").
3. **Options reference** — one subsection per option: values, default, per-backend support
badge row, and a concrete example scenario each ("You stream from your phone at 1080p and
your TV at 4K120: with `identity: per-client` KDE remembers 150 % scaling for the phone and
100 % for the TV").
4. **Multi-monitor** — the two scenarios in user language: *"use your tablet as a second
monitor"* (§6A: connect a second device, arrange it in the console) and *"stream your
dual-monitor setup"* (§6B: which clients support it, what the host does with the layout),
plus the support matrix and the GameStream single-stream note.
5. **Persistent scaling (KDE/Windows)** — the user-visible recipe: connect once, set scaling in
System Settings / Windows Settings while streaming, done — punktfunk's stable identity makes
the DE reapply it. Honest support table (KWin ✅ / Windows ✅ / GNOME ❌ why / Sway recipe).
6. **Troubleshooting** — "my physical monitors stayed off" → release button/endpoint + the
keep_alive×exclusive explanation; "second client gets the wrong resolution" → `join`
semantics; "game restarted on reconnect" → gamescope reconfigure caveat; "second display
declined" → encoder budget (§6.4); KWin/gamescope version floors.
7. **Legacy env knobs** — the §4.2 mapping table, marked deprecated.
Also update: `README.md` status row, `CLAUDE.md` (status + invariant below), `host.env.example`
(point at the JSON/console, list deprecated knobs), and the OpenAPI snapshot.
**New design invariant for CLAUDE.md** (once shipped): *Display lifecycle is owned by the
registry, policy-driven; sessions hold leases, never the keepalive. New backends implement
`VirtualDisplay` + declare capabilities; they never grow their own lifecycle/env knobs. A
display is one data-plane instance — multi-display never muxes into the core packet format.*
## 11. Staged implementation
Each stage lands green (`cargo test/clippy/fmt`, OpenAPI drift check) and is independently
shippable; on-glass validation notes inline. **Heads-up for this box:** the dev VM currently has
no GPU passthrough (RTX 5070 Ti detached at the Proxmox level, 2026-07-01) — KWin-path live
validation needs the GPU back or one of the LAN hosts.
### Status — 2026-07-05 handoff
Branch **`display-mgmt-stage0`** (NOT merged; merge when the whole feature is polished/complete).
On-glass validation boxes: **`.173`** (Windows, pf-vdisplay + a physical monitor), **`.21`** (CachyOS
GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `fedora:43` distrobox;
`.48` Fedora KDE is DOWN). Every commit is `cargo test/clippy/fmt`-green.
- **Stages 04: DONE + on-glass validated.** 0 (policy surface + `/display/settings` + console card),
1 (pure `lifecycle.rs` + `registry.rs` Linux keep-alive pool + ownership split via `DisplayLease` +
`/display/state`/`/display/release`), 2 (topology decoupling — distinct `extend`/`primary`/`exclusive`
via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot
output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`),
4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies).
- **Stage 5: STARTED** — only the critical §6.1 **group-aware exclusive** fix for KWin has landed
(`kwin.rs` `MANAGED_PREFIX` + first-slot-wins), unit-tested but NOT yet driven by two concurrent
sessions on-glass. Everything else in Stage 5 is TODO.
**Decisions / deltas from this plan as written — read before continuing:**
- **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two
concurrent Windows sessions both drive one pf-vdisplay monitor's **single-capturer** IDD-push channel
(newest-delivery-wins) → the 2nd freezes + can WEDGE the 1st (observed live: it wedged `.173`, needed a
reboot — surfaced as Moonlight "no video"). True multi-session Windows capture is §6.6/Stage 7. So on
Windows `separate` (incl. the unconfigured default) resolves to `reject` — a 2nd client gets a clean
503, the live session is protected; `join`/`steal` are explicit opt-ins. Centralised in
`admission::effective_conflict()`, shared by the native handshake + GameStream `h_launch`.
- **Reject IS typed:** punktfunk/1 closes the QUIC connection with app code `0x42` + the reason
`"host busy: streaming WxH@Hz to <client>"`, which the client reads from `ApplicationClosed`.
- **Stage 5's group-aware exclusive fixes a bug Stage 3 introduced:** per-slot names meant a 2nd
`exclusive` session's disable-filter would black out the 1st session's `Virtual-punktfunk-<id>` output.
Fixed on KWin by recognising the whole managed group via the shared `Virtual-punktfunk` prefix.
- **GameStream 503** is implemented (owner-fp on `LaunchSession`, `gamestream_admission()` unit-tested,
shares `effective_conflict()`) but NOT Moonlight-validated (can't drive `/launch` autonomously).
**Deferred (need a display-attached box / a specific compositor / a real client):** the `primary`
physical-keep EFFECT on Linux + a Windows primary-only CCD variant; **wlroots `exclusive`**; the KWin
set-150 %-scaling ROUND-TRIP (SSH can't drive `kscreen-doctor` into the live session — the persist
mechanism itself is already proven); GameStream 503 on-glass; two-concurrent-session validation of the
Stage-5 group-aware exclusive.
- **Stage 0 — policy + plumbing-lite. [DONE ✓]** `policy.rs` (schema/presets/persist/env-compat, fully
unit-tested), mgmt GET/PUT `/display/settings`, console card (settings only), docs page
skeleton with the presets/options tables. Behavior deltas limited to what existing knobs can
express: Windows linger reads the policy; Linux topology auto/extend/exclusive routes through
the existing primary code. *No lifecycle change yet — zero-risk adoption of the surface.*
- **Stage 1 — lifecycle core + Linux keep-alive. [DONE ✓]** `lifecycle.rs` pure machine
(+proptests: no lost teardowns, no double-frees across arbitrary acquire/release/expiry
interleavings), `registry.rs`, the ownership split (`DisplayLease`/`CaptureSource` — the one
cross-cutting refactor, touches `capture_virtual_output` signatures on both OSes), keep-alive
live for **wlroots** and **gamescope-spawn** (the two backends where reuse is structurally
trivial), `/display/state` + `/display/release`, console live-list. Windows manager delegates
linger/pinned decisions to `lifecycle.rs` (its driver specifics untouched).
*Validate:* sway on this box (headless), gamescope spawn: connect → disconnect → verify
vkcube/game still runs → reconnect → same session, no relaunch.
- **Stage 2 — topology decoupling. [DONE ✓]** Kept-node PipeWire re-attach on
KWin and Mutter (each behind its validation; fallback recreate), `primary` (without disable)
on KWin/Mutter/Windows, `exclusive` on wlroots, restore paths regression-tested.
*Validate:* headless KDE session (the `run-headless-kde.sh` rig), GNOME box .248.
- **Stage 3 — identity. [DONE ✓]** Platform-neutral identity map + migration, per-slot KWin output
naming (+ the concurrent-session name-clash fix riding along), GameStream identity wiring,
optional `per-client-mode` keying, per-client `default_scale` on KWin.
*Validate on KDE:* connect client A → set 150 % scaling → disconnect → reconnect → scaling
reapplied; client B unaffected; `kwinoutputconfig.json` inspected for the named entries.
- **Stage 4 — mode-conflict admission. [DONE ✓]** Decision function (`vdisplay/admission.rs`,
`decide`/`admit`/`effective_conflict`) wired into the punktfunk/1 handshake + GameStream `h_launch`,
the typed punktfunk/1 `busy` refusal (QUIC close `0x42` + reason), GameStream 503 path, `steal`
victim signaling reusing the stop-flag plumbing. **The Windows default is `reject`, NOT the
`join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer
IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503
unit-tested, Moonlight-pending.
- **Stage 5 — §6A multi-client monitors. [STARTED]** Display groups, group-aware exclusive/primary/
restore (incl. the name-filter fix), layout auto-row + manual, `/display/layout`, console
arrangement table. Cheap: rides Stages 13 infrastructure, no protocol change.
**Done so far:** KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by
the `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group
primary, unit-tested. **TODO:** Mutter + wlroots group-aware analogues (Mutter is more involved — its
sole-monitor `ApplyMonitorsConfig` must include ALL group virtuals, not just its own); layout
auto-row + manual + `/display/layout` + console table; per-group topology restore (restore the
physical only when the group's LAST member drops); gamescope groups (single-output → decline extras).
*Validate:* two clients (probe + GTK) on the headless KDE box forming a 2-output desktop;
drag a window across; disconnect one → its slot lingers per policy, sibling unaffected,
restore only after both drop.
- **Stage 6 — §6B protocol + Linux host + GTK client.** `VIDEO_CAP_MULTI_DISPLAY`, control-
stream Add/Remove/DisplayAdded, per-flow nonce-salt derivation, per-display pipelines on
KWin/wlroots, input display-index routing, C ABI additions, GTK client multi-window
presenter, stats display dimension.
*Validate:* loopback probe requesting 2 displays → two decodable .h265 outs + per-display
0xCF; then a real dual-monitor Linux client against the KDE box.
- **Stage 7 — Windows multi-monitor** (§6.6: driver proto v3 per-monitor sealed rings, manager
slot map, Windows client multi-window, `separate` un-gated on Windows) — gated on driver CI +
on-glass, deliberately last.
- **Stage 8 — polish.** Docs page finalized with real console screenshots, tray count/release
(stretch), README/CLAUDE.md/host.env.example updates, `local/summary` count, macOS §6B
presenter (its own mini-stage when scheduled).
## 12. Risks & open questions
- **PipeWire node reuse after consumer detach (KWin/Mutter)** — the load-bearing unknown for
Stage 2. If a kept node won't renegotiate for a fresh consumer, keep-alive on those backends
degrades to "topology-stable but recreate-on-reconnect" (still valuable: no desktop reshuffle
when *paired with identity naming*). The fallback is designed in, so the stage can't strand.
- **KWin persistence of `Virtual-*` output config** — if KWin declines to persist virtual
outputs, per-client scaling on KDE needs punktfunk-side scale storage instead (the
`default_scale` adjunct already gives us the mechanism); identity naming stays worthwhile for
the name-clash fix alone.
- **KWin stored-mode vs requested-mode fights** under identity naming (§5.4) — mitigated by
our post-create mode apply + read-back; watch for it in Stage 3 validation.
- **Compositor ceilings on simultaneous virtual outputs** — load-bearing for §6A/§6B: probe
KWin's virtual-output count and Mutter's `RecordVirtual` count (≥2 monitors) empirically in
Stage 2/5; `max_displays` default 4 keeps us under any realistic ceiling.
- **Encoder session exhaustion** (§6.4) — NVENC caps × split-encode × concurrent sessions must
be budgeted in one place (the admission check), or a second display can silently break an
unrelated session's encode. Split-encode is disabled for multi-display sessions by design.
- **Per-display input mapping** — each Linux injector (libei, wlr, gamescope EIS) binds
absolute coordinates differently; the §6B display-index routing is per-injector work with
per-backend validation, not one generic patch.
- **Client-side multi-window fullscreen juggling** (§6.5) — per-monitor DPI on Windows, Spaces
on macOS, pointer capture across our own windows; the reason clients stage GTK/Windows first.
- **Idle kept displays burn resources** — a kept gamescope keeps the game rendering (GPU) at
full rate; a kept KWin output keeps compositing; every §6B display encodes at full rate.
Document; a later refinement could drop a kept session's refresh, out of scope here.
- **Security posture** — keep-alive keeps a user session composited/running unattended;
nothing is unlocked that wasn't, and admission still rides pairing. `steal` on `--open`
hosts is the one sharp edge → docs recommend `reject` there (§5.3). The mgmt endpoints are
bearer-only; `local/summary` exposes counts only. §6B's extra UDP flows reuse the hardened
core `Session` unchanged (per-flow salts derived, never reused) — no new crypto surface.
- **Mutter identity** — blocked on GNOME API surface; re-check per GNOME release.
+134
View File
@@ -0,0 +1,134 @@
---
title: Arch Linux
description: Install a punktfunk host on Arch (and Arch-derived distros) from the signed pacman binary repo.
---
Set up a punktfunk host on **Arch Linux** (or an Arch-derived distro like CachyOS/EndeavourOS). The
host installs from a **signed pacman binary repo**, so it updates with `pacman -Syu` like the rest
of your system — no building required. Host encode is **NVENC on NVIDIA** and **VAAPI on
AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks per GPU).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
> Prefer to build it yourself? A split `PKGBUILD` (host + client + optional web console) is in the
> repo at `packaging/arch/` — see the [appendix](#appendix--build-from-source-pkgbuild). The binary
> repo below is the supported path.
## 1. GPU prerequisites
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (provides NVENC + the EGL/CUDA zero-copy path).
Arch's stock `ffmpeg` already has NVENC built in — no RPM-Fusion-style swap like Fedora needs.
- **AMD / Intel:** the Mesa stack (`mesa`, `libva-mesa-driver` for AMD, `intel-media-driver` for
Intel) provides the VAAPI encoder — usually already installed on a desktop.
## 2. Add the signed repo
The registry **signs its database and every package**, so first trust its key once (after this,
packages install signature-verified):
```sh
# Trust the registry signing key.
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
| sudo pacman-key --add -
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
# verifies signed packages against the key you just trusted.
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
```
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
> release is cut. For the latest `main` build, use `[punktfunk-canary]` instead (same `Server` line,
> just the repo name). Enable exactly one. See [Release Channels](/docs/channels).
## 3. Install the host
```sh
sudo pacman -Sy punktfunk-host # the streaming host
sudo pacman -S punktfunk-web # optional: the browser management console (pairing + status)
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
`punktfunk-client` (the GTK4 couch/Deck client) is in the same repo if this box is also a client.
The host package ships the systemd **user** units, the udev rule, the UDP socket-buffer sysctl
tuning, and example configs. Updates later are just `sudo pacman -Syu`.
## 4. Configure and run
The host runs as a systemd **`--user`** service — it needs your session's PipeWire and D-Bus.
Copy a starting config, enable the service, and enable linger so it starts at boot without a login:
```sh
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env # then edit
systemctl --user daemon-reload
systemctl --user enable --now punktfunk-host
sudo loginctl enable-linger "$USER"
```
Which compositor the host captures depends on your desktop — it drives a per-client virtual output
via KWin (Plasma), Mutter (GNOME), or wlroots (Sway), or spawns a headless **gamescope** session
per connect. For a headless appliance, the package also ships `punktfunk-kde-session.service`
(a dedicated `kwin --virtual` session, same as the [Fedora KDE](/docs/fedora-kde#3-kwin-streaming-session)
guide — `cp /usr/share/punktfunk/host.env.kde ~/.config/punktfunk/host.env` and enable it alongside
the host). See [Configuration](/docs/configuration) for every knob and
[Running as a Service](/docs/running-as-a-service) for the service model.
Check it came up:
```sh
systemctl --user status punktfunk-host # active
journalctl --user -u punktfunk-host -f # watch a client connect
```
### Web console
The console (status, paired devices, arm pairing) ships as `punktfunk-web` — enable it, then open
`http://<host-ip>:47992`:
```sh
systemctl --user enable --now punktfunk-web
```
#### Console login password
On first start `punktfunk-web-init` generates a random login password and saves it to
`~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it back at any time:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own, edit that file and `systemctl --user restart punktfunk-web`. Forgot it? See
[Forgot your Password?](/docs/forgot-password).
## 5. Connect a client
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
the **PIN pairing** — arm it from the host's web console, which displays a 4-digit PIN to type into
the client. (Pairing is required by default; pass `serve --open` only if you deliberately want to
disable it.) See [Clients](/docs/clients) and [Pairing](/docs/pairing).
## Appendix — build from source (PKGBUILD)
To build instead of using the binary repo, use the split `PKGBUILD` in `packaging/arch/` (produces
`punktfunk-host` + `punktfunk-client`; set `PF_WITH_WEB=1` to also build `punktfunk-web`, which needs
`bun`):
```sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk/packaging/arch
# Build the working tree (no git fetch):
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
sudo pacman -U punktfunk-host-*.pkg.tar.zst
```
NVENC/EGL come from the NVIDIA driver (`nvidia-utils`); on a GPU-less builder, symlink the CUDA
stub into the link path first (the `PKGBUILD` header documents this). Full details, the
Fedora→Arch dependency map, and the SteamOS systemd-sysext path are in
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
+33 -26
View File
@@ -24,36 +24,43 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam
## Install
The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora
Atomic box layers and updates it with `rpm-ostree`. Add the repo, then layer the host plus the web
console and reboot:
The host installs as a **systemd system extension (sysext)** — no `rpm-ostree` layering. The
Bazzite docs treat layering as a last resort (layered packages slow every OS update and can block
upgrades until removed); a sysext never enters an rpm-ostree transaction: it overlays `/usr`
read-only from `/var/lib/extensions/`, survives OS updates, installs and updates **without a
reboot**, and is removable in one command. This is the same mechanism the Fedora Atomic
maintainers ship via the [fedora-sysexts](https://fedora-sysexts.github.io/) project.
```sh
# Add the repo. Packages are GPG-signed (gpgcheck=1, the packages@unom.io key) AND the repo
# metadata is Gitea-signed (repo_gpgcheck=1); gpgkey lists both keys so dnf imports each.
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
[gitea-unom-bazzite]
name=punktfunk (unom, Bazzite)
baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
REPO
# Layer the host + the web console, then reboot into the new deployment.
# (punktfunk Recommends punktfunk-web; list it explicitly so it's pulled regardless of weak-dep
# settings — the Gitea registry carries punktfunk-web, which COPR can't build.)
rpm-ostree install punktfunk punktfunk-web
systemctl reboot
# One-time bootstrap (afterwards the updater is on PATH as `punktfunk-sysext`):
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
sudo bash punktfunk-sysext.sh install # add `--channel canary` for rolling builds
```
`rpm-ostree upgrade` then tracks new builds automatically (Bazzite's auto-update timer does this
for you). For a fully baked appliance image there's also a **bootc** Containerfile that installs
the same RPMs from this registry — see `packaging/bootc/` and `packaging/rpm/README.md` in the repo.
Building from source works too (Bazzite is Fedora Atomic underneath, and its FFmpeg builds the host
fine — same steps as [Fedora KDE](/docs/fedora-kde)), but the registry is the supported path.
That downloads the newest image (host + tray + web console, SHA-256-verified over HTTPS from
punktfunk's package registry), merges it, and applies the udev/sysctl setup on the spot — the
host is usable immediately, no reboot. From then on:
```sh
sudo punktfunk-sysext update # fetch + merge the newest build
sudo punktfunk-sysext status # channel, installed vs latest version
sudo punktfunk-sysext remove # unmerge and delete — the box is back to stock
```
Two things to know:
- **After a Bazzite major rebase** (Fedora 43 → 44) the old image **refuses to load** rather than
run against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches
the image built for the new base.
- **Already layering punktfunk?** Install the sysext (it shadows the layered copy immediately),
then drop the layer so it stops slowing your updates:
`sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
For a fully baked appliance image there's also a **bootc** Containerfile that installs the RPMs
from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree`
layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too (see
`packaging/bazzite/README.md`), but the sysext is the supported default. Building from source
also works (Bazzite is Fedora Atomic underneath — same steps as [Fedora KDE](/docs/fedora-kde)).
## Allow controller input
+2
View File
@@ -25,6 +25,8 @@ track per machine; switching is a one-line change.
|---|---|---|
| **apt** (host/client) | `deb [signed-by=…] https://git.unom.io/api/packages/unom/debian canary main` | `… debian stable main` |
| **rpm** (host) | baseurl `…/rpm/bazzite-canary` (or `fedora-44-canary`) | `…/rpm/bazzite` (or `fedora-44`) |
| **sysext** (Bazzite host) | `sudo punktfunk-sysext install --channel canary` | `… install` / default (feeds `…/punktfunk-sysext/f43[-canary]`) |
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
+1 -1
View File
@@ -47,7 +47,7 @@ It ships as a real package, not just a source build — full steps in
`flatpak update`; this is also what the [Decky plugin](/docs/steam-deck) launches.
- **Ubuntu / Debian** — `apt install punktfunk-client` from the punktfunk apt registry.
- **Fedora / Bazzite** — `rpm-ostree install punktfunk-client` from the Gitea RPM registry.
- **Arch / SteamOS** — the `punktfunk-client` split package from the `PKGBUILD`.
- **Arch** — `sudo pacman -Sy punktfunk-client` from the signed binary repo (see [Arch Linux](/docs/arch)).
Launch it, pick your host from the list, and stream. For scripting you can skip the host list and
connect straight away:
+8 -2
View File
@@ -62,9 +62,15 @@ picture.
## Compositor-specific (Linux)
> **Managing virtual displays** — keep-alive after disconnect, exclusive vs. extend, and (on
> Windows/KDE) persistent per-client scaling — now has its own settings surface in the web console
> and `display-settings.json`. See [Virtual displays](/docs/virtual-displays). The two
> `*_VIRTUAL_PRIMARY` knobs and `PUNKTFUNK_MONITOR_LINGER_MS` below still work but are superseded by
> it (a settings file wins over them).
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
| `PUNKTFUNK_MUTTER_VIRTUAL_REFRESH` | `1` | Pin the client's exact WxH**@Hz** via `RecordVirtual`'s custom modes (needed for >60 Hz on Mutter). |
@@ -99,7 +105,7 @@ picture.
|---|---|---|
| `PUNKTFUNK_VDISPLAY` | `pf` | Virtual-display backend. The bundled pf-vdisplay IddCx driver is the only backend now — informational; leave as `pf`. |
| `PUNKTFUNK_SECURE_DDA` | `1` | Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. |
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. |
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. Superseded by the console's **Keep alive** setting — see [Virtual displays](/docs/virtual-displays). |
| `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. |
| `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
+1 -1
View File
@@ -48,7 +48,7 @@ see the linked guide — then it tracks updates with your normal `apt upgrade` /
|--------|---------|-------|
| **Ubuntu / Debian** | `sudo apt install punktfunk-client` | [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Fedora / Bazzite** | `rpm-ostree install punktfunk-client` | [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch / SteamOS** | `punktfunk-client` from the `PKGBUILD` | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
| **Arch** | `sudo pacman -Sy punktfunk-client` (signed binary repo) | [Arch Linux](/docs/arch) |
Then launch it, pick your host from the list, and stream. For scripting, skip the picker:
+7 -5
View File
@@ -17,13 +17,14 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
| Distro | Package manager | One-command happy path | Guide |
|--------|-----------------|------------------------|-------|
| **Ubuntu / Debian** | apt | `sudo apt install punktfunk-host` | [Ubuntu — GNOME](/docs/ubuntu-gnome) · [Ubuntu — KDE](/docs/ubuntu-kde) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Fedora / Bazzite** | rpm-ostree | `rpm-ostree install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [Bazzite](/docs/bazzite) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch** | PKGBUILD | `makepkg -si` | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
| **Bazzite / Fedora Atomic** | systemd-sysext | `sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
| **Fedora (dnf)** | dnf / rpm-ostree | `dnf install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch** | pacman | `pacman -Sy punktfunk-host` (binary repo) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
| **SteamOS (host)** | on-device script | `bash scripts/steamdeck/install.sh` | [SteamOS (Host)](/docs/steamos-host) |
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
one-time step covered in the linked guide; after that, normal `apt upgrade` / `rpm-ostree upgrade`
tracks new builds automatically.
one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds.
> **Stable vs canary.** The repos in the per-distro guides are the **stable** channel — it only
> moves when a `vX.Y.Z` release is cut. For the latest `main` build (fast, possibly broken), point
@@ -59,7 +60,8 @@ fallback without one. More detail — including the CLI `punktfunk-host service
- **`punktfunk-host`** — the streaming host. Install this on your Linux gaming machine.
- **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the
host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`).
host; on RPM list it explicitly (`dnf install punktfunk punktfunk-web`) — the Bazzite sysext
image already includes it.
- **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via
apt / RPM / Arch / Flatpak). On a Steam Deck, this is the package you want.
+2
View File
@@ -11,6 +11,7 @@
"ubuntu-gnome",
"ubuntu-kde",
"fedora-kde",
"arch",
"bazzite",
"steamos-host",
"windows-host",
@@ -23,6 +24,7 @@
"pairing",
"---Configuration---",
"configuration",
"virtual-displays",
"host-cli",
"---Troubleshooting---",
"troubleshooting",
+133
View File
@@ -0,0 +1,133 @@
---
title: Virtual displays
description: Control how punktfunk creates, keeps alive, and arranges the virtual displays it streams — presets, keep-alive, exclusive vs. extend, and persistent per-client scaling.
---
When a client connects, punktfunk creates a **virtual display** sized to exactly that client's
resolution and refresh, renders your desktop or game onto it, and streams it. This page is about the
**policy** for that display: how long it survives a disconnect, whether it takes over your physical
monitors, what happens when a second client connects, and how desktop environments remember
per-client settings like scaling.
You set this policy in the **web console** (Host → *Virtual displays*), or by editing
`~/.config/punktfunk/display-settings.json` directly (`%ProgramData%\punktfunk\display-settings.json`
on Windows). A change applies to the **next** connection — a running session keeps the display it
opened on.
> **You rarely need to touch this.** The default behavior matches how punktfunk has always worked.
> Reach for a preset when you want a specific experience — a dedicated couch/gaming box, a desktop
> you also use in person, or a multi-monitor workstation.
> **What's live today:** this release wires **keep-alive** (linger duration) and **topology**
> (extend / primary / exclusive). The other options below — conflict handling, identity/scaling
> persistence on Linux, and multi-monitor layout — are **stored but not yet enforced**; they arrive
> in following releases. The console marks them accordingly. Windows already persists per-client
> scaling (see [Persistent scaling](#persistent-scaling)).
## 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
the individual options documented further down.
| Preset | What it's for |
|---|---|
| **Default** | Today's behavior. A short linger absorbs reconnects, the streamed output becomes the sole desktop, and extra clients each get their own view. |
| **Gaming rig** | A dedicated couch/headless box. The game and its display survive disconnects indefinitely, and whoever connects takes the box over. *(Arrives with the keep-alive stage.)* |
| **Shared desktop** | A desktop you also use in person. punktfunk never blanks your real monitors and never leaves a ghost display behind; concurrent viewers each get a view. |
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
## Options reference
Choose **Custom** in the console to set these directly.
### Keep alive
How long the virtual display survives after your last session disconnects. On a gamescope game host,
this also keeps the **game itself running** so you can reconnect straight back into it.
- **Off** — tear the display down at session end (nothing lingers).
- **A duration** (seconds) — keep it for that long; a reconnect inside the window drops you straight
back in, with no re-negotiation and no desktop reshuffle.
- **Forever** — keep it until you stop the host or release it from the console. *(Arrives with the
keep-alive lifecycle stage; the console won't let you save it before then.)*
Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down
immediately — a short linger makes reconnects smoother on both.
> **Keep-alive + Exclusive keeps your physical monitors dark after you disconnect**, until the
> linger expires or you release the display. That's intentional for a dedicated gaming box, but
> don't set a long/forever keep-alive together with Exclusive on a machine whose monitors you also
> use in person — use **Shared desktop** there instead.
### Topology
What punktfunk does with your monitor layout while it streams.
- **Extend** — add the virtual display alongside your real monitors; touch nothing else.
- **Primary** — make the virtual display your primary output; your physical monitors stay on.
- **Exclusive** — the virtual display becomes your **only** enabled output (physical monitors are
disabled, then restored when streaming ends). This is what makes the streamed surface *be* the
desktop, so panels and windows land on it.
- **Automatic** *(default)* — Exclusive on Windows and on an auto-detected KDE/GNOME desktop
("stream this desktop" means the streamed output *is* the desktop); Extend when you've pinned a
specific compositor with `PUNKTFUNK_COMPOSITOR` (a test/CI posture).
Per-backend support:
| | KWin | Mutter/GNOME | Sway/wlroots | Windows |
|---|---|---|---|---|
| Extend | ✅ | ✅ | ✅ | ✅ |
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ *(following release)* |
| Exclusive | ✅ | ✅ | ✅ *(following release)* | ✅ |
### Conflict handling · identity · layout
These are **stored but not yet enforced** — they're documented here so you know what's coming and
can set them ahead of the release that turns them on:
- **Conflict handling** — what happens when a *different* client connects while one is already
streaming and asks for a different resolution: give it its own display (**separate**), take the
box over (**steal**), share the existing display at its current mode (**join**), or refuse it
(**reject**).
- **Identity** — whether each client gets a **stable display identity** so your desktop environment
remembers its settings (see below): one shared identity, one **per client**, or one **per client +
resolution**.
- **Layout / max displays** — how multiple virtual displays are arranged (for multi-monitor), and an
upper bound on how many can be live at once.
## Persistent scaling
Set your display **scaling** once and have it stick across reconnects. This works by giving each
client a *stable display identity*, so your desktop environment keys its per-monitor settings to it.
| Host | Supported | How |
|---|---|---|
| **Windows** | ✅ today | Connect, set scaling in Settings while streaming — Windows remembers it per client. |
| **KDE / KWin** | ⏳ following release | A stable per-client output name lets KWin persist scale/mode per client. |
| **GNOME / Mutter** | ❌ | GNOME's virtual-monitor API exposes no stable identity to key config on. |
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
## Legacy environment knobs
These `PUNKTFUNK_*` variables still work, but the console (and `display-settings.json`) supersede
them — when a settings file exists, it wins.
| Legacy knob | Now expressed as |
|---|---|
| `PUNKTFUNK_MONITOR_LINGER_MS` | **Keep alive** → duration *(Windows)* |
| `PUNKTFUNK_NO_ISOLATE` | **Topology** → Extend *(Windows)* |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | **Topology** → Exclusive (when set) / Extend (when `0`) |
## Troubleshooting
**My physical monitors stayed off after I disconnected.** You have keep-alive set together with
Exclusive topology — the display (and your isolated desktop) is being kept for the linger window.
Release it from the console (Host → *Virtual displays*), or switch to the **Shared desktop** preset
so streaming never disables your real monitors.
**The virtual output shows only my wallpaper.** Your topology is Extend, so the streamed display is
an empty extension. Use **Primary** or **Exclusive** so your desktop actually lands on it.
**KWin virtual outputs need KWin ≥ 6.5.6.** Older KWin can't create the virtual output at all —
see [requirements](/docs/requirements).
+8
View File
@@ -21,6 +21,14 @@
// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
#define ABI_VERSION 3
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
// (functions a client links), which can grow without changing a single wire byte — v3's
// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
// live). Bump this ONLY when the handshake/planes actually change incompatibly.
#define WIRE_VERSION 2
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
#define PUNKTFUNK_HIDOUT_LED 1
+25 -8
View File
@@ -17,13 +17,15 @@ packaging/
rpm/punktfunk.spec # the RPM (builds punktfunk-host from source with cargo)
bazzite/host.env # gamescope-default config for a Bazzite appliance
bazzite/README.md # step-by-step Bazzite setup guide
bazzite/*sysext*.sh # the no-layering path: build/install/publish the systemd-sysext
bootc/Containerfile # bake punktfunk into a Bazzite-based atomic image
copr/ # COPR build-from-SCM settings
```
The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt),
[`arch/`](arch/README.md) (PKGBUILD + sysext), [`flatpak/`](flatpak/README.md) (the client),
[`windows/`](windows/README.md) (host installer + drivers), plus `kde/` and `linux/` helpers.
[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
drivers), plus `kde/` and `linux/` helpers.
## What's needed beyond base Fedora
@@ -38,7 +40,22 @@ On **Bazzite** the only genuinely new runtime bits are `ffmpeg-libs` (RPM Fusion
`libei` — the rest of the stack is already there. The default backend is **gamescope**
(`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login.
## Option A — Gitea RPM registry (recommended; per-host, `rpm-ostree`)
## Option A — systemd-sysext (recommended; no layering, no reboot)
On Bazzite / Fedora Atomic the recommended install is the **systemd-sysext** image — rpm-ostree
layering is a last resort per the Bazzite docs (it slows every OS update and can block upgrades),
while a sysext overlays `/usr` at runtime, survives OS updates, and updates in one command with
no reboot. CI wraps the same RPMs below into the image, so content and channels are identical.
```sh
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
sudo bash punktfunk-sysext.sh install # then: sudo punktfunk-sysext update | status | remove
```
Full walkthrough (incl. the F43→F44 rebase behavior and migration off layering):
[`bazzite/README.md`](bazzite/README.md).
## Option B — Gitea RPM registry (per-host, `rpm-ostree` layering)
The host's RPM is published to **unom's self-hosted Gitea RPM registry** (CI builds it on every
push), mirroring the [Debian/apt](debian/README.md) setup. Add one repo file, install, and track
@@ -60,7 +77,7 @@ rpm-ostree install punktfunk && systemctl reboot
# updates: rpm-ostree upgrade && systemctl reboot
```
## Option B — COPR (per-host, `rpm-ostree install`)
## Option C — COPR (per-host, `rpm-ostree install`)
1. Create a COPR project, enable **build-from-SCM** pointing at this repo, spec path
`packaging/rpm/punktfunk.spec` (see `copr/README.md`). Under *External Repositories* add
@@ -78,7 +95,7 @@ rpm-ostree install punktfunk && systemctl reboot
systemctl reboot
```
## Option C — bootc (image-based, atomic)
## Option D — bootc (image-based, atomic)
Layer punktfunk into a Bazzite image once, then rebase any number of hosts onto it — no
per-host drift. See `bootc/Containerfile`:
@@ -89,7 +106,7 @@ podman push ghcr.io/<you>/bazzite-punktfunk
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
```
## First-run setup (either option)
## First-run setup (all options)
```sh
ujust add-user-to-input-group # virtual gamepads need /dev/uinput (then re-login).
@@ -109,8 +126,8 @@ web console at `https://<host-ip>:47992` or directly.
> ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only
> `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host,
> install from the **Gitea RPM registry** (Option A — it carries `punktfunk-web`), which is also why
> `bootc/Containerfile` installs from there rather than COPR.
> install from the **Gitea RPM registry** (Option B — it carries `punktfunk-web`; the sysext image
> includes it too), which is also why `bootc/Containerfile` installs from there rather than COPR.
## Why not Flatpak (for the HOST)?
+23 -9
View File
@@ -10,20 +10,28 @@
# - In-tree / CI: PF_SRCDIR=$(git rev-parse --show-toplevel) makepkg --holdver
# (builds the working tree instead of the tagged source — see build()).
#
# IMPORTANT: host encode is NVENC-only (crates/punktfunk-host/src/encode/linux.rs) — functional on
# NVIDIA hosts; an AMD Deck-as-HOST needs a VAAPI backend first. The CLIENT decodes via VAAPI
# (AMD/Intel, incl. the Deck) with a software fallback, so it works everywhere. See README.md.
# Host encode: NVENC on NVIDIA (nvidia-utils), VAAPI on AMD/Intel (mesa) — PUNKTFUNK_ENCODER=auto
# picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software
# fallback, so it works everywhere. See README.md.
pkgbase=punktfunk
# punktfunk-web (the browser console) is OPT-IN: building it needs `bun` (AUR-only as bun-bin on
# stock Arch/SteamOS), so a default makepkg builds only host+client with no JS tooling — mirroring
# the RPM spec's `%bcond_with web` (off by default). Set PF_WITH_WEB=1 to also build punktfunk-web
# (appended to pkgname + bun to makedepends below).
pkgname=('punktfunk-host' 'punktfunk-client')
pkgver=0.2.0
pkgrel=1
# CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
# X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
# allows only digits+dots, so the run number carries the monotonic ordering).
pkgver="${PF_PKGVER:-0.7.0}"
pkgrel="${PF_PKGREL:-1}"
arch=('x86_64')
url="https://git.unom.io/unom/punktfunk"
license=('MIT OR Apache-2.0')
# !lto: makepkg's `lto` option injects -flto=auto into CFLAGS; aws-lc-sys (rustls' crypto)
# compiles its C with those flags and GCC LTO bitcode objects are unreadable by rust's lld
# linker -> "undefined symbol: aws_lc_*" at link (reproduced 2026-07-04, Arch + rust 1.90).
# !debug: skip the -debug split package (debuginfo bloat, not shipped).
options=('!lto' '!debug')
# All build deps for both crates (Arch runtime packages ship their own headers, so these cover
# build + link). aws-lc/ring need clang+cmake; nasm is for asm.
@@ -36,10 +44,16 @@ if [ "${PF_WITH_WEB:-0}" = 1 ]; then
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
fi
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root and
# build() uses it instead; see the README.
source=("git+https://git.unom.io/unom/punktfunk.git#tag=v${pkgver}")
sha256sums=('SKIP')
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root
# build() uses it instead AND the fetch is skipped entirely (a canary pkgver has no tag to
# clone, and CI already has the checkout).
if [ -z "${PF_SRCDIR:-}" ]; then
source=("git+https://git.unom.io/unom/punktfunk.git#tag=v${pkgver}")
sha256sums=('SKIP')
else
source=()
sha256sums=()
fi
_repo() { printf '%s' "${PF_SRCDIR:-$srcdir/punktfunk}"; }
+39 -1
View File
@@ -23,7 +23,45 @@ default `makepkg` builds only host+client with no JS tooling — mirroring the R
> Arch + NVIDIA **and** AMD/Intel (incl. the Steam Deck — see the on-device path above). The client
> decodes via VAAPI on AMD/Intel with a software fallback.
## Arch Linux (mutable)
## Install from the binary repo (recommended)
CI (`.gitea/workflows/arch.yml`) builds this PKGBUILD in an `archlinux:base-devel` container on
every push and publishes the packages to the **Gitea Arch package registry** — a plain pacman
repo, so an Arch box installs and updates punktfunk with `pacman -Syu` like everything else.
Two repos mirror the deb/rpm channels: `punktfunk` (release tags) and `punktfunk-canary`
(rolling main-branch builds, versioned `X.Y.Z-0.<run#>` so a later release always outranks
them). Enable exactly one.
The registry **signs the repo database and every package**, so first import its key into
pacman's keyring (a one-time step — after this, packages install signature-verified):
```sh
# 1. Trust the registry signing key.
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
| sudo pacman-key --add -
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
# 3. Sync + install.
sudo pacman -Sy punktfunk-host # gaming rig
sudo pacman -Sy punktfunk-client # couch/Deck side
sudo pacman -Sy punktfunk-web # optional browser management console
```
(No `SigLevel` line needed — pacman's default `Required DatabaseOptional` verifies the signed
packages against the key you just trusted. Arch is rolling, so the packages are built against
current Arch sonames — keep the box itself updated too.)
Then the same first-run steps as a source build (printed by the install scriptlet): `input`
group, `host.env`, `systemctl --user enable --now punktfunk-host` — see the next section.
## Build from source — Arch Linux (mutable)
```sh
cd packaging/arch
+75 -41
View File
@@ -12,34 +12,91 @@ flagged explicitly. For the higher-level packaging rationale ("why not Flatpak",
> NVENC, from RPM Fusion **nonfree**), `opus`, and `libei`.
> Source: `packaging/README.md`, `packaging/rpm/punktfunk.spec`.
> ⚠️ **Read this first — the COPR is operator-run, not yet published.**
> Both install paths below pull the punktfunk RPM from a COPR project named
> `enricobuehler/punktfunk`. That COPR is a configuration the maintainer has to **create and
> build** (see `packaging/copr/README.md` — it documents how to set it up, not a live repo URL you
> can assume exists). If `rpm-ostree install punktfunk` 404s, the COPR hasn't been published yet,
> and your only path is to **build the RPM yourself** (see the appendix). The guide flags every
> command that depends on the COPR being live.
> ⚠️ **COPR note (Path C only).** The legacy layering path's commands reference a COPR project
> named `enricobuehler/punktfunk` that is operator-run and may not be published (see
> `packaging/copr/README.md`); layer from the **Gitea RPM registry** instead (`../rpm/README.md`,
> the repo file `https://git.unom.io/api/packages/unom/rpm/bazzite.repo`) — it's what CI
> actually publishes to. Paths A (sysext) and B (bootc) don't involve the COPR at all.
---
## 1. Choose an install path
There are two supported paths on Bazzite, driven by different files in `packaging/`:
There are three paths on Bazzite, driven by different files in `packaging/`:
| Path | Driven by | What it does | Best for |
|---|---|---|---|
| **A — rpm-ostree layering** | `packaging/copr/README.md` + `packaging/rpm/punktfunk.spec` | Layers the `punktfunk` RPM onto your existing Bazzite deployment with `rpm-ostree install` | One host, quick iteration |
| **A — systemd-sysext** ✅ recommended | `packaging/bazzite/punktfunk-sysext.sh` + `build-sysext.sh` (published by `.gitea/workflows/rpm.yml`) | Overlays the host onto `/usr` as a system extension — no layering, no reboot, one-command updates | Everyone; the default |
| **B — bootc / OCI image** | `packaging/bootc/Containerfile` | Bakes punktfunk into a `FROM bazzite-nvidia` image once; you `bootc switch` any number of hosts onto it | Fleets, reproducible appliances, no per-host drift |
| **C — rpm-ostree layering** (legacy) | `packaging/rpm/` + the Gitea RPM registry | Layers the `punktfunk` RPM onto your deployment with `rpm-ostree install` | Only if you specifically want the RPM database to own the files |
**Trade-off:** Path A is a per-host package layer — simple, but each host accumulates its own
layered-package state. Path B builds one image (RPM Fusion + the Gitea RPM repo + the host and
**web console** + udev rule pre-installed) that you push to a registry and rebase hosts onto
atomically — no per-host `rpm-ostree install` drift, at the cost of running a `podman build`/`push`
pipeline. Both require the **same first-run setup** (sections 36); note Path B installs from the
**Gitea RPM registry** (which carries `punktfunk-web`), whereas Path A's COPR builds host+client
only — for the web console on Path A, layer from the Gitea registry instead (`../rpm/README.md`).
**Why A over C:** the Bazzite docs treat layering as a last resort — every layered package makes
every OS update slower and can **block upgrades entirely** until removed. A sysext never enters an
rpm-ostree transaction: it merges/unmerges at runtime, survives OS updates, and updating punktfunk
is one command with **no reboot** (layering needs one per update). It's the mechanism the Fedora
Atomic maintainers ship via [fedora-sysexts](https://fedora-sysexts.github.io/). All paths require
the **same first-run setup** (sections 36).
### Path A — rpm-ostree layering from the COPR
### Path A — systemd-sysext (recommended)
Run on the Bazzite host:
```sh
# One-time bootstrap; afterwards the tool is on PATH as `punktfunk-sysext` (it ships inside
# the image). `--channel canary` for rolling main-branch builds instead of releases.
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
sudo bash punktfunk-sysext.sh install
```
This downloads the newest image for your Fedora base (host + tray + **web console**,
SHA-256-verified from the feed `…/packages/unom/generic/punktfunk-sysext/f<ver>[-canary]/`),
installs it as `/var/lib/extensions/punktfunk.raw`, merges it, and immediately applies what the
RPM scriptlets would have (udev reload, sysctl) plus the two `/etc` files a sysext can't carry
(the gamescope-session drop-in and the tray autostart entry, staged under
`/usr/share/punktfunk/etc/`). No reboot at any point. Day-2:
```sh
sudo punktfunk-sysext update # fetch + merge the newest build (then restart the user service)
sudo punktfunk-sysext status # merged?, installed vs latest, channel/feed
sudo punktfunk-sysext remove # unmerge + delete; ~/.config/punktfunk is left alone
```
Details worth knowing:
- The image embeds `ID=fedora` + `VERSION_ID` (matched through Bazzite's `ID_LIKE`), so after a
**major Bazzite rebase** (F43 → F44) the old image is **refused** instead of merging
soname-broken binaries — `punktfunk-sysext update` then fetches the image built for the new
base (feeds exist per Fedora major, from the same CI matrix as the RPM groups).
- SELinux labels are baked into the image at build time (squashfs pseudo-xattrs computed from
the targeted policy) — without them udev couldn't read the gamepad rule under enforcing.
Validated live on Bazzite 43.
- **Migrating from layering (path C):** install the sysext (it shadows the layered copy at
once), then `sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
### Path B — bootc image (`FROM bazzite-nvidia`)
The image is built **off-host** (on any machine with `podman`) from
`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable`
(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM
repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web
console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically
because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`).
```sh
# Build + push (run from the repo root, on your builder machine):
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
podman push ghcr.io/<you>/bazzite-punktfunk
# On each target Bazzite host:
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
```
> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on
> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` +
> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata
> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`.
### Path C — rpm-ostree layering (legacy)
Run on the Bazzite host. (Commands verbatim from `packaging/README.md`.)
@@ -62,7 +119,7 @@ systemctl reboot
> The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes
> effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk.
#### Updating a Path-A host — `rpm-ostree upgrade` is NOT enough
#### Updating a Path-C host — `rpm-ostree upgrade` is NOT enough
> ⚠️ **`rpm-ostree upgrade` will not update punktfunk on its own.** `upgrade` bumps the **base
> image** and only re-resolves *layered* packages **when the base changes**. A Bazzite base can
@@ -94,29 +151,6 @@ sudo bash packaging/bazzite/update-punktfunk.sh --reboot # stage + reboot now
> `punktfunk.repo`, canary's `<next-minor>.0-0.ciN` **outranks** the stable `X.Y.Z-1` and the box
> silently tracks canary. Enable exactly one channel — set `enabled=0` in the other repo file.
### Path B — bootc image (`FROM bazzite-nvidia`)
The image is built **off-host** (on any machine with `podman`) from
`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable`
(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM
repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web
console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically
because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`).
```sh
# Build + push (run from the repo root, on your builder machine):
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
podman push ghcr.io/<you>/bazzite-punktfunk
# On each target Bazzite host:
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
```
> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on
> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` +
> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata
> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`.
---
## 2. Prerequisites — what Bazzite gives you vs. what you must still do
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Build the punktfunk systemd-sysext image for Bazzite / Fedora Atomic from the built RPMs —
# the no-layering install path (rpm-ostree layering slows every update and can block upgrades;
# a sysext never enters an rpm-ostree transaction). The .raw overlays /usr read-only from
# /var/lib/extensions/, survives OS updates, and is toggled/updated without a reboot.
#
# Counterpart to ../arch/build-sysext.sh (which wraps a pacman package for SteamOS). This one
# wraps the Fedora RPMs (punktfunk + punktfunk-web) and additionally:
# * relocates the RPMs' /etc payload to /usr/share/punktfunk/etc/ (a sysext carries ONLY /usr;
# punktfunk-sysext(8) copies these into the real /etc on install),
# * bakes SELinux labels in as squashfs pseudo-xattrs, computed with matchpathcon from the
# build container's targeted policy. Without them every file is unlabeled_t at runtime:
# fine for the user session + systemd --user units (unconfined), but system daemons are
# DENIED — udev couldn't read 60-punktfunk.rules and systemd-sysctl couldn't read the
# sysctl drop-in (validated live on Bazzite 43, SELinux enforcing, 2026-07-04),
# * pins compatibility via ID=fedora + VERSION_ID: merges on Bazzite/Silverblue/Aurora of the
# SAME Fedora major (ID_LIKE matching, systemd >= 256) and is REFUSED after a major rebase
# instead of running soname-broken binaries (`punktfunk-sysext update` then re-resolves),
# * embeds the punktfunk-sysext helper so an installed box can update itself.
#
# Build in the matching Fedora container (ci/fedora*-rpm.Dockerfile) — matchpathcon needs the
# Fedora targeted policy (libselinux-utils + selinux-policy-targeted), and the RPMs are
# soname-coupled to their base anyway. Needs: rpm2cpio, cpio, mksquashfs (>= 4.6), matchpathcon.
#
# Usage:
# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \
# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm
#
# The installed image MUST be named punktfunk.raw (the embedded extension-release marker is
# extension-release.punktfunk; systemd-sysext requires marker == image name) — the feed carries
# versioned filenames and punktfunk-sysext installs to the fixed name.
set -euo pipefail
VERSION_ID="" OUT="" RPMS=()
while [ $# -gt 0 ]; do
case "$1" in
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
--out) OUT="${2:?}"; shift 2 ;;
*) RPMS+=("$1"); shift ;;
esac
done
[ -n "$VERSION_ID" ] || { echo "missing --version-id <fedora major, e.g. 43>" >&2; exit 1; }
[ -n "$OUT" ] || { echo "missing --out <image.raw>" >&2; exit 1; }
[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs given" >&2; exit 1; }
for tool in rpm2cpio cpio mksquashfs matchpathcon; do
command -v "$tool" >/dev/null || { echo "missing tool: $tool" >&2; exit 1; }
done
HERE="$(cd "$(dirname "$0")" && pwd)"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# SYSEXT_VERSION_ID from the punktfunk RPM (V-R without the dist tag): what
# `punktfunk-sysext status` reports as the installed version.
PF_VR=""
SEEN_NAMES=" "
for rpm in "${RPMS[@]}"; do
[ -f "$rpm" ] || { echo "no such RPM: $rpm" >&2; exit 1; }
name="$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)"
# Two RPMs of the same NAME (e.g. a stale noarch next to the current x86_64 from a sloppy
# download glob) silently shadow each other's files — refuse instead of building a chimera.
case "$SEEN_NAMES" in *" $name "*) echo "duplicate RPM name '$name' in inputs — pass exactly one RPM per package" >&2; exit 1 ;; esac
SEEN_NAMES="$SEEN_NAMES$name "
if [ "$name" = punktfunk ]; then
PF_VR="$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)"
PF_VR="${PF_VR%.fc*}"
fi
rpm2cpio "$rpm" | ( cd "$STAGE" && cpio -idmu --quiet )
done
[ -n "$PF_VR" ] || { echo "the punktfunk (host) RPM must be among the inputs" >&2; exit 1; }
# A sysext carries only /usr. Relocate the RPMs' /etc payload (gamescope-session drop-in, tray
# autostart entry) under /usr/share/punktfunk/etc/ — punktfunk-sysext copies it into /etc.
if [ -d "$STAGE/etc" ]; then
mkdir -p "$STAGE/usr/share/punktfunk/etc"
cp -a "$STAGE/etc/." "$STAGE/usr/share/punktfunk/etc/"
rm -rf "${STAGE:?}/etc"
fi
rm -rf "${STAGE:?}/var" # rpm ghosts etc. — nothing outside /usr may remain
# Self-update: the helper rides inside the image.
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
# Compatibility marker. ID=fedora matches Bazzite & friends through os-release ID_LIKE;
# VERSION_ID makes a major-rebased host refuse the old ABI instead of merging it.
install -d "$STAGE/usr/lib/extension-release.d"
cat > "$STAGE/usr/lib/extension-release.d/extension-release.punktfunk" <<EOF
ID=fedora
VERSION_ID=$VERSION_ID
ARCHITECTURE=x86-64
SYSEXT_ID=punktfunk
SYSEXT_VERSION_ID=$PF_VR
EXTENSION_RELOAD_MANAGER=1
EOF
# SELinux labels as pseudo-xattrs (see header). matchpathcon resolves each target path against
# the targeted policy's file_contexts; <<none>> means "no specific entry" — skip those (the
# handful of matches all resolve to real contexts for our payload).
PSEUDO="$STAGE.pseudo"
( cd "$STAGE" && find . -mindepth 1 \( -type f -o -type d \) -printf '/%P\n' ) | sort \
| while IFS= read -r path; do
ctx="$(matchpathcon -n "$path" 2>/dev/null || true)"
case "$ctx" in ''|'<<none>>') continue ;; esac
printf '%s x security.selinux=%s\n' "$path" "$ctx"
done > "$PSEUDO"
[ -s "$PSEUDO" ] || { echo "matchpathcon produced no labels — refusing to build an unlabeled image" >&2; exit 1; }
rm -f "$OUT"; mkdir -p "$(dirname "$OUT")"
# -xattrs-exclude drops any security.selinux the staging fs already had (would collide with the
# pseudo defs when building on an SELinux host); -all-root because cpio extracted as the CI uid.
mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet \
-xattrs-exclude '^security.selinux' -pf "$PSEUDO"
rm -f "$PSEUDO"
echo "built $OUT (punktfunk $PF_VR, fedora $VERSION_ID, $(du -h "$OUT" | cut -f1))"
echo " install on the box: punktfunk-sysext install (or --from-file $OUT)"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Publish a punktfunk sysext image into its feed on the Gitea generic package registry —
# called by .gitea/workflows/rpm.yml after the RPM publish. A feed is one fixed URL
# (…/punktfunk-sysext/<feed>/) holding versioned .raw files plus a SHA256SUMS manifest;
# punktfunk-sysext(8) on the boxes reads SHA256SUMS to find + verify the newest image
# (the layout is also exactly what systemd-sysupdate's url-file source expects, so a
# .transfer feed can be added later without re-publishing anything).
#
# Usage: TOKEN=… [KEEP=6] bash publish-sysext-feed.sh <feed> <image.raw>
# <feed> e.g. f43, f43-canary, f44 (Fedora major x channel)
# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name)
set -euo pipefail
FEED="${1:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
RAW="${2:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; }
REGISTRY="${REGISTRY:-git.unom.io}"
OWNER="${OWNER:-unom}"
KEEP="${KEEP:-0}"
AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}")
BASE="https://$REGISTRY/api/packages/$OWNER/generic/punktfunk-sysext/$FEED"
FNAME="$(basename "$RAW")"
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
# Merge into the existing manifest: drop any prior line for this filename, append ours.
SUMS="$(mktemp)"; trap 'rm -f "$SUMS"' EXIT
curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry.
PRUNE=()
if [ "$KEEP" -gt 0 ]; then
mapfile -t PRUNE < <(awk '{print $2}' "$SUMS" | sort -V | head -n -"$KEEP")
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && sed -i "\| $f\$|d" "$SUMS"
done
fi
# Upload order keeps consumers consistent: image first, then the manifest referencing it,
# then prune deletions (already absent from the manifest). Delete-before-put makes workflow
# re-runs idempotent (the registry 409s on duplicate filenames; first-publish 404s are fine).
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$FNAME" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$RAW" "$BASE/$FNAME"
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
done
echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)"
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# punktfunk-sysext — install/update the punktfunk host on Bazzite / Fedora Atomic as a
# systemd-sysext, the no-layering path (rpm-ostree layering is a last resort per the Bazzite
# docs: it slows every update and can block upgrades; a sysext never enters an rpm-ostree
# transaction, needs no reboot, and is trivially removable).
#
# The image overlays /usr from /var/lib/extensions/punktfunk.raw with the host, tray and web
# console + their udev/sysctl/systemd-user payload; the RPMs' two /etc files (gamescope
# session drop-in, tray autostart) ride inside at /usr/share/punktfunk/etc/ and are copied
# into the real /etc here (a sysext can only carry /usr).
#
# Bootstrap (the script also ships inside the image as /usr/bin/punktfunk-sysext):
# curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
# sudo bash punktfunk-sysext.sh install # or: install --channel canary
# Thereafter:
# sudo punktfunk-sysext update | status | remove
#
# Feed: the Gitea generic package registry, one feed per Fedora major x channel
# (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + versioned .raw files —
# published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path uses.
# The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused
# (not merged broken) and `punktfunk-sysext update` re-resolves against the new release.
set -euo pipefail
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}"
CONF=/etc/punktfunk-sysext.conf
EXT_DIR=/var/lib/extensions
IMG="$EXT_DIR/punktfunk.raw"
SIDECAR="$EXT_DIR/.punktfunk.version"
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
ETC_SRC=/usr/share/punktfunk/etc
usage() {
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]"
echo " punktfunk-sysext update [--from-file X.raw] | status | remove"
exit "${1:-0}"
}
need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; }
os_version_id() { . /etc/os-release; echo "${VERSION_ID%%.*}"; }
channel() { # shellcheck disable=SC1090
[ -f "$CONF" ] && . "$CONF"; echo "${CHANNEL:-stable}"; }
feed_url() {
local suffix=""
[ "$(channel)" = canary ] && suffix="-canary"
echo "$REGISTRY/f$(os_version_id)$suffix"
}
# latest -> "VERSION FILENAME SHA256" from the feed's SHA256SUMS (highest by version sort).
latest() {
local feed; feed="$(feed_url)"
curl -fsSL "$feed/SHA256SUMS" \
| awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
| sort -V | tail -n1
}
installed_version() {
if [ -f "$MARKER" ]; then
sed -n 's/^SYSEXT_VERSION_ID=//p' "$MARKER"
elif [ -f "$SIDECAR" ]; then
cat "$SIDECAR"
fi
}
merged() { [ -f "$MARKER" ]; }
post_merge() {
if ! merged; then
echo "!! image installed but NOT merged — 'systemd-sysext status' / 'journalctl -u systemd-sysext'" >&2
echo "!! (an OS release the image doesn't match? 'punktfunk-sysext update' fetches the right one)" >&2
return 1
fi
# What the RPM scriptlets would have done: pick up the uinput/uhid rule + the UDP buffer
# sysctl now, no reboot (both also auto-apply at boot once merged — the files live in /usr/lib).
udevadm control --reload 2>/dev/null || :
udevadm trigger --subsystem-match=misc 2>/dev/null || :
for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
done
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
&& [ ! -e /etc/gamescope-session-plus/sessions.d/steam ]; then
install -Dm0644 "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
/etc/gamescope-session-plus/sessions.d/steam
fi
if [ -f "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" ]; then
install -Dm0644 "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" \
/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
fi
}
# do_install VERSION FILENAME SHA256 | do_install --from-file X.raw
do_install() {
need_root
mkdir -p "$EXT_DIR"
local tmp="$EXT_DIR/.punktfunk.raw.new" ver
if [ "$1" = --from-file ]; then
ver="(local: $(basename "$2"))"
cp -f "$2" "$tmp"
else
ver="$1"
echo "downloading punktfunk $ver ($(channel), fedora $(os_version_id))…"
curl -fL --progress-bar -o "$tmp" "$(feed_url)/$2"
echo "$3 $tmp" | sha256sum -c --quiet
fi
mv -f "$tmp" "$IMG" # marker inside is extension-release.punktfunk — name must match
echo "$ver" > "$SIDECAR"
systemctl enable --now systemd-sysext.service >/dev/null 2>&1 || :
systemd-sysext refresh
post_merge
echo "punktfunk $ver merged into /usr."
}
layering_hint() {
if command -v rpm-ostree >/dev/null 2>&1 \
&& rpm-ostree status 2>/dev/null | grep -q 'LayeredPackages:.*punktfunk'; then
cat >&2 <<'EOF'
!! punktfunk is ALSO layered via rpm-ostree. The sysext now shadows it, but remove the
!! layer so it stops slowing/blocking OS updates (the reason this sysext exists):
!! sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot
EOF
fi
}
cmd_install() {
need_root
local from_file=""
while [ $# -gt 0 ]; do
case "$1" in
--channel) printf 'CHANNEL=%s\n' "${2:?}" > "$CONF"; shift 2 ;;
--from-file) from_file="${2:?}"; shift 2 ;;
*) usage 1 ;;
esac
done
if [ -n "$from_file" ]; then
do_install --from-file "$from_file"
else
local l; l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
# shellcheck disable=SC2086
do_install $l
fi
layering_hint
cat <<'EOF'
First-run (once):
ujust add-user-to-input-group # virtual gamepads; then log out + back in
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host
Updates: sudo punktfunk-sysext update
EOF
}
cmd_update() {
need_root
if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi
local cur l ver
cur="$(installed_version)"
l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
ver="${l%% *}"
if [ "$ver" = "$cur" ] && merged; then
echo "already on $cur (channel $(channel)) — nothing to do."
return
fi
echo "updating: ${cur:-<none>} -> $ver"
# shellcheck disable=SC2086
do_install $l
echo "restart the host to pick up the new binary: systemctl --user restart punktfunk-host"
}
cmd_status() {
echo "channel: $(channel)"
echo "feed: $(feed_url)"
echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')"
echo "merged: $(merged && echo yes || echo no)"
echo "installed: $(installed_version || true)"
echo "latest: $(latest 2>/dev/null | cut -d' ' -f1 || true)"
}
cmd_remove() {
need_root
# /etc cleanup needs the /usr payload for the unmodified-compare — do it BEFORE unmerging.
if merged; then
if cmp -s "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
/etc/gamescope-session-plus/sessions.d/steam 2>/dev/null; then
rm -f /etc/gamescope-session-plus/sessions.d/steam
fi
fi
rm -f /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
rm -f "$IMG" "$SIDECAR" "$CONF"
systemd-sysext refresh 2>/dev/null || :
echo "punktfunk sysext removed (user config in ~/.config/punktfunk is untouched)."
}
case "${1:-}" in
install) shift; cmd_install "$@" ;;
update) shift; cmd_update "$@" ;;
status) shift; cmd_status ;;
remove) shift; cmd_remove ;;
*) usage ;;
esac
+8
View File
@@ -23,6 +23,14 @@ if [[ $EUID -ne 0 ]]; then
exit 1
fi
# The sysext path (packaging/bazzite/punktfunk-sysext.sh) supersedes layering entirely — if the
# box runs the sysext, it shadows any layered copy and THIS script won't change what executes.
if [[ -f /var/lib/extensions/punktfunk.raw ]]; then
echo "NOTE: the punktfunk sysext is installed — update with 'punktfunk-sysext update' instead." >&2
echo " (a layered punktfunk is shadowed by the sysext; consider removing the layer:" >&2
echo " rpm-ostree uninstall punktfunk punktfunk-web)" >&2
fi
# Which punktfunk packages are actually layered right now (host, web, or both).
mapfile -t layered < <(rpm-ostree status --json 2>/dev/null \
| grep -oE '"punktfunk(-web)?"' | tr -d '"' | sort -u)
+30
View File
@@ -47,6 +47,36 @@
"gpu_none": "Keine GPUs erkannt.",
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"host_displays": "Virtuelle Displays",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_preset": "Voreinstellung",
"display_preset_custom": "Benutzerdefiniert",
"display_preset_default": "Standard",
"display_preset_gaming_rig": "Gaming-Rig",
"display_preset_shared_desktop": "Geteilter Desktop",
"display_preset_hotdesk": "Hot-Desk",
"display_preset_workstation": "Workstation",
"display_keep_alive": "Nach Trennung aktiv halten",
"display_keep_alive_off": "Aus",
"display_keep_alive_seconds": "Sekunden",
"display_topology": "Topologie",
"display_topology_auto": "Automatisch",
"display_topology_extend": "Erweitern",
"display_topology_primary": "Primär",
"display_topology_exclusive": "Exklusiv",
"display_max": "Max. Displays",
"display_save": "Speichern",
"display_effective": "Aktiv",
"display_pending_note": "Konfliktbehandlung, Identität und Layout werden gespeichert, aber noch nicht angewendet — sie folgen in späteren Versionen.",
"display_live": "Aktive Displays",
"display_none_live": "Derzeit keine virtuellen Displays.",
"display_state_active": "Aktiv",
"display_state_lingering": "Wird gehalten",
"display_state_pinned": "Angeheftet",
"display_release_btn": "Freigeben",
"display_release_all": "Alle gehaltenen freigeben",
"display_expires_in": "Abbau in {sec}s",
"display_sessions": "{count} streamend",
"clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
+30
View File
@@ -47,6 +47,36 @@
"gpu_none": "No GPUs detected.",
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"host_displays": "Virtual displays",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_preset": "Preset",
"display_preset_custom": "Custom",
"display_preset_default": "Default",
"display_preset_gaming_rig": "Gaming rig",
"display_preset_shared_desktop": "Shared desktop",
"display_preset_hotdesk": "Hot-desk",
"display_preset_workstation": "Workstation",
"display_keep_alive": "Keep alive after disconnect",
"display_keep_alive_off": "Off",
"display_keep_alive_seconds": "seconds",
"display_topology": "Topology",
"display_topology_auto": "Automatic",
"display_topology_extend": "Extend",
"display_topology_primary": "Primary",
"display_topology_exclusive": "Exclusive",
"display_max": "Max displays",
"display_save": "Save",
"display_effective": "In effect",
"display_pending_note": "Conflict handling, identity, and layout are stored but not enforced yet — they arrive in later releases.",
"display_live": "Live displays",
"display_none_live": "No virtual displays right now.",
"display_state_active": "Active",
"display_state_lingering": "Lingering",
"display_state_pinned": "Pinned",
"display_release_btn": "Release",
"display_release_all": "Release all kept",
"display_expires_in": "tears down in {sec}s",
"display_sessions": "{count} streaming",
"clients_title": "Paired clients",
"clients_empty": "No paired clients yet.",
"clients_name": "Name",
+362
View File
@@ -0,0 +1,362 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { type FC, useEffect, useState } from "react";
import {
getGetDisplayStateQueryKey,
getGetDisplaySettingsQueryKey,
useGetDisplaySettings,
useGetDisplayState,
useReleaseDisplay,
useSetDisplaySettings,
} from "@/api/gen/display/display";
import type { ApiDisplayInfo } from "@/api/gen/model";
import { ApiError } from "@/api/fetcher";
import type {
DisplayPolicy,
EffectivePolicy,
KeepAlive,
Preset,
Topology,
} from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/**
* Container: the host's virtual-display management policy (design/display-management.md). Reads the
* stored policy + preset expansions, lets the operator pick a preset or set Custom fields, and PUTs
* the result — a change applies to the next session. Stage 0 enforces keep-alive + topology; the
* other stored options are shown but marked not-yet-enforced.
*/
export const DisplaySection: FC = () => {
const qc = useQueryClient();
const q = useGetDisplaySettings();
const save = useSetDisplaySettings();
// Local edit buffer, seeded once from the server and re-seeded after a successful save.
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
useEffect(() => {
if (q.data && draft === null) setDraft(q.data.settings);
}, [q.data, draft]);
const onSave = () => {
if (!draft) return;
save.mutate(
{ data: draft },
{
onSuccess: (res) => {
setDraft(res.settings);
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle>{m.host_displays()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{q.data && draft && (
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={q.data.presets}
onSave={onSave}
busy={save.isPending}
error={apiErrorMessage(save.error)}
/>
)}
</QueryState>
<LiveDisplays />
</CardContent>
</Card>
);
};
/**
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
* for lingering/pinned ones (active displays can't be released — that's session control).
*/
const LiveDisplays: FC = () => {
const qc = useQueryClient();
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
const release = useReleaseDisplay();
const displays = state.data?.displays ?? [];
const kept = displays.filter((d) => d.state !== "active");
const doRelease = (slot?: number) =>
release.mutate(
{ data: { slot: slot ?? null } },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
);
return (
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between gap-4">
<h4 className="text-sm font-medium">{m.display_live()}</h4>
{kept.length > 0 && (
<Button
size="sm"
variant="outline"
disabled={release.isPending}
onClick={() => doRelease()}
>
{m.display_release_all()}
</Button>
)}
</div>
{displays.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
) : (
<ul className="divide-y rounded-md border">
{displays.map((d) => (
<DisplayRow
key={d.slot}
d={d}
busy={release.isPending}
onRelease={() => doRelease(d.slot)}
/>
))}
</ul>
)}
</div>
);
};
const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({
d,
busy,
onRelease,
}) => {
const active = d.state === "active";
const stateLabel =
d.state === "active"
? m.display_state_active()
: d.state === "pinned"
? m.display_state_pinned()
: m.display_state_lingering();
return (
<li className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{d.mode}</span>
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
{active && d.sessions > 0 && (
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge>
)}
</div>
<code className="text-xs text-muted-foreground">
{d.backend}
{d.expires_in_ms != null
? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}`
: ""}
</code>
</div>
{!active && (
<Button size="sm" variant="outline" disabled={busy} onClick={onRelease}>
{m.display_release_btn()}
</Button>
)}
</li>
);
};
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
const apiErrorMessage = (err: unknown): string | undefined => {
if (err instanceof ApiError) {
const data = err.data as { error?: string } | undefined;
return data?.error ?? err.message;
}
return err ? String(err) : undefined;
};
/** The `gaming-rig` preset expands to `keep_alive: forever`, which the host rejects until the
* display-lifecycle stage — disable it rather than let the Save 400. */
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
const PRESET_LABEL: Record<string, () => string> = {
custom: m.display_preset_custom,
default: m.display_preset_default,
"gaming-rig": m.display_preset_gaming_rig,
"shared-desktop": m.display_preset_shared_desktop,
hotdesk: m.display_preset_hotdesk,
workstation: m.display_preset_workstation,
};
const TOPOLOGY_LABEL: Record<Topology, () => string> = {
auto: m.display_topology_auto,
extend: m.display_topology_extend,
primary: m.display_topology_primary,
exclusive: m.display_topology_exclusive,
};
const fmtKeepAlive = (k: KeepAlive): string => {
switch (k.mode) {
case "off":
return m.display_keep_alive_off();
case "duration":
return `${k.seconds} ${m.display_keep_alive_seconds()}`;
case "forever":
return "∞";
}
};
const DisplayForm: FC<{
draft: DisplayPolicy;
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
onSave: () => void;
busy: boolean;
error?: string;
}> = ({ draft, setDraft, presets, onSave, busy, error }) => {
const preset: Preset = draft.preset ?? "custom";
const isCustom = preset === "custom";
const keepAlive: KeepAlive = draft.keep_alive ?? { mode: "duration", seconds: 10 };
const topology: Topology = draft.topology ?? "auto";
// Preview the effective fields: from the selected preset's expansion, or the Custom fields.
const effective: EffectivePolicy | undefined = isCustom
? {
keep_alive: keepAlive,
topology,
mode_conflict: draft.mode_conflict ?? "separate",
identity: draft.identity ?? "per-client",
layout: draft.layout ?? { mode: "auto-row", positions: {} },
max_displays: draft.max_displays ?? 4,
}
: presets.find((p) => p.id === preset)?.fields;
const presetSummary = presets.find((p) => p.id === preset)?.summary;
const secondsValue = keepAlive.mode === "duration" ? keepAlive.seconds : 300;
return (
<div className="space-y-5">
{/* Preset picker */}
<div className="space-y-2">
<Label>{m.display_preset()}</Label>
<div className="flex flex-wrap gap-2">
{(["custom", "default", "gaming-rig", "shared-desktop", "hotdesk", "workstation"] as const).map(
(id) => (
<Button
key={id}
size="sm"
variant={preset === id ? "default" : "outline"}
disabled={busy || DISABLED_PRESETS.has(id)}
onClick={() => setDraft({ ...draft, preset: id as Preset })}
>
{(PRESET_LABEL[id] ?? (() => id))()}
</Button>
),
)}
</div>
{presetSummary && !isCustom && (
<p className="text-xs text-muted-foreground">{presetSummary}</p>
)}
</div>
{/* Custom fields: keep-alive + topology + max displays */}
{isCustom && (
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-2">
<Label>{m.display_keep_alive()}</Label>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={keepAlive.mode === "off" ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
>
{m.display_keep_alive_off()}
</Button>
<Input
type="number"
min={0}
className="w-24"
value={secondsValue}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
keep_alive: {
mode: "duration",
seconds: Math.max(0, Number(e.target.value) || 0),
},
})
}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
</div>
<div className="space-y-2">
<Label>{m.display_topology()}</Label>
<div className="flex flex-wrap gap-2">
{(["auto", "extend", "primary", "exclusive"] as const).map((t) => (
<Button
key={t}
size="sm"
variant={topology === t ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, topology: t })}
>
{TOPOLOGY_LABEL[t]()}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="disp-max">{m.display_max()}</Label>
<Input
id="disp-max"
type="number"
min={1}
max={16}
className="w-24"
value={draft.max_displays ?? 4}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
</div>
</div>
)}
{/* Effective preview */}
{effective && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">{TOPOLOGY_LABEL[effective.topology]()}</Badge>
<Badge variant="outline">{effective.mode_conflict}</Badge>
<Badge variant="outline">{effective.identity}</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
</div>
)}
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
{error && (
<p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>
)}
<Button onClick={onSave} disabled={busy}>
{m.display_save()}
</Button>
</div>
);
};
+7 -1
View File
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n";
import { DisplaySection } from "./DisplayCard";
import { GpuSection } from "./GpuCard";
import { HostView } from "./view";
@@ -10,6 +11,11 @@ export const SectionHost: FC = () => {
const compositors = useListCompositors();
return (
<HostView host={host} compositors={compositors} gpu={<GpuSection />} />
<HostView
host={host}
compositors={compositors}
gpu={<GpuSection />}
displays={<DisplaySection />}
/>
);
};
+5 -1
View File
@@ -13,7 +13,9 @@ export const HostView: FC<{
compositors: Loadable<AvailableCompositor[]>;
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
gpu?: ReactNode;
}> = ({ host, compositors, gpu }) => {
/** The virtual-display management card (self-contained container — see `DisplayCard.tsx`). */
displays?: ReactNode;
}> = ({ host, compositors, gpu, displays }) => {
const h = host.data;
return (
<Section maxWidth={false}>
@@ -81,6 +83,8 @@ export const HostView: FC<{
{gpu}
{displays}
<Card>
<CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle>