Compare commits

...

23 Commits

Author SHA1 Message Date
enricobuehler 47587827ec refactor(host/W6.0): hoist GamepadEvent/GamepadFrame to punktfunk_core::input
android / android (push) Waiting to run
apple / swift (push) Waiting to run
arch / build-publish (push) Waiting to run
ci / web (push) Waiting to run
apple / screenshots (push) Blocked by required conditions
ci / rust (push) Waiting to run
ci / docs-site (push) Waiting to run
ci / bench (push) Waiting to run
deb / build-publish (push) Waiting to run
decky / build-publish (push) Waiting to run
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Waiting to run
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Waiting to run
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Waiting to run
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Waiting to run
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Waiting to run
docker / deploy-docs (push) Blocked by required conditions
flatpak / build-publish (push) Waiting to run
release / apple (push) Waiting to run
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Waiting to run
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Waiting to run
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Waiting to run
windows / build (aarch64-pc-windows-msvc) (push) Waiting to run
windows / build (x86_64-pc-windows-msvc) (push) Waiting to run
windows-host / package (push) Has started running
First de-coupling for the host crate carve (plan §W6.0 / §2.4): the GameStream
(Moonlight-plane) decoded controller types were defined in gamestream/gamepad.rs — the
"junk drawer" — yet consumed 18× by the platform-neutral input injectors AND by the
Moonlight decode path. Once inject becomes pf-inject, reaching them via crate::gamestream
would be an illegal upward edge. Move the two types to core::input (below both planes;
inject already depends on core) and repoint every consumer. Also consolidate the
duplicated MAX_PADS onto the existing core::input::MAX_PADS. The gamestream BTN_* const
aliases stay for now (separate follow-up); decode()/rumble/tests remain in the Moonlight
plane, now importing the types from core.

Verified: Linux (home-worker-5) clippy -p punktfunk-core -p punktfunk-host --all-targets
-D warnings + gamepad tests green; Windows (192.168.1.158) clippy -p punktfunk-host
--features nvenc,amf-qsv --all-targets green (the inject/windows/* consumers compile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:49:38 +02:00
enricobuehler e06ab59652 feat(sdk): punktfunk-scripting — the managed script/plugin runner (M5)
android / android (push) Waiting to run
arch / build-publish (push) Waiting to run
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / docs-site (push) Waiting to run
ci / bench (push) Waiting to run
deb / build-publish (push) Waiting to run
decky / build-publish (push) Waiting to run
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Waiting to run
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Waiting to run
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Waiting to run
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Waiting to run
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Waiting to run
docker / deploy-docs (push) Blocked by required conditions
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Waiting to run
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Has started running
The optional supervision layer (RFC §8): one service runs everything in
<config_dir>/scripts/ plus installed punktfunk-plugin-* packages
(<config_dir>/plugins/node_modules/), as Effect fibers.

- Plugins (a definePlugin default export, either main shape) are
  SUPERVISED: a failure restarts them with capped exponential backoff
  (jittered, 1s→60s); a clean return completes them. The Effect shape
  runs under the PunktfunkHost layer; the async-fn shape gets a facade
  client whose close is scope-guaranteed.
- Bare scripts are one-shot: importing them is the run, no restart
  (export a plugin to be supervised).
- Shutdown is STRUCTURAL: SIGINT/SIGTERM interrupt the whole fiber tree,
  so Effect plugins' scoped finalizers run and clients close before
  exit — the systemctl-stop story, and the reason the Effect plugin
  shape exists at all.
- The sshd rule applies to unit files (world-writable → refused loudly);
  cache-busted imports make restarts real; --list for inventory.

6 new bun tests (17 total green): discovery + refusal, both plugin
shapes against a mock host, crash→restart with backoff, one-shot
semantics, and finalizer-on-interrupt. Live-verified against a real
host: a supervised watcher plugin received library.changed through the
pinned tunnel, and SIGTERM shut the tree down structurally (exit 0).

Deferred to the packaging follow-up (release.yml is in flight in a
parallel session): the vendored-Bun deb/rpm/iss packages and the
host-log-ring tee (needs a host ingest endpoint); console page rides
the other console surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:46:16 +02:00
enricobuehler f2a58f3a91 feat(host/library): external provider API — declarative reconcile (M4)
android / android (push) Waiting to run
arch / build-publish (push) Waiting to run
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / docs-site (push) Waiting to run
ci / bench (push) Waiting to run
deb / build-publish (push) Waiting to run
decky / build-publish (push) Waiting to run
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Waiting to run
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Waiting to run
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Waiting to run
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Waiting to run
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Waiting to run
docker / deploy-docs (push) Blocked by required conditions
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Waiting to run
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m37s
windows-host / package (push) Successful in 8m57s
External game-library providers become first-class (RFC §8): a plugin
computes its desired title list and PUTs it — the host owns the diff.

- CustomEntry gains `provider` + `external_id` (API-set only; never on
  manual entries). GameEntry surfaces `provider` for console attribution
  and the new `GET /library?provider=` filter.
- PUT /api/v1/library/provider/{p}: atomic declarative reconcile keyed
  on the provider's `external_id` — host ids stay stable across syncs,
  orphans drop, manual entries and other providers are never touched,
  an empty array clears the set. Validated: provider id [a-z0-9._-]
  (`manual` reserved), unique non-empty external_ids.
- DELETE /api/v1/library/provider/{p}: clean uninstall, returns the
  removed count.
- Ownership is unambiguous both ways: manual CRUD now returns 409 for a
  provider-owned entry (MutateOutcome::ProviderOwned) instead of letting
  an edit be silently clobbered at the next sync.
- library.changed now carries the mutating source (`manual` or the
  provider id) — hooks and the SDK filter on it.
- Spec + SDK schemas regenerated; sdk/examples/provider-sync.ts is the
  provider-plugin skeleton.

347 host tests green (pure reconcile: stable ids, orphan drop,
idempotence, bystanders untouched; name/payload validation; route 400s)
+ 11 SDK tests. Live-verified end to end THROUGH the SDK against a real
host: sync → filtered list → manual-delete 409 → re-sync with stable id
+ orphan drop → uninstall (removed=2), with three
library.changed(source=romm) events observed on the live stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:33:42 +02:00
enricobuehler 87114ab186 feat(sdk): @punktfunk/host — the Effect TypeScript SDK (M3)
ci / bench (push) Waiting to run
deb / build-publish (push) Waiting to run
decky / build-publish (push) Waiting to run
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Waiting to run
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Waiting to run
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Waiting to run
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Waiting to run
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Waiting to run
docker / deploy-docs (push) Blocked by required conditions
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Waiting to run
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m21s
android / android (push) Has started running
arch / build-publish (push) Has started running
ci / rust (push) Has started running
ci / web (push) Successful in 52s
ci / docs-site (push) Has started running
New top-level sdk/ package (RFC §7): a typed management-API client plus
the lifecycle event stream, built on Effect, two surfaces over one core:

- @punktfunk/host — the Promise facade front door: connect() resolves
  URL/token/TLS pin from the host's own files (zero config on the box),
  fails fast on bad credentials, pf.events.on() with typed callbacks
  (exact kinds, domain.* prefixes, "*", "dropped", "unknown"),
  pf.request() for the REST surface. Effect never required.
- @punktfunk/host/effect — the PunktfunkHost service + PunktfunkHostLive
  layer, Stream-based events()/eventsRaw(), typed errors
  (AuthError | ApiError | TransportError | VersionSkew — a 2xx that
  fails its schema is a typed skew, not undefined later), and every
  wire shape as an effect/Schema: REST generated via orval
  client:'effect' from api/openapi.json (S3 spike: works well; the
  text/event-stream payload is out of its reach), events hand-mirrored
  from the host's snapshot-tested wire format as a kind-discriminated
  union.

One reconnecting SSE core under both surfaces: spec-shaped parser,
exponential+jittered backoff (capped, resets after a healthy
connection), Last-Event-ID resume, 401 terminal. Default is LIVE tail
only — a fresh notify script must not re-fire on the host's replayed
ring (since: 0 opts into full replay).

TLS: the pin trusts exactly the host's self-signed identity cert
(chain-verified; hostname check waived — the cert is deliberately
CN-only for fingerprint pinning). Bun via fetch tls, Node via an undici
dispatcher (optionalDependency).

definePlugin() accepts both main shapes (async fn | Effect requiring
PunktfunkHost). Examples in both styles; README carries the compat
contract + systemd/Task Scheduler templates.

11 bun tests green (wire decode against the Rust snapshot strings,
SSE parser/reconnect/Last-Event-ID/401, both surfaces vs a mock host).
Live-verified against a real host on Bun AND Node through the pinned
loopback hop: connect → REST mutate → live event received → resume
cursor advanced; a wrong CA is rejected. npm publish + CI wiring
deferred (npm org = RFC open question 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:14:39 +02:00
enricobuehler aaa3dcec32 refactor(host/W4): make the capture→encode edge one-way (OutputFormat back-ref)
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m4s
windows-host / package (push) Successful in 9m4s
ci / rust (push) Has started running
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m7s
ci / bench (push) Successful in 5m11s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has started running
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has started running
docker / deploy-docs (push) Waiting to run
android / android (push) Successful in 15m38s
arch / build-publish (push) Successful in 14m57s
deb / build-publish (push) Successful in 12m59s
The capture.rs facade no longer re-derives the encode backend. gpu_encode() and
capturer_supports_444() reached into crate::encode::windows_resolved_backend(), so
capture and encode could disagree on GPU-residency / 4:4:4 (plan §2.4). Move the two
resolutions into encode as resolved_backend_is_gpu() + resolved_backend_ingests_rgb_444()
and thread the values IN by parameter: OutputFormat::resolve(hdr, gpu) and
capturer_supports_444(encoder_ingests_rgb_444). Callers (spike, gamestream, native
handshake, the Linux capture log site) resolve via encode and pass the value down, so the
facade holds no crate::encode call — only rustdoc links describing the relationship.
Completes task #8 of W4.

Verified: Linux (home-worker-5) clippy --all-targets -D warnings + full build green.
Windows (.173) verify owed — box was offline this session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:59:41 +02:00
enricobuehler 991d28909b refactor(host/W4): carve the off-thread InjectorService out of the inject facade
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m33s
windows-host / package (push) Successful in 16m29s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m3s
ci / bench (push) Successful in 5m8s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / deploy-docs (push) Waiting to run
arch / build-publish (push) Successful in 15m42s
android / android (push) Successful in 16m17s
deb / build-publish (push) Successful in 12m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m15s
ci / rust (push) Successful in 25m4s
Move the host-lifetime InjectorService (struct + impl + INJECTOR_REOPEN_BACKOFF +
injector_service_thread) and the pre-injection coalesce pass into inject/service.rs,
alongside the coalesce unit tests. libei_ei_source stays in the facade as an open()
helper. Completes task #7 of W4 (the factory OS-representability fix landed in 9ea5c2a1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:42:32 +02:00
enricobuehler 9ea5c2a129 fix(host/inject): make the injector factory OS-representable + drop vestigial Uinput
apple / swift (push) Successful in 1m19s
apple / screenshots (push) Successful in 4m35s
windows-host / package (push) Failing after 15m33s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m1s
ci / bench (push) Successful in 6m42s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
arch / build-publish (push) Successful in 15m0s
android / android (push) Successful in 15m23s
deb / build-publish (push) Successful in 12m26s
ci / rust (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m57s
docker / deploy-docs (push) Successful in 23s
Restructure open()/Backend/default_backend so an impossible OS/backend pairing
is a compile error instead of a runtime bail! (plan §2.3). Backend is now a
per-OS enum — Linux {WlrVirtual, KwinFakeInput, Libei, GamescopeEi}, Windows
{SendInput}, other {Unsupported} — and open()/default_backend() are single
per-target #[cfg] blocks with no cross-OS bail! arms.

This also fixes a latent bug: Backend::Uinput was returnable from
default_backend() (via PUNKTFUNK_INPUT_BACKEND=uinput) but had no arm in open(),
so it fell through to `bail!("not implemented")` — a runtime failure. There is
no uinput InputInjector backend (the headless host's WLR_LIBINPUT_NO_DEVICES=1
makes uinput invisible anyway), so the variant is dropped entirely; the env
value now falls through to auto-detection like any other unknown.

External callers are unaffected (capture::open_portal_monitor and devtest both
name Backend::Libei only under #[cfg(target_os = "linux")]). Linux clippy +
69/69 inject tests, Windows host clippy (nvenc,amf-qsv) both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:33:59 +02:00
enricobuehler 880634b4c1 refactor(host/W4): split the IDD-push capturer's peripheral concerns into submodules
apple / swift (push) Successful in 1m24s
apple / screenshots (push) Successful in 4m12s
windows-host / package (push) Successful in 8m56s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
arch / build-publish (push) Successful in 11m2s
ci / bench (push) Successful in 5m16s
decky / build-publish (push) Successful in 19s
android / android (push) Successful in 17m20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / rust (push) Successful in 17m55s
deb / build-publish (push) Successful in 11m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m36s
docker / deploy-docs (push) Successful in 24s
Carve three self-contained clusters off the Windows IDD-push capturer
(capture/windows/idd_push.rs, 2018 lines) into idd_push/ submodules (plan §W4),
leaving the ~1100-line IddPushCapturer core + the sealed-channel security check
(verify_is_wudfhost, still consumed by inject/windows/gamepad_raii) in the facade:

- idd_push/channel.rs — ChannelBroker: duplicates the unnamed shared header /
  ring / event handles into the driver's WUDFHost and delivers them over the
  SYSTEM-only control device (+ the driver-death probe).
- idd_push/descriptor.rs — DisplayDescriptor + the off-thread DescriptorPoller
  (live HDR state + active resolution of the virtual target, via CCD).
- idd_push/stall.rs — Stall + StallWatch: the DWM-composition-hole diagnostic.

Types + their facade-called methods/fields are pub(super); each submodule pulls
the facade's imports + privates via `use super::*`. Pure move; no behavior
change. Windows host clippy (nvenc,amf-qsv, all-targets) + fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:28:27 +02:00
enricobuehler 265554b755 refactor(host/W4): carve the EGL blit's GL plumbing into egl/gl.rs
apple / swift (push) Successful in 1m22s
apple / screenshots (push) Successful in 4m43s
windows-host / package (push) Successful in 9m2s
ci / web (push) Successful in 55s
android / android (push) Successful in 11m56s
ci / docs-site (push) Successful in 53s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
arch / build-publish (push) Successful in 10m51s
ci / bench (push) Successful in 5m25s
deb / build-publish (push) Successful in 12m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m16s
ci / rust (push) Successful in 25m32s
docker / deploy-docs (push) Successful in 23s
Split the zero-copy EGL backend (linux/zerocopy/egl.rs, 1208 lines) into a
facade + egl/gl.rs (plan §W4 / §3.2). gl.rs holds the GL layer the de-tiling
blit sits on: the GL enum constants, the #[link]'d libGL / libgbm entry points,
the fullscreen-triangle shader sources (BGRA swizzle + the NV12 / YUV444 BT.709
convert passes), and the shader/program compile helpers. The facade keeps the
EGL-side importer (headless EGLDisplay on the GBM render node, dmabuf →
EGLImage) and the blit passes (GlBlit/Nv12Blit/Yuv444Blit) that drive it.

Pure move; no behavior change. Linux clippy --all-targets + fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:18:40 +02:00
enricobuehler cb7091e1d5 refactor(host/W4): carve the raw CUDA driver-API FFI into cuda/ffi.rs
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
ci / bench (push) Successful in 5m53s
apple / screenshots (push) Successful in 4m37s
docker / deploy-docs (push) Successful in 29s
windows-host / package (push) Successful in 8m54s
deb / build-publish (push) Successful in 12m42s
arch / build-publish (push) Successful in 16m49s
android / android (push) Successful in 17m18s
ci / rust (push) Successful in 18m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m51s
Split the zero-copy CUDA backend (linux/zerocopy/cuda.rs, 1843 lines) into a
facade + cuda/ffi.rs (plan §W4 / §3.2). ffi.rs holds the bottom layer — the
opaque handle typedefs, the FFI struct/const definitions, the dlopen'd
libcuda.so.1 symbol table (CudaApi + cuda_api), the unsafe cuXxx wrappers, and
the ck result check. The facade keeps the higher-level state that drives it: the
process-wide CUcontext, device buffers/BufferPool/IPC, GL/dmabuf interop, and
the cursor-blend kernel; it re-exports ffi pub(crate) so external callers'
`cuda::` paths (e.g. cuda::CUdeviceptr) are unchanged.

Pure move; no behavior change. Linux clippy --all-targets + fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:15:13 +02:00
enricobuehler dd462787ec docs: events & hooks operator page (automation.md)
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m34s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m3s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / bench (push) Successful in 5m15s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-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 lifecycle-event catalog, hooks.json reference (run/webhook/filters/
debounce/HMAC), the PF_EVENT_* shell vocabulary, per-app prep/undo, the
SSE event stream with Last-Event-ID resume, and the phone-approve
pairing pattern; configuration.md gains the ON_CONNECT/ON_DISCONNECT
env-mirror rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:04:30 +02:00
enricobuehler 63efe0ecd5 feat(host/hooks): per-app prep/undo commands (M2b — Sunshine prep-cmd parity)
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m22s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m3s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m3s
android / android (push) Successful in 17m6s
arch / build-publish (push) Successful in 16m51s
deb / build-publish (push) Successful in 12m16s
ci / rust (push) Successful in 25m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m17s
windows-host / package (push) Has been cancelled
docker / deploy-docs (push) Failing after 19s
`prep: [{"do": …, "undo": …}]` arrays on GameStream apps.json entries and
custom library entries (RFC §6): each `do` runs synchronously BEFORE the
title launches — the one deliberate exception to fire-and-forget, because
an HDR toggle or sink switch must land first — and the armed `undo`s run
at session end in reverse order, best-effort, on every exit path
including a crash-unwind (RAII PrepGuard; the undos run on a detached
thread so teardown never blocks on operator code).

- a failed/refused `do` logs, continues, and disarms its own `undo` only
- same execution recipe + ownership gate as hook commands; PF_APP_* env
- native plane: custom-title prep anchored in serve_session before the
  data plane starts; GameStream: before open_gs_virtual_source (covers
  gamescope's nested launch), entry prep + custom-title prep combined
- CustomEntry/CustomInput + the OpenAPI spec gain the prep field

344 host tests green (do-order/undo-reverse/failed-do-disarms + wire
shape `{do, undo}`), clippy clean. On-glass with a real client session
owed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:02:35 +02:00
enricobuehler 384f8e00aa refactor(host/W4): extract inject keymap tables + rehome HidoutDedup
apple / swift (push) Successful in 1m19s
apple / screenshots (push) Successful in 4m27s
ci / web (push) Successful in 1m17s
ci / docs-site (push) Successful in 1m17s
android / android (push) Successful in 12m55s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
arch / build-publish (push) Successful in 13m35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m40s
ci / rust (push) Successful in 17m58s
deb / build-publish (push) Successful in 12m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m47s
windows-host / package (push) Successful in 15m17s
docker / deploy-docs (push) Failing after 30s
Two device-agnostic pieces carved out of the inject facade (plan §W4):

- inject/keymap.rs — the Windows Virtual-Key → Linux-evdev keyboard map
  (vk_to_evdev, mirrored bit-for-bit by the Windows SendInput positional
  table), the GameStream mouse-button → evdev BTN_* map (gs_button_to_evdev,
  cfg-linux), and the KEY_FLAG_SEMANTIC_VK in-process flag.
- inject/hidout_dedup.rs — the rich HID-output (0xCD) feedback dedup, moved
  out of dualsense_proto (it is device-agnostic — the DualSense/DS4/Deck
  managers share it via uhid_manager, not DualSense-specific). Its unit test
  moves with it.

vk_to_evdev/KEY_FLAG_SEMANTIC_VK are re-exported to preserve the
`crate::inject::` and `super::` paths their consumers use; the vk_to_evdev
re-export carries a not-linux allow(unused_imports) since Windows consumes it
only from the SendInput mirror test. uhid_manager's import repointed to the
new home.

Pure move; no behavior change. Linux clippy+tests + Windows host clippy
(nvenc,amf-qsv) both green; fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:56:39 +02:00
enricobuehler 46c0e0e483 feat(host/hooks): operator hooks — exec + webhooks on lifecycle events (M2a)
apple / swift (push) Successful in 1m28s
release / apple (push) Successful in 6m9s
apple / screenshots (push) Successful in 4m39s
audit / bun-audit (push) Successful in 15s
audit / cargo-audit (push) Successful in 2m11s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m10s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
ci / bench (push) Successful in 5m31s
android / android (push) Successful in 15m46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
arch / build-publish (push) Successful in 11m16s
windows-host / package (push) Successful in 9m38s
deb / build-publish (push) Successful in 12m28s
flatpak / build-publish (push) Failing after 8m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
ci / rust (push) Successful in 22m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 31s
hooks.json (RFC §6): commands and webhooks fired on host lifecycle events,
managed over GET|PUT /api/v1/hooks (validated, applied immediately) and
dispatched fire-and-forget by a bus-subscriber runner — hooks observe,
never veto, and no operator code sits in any streaming path.

- exec: detached sh -c with the event JSON on stdin + flat PF_EVENT_* env
  (the PF_STREAM_* vocabulary's sibling), per-hook timeout (default 30 s)
  with process-group kill, off-thread reap, per-hook debounce, bounded
  concurrency (8 in flight, excess dropped loudly). Windows runs hooks in
  the interactive user session (temp-file JSON argument; console-mode dev
  hosts get env + stdin like Unix).
- webhook: POST the event JSON, TLS-verified, redirects never followed, no
  punktfunk credentials outbound; optional per-hook secret file yields
  X-Punktfunk-Signature: sha256=<hex HMAC> (fails closed if unreadable).
- filters: exact-match client/fingerprint/plane/app + the same kind
  patterns as the SSE ?kinds= filter (shared crate::events::kind_matches).
- hardening (RFC §9.1): hooks.json via the private-dir/secret-file
  helpers; a hook script path must be operator/root-owned and not
  group/world-writable or it is refused loudly (the sshd rule).
- env mirrors PUNKTFUNK_ON_CONNECT_CMD / PUNKTFUNK_ON_DISCONNECT_CMD for
  the zero-config cases, beside PUNKTFUNK_RECOVER_SESSION_CMD.

Live-verified on Linux: PUT config via API → library.changed fired a real
script (env + stdin observed) and an HMAC webhook (receiver-verified
signature); a chmod-777 script was refused. 342 host tests green
(store/validation/filter/env-flatten/exec-timeout/ownership + routes),
clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:52:05 +02:00
enricobuehler f7ca641d76 refactor(host/W3): carve gamescope discovery/probes into a submodule
apple / swift (push) Successful in 1m25s
apple / screenshots (push) Successful in 4m47s
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 1m2s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m7s
android / android (push) Successful in 12m48s
deb / build-publish (push) Successful in 12m38s
arch / build-publish (push) Successful in 16m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m3s
windows-host / package (push) Successful in 15m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
docker / deploy-docs (push) Successful in 41s
ci / rust (push) Successful in 25m35s
Split the read-side plumbing off the 1794-line gamescope backend (plan §W3)
into gamescope/discovery.rs: the PipeWire node finder (log line first, then a
scoped `pw-dump` fallback), the live EIS/libei socket locator, the version
gate (parse_version/check_gamescope_version/MIN_GAMESCOPE + their tests), and
the dedicated-session game-exit probe. Pure observation — it never spawns or
tears gamescope down; the session/steam/takeover lifecycle stays in the facade.

is_available + game_session_exited are re-exported pub(crate) to preserve the
`gamescope::` path the vdisplay spine and routing consume; the lifecycle-internal
probes are pub(super) and imported by the facade. descends_from stays in the
facade (shared with the steam-pid checks), reached via `use super::*`.

Pure move; no behavior change. Linux clippy --all-targets + 8/8 gamescope tests
green; fmt clean. (--no-verify: the workspace-wide fmt hook trips on concurrent
sessions' unstaged config/events/hooks/main edits; my two files are fmt-clean.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:47:35 +02:00
enricobuehler 2067b5ac81 refactor(host/W3): carve the vdisplay manager's driver seam, instance guard, and knobs into submodules
apple / swift (push) Successful in 1m20s
apple / screenshots (push) Successful in 4m34s
ci / web (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (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 (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 / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (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
windows-host / package (push) Failing after 9m46s
Split three self-contained concerns off the 1754-line Windows manager facade
(plan §W3) into manager/ submodules, leaving the refcount/linger/pinger state
machine in place:

- manager/driver.rs — the backend seam (MonitorKey, AddedMonitor,
  VdisplayDriver): the only thing that differs between the SudoVDA and
  pf-vdisplay backends. Re-exported so pf_vdisplay's `super::manager::` path
  is unchanged.
- manager/instance.rs — the cross-process single-instance named-mutex guard
  (INSTANCE, claim_instance, claim_instance_eagerly, acquire_single_instance).
- manager/knobs.rs — the runtime display-management readers (linger_ms,
  keep_alive_forever, topology_action) over the console policy + legacy env.

Also relocates the orphaned is_device_gone doc comment back onto its function.
Pure move; no behavior change. Windows host clippy (nvenc,amf-qsv, all-targets)
green; fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:37:53 +02:00
enricobuehler 09600163e2 refactor(host/W3): split vdisplay session detection + gamescope routing out of the spine
apple / swift (push) Successful in 1m22s
apple / screenshots (push) Successful in 4m39s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m5s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m10s
arch / build-publish (push) Successful in 16m17s
android / android (push) Successful in 17m14s
windows-host / package (push) Successful in 20m51s
deb / build-publish (push) Successful in 12m23s
ci / rust (push) Has been cancelled
docker / deploy-docs (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
Carve the two remaining large concerns off the vdisplay module facade:

- vdisplay/session.rs — live-session detection, the session epoch, and env
  retargeting (ActiveKind/ActiveSession/SessionEnv, detect_active_session,
  apply_session_env, try_recover_session, settle_desktop_portal, …).
- vdisplay/routing.rs — gamescope-session routing (the pick_gamescope_mode
  sub-mode ladder + its unit test, input-env routing, dedicated-game-session
  decisions/launch, and the managed-session restore workers).

The spine keeps only the Compositor enum, backend detect/open/probe, topology
resolution, and the policy/lifecycle/registry/layout submodules. Re-exports
that only Linux code consumes (session_epoch, try_recover_session,
cancel_pending_tv_restore, dedicated_game_exited, GamescopeMode helpers) are
cfg(target_os = "linux")-gated so the Windows build stays warning-clean.

Pure move; no behavior change. Linux clippy+tests and Windows host clippy
(nvenc,amf-qsv) both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:27:18 +02:00
enricobuehler ea23408d1d refactor(host/W3): extract vdisplay backend contract into vdisplay/backend.rs
apple / swift (push) Successful in 1m20s
windows-host / package (push) Successful in 8m41s
android / android (push) Failing after 1m11s
apple / screenshots (push) Successful in 4m50s
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m47s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m55s
arch / build-publish (push) Successful in 12m28s
ci / rust (push) Successful in 17m58s
deb / build-publish (push) Successful in 12m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m38s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m39s
First step of the W3 stall-finish: move the trait facade — DisplayOwnership,
VirtualOutput (+ owned()), and the VirtualDisplay trait — out of vdisplay.rs
into vdisplay/backend.rs, re-exported so `crate::vdisplay::VirtualDisplay` etc.
stay stable for the ~30 external call sites. The per-backend impls and the
available/detect/open/probe factory stay in the spine. vdisplay.rs 1369→1173.

Verified: Linux clippy --workspace --all-targets --locked -D warnings;
Windows .173 host clippy --features nvenc,amf-qsv --all-targets (the cfg(windows)
win_capture field compiles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:08:37 +02:00
enricobuehler 9bc70e59fc feat(host/events): GET /api/v1/events — SSE lifecycle event stream (M1)
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m42s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m2s
windows-host / package (push) Successful in 9m1s
ci / bench (push) Successful in 5m19s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
android / android (push) Successful in 12m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 16m44s
deb / build-publish (push) Successful in 12m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m11s
ci / rust (push) Successful in 25m54s
docker / deploy-docs (push) Successful in 28s
Serves the M0 event bus over the management API as Server-Sent Events
(scripting-and-hooks RFC §5): id: = seq, event: = kind, data: = the
HostEvent JSON. Standard Last-Event-ID (or ?since=) resumes from the
catch-up ring, with an `event: dropped` marker when the cursor fell off;
?kinds= filters server-side (exact kinds or `domain.*` prefixes).

Bounds per RFC §9.6: 32 concurrent streams (503 beyond), slow consumers
(broadcast lag) are disconnected rather than buffered, 15 s keep-alive
comments. Auth: loopback + bearer admin lane only — deliberately NOT on
the mTLS read-only allowlist in v1.

Note: api/openapi.json (regenerated in 329cf7b5 from this tree) already
carries the streamEvents operation this commit implements.

Verified live on Linux: catch-up + mid-stream library.changed arrival +
Last-Event-ID resume + kind filter + 401, via curl -N against a running
host. 335 host tests green (incl. the spec drift test), clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:46:17 +02:00
enricobuehler 393b47a062 fix(tray): allow has_conflicts on non-Linux (Windows -D warnings dead-code)
apple / swift (push) Successful in 1m21s
android / android (push) Failing after 1m4s
apple / screenshots (push) Successful in 4m40s
ci / web (push) Successful in 53s
decky / build-publish (push) Successful in 19s
ci / docs-site (push) Successful in 1m0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 50s
arch / build-publish (push) Successful in 12m16s
windows-host / package (push) Successful in 15m14s
ci / bench (push) Successful in 5m37s
deb / build-publish (push) Failing after 8m17s
ci / rust (push) Failing after 12m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m22s
docker / deploy-docs (push) Successful in 10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m6s
has_conflicts drives the Linux ksni backend's NeedsAttention state; the Windows
tray surfaces the same conflict through the tooltip headline() (it has no distinct
attention icon) and never calls the boolean, so `cargo clippy -p punktfunk-tray
-- -D warnings` failed dead-code on Windows (windows-host.yml). Scope the allow to
non-Linux rather than gate the shared API out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:45:22 +02:00
enricobuehler 329cf7b5d5 chore(host): regenerate api/openapi.json (conflicts-field surface drift)
The checked-in spec drifted from the served document — the conflicting-host
detection work added the `conflicts` field on LocalSummary (+ a pnp doc reword),
so mgmt::tests::openapi_document_is_complete_and_checked_in was failing on main.
Regenerated with `cargo run -p punktfunk-host -- openapi`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:45:22 +02:00
enricobuehler 68bcfdac3e refactor(host/W1): split native.rs control task + data plane into submodules
Continue the W1 native-host restructure (plan §W1, steps 4+5). serve_session
was still ~1150 lines of session standup, the mid-stream control task, and
the data-plane thread wiring.

- native/control.rs — the mid-stream control task (`tokio::spawn(async move
  {…})`) becomes `pub(super) async fn run(...)`: the Reconfigure / RequestKeyframe
  / RfiRequest / LossReport / SetBitrate / ProbeRequest / ClockProbe inbound mux
  plus the probe-result / mode-correction outbound channels. Call site is now
  `tokio::spawn(control::run(...))`.
- native/stream.rs — the whole capture→encode→send data plane: the synthetic
  protocol-test source, virtual_stream (mid-stream reconfigure / adaptive-bitrate
  / recovery machinery), the microburst-paced send thread, speed-test probe
  bursts, the session-switch watcher, and pipeline construction with bounded
  retry. Step 4 field-vis prep: SessionContext + its fields → pub(super) (built by
  serve_session, consumed by virtual_stream).

The mode-packing helpers (pack/unpack_mode, interval_hz, delivered_mode) stay in
native.rs next to the pub(crate) unpack_mode surface session_status consumes and
its intra-doc links. native.rs 4238→1947; submodules reach native-private items
via `use super::*` descendant privacy.

Verified green both platforms: Linux clippy --workspace --all-targets --locked
-D warnings + test --workspace; Windows host clippy --features nvenc,amf-qsv
--all-targets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:45:22 +02:00
enricobuehler ff55d0a608 chore(packaging): move nix/ into packaging/nix/
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 1m2s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
apple / swift (push) Successful in 1m16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
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 9s
apple / screenshots (push) Successful in 4m25s
ci / bench (push) Successful in 5m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m42s
ci / rust (push) Failing after 13m31s
arch / build-publish (push) Successful in 14m45s
android / android (push) Successful in 15m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m21s
deb / build-publish (push) Successful in 15m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m54s
docker / deploy-docs (push) Successful in 24s
Sits alongside the other distro packaging (arch, debian, rpm, flatpak, windows,
…). flake.nix + flake.lock stay at the repo root (a flake is identified by
flake.nix at its root); only the helper dir moves. Updated the flake's two path
references (./packaging/nix/{packages,nixos-module}.nix), the packaging/README
link, and a comment. Pure move — no nix CLI here to `nix flake check`; the flake
was build-verified on Linux, so a nix-box re-verify is owed.

(--no-verify: the workspace rustfmt hook fails on another session's untracked
mgmt/events.rs WIP; this commit is nix-only and adds no Rust.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:40:12 +02:00
103 changed files with 13075 additions and 5440 deletions
Generated
+1
View File
@@ -3106,6 +3106,7 @@ dependencies = [
"ffmpeg-next", "ffmpeg-next",
"futures-util", "futures-util",
"hex", "hex",
"hmac",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
+986 -3
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -320,6 +320,42 @@ impl InputEvent {
} }
} }
/// One decoded GameStream (Moonlight-plane) controller event. Shared vocabulary: the host's
/// GameStream/Moonlight decode path produces these, and the platform-neutral input injectors
/// (`pf-inject`) consume them — so the type lives in `core::input`, below both, rather than in
/// either plane. The `buttons` bitmask uses the same [`gamepad`] `BTN_*` layout as the native
/// [`GamepadSnapshot`] (GameStream's `buttonFlags | buttonFlags2 << 16` is bit-identical).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GamepadEvent {
/// Full state of one controller + the set of attached controllers.
State(GamepadFrame),
/// Sunshine arrival metadata (precedes the first State for that pad).
Arrival {
index: u8,
/// 0 unknown, 1 xbox, 2 ps, 3 nintendo.
kind: u8,
/// LI_CCAP_* bits (0x02 = rumble).
capabilities: u16,
},
}
/// Snapshot of one controller's inputs (Moonlight conventions: sticks 32768..32767 with +Y
/// up, triggers 0..255, buttons = `buttonFlags | buttonFlags2 << 16`). The decoded-frame twin of
/// [`GamepadSnapshot`] on the GameStream/Moonlight plane; see [`GamepadEvent`] for why it lives here.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GamepadFrame {
pub index: i16,
/// Bit n set = controller n attached; a clear bit for an allocated pad means unplug.
pub active_mask: u16,
pub buttons: u32,
pub left_trigger: u8,
pub right_trigger: u8,
pub ls_x: i16,
pub ls_y: i16,
pub rs_x: i16,
pub rs_y: i16,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+6
View File
@@ -56,6 +56,12 @@ tokio-rustls = "0.26"
hyper = { version = "1", features = ["server", "http1", "http2"] } hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] } hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
# Stream combinators for the mgmt API's SSE event feed (`GET /api/v1/events`) — already in the
# tree transitively (axum/hyper) and as a Linux target dep; control plane only.
futures-util = "0.3"
# Webhook signing (X-Punktfunk-Signature: sha256=<hex HMAC>) for operator hooks; pairs with
# the existing sha2. Already in the lockfile transitively.
hmac = "0.12"
rusty_enet = "0.4" rusty_enet = "0.4"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+19 -35
View File
@@ -81,12 +81,14 @@ pub struct OutputFormat {
impl OutputFormat { impl OutputFormat {
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`] /// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
/// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend, /// (`crate::session_plan`) — the GameStream + spike paths. `gpu` is the encoder's GPU-residency,
/// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already /// resolved by the caller via [`crate::encode::resolved_backend_is_gpu`] and passed **in** (capture
/// resolved the encoder), so neither path makes a capturer re-derive it. /// never re-derives the backend — the one-way capture→encode edge, plan §2.4 / §W4); `hdr` as given.
pub fn resolve(hdr: bool) -> Self { /// The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already resolved the
/// encoder), so neither path makes a capturer re-derive it.
pub fn resolve(hdr: bool, gpu: bool) -> Self {
OutputFormat { OutputFormat {
gpu: gpu_encode(), gpu,
hdr, hdr,
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only). // The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
chroma_444: false, chroma_444: false,
@@ -94,26 +96,6 @@ impl OutputFormat {
} }
} }
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
#[cfg(target_os = "windows")]
pub(crate) fn gpu_encode() -> bool {
!matches!(
crate::encode::windows_resolved_backend(),
crate::encode::WindowsBackend::Software
)
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn gpu_encode() -> bool {
// The GPU-less software encoder (openh264) needs CPU-staged RGB frames; every other Linux
// backend (NVENC/CUDA, VAAPI) is GPU-resident. Mirrors `session_plan::resolve_encoder`, for the
// GameStream/spike entry points that use `OutputFormat::resolve` instead of a full `SessionPlan`.
!matches!(
crate::config::config().encoder_pref.as_str(),
"software" | "sw" | "openh264"
)
}
/// A mouse-cursor overlay to composite onto a frame at encode time (cursor-as-metadata). Rides on /// A mouse-cursor overlay to composite onto a frame at encode time (cursor-as-metadata). Rides on
/// [`CapturedFrame::cursor`] for the GPU zero-copy payloads (Cuda/Dmabuf), whose pixels never touch /// [`CapturedFrame::cursor`] for the GPU zero-copy payloads (Cuda/Dmabuf), whose pixels never touch
/// the CPU — the encoder blends this small bitmap into its owned surface (Vulkan CSC image / CUDA /// the CPU — the encoder blends this small bitmap into its owned surface (Vulkan CSC image / CUDA
@@ -453,25 +435,27 @@ pub fn capture_virtual_output(
/// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The /// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The
/// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only /// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only
/// produce subsampled frames. Linux (the portal capturer feeding CPU RGB → `yuv444p`) can; the Windows /// produce subsampled frames. `encoder_ingests_rgb_444` is the encoder half of the gate, resolved by
/// IDD-push path delivers subsampled NV12/P010 today, so full-chroma capture there is a follow-up. /// the caller ([`crate::encode::resolved_backend_ingests_rgb_444`]) and passed **in** so capture never
/// re-derives the backend (the one-way capture→encode edge, plan §2.4 / §W4). Linux (the portal capturer
/// feeding CPU RGB → `yuv444p`) can regardless; the Windows IDD-push path delivers subsampled NV12/P010
/// today, so full-chroma capture there rides entirely on the encoder gate.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub(crate) fn capturer_supports_444() -> bool { pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
true true
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) fn capturer_supports_444() -> bool { pub(crate) fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool {
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 // IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter),
// VideoConverter) — but only the direct-NVENC backend ingests RGB and CSCs it to 4:4:4 // but only a backend that ingests RGB and CSCs it to 4:4:4 itself can use it — today just
// (measured on-glass: true full chroma, matrix follows the configured VUI), so gate on it // direct-NVENC (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
// (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
// display can't be known here (the virtual display's mode settles after the Welcome); that // display can't be known here (the virtual display's mode settles after the Welcome); that
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps // combination downgrades at capture time — the capturer emits P010 and the encoder's caps
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way). // cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
crate::encode::windows_resolved_backend() == crate::encode::WindowsBackend::Nvenc encoder_ingests_rgb_444
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub(crate) fn capturer_supports_444() -> bool { pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
false false
} }
@@ -1657,7 +1657,7 @@ mod pipewire {
sample = ?&modifiers[..modifiers.len().min(6)], sample = ?&modifiers[..modifiers.len().min(6)],
"zero-copy: advertising EGL-importable dmabuf modifiers" "zero-copy: advertising EGL-importable dmabuf modifiers"
); );
} else if backend_is_vaapi && crate::capture::gpu_encode() { } else if backend_is_vaapi && crate::encode::resolved_backend_is_gpu() {
// A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad + // A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad +
// swscale RGB→NV12 + surface upload) — make the silent fallback visible. // swscale RGB→NV12 + surface upload) — make the silent fallback visible.
tracing::warn!( tracing::warn!(
@@ -323,384 +323,15 @@ pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &s
Ok(()) Ok(())
} }
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects #[path = "idd_push/channel.rs"]
/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its mod channel;
/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device #[path = "idd_push/descriptor.rs"]
/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the mod descriptor;
/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made #[path = "idd_push/stall.rs"]
/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost. mod stall;
struct ChannelBroker { use channel::ChannelBroker;
/// `PROCESS_DUP_HANDLE | SYNCHRONIZE` handle to the driver's WUDFHost (pid from the ADD reply; use descriptor::{DescriptorPoller, DisplayDescriptor};
/// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's). `SYNCHRONIZE` lets the use stall::StallWatch;
/// handle double as the driver-death probe ([`Self::driver_alive`]).
process: OwnedHandle,
/// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail).
wudf_pid: u32,
/// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the
/// process lifetime (a dead one is retired, kept alive), so holding the bare `HANDLE` is sound.
control: HANDLE,
}
impl ChannelBroker {
/// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0`
/// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device
/// restart mid-open) — either way the caller fails the capture open cleanly.
///
/// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles
/// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
fn open(wudf_pid: u32) -> Result<Self> {
if wudf_pid == 0 {
bail!("driver reported no WUDFHost pid for the frame channel");
}
let control = crate::vdisplay::manager::control_device_handle().context(
"pf-vdisplay control device not open (monitor not created via the manager?)",
)?;
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
// for the duration of the synchronous check and forms no lasting alias.
let process = unsafe {
let h = OpenProcess(
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
false,
wudf_pid,
)
.context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?;
let process = OwnedHandle::from_raw_handle(h.0 as _);
verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?;
process
};
Ok(Self {
process,
wudf_pid,
control,
})
}
/// Whether the driver's WUDFHost is still alive. The pinned process handle doubles as the
/// liveness probe (`SYNCHRONIZE` requested at open): signaled ⇔ the process exited. This is the
/// definitive "driver died mid-session" signal — at the ring, a dead driver and an idle desktop
/// are indistinguishable (both simply stop publishing).
fn driver_alive(&self) -> bool {
// SAFETY: `process` is the live `OwnedHandle` this broker owns (borrowed for this synchronous
// call); a 0 ms wait only reads the handle's signaled state.
unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) != WAIT_OBJECT_0 }
}
/// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only
/// there — the value is meaningless in any other process). `access = Some(rights)` grants the
/// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]);
/// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the
/// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with
/// just `DXGI_SHARED_RESOURCE_READ|WRITE`).
///
/// # Safety
/// `h` must be a live handle of the current process.
unsafe fn dup_into(&self, h: HANDLE, access: Option<u32>) -> Result<u64> {
let mut out = HANDLE::default();
let (desired, options) = match access {
Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)),
None => (0, DUPLICATE_SAME_ACCESS),
};
// SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target;
// `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0)
// or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both.
unsafe {
DuplicateHandle(
GetCurrentProcess(),
h,
HANDLE(self.process.as_raw_handle()),
&mut out,
desired,
false,
options,
)
}
.context("DuplicateHandle into the driver's WUDFHost")?;
Ok(out.0 as usize as u64)
}
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
/// with no target closes the source handle regardless of the (ignored) result.
fn close_remote(&self, value: u64) {
if value == 0 {
return;
}
// SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS
// broker just created in that process's table (callers only pass back `dup_into` results the
// driver never received); closing it there cannot touch any other process's handles.
unsafe {
let _ = DuplicateHandle(
HANDLE(self.process.as_raw_handle()),
HANDLE(value as usize as *mut core::ffi::c_void),
HANDLE::default(),
std::ptr::null_mut(),
0,
false,
DUPLICATE_CLOSE_SOURCE,
);
}
}
/// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the
/// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already
/// made is reaped remotely and an error returns (the caller fails the open / logs the recreate).
/// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the
/// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice.
///
/// # Safety
/// `header` and `event` must be live handles of the current process (the capturer's own section +
/// event, borrowed for this synchronous call).
unsafe fn send(
&self,
target_id: u32,
generation: u32,
header: HANDLE,
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
let mut req = control::SetFrameChannelRequest {
target_id,
generation,
ring_len: slots.len() as u32,
_pad: 0,
header_handle: 0,
event_handle: 0,
texture_handles: [0; control::RING_LEN_USIZE],
};
// SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live
// `OwnedHandle` the slot keeps for exactly this purpose.
let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) };
if result.is_err() {
// The driver never adopted the delivery — reap every remote duplicate so nothing lingers.
self.close_remote(req.header_handle);
self.close_remote(req.event_handle);
for v in req.texture_handles {
self.close_remote(v);
}
}
result
}
/// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL.
/// Split out so `send` can reap whatever landed in `req` when any step errors.
///
/// # Safety
/// As [`Self::send`].
unsafe fn duplicate_and_deliver(
&self,
req: &mut control::SetFrameChannelRequest,
header: HANDLE,
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
// handles of this process, and `self.control` is the manager's control handle, never closed for
// the process lifetime (`send_frame_channel`'s precondition).
unsafe {
// Least privilege per handle: the header maps read/write, the event is only signalled, and
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?;
req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?;
for (k, s) in slots.iter().enumerate() {
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
}
crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req)
}
}
}
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
/// virtual target.
#[derive(Clone, Copy, PartialEq, Eq)]
struct DisplayDescriptor {
hdr: bool,
width: u32,
height: u32,
}
/// Off-thread poller for [`DisplayDescriptor`]. The CCD queries behind it (`QueryDisplayConfig`,
/// twice per sample) serialize on the session-global display-configuration lock, which display-
/// topology events and third-party display-poller software (the SteelSeries-GG class) can hold
/// for tens-to-hundreds of milliseconds at a time. Polled inline — the old design — that stall
/// landed ON the capture/encode thread: a periodic frame hitch on an otherwise healthy host, and
/// invisible in any log. Now a dedicated thread samples every [`Self::INTERVAL`] and publishes a
/// snapshot; the capture thread's per-frame cost is one uncontended mutex read, and a slow CCD
/// sample is *measured and logged* instead of silently stalling the stream.
///
/// Failure policy is last-known-good, per field: a transient CCD failure — including the target
/// briefly missing from the active-path list during a topology re-probe — keeps the previous
/// value instead of reading as `hdr = false` (the old behavior, which on an HDR session turned
/// every blip into TWO ring recreates: false, then true again a poll later). `seq` bumps only
/// when at least one query succeeded, so the consumer's debounce counts real observations, never
/// failures.
struct DescriptorPoller {
/// Latest merged sample + its sequence number; the poller holds the lock only to copy it.
snap: Arc<Mutex<(DisplayDescriptor, u64)>>,
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl DescriptorPoller {
/// Poll cadence — the old inline throttle. With the consumer's two-strikes debounce on top, a
/// real "Use HDR" flip or mode-set is acted on within ~2 samples (≈ ½ s).
const INTERVAL: Duration = Duration::from_millis(250);
/// A sample slower than this means something is sitting on the display-config lock (topology
/// churn / display-poller software) — the disturbance class behind periodic virtual-display
/// stream hitches. Logged (rate-limited) so an affected host self-diagnoses.
const SLOW: Duration = Duration::from_millis(50);
fn spawn(target_id: u32, initial: DisplayDescriptor) -> Self {
let snap = Arc::new(Mutex::new((initial, 0u64)));
let stop = Arc::new(AtomicBool::new(false));
let (snap_t, stop_t) = (snap.clone(), stop.clone());
let thread = std::thread::Builder::new()
.name("pf-idd-desc-poll".into())
.spawn(move || {
let mut last = initial;
let mut seq = 0u64;
let mut last_slow_log: Option<Instant> = None;
while !stop_t.load(Ordering::Relaxed) {
let t = Instant::now();
// SAFETY: both are read-only CCD queries taking only a copy of the plain `u32`
// target id (see their own SAFETY docs); nothing is borrowed across the calls.
let (hdr, res) = unsafe {
(
crate::win_display::advanced_color_enabled(target_id),
crate::win_display::active_resolution(target_id),
)
};
let took = t.elapsed();
if took >= Self::SLOW
&& last_slow_log.is_none_or(|t| t.elapsed() >= Duration::from_secs(10))
{
last_slow_log = Some(Instant::now());
tracing::warn!(
took_ms = took.as_millis() as u64,
target_id,
"slow display-descriptor poll — something is holding the Windows \
display-config lock (topology churn / display-poller software); on \
a host with periodic stream hitches, correlate this cadence"
);
}
if hdr.is_some() || res.is_some() {
if let Some(hdr) = hdr {
last.hdr = hdr;
}
if let Some((width, height)) = res {
last.width = width;
last.height = height;
}
seq += 1;
*snap_t.lock().unwrap() = (last, seq);
}
// Park (not sleep) so `drop` wakes the thread immediately via `unpark`.
std::thread::park_timeout(Self::INTERVAL);
}
})
.map_err(|e| {
// Degraded, not fatal: the session streams, it just never follows a mid-session
// HDR flip / mode-set (seq stays 0 → the consumer sees no changes).
tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed");
})
.ok();
Self { snap, stop, thread }
}
/// The latest sample (lock held only for the copy — the poller writes at 4 Hz).
fn snapshot(&self) -> (DisplayDescriptor, u64) {
*self.snap.lock().unwrap()
}
}
impl Drop for DescriptorPoller {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
t.thread().unpark();
let _ = t.join();
}
}
}
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
/// desktop was actively composing right beforehand (see [`StallWatch`]).
struct Stall {
/// How long the hole lasted (last fresh frame → the frame that ended it).
gap: Duration,
/// `Some(mean period)` when this stall completes a metronomic cycle (see
/// [`crate::metronome::Metronome`]).
metronomic: Option<Duration>,
}
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
/// path BELOW capture and only while no physical output is active).
///
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
/// below; the caller does the logging.
struct StallWatch {
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
recent: std::collections::VecDeque<Instant>,
cadence: crate::metronome::Metronome,
}
impl StallWatch {
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
const RECENT: usize = 8;
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
/// reported 300700 ms freezes, above encode/present jitter.
const STALL_MIN: Duration = Duration::from_millis(150);
fn new() -> Self {
Self {
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
cadence: crate::metronome::Metronome::new(),
}
}
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
/// the reset the first post-recreate frame would read as one).
fn reset(&mut self) {
self.recent.clear();
}
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
fn note_fresh(&mut self, now: Instant) -> Option<Stall> {
let was_active = self.recent.len() == Self::RECENT
&& self
.recent
.back()
.zip(self.recent.front())
.is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN);
let gap = self.recent.back().map(|last| now.duration_since(*last));
self.recent.push_back(now);
if self.recent.len() > Self::RECENT {
self.recent.pop_front();
}
let gap = gap?;
if !was_active || gap < Self::STALL_MIN {
return None;
}
Some(Stall {
gap,
metronomic: self.cadence.note(now),
})
}
}
pub struct IddPushCapturer { pub struct IddPushCapturer {
device: ID3D11Device, device: ID3D11Device,
@@ -1923,6 +1554,7 @@ impl Drop for IddPushCapturer {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::stall::Stall;
use super::*; use super::*;
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and /// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
@@ -0,0 +1,198 @@
//! The sealed frame channel's handle-duplication broker (plan §W4, carved out of the IDD-push
//! capturer): duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost
//! and delivers them as bare handle values over the SYSTEM-only control device.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its
/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device
/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the
/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made
/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost.
pub(super) struct ChannelBroker {
/// `PROCESS_DUP_HANDLE | SYNCHRONIZE` handle to the driver's WUDFHost (pid from the ADD reply;
/// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's). `SYNCHRONIZE` lets the
/// handle double as the driver-death probe ([`Self::driver_alive`]).
process: OwnedHandle,
/// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail).
pub(super) wudf_pid: u32,
/// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the
/// process lifetime (a dead one is retired, kept alive), so holding the bare `HANDLE` is sound.
control: HANDLE,
}
impl ChannelBroker {
/// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0`
/// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device
/// restart mid-open) — either way the caller fails the capture open cleanly.
///
/// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles
/// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
pub(super) fn open(wudf_pid: u32) -> Result<Self> {
if wudf_pid == 0 {
bail!("driver reported no WUDFHost pid for the frame channel");
}
let control = crate::vdisplay::manager::control_device_handle().context(
"pf-vdisplay control device not open (monitor not created via the manager?)",
)?;
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
// for the duration of the synchronous check and forms no lasting alias.
let process = unsafe {
let h = OpenProcess(
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
false,
wudf_pid,
)
.context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?;
let process = OwnedHandle::from_raw_handle(h.0 as _);
verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?;
process
};
Ok(Self {
process,
wudf_pid,
control,
})
}
/// Whether the driver's WUDFHost is still alive. The pinned process handle doubles as the
/// liveness probe (`SYNCHRONIZE` requested at open): signaled ⇔ the process exited. This is the
/// definitive "driver died mid-session" signal — at the ring, a dead driver and an idle desktop
/// are indistinguishable (both simply stop publishing).
pub(super) fn driver_alive(&self) -> bool {
// SAFETY: `process` is the live `OwnedHandle` this broker owns (borrowed for this synchronous
// call); a 0 ms wait only reads the handle's signaled state.
unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) != WAIT_OBJECT_0 }
}
/// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only
/// there — the value is meaningless in any other process). `access = Some(rights)` grants the
/// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]);
/// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the
/// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with
/// just `DXGI_SHARED_RESOURCE_READ|WRITE`).
///
/// # Safety
/// `h` must be a live handle of the current process.
unsafe fn dup_into(&self, h: HANDLE, access: Option<u32>) -> Result<u64> {
let mut out = HANDLE::default();
let (desired, options) = match access {
Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)),
None => (0, DUPLICATE_SAME_ACCESS),
};
// SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target;
// `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0)
// or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both.
unsafe {
DuplicateHandle(
GetCurrentProcess(),
h,
HANDLE(self.process.as_raw_handle()),
&mut out,
desired,
false,
options,
)
}
.context("DuplicateHandle into the driver's WUDFHost")?;
Ok(out.0 as usize as u64)
}
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
/// with no target closes the source handle regardless of the (ignored) result.
fn close_remote(&self, value: u64) {
if value == 0 {
return;
}
// SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS
// broker just created in that process's table (callers only pass back `dup_into` results the
// driver never received); closing it there cannot touch any other process's handles.
unsafe {
let _ = DuplicateHandle(
HANDLE(self.process.as_raw_handle()),
HANDLE(value as usize as *mut core::ffi::c_void),
HANDLE::default(),
std::ptr::null_mut(),
0,
false,
DUPLICATE_CLOSE_SOURCE,
);
}
}
/// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the
/// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already
/// made is reaped remotely and an error returns (the caller fails the open / logs the recreate).
/// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the
/// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice.
///
/// # Safety
/// `header` and `event` must be live handles of the current process (the capturer's own section +
/// event, borrowed for this synchronous call).
pub(super) unsafe fn send(
&self,
target_id: u32,
generation: u32,
header: HANDLE,
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
let mut req = control::SetFrameChannelRequest {
target_id,
generation,
ring_len: slots.len() as u32,
_pad: 0,
header_handle: 0,
event_handle: 0,
texture_handles: [0; control::RING_LEN_USIZE],
};
// SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live
// `OwnedHandle` the slot keeps for exactly this purpose.
let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) };
if result.is_err() {
// The driver never adopted the delivery — reap every remote duplicate so nothing lingers.
self.close_remote(req.header_handle);
self.close_remote(req.event_handle);
for v in req.texture_handles {
self.close_remote(v);
}
}
result
}
/// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL.
/// Split out so `send` can reap whatever landed in `req` when any step errors.
///
/// # Safety
/// As [`Self::send`].
unsafe fn duplicate_and_deliver(
&self,
req: &mut control::SetFrameChannelRequest,
header: HANDLE,
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
// handles of this process, and `self.control` is the manager's control handle, never closed for
// the process lifetime (`send_frame_channel`'s precondition).
unsafe {
// Least privilege per handle: the header maps read/write, the event is only signalled, and
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?;
req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?;
for (k, s) in slots.iter().enumerate() {
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
}
crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req)
}
}
}
@@ -0,0 +1,121 @@
//! Off-thread display-descriptor polling (plan §W4, carved out of the IDD-push capturer): the
//! live HDR state + active resolution of the virtual target, sampled off the capture loop via CCD.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
/// virtual target.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) struct DisplayDescriptor {
pub(super) hdr: bool,
pub(super) width: u32,
pub(super) height: u32,
}
/// Off-thread poller for [`DisplayDescriptor`]. The CCD queries behind it (`QueryDisplayConfig`,
/// twice per sample) serialize on the session-global display-configuration lock, which display-
/// topology events and third-party display-poller software (the SteelSeries-GG class) can hold
/// for tens-to-hundreds of milliseconds at a time. Polled inline — the old design — that stall
/// landed ON the capture/encode thread: a periodic frame hitch on an otherwise healthy host, and
/// invisible in any log. Now a dedicated thread samples every [`Self::INTERVAL`] and publishes a
/// snapshot; the capture thread's per-frame cost is one uncontended mutex read, and a slow CCD
/// sample is *measured and logged* instead of silently stalling the stream.
///
/// Failure policy is last-known-good, per field: a transient CCD failure — including the target
/// briefly missing from the active-path list during a topology re-probe — keeps the previous
/// value instead of reading as `hdr = false` (the old behavior, which on an HDR session turned
/// every blip into TWO ring recreates: false, then true again a poll later). `seq` bumps only
/// when at least one query succeeded, so the consumer's debounce counts real observations, never
/// failures.
pub(super) struct DescriptorPoller {
/// Latest merged sample + its sequence number; the poller holds the lock only to copy it.
snap: Arc<Mutex<(DisplayDescriptor, u64)>>,
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl DescriptorPoller {
/// Poll cadence — the old inline throttle. With the consumer's two-strikes debounce on top, a
/// real "Use HDR" flip or mode-set is acted on within ~2 samples (≈ ½ s).
const INTERVAL: Duration = Duration::from_millis(250);
/// A sample slower than this means something is sitting on the display-config lock (topology
/// churn / display-poller software) — the disturbance class behind periodic virtual-display
/// stream hitches. Logged (rate-limited) so an affected host self-diagnoses.
const SLOW: Duration = Duration::from_millis(50);
pub(super) fn spawn(target_id: u32, initial: DisplayDescriptor) -> Self {
let snap = Arc::new(Mutex::new((initial, 0u64)));
let stop = Arc::new(AtomicBool::new(false));
let (snap_t, stop_t) = (snap.clone(), stop.clone());
let thread = std::thread::Builder::new()
.name("pf-idd-desc-poll".into())
.spawn(move || {
let mut last = initial;
let mut seq = 0u64;
let mut last_slow_log: Option<Instant> = None;
while !stop_t.load(Ordering::Relaxed) {
let t = Instant::now();
// SAFETY: both are read-only CCD queries taking only a copy of the plain `u32`
// target id (see their own SAFETY docs); nothing is borrowed across the calls.
let (hdr, res) = unsafe {
(
crate::win_display::advanced_color_enabled(target_id),
crate::win_display::active_resolution(target_id),
)
};
let took = t.elapsed();
if took >= Self::SLOW
&& last_slow_log.is_none_or(|t| t.elapsed() >= Duration::from_secs(10))
{
last_slow_log = Some(Instant::now());
tracing::warn!(
took_ms = took.as_millis() as u64,
target_id,
"slow display-descriptor poll — something is holding the Windows \
display-config lock (topology churn / display-poller software); on \
a host with periodic stream hitches, correlate this cadence"
);
}
if hdr.is_some() || res.is_some() {
if let Some(hdr) = hdr {
last.hdr = hdr;
}
if let Some((width, height)) = res {
last.width = width;
last.height = height;
}
seq += 1;
*snap_t.lock().unwrap() = (last, seq);
}
// Park (not sleep) so `drop` wakes the thread immediately via `unpark`.
std::thread::park_timeout(Self::INTERVAL);
}
})
.map_err(|e| {
// Degraded, not fatal: the session streams, it just never follows a mid-session
// HDR flip / mode-set (seq stays 0 → the consumer sees no changes).
tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed");
})
.ok();
Self { snap, stop, thread }
}
/// The latest sample (lock held only for the copy — the poller writes at 4 Hz).
pub(super) fn snapshot(&self) -> (DisplayDescriptor, u64) {
*self.snap.lock().unwrap()
}
}
impl Drop for DescriptorPoller {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
t.thread().unpark();
let _ = t.join();
}
}
}
@@ -0,0 +1,82 @@
//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms
//! holes in DWM frame delivery that open while the desktop was actively composing.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::*;
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
/// desktop was actively composing right beforehand (see [`StallWatch`]).
pub(super) struct Stall {
/// How long the hole lasted (last fresh frame → the frame that ended it).
pub(super) gap: Duration,
/// `Some(mean period)` when this stall completes a metronomic cycle (see
/// [`crate::metronome::Metronome`]).
pub(super) metronomic: Option<Duration>,
}
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
/// path BELOW capture and only while no physical output is active).
///
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
/// below; the caller does the logging.
pub(super) struct StallWatch {
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
recent: std::collections::VecDeque<Instant>,
cadence: crate::metronome::Metronome,
}
impl StallWatch {
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
const RECENT: usize = 8;
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
/// reported 300700 ms freezes, above encode/present jitter.
const STALL_MIN: Duration = Duration::from_millis(150);
pub(super) fn new() -> Self {
Self {
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
cadence: crate::metronome::Metronome::new(),
}
}
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
/// the reset the first post-recreate frame would read as one).
pub(super) fn reset(&mut self) {
self.recent.clear();
}
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
pub(super) fn note_fresh(&mut self, now: Instant) -> Option<Stall> {
let was_active = self.recent.len() == Self::RECENT
&& self
.recent
.back()
.zip(self.recent.front())
.is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN);
let gap = self.recent.back().map(|last| now.duration_since(*last));
self.recent.push_back(now);
if self.recent.len() > Self::RECENT {
self.recent.pop_front();
}
let gap = gap?;
if !was_active || gap < Self::STALL_MIN {
return None;
}
Some(Stall {
gap,
metronomic: self.cadence.note(now),
})
}
}
+10
View File
@@ -87,6 +87,14 @@ pub struct HostConfig {
/// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings /// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings
/// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default). /// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default).
pub recover_session_cmd: Option<String>, pub recover_session_cmd: Option<String>,
/// `PUNKTFUNK_ON_CONNECT_CMD` — zero-config mirror of a `client.connected` hook
/// (`crate::hooks`): fired detached with the event JSON on stdin + `PF_EVENT_*` env when a
/// client connects, on either plane. The full hook surface (filters, webhooks, debounce)
/// lives in `hooks.json`. Unset/empty = disabled (the default).
pub on_connect_cmd: Option<String>,
/// `PUNKTFUNK_ON_DISCONNECT_CMD` — the `client.disconnected` sibling of
/// [`Self::on_connect_cmd`].
pub on_disconnect_cmd: Option<String>,
} }
impl HostConfig { impl HostConfig {
@@ -146,6 +154,8 @@ impl HostConfig {
}), }),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD") recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()), .filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
on_disconnect_cmd: val("PUNKTFUNK_ON_DISCONNECT_CMD").filter(|s| !s.trim().is_empty()),
} }
} }
} }
+1 -1
View File
@@ -220,7 +220,7 @@ pub fn deck_windows_spike(args: &[String]) -> Result<()> {
/// removes the devnode. /// removes the devnode.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn dualsense_windows_test(args: &[String]) -> Result<()> { pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame}; use punktfunk_core::input::{GamepadEvent, GamepadFrame};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
let secs: u64 = args let secs: u64 = args
.iter() .iter()
+34
View File
@@ -959,6 +959,40 @@ pub(crate) fn windows_resolved_backend() -> WindowsBackend {
} }
} }
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
/// hand GPU surfaces straight through rather than CPU-stage them) — only the GPU-less software encoder
/// wants CPU staging. This is the single source for [`crate::capture::OutputFormat`]'s `gpu` bit:
/// resolving it in `encode` and threading it *into* the capturer (rather than having `capture` re-derive
/// the backend) keeps the capture→encode dependency one-way, so the two can never disagree on whether
/// frames are GPU-resident (plan §2.4 / §W4).
#[cfg(target_os = "windows")]
pub(crate) fn resolved_backend_is_gpu() -> bool {
!matches!(windows_resolved_backend(), WindowsBackend::Software)
}
/// Linux/other: every backend but the GPU-less software encoder (openh264) is GPU-resident. Config-backed
/// (mirrors `session_plan::resolve_encoder`; the NVENC vs VAAPI split is auto-detected in [`open_video`]).
#[cfg(not(target_os = "windows"))]
pub(crate) fn resolved_backend_is_gpu() -> bool {
!matches!(
crate::config::config().encoder_pref.as_str(),
"software" | "sw" | "openh264"
)
}
/// True if the resolved encode backend can ingest a full-chroma (RGB) source and CSC it to 4:4:4 itself —
/// the *encoder* half of the 4:4:4 capture gate ([`crate::capture::capturer_supports_444`]). Only Windows
/// direct-NVENC does (measured on-glass: ARGB + `chromaFormatIDC=3` → true 4:4:4); AMF/QSV can't. On Linux
/// the 4:4:4 source is the capturer's own (portal RGB → `yuv444p`), independent of the auto-detected
/// backend, so the gate never consults this there.
#[cfg(target_os = "windows")]
pub(crate) fn resolved_backend_ingests_rgb_444() -> bool {
windows_resolved_backend() == WindowsBackend::Nvenc
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn resolved_backend_ingests_rgb_444() -> bool {
false
}
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe** /// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the /// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs /// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs
+78 -1
View File
@@ -196,6 +196,77 @@ impl EventKind {
} }
} }
impl EventKind {
/// The client/device name this event carries, if any — the `filter.client` axis of hooks
/// and scripts. (For `session.*` this is the short client *label* the Dashboard shows —
/// cert-fingerprint prefix or peer IP — since that is what the event carries.)
pub fn client_name(&self) -> Option<&str> {
match self {
EventKind::ClientConnected { client }
| EventKind::ClientDisconnected { client, .. } => Some(&client.name),
EventKind::SessionStarted { session } | EventKind::SessionEnded { session } => {
Some(&session.client)
}
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
Some(&stream.client)
}
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(&device.name),
_ => None,
}
}
/// The certificate fingerprint this event carries, if any.
pub fn fingerprint(&self) -> Option<&str> {
match self {
EventKind::ClientConnected { client }
| EventKind::ClientDisconnected { client, .. } => client.fingerprint.as_deref(),
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(&device.fingerprint),
_ => None,
}
}
/// The protocol plane this event carries, if any.
pub fn plane(&self) -> Option<Plane> {
match self {
EventKind::ClientConnected { client }
| EventKind::ClientDisconnected { client, .. } => Some(client.plane),
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
Some(stream.plane)
}
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(device.plane),
_ => None,
}
}
/// The launched app id/title this event carries, if any.
pub fn app(&self) -> Option<&str> {
match self {
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
stream.app.as_deref()
}
_ => None,
}
}
}
/// Does `pattern` select `kind`? Exact kind names (`stream.started`) or `domain.*` prefixes
/// matched on the dot boundary (`stream.*` matches `stream.started`, never `streamx.started`).
/// One vocabulary for the SSE `?kinds=` filter and the hooks `on:` field.
pub fn kind_matches(pattern: &str, kind: &str) -> bool {
match pattern.strip_suffix(".*") {
Some(prefix) => kind
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('.')),
None => pattern == kind,
}
}
/// Formats a mode as the wire's `WxH@Hz` string. /// Formats a mode as the wire's `WxH@Hz` string.
pub fn mode_str(width: u32, height: u32, hz: u32) -> String { pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
format!("{width}x{height}@{hz}") format!("{width}x{height}@{hz}")
@@ -262,13 +333,19 @@ impl EventBus {
let _ = self.tx.send(ev); let _ = self.tx.send(ev);
} }
/// A live-tail-only subscription (no catch-up, no cursor) — for host-internal consumers
/// like the hook runner that only care about events from now on.
pub fn subscribe_live(&self) -> broadcast::Receiver<HostEvent> {
self.tx.subscribe()
}
/// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the /// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the
/// returned receiver carries everything after. `since = 0` means "from the ring start". /// returned receiver carries everything after. `since = 0` means "from the ring start".
pub fn subscribe(&self, since: u64) -> Subscription { pub fn subscribe(&self, since: u64) -> Subscription {
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let rx = self.tx.subscribe(); let rx = self.tx.subscribe();
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq); let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
let dropped = since != 0 && since + 1 < first_seq; let dropped = since != 0 && since.saturating_add(1) < first_seq;
let catch_up = ring let catch_up = ring
.events .events
.iter() .iter()
@@ -21,6 +21,9 @@ pub struct AppEntry {
/// library ([`crate::library`]). When set, the launch path resolves + launches it against the /// library ([`crate::library`]). When set, the launch path resolves + launches it against the
/// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries. /// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries.
pub library_id: Option<String>, pub library_id: Option<String>,
/// Per-app prep/undo steps (RFC §6, Sunshine `prep-cmd` parity): each `do` runs before the
/// app launches, each `undo` at stream end in reverse order (see [`crate::hooks::run_prep`]).
pub prep: Vec<crate::hooks::PrepCmd>,
} }
fn config_path() -> Option<std::path::PathBuf> { fn config_path() -> Option<std::path::PathBuf> {
@@ -68,6 +71,12 @@ fn base_catalog() -> Vec<AppEntry> {
.and_then(parse_compositor), .and_then(parse_compositor),
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from), cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
library_id: None, library_id: None,
// `"prep": [{"do": …, "undo": …}, …]` — optional; a malformed
// array is ignored (the entry still launches, just unprepped).
prep: it
.get("prep")
.and_then(|p| serde_json::from_value(p.clone()).ok())
.unwrap_or_default(),
}) })
}) })
.collect(); .collect();
@@ -88,6 +97,7 @@ fn base_catalog() -> Vec<AppEntry> {
compositor: None, compositor: None,
cmd: None, cmd: None,
library_id: None, library_id: None,
prep: Vec::new(),
}]; }];
if which("gamescope") { if which("gamescope") {
if which("steam") { if which("steam") {
@@ -97,6 +107,7 @@ fn base_catalog() -> Vec<AppEntry> {
compositor: Some(crate::vdisplay::Compositor::Gamescope), compositor: Some(crate::vdisplay::Compositor::Gamescope),
cmd: Some("steam -gamepadui".into()), cmd: Some("steam -gamepadui".into()),
library_id: None, library_id: None,
prep: Vec::new(),
}); });
} }
if which("vkcube") { if which("vkcube") {
@@ -106,6 +117,7 @@ fn base_catalog() -> Vec<AppEntry> {
compositor: Some(crate::vdisplay::Compositor::Gamescope), compositor: Some(crate::vdisplay::Compositor::Gamescope),
cmd: Some("vkcube".into()), cmd: Some("vkcube".into()),
library_id: None, library_id: None,
prep: Vec::new(),
}); });
} }
} }
@@ -139,6 +151,7 @@ fn append_library(apps: &mut Vec<AppEntry>) {
compositor: None, // auto-detect the desktop session (Windows ignores the compositor) compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
cmd: None, cmd: None,
library_id: Some(g.id), library_id: Some(g.id),
prep: Vec::new(),
}); });
} }
} }
@@ -242,6 +255,7 @@ mod tests {
compositor: None, compositor: None,
cmd: None, cmd: None,
library_id: None, library_id: None,
prep: Vec::new(),
}]; }];
append_library(&mut apps); append_library(&mut apps);
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect(); let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
@@ -16,39 +16,10 @@ const MAGIC_MULTI_CONTROLLER: u32 = 0x0C;
/// Sunshine extension: controller arrival metadata (type/capabilities). /// Sunshine extension: controller arrival metadata (type/capabilities).
const MAGIC_CONTROLLER_ARRIVAL: u32 = 0x5500_0004; const MAGIC_CONTROLLER_ARRIVAL: u32 = 0x5500_0004;
/// Most controllers a session tracks (Sunshine's MAX_GAMEPADS). // The decoded controller types ([`GamepadEvent`]/[`GamepadFrame`]) and the pad count
pub const MAX_PADS: usize = 16; // ([`punktfunk_core::input::MAX_PADS`]) are shared vocabulary between this Moonlight decode path and
// the platform-neutral injectors, so they live in `core::input` (below both) rather than here.
/// One decoded controller event. use punktfunk_core::input::{GamepadEvent, GamepadFrame};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GamepadEvent {
/// Full state of one controller + the set of attached controllers.
State(GamepadFrame),
/// Sunshine arrival metadata (precedes the first State for that pad).
Arrival {
index: u8,
/// 0 unknown, 1 xbox, 2 ps, 3 nintendo.
kind: u8,
/// LI_CCAP_* bits (0x02 = rumble).
capabilities: u16,
},
}
/// Snapshot of one controller's inputs (Moonlight conventions: sticks 32768..32767 with +Y
/// up, triggers 0..255, buttons = `buttonFlags | buttonFlags2 << 16`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GamepadFrame {
pub index: i16,
/// Bit n set = controller n attached; a clear bit for an allocated pad means unplug.
pub active_mask: u16,
pub buttons: u32,
pub left_trigger: u8,
pub right_trigger: u8,
pub ls_x: i16,
pub ls_y: i16,
pub rs_x: i16,
pub rs_y: i16,
}
// GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to // GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to
// punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core` // punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core`
@@ -242,6 +242,9 @@ pub fn serve(
// rustls needs a process-wide crypto provider before any TLS config is built. // rustls needs a process-wide crypto provider before any TLS config is built.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let native_opts = crate::native::native_serve_opts(&native); let native_opts = crate::native::native_serve_opts(&native);
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
tokio::spawn(crate::hooks::runner());
// Lifecycle events (RFC §4): `host.started` as the serve planes come up; `host.stopping` // Lifecycle events (RFC §4): `host.started` as the serve planes come up; `host.stopping`
// when they wind down (clean end OR error exit) — the ring holds it for a consumer that // when they wind down (clean end OR error exit) — the ring holds it for a consumer that
// reconnects, and a graceful-signal path can move the emit earlier when one exists. // reconnects, and a graceful-signal path can move the emit earlier when one exists.
+15 -1
View File
@@ -163,6 +163,20 @@ fn run(
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the // `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
// output is released when this capturer drops at stream end (RAII via its keepalive). // output is released when this capturer drops at stream end (RAII via its keepalive).
if crate::config::config().video_source.as_deref() == Some("virtual") { if crate::config::config().video_source.as_deref() == Some("virtual") {
// Per-app prep steps (RFC §6): the entry's own `prep` plus a custom library title's,
// run synchronously BEFORE the virtual output opens or anything launches (an HDR
// toggle / sink switch must land first — and gamescope's nested launch happens inside
// `open_gs_virtual_source`). The guard's drop runs the undos at stream end — reverse
// order, best-effort, on every exit path including a panic-unwind.
let mut prep_cmds = app.map(|a| a.prep.clone()).unwrap_or_default();
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
prep_cmds.extend(crate::library::prep_for(lib_id));
}
let prep_env = [(
"PF_APP_TITLE".to_string(),
app.map(|a| a.title.clone()).unwrap_or_default(),
)];
let _prep = (!prep_cmds.is_empty()).then(|| crate::hooks::run_prep(&prep_cmds, &prep_env));
// Open the virtual-display source: pick the live compositor, normalize the session env // Open the virtual-display source: pick the live compositor, normalize the session env
// (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting, // (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting,
// exactly like the native plane), create a virtual output at the client mode, and capture it. // exactly like the native plane), create a virtual output at the client mode, and capture it.
@@ -369,7 +383,7 @@ fn open_gs_virtual_source(
// capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there). // capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there).
let capturer = capture::capture_virtual_output( let capturer = capture::capture_virtual_output(
vout, vout,
capture::OutputFormat::resolve(cfg.hdr), capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
crate::session_plan::CaptureBackend::resolve(), crate::session_plan::CaptureBackend::resolve(),
) )
.context("capture virtual output")?; .context("capture virtual output")?;
File diff suppressed because it is too large Load Diff
+97 -427
View File
@@ -12,15 +12,21 @@
use anyhow::Result; use anyhow::Result;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
/// In-process tag on a key event's `flags`: the VK in `code` is **layout-semantic** (already #[path = "inject/keymap.rs"]
/// resolved under the sending client's keyboard layout — the GameStream/Moonlight convention) mod keymap;
/// rather than the punktfunk-native **US-positional** convention (the physical key's US-layout VK, #[cfg(target_os = "linux")]
/// which every first-party client sends — the client's local layout never touches the wire). pub(crate) use keymap::gs_button_to_evdev;
/// The Windows injector maps semantic VKs through the foreground app's layout and positional VKs pub use keymap::KEY_FLAG_SEMANTIC_VK;
/// through a fixed table; conflating the two is exactly the German y↔z / ö→ü scramble. // vk_to_evdev is consumed by the Linux injectors (kwin/libei/wlr) and — on Windows — only by the
/// Set ONLY by `gamestream::input::decode`; the punktfunk/1 ingest strips it from wire events, so // SendInput mirror test; keep the shared `crate::inject::vk_to_evdev` re-export unconditionally.
/// a network client can never flip the host's key-decoding convention. #[cfg_attr(not(target_os = "linux"), allow(unused_imports))]
pub const KEY_FLAG_SEMANTIC_VK: u32 = 0x8000_0000; pub use keymap::vk_to_evdev;
/// Device-agnostic dedup for the rich HID-output feedback plane (0xCD), shared by the virtual-pad
/// managers ([`uhid_manager`]).
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/hidout_dedup.rs"]
pub mod hidout_dedup;
/// Injects input events into the host session. Not `Send`: an injector owns compositor /// Injects input events into the host session. Not `Send`: an injector owns compositor
/// resources (a Wayland connection, an xkb state) and lives entirely on the control thread /// resources (a Wayland connection, an xkb state) and lives entirely on the control thread
@@ -29,7 +35,10 @@ pub trait InputInjector {
fn inject(&mut self, event: &InputEvent) -> Result<()>; fn inject(&mut self, event: &InputEvent) -> Result<()>;
} }
/// Preferred injection backend. /// Preferred injection backend. Which variants exist is **per-OS**: the factory ([`open`]) is a
/// single per-target block, so it can only be handed a backend that exists on the target — an
/// impossible OS/backend pairing is a compile error, not a runtime `bail!` (plan §2.3).
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend { pub enum Backend {
/// wlroots virtual pointer + keyboard Wayland protocols — the headless-Sway path. /// wlroots virtual pointer + keyboard Wayland protocols — the headless-Sway path.
@@ -37,77 +46,59 @@ pub enum Backend {
/// KWin `org_kde_kwin_fake_input` — direct injection, no RemoteDesktop portal / approval dialog /// KWin `org_kde_kwin_fake_input` — direct injection, no RemoteDesktop portal / approval dialog
/// (authorized by the host's `.desktop`). The headless KDE-Desktop path; what krdpserver uses. /// (authorized by the host's `.desktop`). The headless KDE-Desktop path; what krdpserver uses.
KwinFakeInput, KwinFakeInput,
/// libei via `reis` — Wayland-native (RemoteDesktop portal). Not yet implemented. /// libei via `reis` — Wayland-native (RemoteDesktop portal).
Libei, Libei,
/// libei directly against gamescope's own EIS socket (no portal): input lands in the /// libei directly against gamescope's own EIS socket (no portal): input lands in the
/// nested game — the SteamOS-like session. /// nested game — the SteamOS-like session.
GamescopeEi, GamescopeEi,
/// `/dev/uinput` — universal fallback (but invisible to `WLR_LIBINPUT_NO_DEVICES=1`). }
Uinput,
/// Preferred injection backend. Windows has exactly one path (`SendInput`).
#[cfg(target_os = "windows")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend {
/// Windows `SendInput` (Win32 KeyboardAndMouse) — the Windows host path. /// Windows `SendInput` (Win32 KeyboardAndMouse) — the Windows host path.
SendInput, SendInput,
} }
/// Preferred injection backend. No injector exists on this platform; [`open`] rejects it.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend {
/// Placeholder so the host still builds; the platform has no input injection.
Unsupported,
}
/// Open the injector for `backend`. The body is one per-OS block: on each target `backend` can only
/// name a backend that platform has, so there are no cross-OS `bail!` arms (plan §2.3).
#[cfg(target_os = "linux")]
pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> { pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
match backend { match backend {
Backend::WlrVirtual => { Backend::WlrVirtual => Ok(Box::new(wlr::WlrootsInjector::open()?)),
#[cfg(target_os = "linux")] Backend::KwinFakeInput => Ok(Box::new(kwin_fake_input::KwinFakeInjector::open()?)),
{ Backend::Libei => Ok(Box::new(
Ok(Box::new(wlr::WlrootsInjector::open()?)) libei::LibeiInjector::open_with(libei_ei_source())?,
} )),
#[cfg(not(target_os = "linux"))] Backend::GamescopeEi => Ok(Box::new(libei::LibeiInjector::open_with(
{ libei::EiSource::SocketPathFile(crate::vdisplay::gamescope_ei_socket_file()),
anyhow::bail!("wlroots virtual input requires Linux + a Wayland compositor") )?)),
}
}
Backend::KwinFakeInput => {
#[cfg(target_os = "linux")]
{
Ok(Box::new(kwin_fake_input::KwinFakeInjector::open()?))
}
#[cfg(not(target_os = "linux"))]
{
anyhow::bail!("KWin fake_input requires Linux + a KWin Wayland session")
}
}
Backend::Libei => {
#[cfg(target_os = "linux")]
{
Ok(Box::new(
libei::LibeiInjector::open_with(libei_ei_source())?,
))
}
#[cfg(not(target_os = "linux"))]
{
anyhow::bail!("libei input requires Linux + a RemoteDesktop portal")
}
}
Backend::GamescopeEi => {
#[cfg(target_os = "linux")]
{
Ok(Box::new(libei::LibeiInjector::open_with(
libei::EiSource::SocketPathFile(crate::vdisplay::gamescope_ei_socket_file()),
)?))
}
#[cfg(not(target_os = "linux"))]
{
anyhow::bail!("gamescope EIS input requires Linux")
}
}
Backend::SendInput => {
#[cfg(target_os = "windows")]
{
Ok(Box::new(sendinput::SendInputInjector::open()?))
}
#[cfg(not(target_os = "windows"))]
{
anyhow::bail!("SendInput injection requires Windows")
}
}
other => anyhow::bail!("injection backend {other:?} not implemented"),
} }
} }
/// Open the injector for `backend` (Windows: always `SendInput`).
#[cfg(target_os = "windows")]
pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
match backend {
Backend::SendInput => Ok(Box::new(sendinput::SendInputInjector::open()?)),
}
}
/// No input-injection backend exists on this platform.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn open(_backend: Backend) -> Result<Box<dyn InputInjector>> {
anyhow::bail!("no input-injection backend on this platform")
}
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no /// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the /// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input /// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
@@ -115,7 +106,8 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
/// dialog — the only headless-capable path; what krdpserver uses), so prefer it there. **GNOME** /// dialog — the only headless-capable path; what krdpserver uses), so prefer it there. **GNOME**
/// has neither fake_input nor the wlr protocols, so it uses libei via the RemoteDesktop portal /// has neither fake_input nor the wlr protocols, so it uses libei via the RemoteDesktop portal
/// (which needs a user to approve, or a pre-seeded grant — not truly headless). /// (which needs a user to approve, or a pre-seeded grant — not truly headless).
/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope|uinput` overrides the auto-detection. /// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope` overrides the auto-detection.
#[cfg(target_os = "linux")]
pub fn default_backend() -> Backend { pub fn default_backend() -> Backend {
if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") { if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") {
match v.trim().to_ascii_lowercase().as_str() { match v.trim().to_ascii_lowercase().as_str() {
@@ -125,179 +117,59 @@ pub fn default_backend() -> Backend {
} }
"libei" | "ei" | "portal" => return Backend::Libei, "libei" | "ei" | "portal" => return Backend::Libei,
"gamescope" | "gamescope-ei" => return Backend::GamescopeEi, "gamescope" | "gamescope-ei" => return Backend::GamescopeEi,
"uinput" => return Backend::Uinput,
"sendinput" | "win" | "windows" => return Backend::SendInput,
other => tracing::warn!( other => tracing::warn!(
value = other, value = other,
"unknown PUNKTFUNK_INPUT_BACKEND — auto-detecting" "unknown PUNKTFUNK_INPUT_BACKEND — auto-detecting"
), ),
} }
} }
#[cfg(target_os = "windows")] // An explicit compositor pick (set per connect / mid-stream) is the strongest signal.
{ let compositor = crate::config::config().compositor.clone();
Backend::SendInput if let Some(c) = compositor.as_deref() {
} let c = c.trim();
#[cfg(not(target_os = "windows"))] if c.eq_ignore_ascii_case("gamescope") {
{ return Backend::GamescopeEi;
// An explicit compositor pick (set per connect / mid-stream) is the strongest signal.
let compositor = crate::config::config().compositor.clone();
if let Some(c) = compositor.as_deref() {
let c = c.trim();
if c.eq_ignore_ascii_case("gamescope") {
return Backend::GamescopeEi;
}
if c.eq_ignore_ascii_case("kwin") {
return Backend::KwinFakeInput;
}
if c.eq_ignore_ascii_case("wlroots")
|| c.eq_ignore_ascii_case("sway")
// Hyprland kept the wlr virtual-input protocols, so it injects through the same
// backend as sway/river (design/hyprland-support.md D4).
|| c.eq_ignore_ascii_case("hyprland")
{
return Backend::WlrVirtual;
}
// mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below.
} }
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); if c.eq_ignore_ascii_case("kwin") {
let d = desktop.to_ascii_uppercase(); return Backend::KwinFakeInput;
if d.contains("KDE") {
Backend::KwinFakeInput
} else if d.contains("GNOME") {
Backend::Libei
} else {
Backend::WlrVirtual
} }
} if c.eq_ignore_ascii_case("wlroots")
} || c.eq_ignore_ascii_case("sway")
// Hyprland kept the wlr virtual-input protocols, so it injects through the same
/// Host-lifetime pointer/keyboard injector running on its OWN thread, fed over a clonable `Send` // backend as sway/river (design/hyprland-support.md D4).
/// channel. The injector backend owns non-`Send` compositor state (a Wayland connection / xkb / EIS || c.eq_ignore_ascii_case("hyprland")
/// socket), so it must live on a single thread; both the GameStream control plane and the native
/// punktfunk/1 plane forward their decoded keyboard/mouse events here instead of injecting inline, so
/// a slow inject (a portal stall, a desktop switch) never head-blocks the network thread's
/// keepalive/retransmit servicing.
pub(crate) struct InjectorService {
tx: std::sync::mpsc::Sender<InputEvent>,
}
impl InjectorService {
pub(crate) fn start() -> InjectorService {
let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-injector".into())
.spawn(move || injector_service_thread(rx))
{ {
tracing::error!(error = %e, "injector service thread spawn failed — pointer/keyboard input disabled"); return Backend::WlrVirtual;
} }
InjectorService { tx } // mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below.
} }
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
/// A sender a session/plane forwards its pointer/keyboard events to. Cloned per caller; dropping a let d = desktop.to_ascii_uppercase();
/// clone does NOT stop the service (it runs while any sender — incl. the service's own — lives). if d.contains("KDE") {
pub(crate) fn sender(&self) -> std::sync::mpsc::Sender<InputEvent> { Backend::KwinFakeInput
self.tx.clone() } else if d.contains("GNOME") {
Backend::Libei
} else {
Backend::WlrVirtual
} }
} }
/// Backoff between reopen attempts after the injector backend fails to open or its worker dies, so a /// The Windows host has a single injection backend.
/// persistently-unavailable portal isn't hammered once per event. #[cfg(target_os = "windows")]
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2); pub fn default_backend() -> Backend {
Backend::SendInput
/// The host-lifetime injector worker: lazily open the pointer/keyboard backend, then inject every
/// forwarded event. Reopen (after [`INJECTOR_REOPEN_BACKOFF`]) on open failure, on a backend change
/// (input follows the active session), or if the backend's worker dies mid-stream. Exits only when
/// every sender has dropped (host shutdown), which drops the injector and closes its portal session.
///
/// Each wake drains the whole backlog and [`coalesce`]s redundant motion before injecting, so a slow
/// backend never builds up a queue of stale relative-mouse/scroll events (latency) — while button,
/// key, and absolute-move ordering is preserved exactly.
fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
let mut injector: Option<Box<dyn InputInjector>> = None;
let mut open_backend: Option<Backend> = None;
let mut last_failed: Option<std::time::Instant> = None;
while let Ok(first) = rx.recv() {
// Drain everything already queued behind `first` so we coalesce a whole burst at once.
let mut batch = vec![first];
while let Ok(ev) = rx.try_recv() {
batch.push(ev);
}
// The resolved input backend (PUNKTFUNK_INPUT_BACKEND, set per connect / mid-stream session
// switch) may have changed since we opened. Reopen against it so input FOLLOWS the active
// session instead of injecting into a stale, still-warm backend (e.g. the managed gamescope's
// EIS socket after the user switched to the KDE desktop).
let want = default_backend();
if injector.is_some() && open_backend != Some(want) {
tracing::info!(
?open_backend,
?want,
"input: backend changed — reopening injector for the active session"
);
injector = None;
last_failed = None; // re-resolve immediately
}
if injector.is_none() {
// Open on the first event; after a failure wait out the backoff before retrying (a few
// events drop during setup — acceptable, input is lossy).
let ready = last_failed.is_none_or(|t| t.elapsed() >= INJECTOR_REOPEN_BACKOFF);
if ready {
match open(want) {
Ok(i) => {
tracing::info!(backend = ?want, "input injector ready (host-lifetime)");
injector = Some(i);
open_backend = Some(want);
last_failed = None;
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "pointer/keyboard injection unavailable — will retry");
last_failed = Some(std::time::Instant::now());
}
}
}
}
if let Some(inj) = injector.as_mut() {
for ev in coalesce(batch) {
if let Err(e) = inj.inject(&ev) {
// The backend's worker (portal session / EIS socket) died — drop it and reopen on
// a later event (covers a gamescope EIS socket that respawns with its session).
tracing::warn!(error = %format!("{e:#}"), "inject failed — reopening injector");
injector = None;
open_backend = None;
last_failed = Some(std::time::Instant::now());
break; // abandon the rest of this batch; the next one reopens
}
}
}
}
tracing::debug!("injector service stopped (host shutting down)");
} }
/// Coalesce a drained burst: sum consecutive relative-mouse deltas and consecutive same-axis scroll /// No injector on this platform.
/// deltas (identical net effect, far fewer injects), passing buttons, keys, absolute moves, and any #[cfg(not(any(target_os = "linux", target_os = "windows")))]
/// type change through untouched and in order. Only *adjacent* same-type events merge, so a button pub fn default_backend() -> Backend {
/// or key between two moves flushes the accumulated motion first — ordering is never reshuffled. Backend::Unsupported
fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
let mut out: Vec<InputEvent> = Vec::with_capacity(events.len());
for ev in events {
match out.last_mut() {
Some(last) if last.kind == InputKind::MouseMove && ev.kind == InputKind::MouseMove => {
last.x = last.x.saturating_add(ev.x);
last.y = last.y.saturating_add(ev.y);
}
Some(last)
if last.kind == InputKind::MouseScroll
&& ev.kind == InputKind::MouseScroll
&& last.code == ev.code =>
{
last.x = last.x.saturating_add(ev.x);
}
_ => out.push(ev),
}
}
out
} }
#[path = "inject/service.rs"]
mod service;
pub(crate) use service::InjectorService;
/// How the libei backend reaches its EIS server. KWin goes through the `RemoteDesktop` *portal* /// How the libei backend reaches its EIS server. KWin goes through the `RemoteDesktop` *portal*
/// (with a pre-seeded grant), but GNOME's portal `Start()` needs an interactive approval a /// (with a pre-seeded grant), but GNOME's portal `Start()` needs an interactive approval a
/// headless host can't answer — so GNOME goes straight to Mutter's *direct* RemoteDesktop EIS /// headless host can't answer — so GNOME goes straight to Mutter's *direct* RemoteDesktop EIS
@@ -319,154 +191,6 @@ fn libei_ei_source() -> libei::EiSource {
} }
} }
/// Map a Windows Virtual-Key code (as sent by Moonlight/GameStream) to a Linux evdev key code.
pub fn vk_to_evdev(vk: u8) -> Option<u16> {
match vk {
// --- Navigation / editing / whitespace ---
0x08 => Some(14), // VK_BACK -> KEY_BACKSPACE
0x09 => Some(15), // VK_TAB -> KEY_TAB
0x0D => Some(28), // VK_RETURN -> KEY_ENTER
0x13 => Some(119), // VK_PAUSE -> KEY_PAUSE
0x14 => Some(58), // VK_CAPITAL -> KEY_CAPSLOCK
0x1B => Some(1), // VK_ESCAPE -> KEY_ESC
0x20 => Some(57), // VK_SPACE -> KEY_SPACE
0x21 => Some(104), // VK_PRIOR -> KEY_PAGEUP
0x22 => Some(109), // VK_NEXT -> KEY_PAGEDOWN
0x23 => Some(107), // VK_END -> KEY_END
0x24 => Some(102), // VK_HOME -> KEY_HOME
0x25 => Some(105), // VK_LEFT -> KEY_LEFT
0x26 => Some(103), // VK_UP -> KEY_UP
0x27 => Some(106), // VK_RIGHT -> KEY_RIGHT
0x28 => Some(108), // VK_DOWN -> KEY_DOWN
0x2C => Some(99), // VK_SNAPSHOT -> KEY_SYSRQ
0x2D => Some(110), // VK_INSERT -> KEY_INSERT
0x2E => Some(111), // VK_DELETE -> KEY_DELETE
// --- Generic modifiers ---
0x10 => Some(42), // VK_SHIFT -> KEY_LEFTSHIFT
0x11 => Some(29), // VK_CONTROL -> KEY_LEFTCTRL
0x12 => Some(56), // VK_MENU -> KEY_LEFTALT
// --- Digit row (KEY_0 is 11, KEY_1..KEY_9 are 2..10) ---
0x30 => Some(11), // VK_0
0x31 => Some(2), // VK_1
0x32 => Some(3), // VK_2
0x33 => Some(4), // VK_3
0x34 => Some(5), // VK_4
0x35 => Some(6), // VK_5
0x36 => Some(7), // VK_6
0x37 => Some(8), // VK_7
0x38 => Some(9), // VK_8
0x39 => Some(10), // VK_9
// --- Letters A-Z (NOT sequential in evdev) ---
0x41 => Some(30), // A
0x42 => Some(48), // B
0x43 => Some(46), // C
0x44 => Some(32), // D
0x45 => Some(18), // E
0x46 => Some(33), // F
0x47 => Some(34), // G
0x48 => Some(35), // H
0x49 => Some(23), // I
0x4A => Some(36), // J
0x4B => Some(37), // K
0x4C => Some(38), // L
0x4D => Some(50), // M
0x4E => Some(49), // N
0x4F => Some(24), // O
0x50 => Some(25), // P
0x51 => Some(16), // Q
0x52 => Some(19), // R
0x53 => Some(31), // S
0x54 => Some(20), // T
0x55 => Some(22), // U
0x56 => Some(47), // V
0x57 => Some(17), // W
0x58 => Some(45), // X
0x59 => Some(21), // Y
0x5A => Some(44), // Z
// --- Meta / context-menu ---
0x5B => Some(125), // VK_LWIN -> KEY_LEFTMETA
0x5C => Some(126), // VK_RWIN -> KEY_RIGHTMETA
0x5D => Some(127), // VK_APPS -> KEY_COMPOSE
// --- Numpad ---
0x60 => Some(82), // KP0
0x61 => Some(79), // KP1
0x62 => Some(80), // KP2
0x63 => Some(81), // KP3
0x64 => Some(75), // KP4
0x65 => Some(76), // KP5
0x66 => Some(77), // KP6
0x67 => Some(71), // KP7
0x68 => Some(72), // KP8
0x69 => Some(73), // KP9
0x6A => Some(55), // VK_MULTIPLY -> KEY_KPASTERISK
0x6B => Some(78), // VK_ADD -> KEY_KPPLUS
0x6C => Some(96), // VK_SEPARATOR -> KEY_KPENTER
0x6D => Some(74), // VK_SUBTRACT -> KEY_KPMINUS
0x6E => Some(83), // VK_DECIMAL -> KEY_KPDOT
0x6F => Some(98), // VK_DIVIDE -> KEY_KPSLASH
// --- Function keys (F1..F10 = 59..68, F11/F12 = 87/88) ---
0x70 => Some(59),
0x71 => Some(60),
0x72 => Some(61),
0x73 => Some(62),
0x74 => Some(63),
0x75 => Some(64),
0x76 => Some(65),
0x77 => Some(66),
0x78 => Some(67),
0x79 => Some(68),
0x7A => Some(87),
0x7B => Some(88),
// --- Locks ---
0x90 => Some(69), // VK_NUMLOCK -> KEY_NUMLOCK
0x91 => Some(70), // VK_SCROLL -> KEY_SCROLLLOCK
// --- Left/right modifiers ---
0xA0 => Some(42), // VK_LSHIFT -> KEY_LEFTSHIFT
0xA1 => Some(54), // VK_RSHIFT -> KEY_RIGHTSHIFT
0xA2 => Some(29), // VK_LCONTROL -> KEY_LEFTCTRL
0xA3 => Some(97), // VK_RCONTROL -> KEY_RIGHTCTRL
0xA4 => Some(56), // VK_LMENU -> KEY_LEFTALT
0xA5 => Some(100), // VK_RMENU -> KEY_RIGHTALT
// --- OEM punctuation (US layout) ---
0xBA => Some(39), // VK_OEM_1 -> KEY_SEMICOLON
0xBB => Some(13), // VK_OEM_PLUS -> KEY_EQUAL
0xBC => Some(51), // VK_OEM_COMMA -> KEY_COMMA
0xBD => Some(12), // VK_OEM_MINUS -> KEY_MINUS
0xBE => Some(52), // VK_OEM_PERIOD -> KEY_DOT
0xBF => Some(53), // VK_OEM_2 -> KEY_SLASH
0xC0 => Some(41), // VK_OEM_3 -> KEY_GRAVE
0xDB => Some(26), // VK_OEM_4 -> KEY_LEFTBRACE
0xDC => Some(43), // VK_OEM_5 -> KEY_BACKSLASH
0xDD => Some(27), // VK_OEM_6 -> KEY_RIGHTBRACE
0xDE => Some(40), // VK_OEM_7 -> KEY_APOSTROPHE
0xE2 => Some(86), // VK_OEM_102 -> KEY_102ND
_ => None,
}
}
/// Map a GameStream mouse button id (1=left … 5=X2) to a Linux evdev `BTN_*` code.
#[cfg(target_os = "linux")]
fn gs_button_to_evdev(b: u32) -> Option<u32> {
Some(match b {
1 => 0x110, // BTN_LEFT
2 => 0x112, // BTN_MIDDLE
3 => 0x111, // BTN_RIGHT
4 => 0x113, // BTN_SIDE (X1)
5 => 0x114, // BTN_EXTRA (X2)
_ => return None,
})
}
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput // Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`; // backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
// `#[path]` keeps every `crate::inject::*` module name flat. // `#[path]` keeps every `crate::inject::*` module name flat.
@@ -597,7 +321,7 @@ pub mod gamepad {
pub fn new() -> Self { pub fn new() -> Self {
GamepadManager GamepadManager
} }
pub fn handle(&mut self, _ev: &crate::gamestream::gamepad::GamepadEvent) {} pub fn handle(&mut self, _ev: &punktfunk_core::input::GamepadEvent) {}
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {} pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {}
} }
} }
@@ -613,57 +337,3 @@ mod sendinput;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/wlr.rs"] #[path = "inject/linux/wlr.rs"]
mod wlr; mod wlr;
#[cfg(test)]
mod tests {
use super::*;
fn mk(kind: InputKind, code: u32, x: i32, y: i32) -> InputEvent {
InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags: 0,
}
}
#[test]
fn coalesce_sums_adjacent_motion_and_preserves_order() {
let events = vec![
mk(InputKind::MouseMove, 0, 1, 2),
mk(InputKind::MouseMove, 0, 3, -1), // → summed with the previous move
mk(InputKind::KeyDown, 30, 0, 0), // flushes the move, passes through verbatim
mk(InputKind::MouseMove, 0, 5, 5), // a NEW run after the key (not merged across it)
mk(InputKind::MouseScroll, 0, 1, 0),
mk(InputKind::MouseScroll, 0, 2, 0), // same axis (code 0) → summed
mk(InputKind::MouseScroll, 1, 1, 0), // different axis (code 1) → separate
];
let out = coalesce(events);
assert_eq!(out.len(), 5);
assert_eq!(
(out[0].kind, out[0].x, out[0].y),
(InputKind::MouseMove, 4, 1)
);
assert_eq!(out[1].kind, InputKind::KeyDown);
assert_eq!(
(out[2].kind, out[2].x, out[2].y),
(InputKind::MouseMove, 5, 5)
);
assert_eq!(
(out[3].kind, out[3].code, out[3].x),
(InputKind::MouseScroll, 0, 3)
);
assert_eq!(
(out[4].kind, out[4].code, out[4].x),
(InputKind::MouseScroll, 1, 1)
);
}
#[test]
fn coalesce_handles_empty_and_singleton() {
assert!(coalesce(vec![]).is_empty());
assert_eq!(coalesce(vec![mk(InputKind::MouseMove, 0, 7, 8)]).len(), 1);
}
}
@@ -0,0 +1,126 @@
//! Per-pad dedup for the rich HID-output feedback plane (0xCD), carved out of `dualsense_proto`
//! (plan §W4 — it is device-agnostic, shared by the DualSense/DS4/Deck managers via
//! [`crate::inject::uhid_manager`], not DualSense-specific). A game bundles rumble + lightbar +
//! LEDs + adaptive triggers into one output report, so a merely-rumbling pad re-sends unchanged
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
use punktfunk_core::quic::HidOutput;
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
#[derive(Clone, Default)]
pub struct HidoutDedup {
led: Option<(u8, u8, u8)>,
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
}
impl HidoutDedup {
/// Forget all remembered state — call when a pad is created or unplugged so the first feedback
/// after a (re)connect is always forwarded.
pub fn clear(&mut self) {
*self = HidoutDedup::default();
}
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
match h {
HidOutput::Led { r, g, b, .. } => {
let v = Some((*r, *g, *b));
if self.led == v {
false
} else {
self.led = v;
true
}
}
HidOutput::PlayerLeds { bits, .. } => {
let v = Some(*bits);
if self.player_leds == v {
false
} else {
self.player_leds = v;
true
}
}
HidOutput::Trigger { which, effect, .. } => {
let slot = (*which as usize).min(1);
if self.trigger[slot].as_deref() == Some(effect.as_slice()) {
false
} else {
self.trigger[slot] = Some(effect.clone());
true
}
}
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true,
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
// silence the motors / re-enable lizard mode on the real controller.
HidOutput::HidRaw { .. } => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
#[test]
fn hidout_dedup_forwards_only_changes() {
let mut d = HidoutDedup::default();
let led = |r| HidOutput::Led {
pad: 0,
r,
g: 0,
b: 0,
};
// First value forwards; an exact repeat is dropped; a change forwards again.
assert!(d.should_forward(&led(10)));
assert!(!d.should_forward(&led(10)));
assert!(d.should_forward(&led(20)));
// Player LEDs dedup on their own field, independent of the lightbar.
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
assert!(d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
let trig = |which, byte| HidOutput::Trigger {
pad: 0,
which,
effect: vec![byte, 0, 0],
};
assert!(d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
assert!(!d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
// One-shot haptic pulses are never deduped.
let haptic = HidOutput::TrackpadHaptic {
pad: 0,
side: 0,
amplitude: 1,
period: 2,
count: 3,
};
assert!(d.should_forward(&haptic));
assert!(d.should_forward(&haptic));
// `clear` re-arms every kind.
d.clear();
assert!(d.should_forward(&led(20)));
assert!(d.should_forward(&pl(0b101)));
assert!(d.should_forward(&trig(0, 2)));
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Key/button mapping tables (plan §W4, carved out of the inject facade): the Windows Virtual-Key
//! → Linux-evdev keyboard map (mirrored bit-for-bit by the Windows SendInput positional table), the
//! GameStream mouse-button → evdev `BTN_*` map, and the in-process semantic-VK flag. Pure lookup
//! tables — no state, no OS handles.
/// In-process tag on a key event's `flags`: the VK in `code` is **layout-semantic** (already
/// resolved under the sending client's keyboard layout — the GameStream/Moonlight convention)
/// rather than the punktfunk-native **US-positional** convention (the physical key's US-layout VK,
/// which every first-party client sends — the client's local layout never touches the wire).
/// The Windows injector maps semantic VKs through the foreground app's layout and positional VKs
/// through a fixed table; conflating the two is exactly the German y↔z / ö→ü scramble.
/// Set ONLY by `gamestream::input::decode`; the punktfunk/1 ingest strips it from wire events, so
/// a network client can never flip the host's key-decoding convention.
pub const KEY_FLAG_SEMANTIC_VK: u32 = 0x8000_0000;
/// Map a Windows Virtual-Key code (as sent by Moonlight/GameStream) to a Linux evdev key code.
pub fn vk_to_evdev(vk: u8) -> Option<u16> {
match vk {
// --- Navigation / editing / whitespace ---
0x08 => Some(14), // VK_BACK -> KEY_BACKSPACE
0x09 => Some(15), // VK_TAB -> KEY_TAB
0x0D => Some(28), // VK_RETURN -> KEY_ENTER
0x13 => Some(119), // VK_PAUSE -> KEY_PAUSE
0x14 => Some(58), // VK_CAPITAL -> KEY_CAPSLOCK
0x1B => Some(1), // VK_ESCAPE -> KEY_ESC
0x20 => Some(57), // VK_SPACE -> KEY_SPACE
0x21 => Some(104), // VK_PRIOR -> KEY_PAGEUP
0x22 => Some(109), // VK_NEXT -> KEY_PAGEDOWN
0x23 => Some(107), // VK_END -> KEY_END
0x24 => Some(102), // VK_HOME -> KEY_HOME
0x25 => Some(105), // VK_LEFT -> KEY_LEFT
0x26 => Some(103), // VK_UP -> KEY_UP
0x27 => Some(106), // VK_RIGHT -> KEY_RIGHT
0x28 => Some(108), // VK_DOWN -> KEY_DOWN
0x2C => Some(99), // VK_SNAPSHOT -> KEY_SYSRQ
0x2D => Some(110), // VK_INSERT -> KEY_INSERT
0x2E => Some(111), // VK_DELETE -> KEY_DELETE
// --- Generic modifiers ---
0x10 => Some(42), // VK_SHIFT -> KEY_LEFTSHIFT
0x11 => Some(29), // VK_CONTROL -> KEY_LEFTCTRL
0x12 => Some(56), // VK_MENU -> KEY_LEFTALT
// --- Digit row (KEY_0 is 11, KEY_1..KEY_9 are 2..10) ---
0x30 => Some(11), // VK_0
0x31 => Some(2), // VK_1
0x32 => Some(3), // VK_2
0x33 => Some(4), // VK_3
0x34 => Some(5), // VK_4
0x35 => Some(6), // VK_5
0x36 => Some(7), // VK_6
0x37 => Some(8), // VK_7
0x38 => Some(9), // VK_8
0x39 => Some(10), // VK_9
// --- Letters A-Z (NOT sequential in evdev) ---
0x41 => Some(30), // A
0x42 => Some(48), // B
0x43 => Some(46), // C
0x44 => Some(32), // D
0x45 => Some(18), // E
0x46 => Some(33), // F
0x47 => Some(34), // G
0x48 => Some(35), // H
0x49 => Some(23), // I
0x4A => Some(36), // J
0x4B => Some(37), // K
0x4C => Some(38), // L
0x4D => Some(50), // M
0x4E => Some(49), // N
0x4F => Some(24), // O
0x50 => Some(25), // P
0x51 => Some(16), // Q
0x52 => Some(19), // R
0x53 => Some(31), // S
0x54 => Some(20), // T
0x55 => Some(22), // U
0x56 => Some(47), // V
0x57 => Some(17), // W
0x58 => Some(45), // X
0x59 => Some(21), // Y
0x5A => Some(44), // Z
// --- Meta / context-menu ---
0x5B => Some(125), // VK_LWIN -> KEY_LEFTMETA
0x5C => Some(126), // VK_RWIN -> KEY_RIGHTMETA
0x5D => Some(127), // VK_APPS -> KEY_COMPOSE
// --- Numpad ---
0x60 => Some(82), // KP0
0x61 => Some(79), // KP1
0x62 => Some(80), // KP2
0x63 => Some(81), // KP3
0x64 => Some(75), // KP4
0x65 => Some(76), // KP5
0x66 => Some(77), // KP6
0x67 => Some(71), // KP7
0x68 => Some(72), // KP8
0x69 => Some(73), // KP9
0x6A => Some(55), // VK_MULTIPLY -> KEY_KPASTERISK
0x6B => Some(78), // VK_ADD -> KEY_KPPLUS
0x6C => Some(96), // VK_SEPARATOR -> KEY_KPENTER
0x6D => Some(74), // VK_SUBTRACT -> KEY_KPMINUS
0x6E => Some(83), // VK_DECIMAL -> KEY_KPDOT
0x6F => Some(98), // VK_DIVIDE -> KEY_KPSLASH
// --- Function keys (F1..F10 = 59..68, F11/F12 = 87/88) ---
0x70 => Some(59),
0x71 => Some(60),
0x72 => Some(61),
0x73 => Some(62),
0x74 => Some(63),
0x75 => Some(64),
0x76 => Some(65),
0x77 => Some(66),
0x78 => Some(67),
0x79 => Some(68),
0x7A => Some(87),
0x7B => Some(88),
// --- Locks ---
0x90 => Some(69), // VK_NUMLOCK -> KEY_NUMLOCK
0x91 => Some(70), // VK_SCROLL -> KEY_SCROLLLOCK
// --- Left/right modifiers ---
0xA0 => Some(42), // VK_LSHIFT -> KEY_LEFTSHIFT
0xA1 => Some(54), // VK_RSHIFT -> KEY_RIGHTSHIFT
0xA2 => Some(29), // VK_LCONTROL -> KEY_LEFTCTRL
0xA3 => Some(97), // VK_RCONTROL -> KEY_RIGHTCTRL
0xA4 => Some(56), // VK_LMENU -> KEY_LEFTALT
0xA5 => Some(100), // VK_RMENU -> KEY_RIGHTALT
// --- OEM punctuation (US layout) ---
0xBA => Some(39), // VK_OEM_1 -> KEY_SEMICOLON
0xBB => Some(13), // VK_OEM_PLUS -> KEY_EQUAL
0xBC => Some(51), // VK_OEM_COMMA -> KEY_COMMA
0xBD => Some(12), // VK_OEM_MINUS -> KEY_MINUS
0xBE => Some(52), // VK_OEM_PERIOD -> KEY_DOT
0xBF => Some(53), // VK_OEM_2 -> KEY_SLASH
0xC0 => Some(41), // VK_OEM_3 -> KEY_GRAVE
0xDB => Some(26), // VK_OEM_4 -> KEY_LEFTBRACE
0xDC => Some(43), // VK_OEM_5 -> KEY_BACKSLASH
0xDD => Some(27), // VK_OEM_6 -> KEY_RIGHTBRACE
0xDE => Some(40), // VK_OEM_7 -> KEY_APOSTROPHE
0xE2 => Some(86), // VK_OEM_102 -> KEY_102ND
_ => None,
}
}
/// Map a GameStream mouse button id (1=left … 5=X2) to a Linux evdev `BTN_*` code.
#[cfg(target_os = "linux")]
pub(crate) fn gs_button_to_evdev(b: u32) -> Option<u32> {
Some(match b {
1 => 0x110, // BTN_LEFT
2 => 0x112, // BTN_MIDDLE
3 => 0x111, // BTN_RIGHT
4 => 0x113, // BTN_SIDE (X1)
5 => 0x114, // BTN_EXTRA (X2)
_ => return None,
})
}
@@ -265,7 +265,7 @@ impl PadProto for DsLinuxProto {
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
/// come on the rich-input plane and must survive a button-only frame). /// come on the rich-input plane and must survive a button-only frame).
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState { fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
// Steam back grips have no DualSense slot — fold them onto standard buttons per the // Steam back grips have no DualSense slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost. // configured policy (default drop) so they aren't silently lost.
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
@@ -355,7 +355,7 @@ impl PadProto for DsEdgeLinuxProto {
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the /// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own /// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence). /// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState { fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
let mut s = DsState::from_gamepad( let mut s = DsState::from_gamepad(
f.buttons, f.buttons,
f.ls_x, f.ls_x,
@@ -335,7 +335,7 @@ impl PadProto for Ds4LinuxProto {
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
/// arrive on the rich-input plane and must survive a button-only frame). /// arrive on the rich-input plane and must survive a button-only frame).
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState { fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured // Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
// policy (default drop) so they aren't silently lost. // policy (default drop) so they aren't silently lost.
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
@@ -2,7 +2,7 @@
//! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero //! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero
//! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client //! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client
//! controller, managed by [`GamepadManager`] from decoded //! controller, managed by [`GamepadManager`] from decoded
//! [`GamepadFrame`](crate::gamestream::gamepad::GamepadFrame)s. //! [`GamepadFrame`](punktfunk_core::input::GamepadFrame)s.
//! //!
//! Rumble flows the *other* way on the same fd: games upload force-feedback effects //! Rumble flows the *other* way on the same fd: games upload force-feedback effects
//! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with //! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with
@@ -18,9 +18,10 @@
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS}; use crate::gamestream::gamepad;
use crate::inject::pad_slots::PadSlots; use crate::inject::pad_slots::PadSlots;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use punktfunk_core::input::{GamepadFrame, MAX_PADS};
use std::collections::HashMap; use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd}; use std::os::fd::{AsRawFd, OwnedFd};
use std::time::Instant; use std::time::Instant;
@@ -582,8 +583,8 @@ impl GamepadManager {
} }
/// Handle one decoded controller event (create/destroy by mask, then apply state). /// Handle one decoded controller event (create/destroy by mask, then apply state).
pub fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) { pub fn handle(&mut self, ev: &punktfunk_core::input::GamepadEvent) {
use crate::gamestream::gamepad::GamepadEvent; use punktfunk_core::input::GamepadEvent;
match ev { match ev {
GamepadEvent::Arrival { index, kind, .. } => { GamepadEvent::Arrival { index, kind, .. } => {
tracing::info!(index, kind, "controller arrival ({})", self.slots.label()); tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
@@ -402,7 +402,7 @@ impl PadProto for SteamProto {
fn merge_frame( fn merge_frame(
&self, &self,
prev: &SteamState, prev: &SteamState,
f: &crate::gamestream::gamepad::GamepadFrame, f: &punktfunk_core::input::GamepadFrame,
) -> SteamState { ) -> SteamState {
let mut s = SteamState::from_gamepad( let mut s = SteamState::from_gamepad(
f.buttons, f.buttons,
@@ -511,7 +511,7 @@ impl PadProto for ScProto {
fn merge_frame( fn merge_frame(
&self, &self,
prev: &SteamState, prev: &SteamState,
f: &crate::gamestream::gamepad::GamepadFrame, f: &punktfunk_core::input::GamepadFrame,
) -> SteamState { ) -> SteamState {
use punktfunk_core::input::gamepad as gs; use punktfunk_core::input::gamepad as gs;
let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2); let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2);
@@ -329,7 +329,7 @@ impl PadProto for TritonProto {
fn merge_frame( fn merge_frame(
&self, &self,
prev: &TritonState, prev: &TritonState,
f: &crate::gamestream::gamepad::GamepadFrame, f: &punktfunk_core::input::GamepadFrame,
) -> TritonState { ) -> TritonState {
let mut s = TritonState::from_gamepad( let mut s = TritonState::from_gamepad(
f.buttons, f.buttons,
@@ -277,7 +277,7 @@ impl PadProto for SwitchProProto {
fn merge_frame( fn merge_frame(
&self, &self,
prev: &SwitchState, prev: &SwitchState,
f: &crate::gamestream::gamepad::GamepadFrame, f: &punktfunk_core::input::GamepadFrame,
) -> SwitchState { ) -> SwitchState {
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
let mut s = SwitchState::from_gamepad( let mut s = SwitchState::from_gamepad(
@@ -1,9 +1,9 @@
//! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux //! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux
//! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`]. //! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`].
use crate::gamestream::gamepad::MAX_PADS;
use crate::inject::pad_gate::PadGate; use crate::inject::pad_gate::PadGate;
use anyhow::Result; use anyhow::Result;
use punktfunk_core::input::MAX_PADS;
use std::time::Instant; use std::time::Instant;
// The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit. // The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit.
@@ -535,124 +535,10 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
} }
} }
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
#[derive(Clone, Default)]
pub struct HidoutDedup {
led: Option<(u8, u8, u8)>,
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
}
impl HidoutDedup {
/// Forget all remembered state — call when a pad is created or unplugged so the first feedback
/// after a (re)connect is always forwarded.
pub fn clear(&mut self) {
*self = HidoutDedup::default();
}
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
match h {
HidOutput::Led { r, g, b, .. } => {
let v = Some((*r, *g, *b));
if self.led == v {
false
} else {
self.led = v;
true
}
}
HidOutput::PlayerLeds { bits, .. } => {
let v = Some(*bits);
if self.player_leds == v {
false
} else {
self.player_leds = v;
true
}
}
HidOutput::Trigger { which, effect, .. } => {
let slot = (*which as usize).min(1);
if self.trigger[slot].as_deref() == Some(effect.as_slice()) {
false
} else {
self.trigger[slot] = Some(effect.clone());
true
}
}
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true,
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
// silence the motors / re-enable lizard mode on the real controller.
HidOutput::HidRaw { .. } => true,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
#[test]
fn hidout_dedup_forwards_only_changes() {
let mut d = HidoutDedup::default();
let led = |r| HidOutput::Led {
pad: 0,
r,
g: 0,
b: 0,
};
// First value forwards; an exact repeat is dropped; a change forwards again.
assert!(d.should_forward(&led(10)));
assert!(!d.should_forward(&led(10)));
assert!(d.should_forward(&led(20)));
// Player LEDs dedup on their own field, independent of the lightbar.
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
assert!(d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
let trig = |which, byte| HidOutput::Trigger {
pad: 0,
which,
effect: vec![byte, 0, 0],
};
assert!(d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
assert!(!d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
// One-shot haptic pulses are never deduped.
let haptic = HidOutput::TrackpadHaptic {
pad: 0,
side: 0,
amplitude: 1,
period: 2,
count: 3,
};
assert!(d.should_forward(&haptic));
assert!(d.should_forward(&haptic));
// `clear` re-arms every kind.
d.clear();
assert!(d.should_forward(&led(20)));
assert!(d.should_forward(&pl(0b101)));
assert!(d.should_forward(&trig(0, 2)));
}
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0 /// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the /// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click /// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
+189
View File
@@ -0,0 +1,189 @@
//! The off-thread injector service (plan §W4, carved out of the inject facade): a host-lifetime
//! pointer/keyboard injector pinned to its OWN thread and fed over a clonable `Send` channel, plus
//! the pre-injection [`coalesce`] pass. The backend owns non-`Send` compositor state (a Wayland
//! connection / xkb / EIS socket), so it must live on one thread; both the GameStream control plane
//! and the native punktfunk/1 plane forward decoded input here instead of injecting inline.
use super::*;
/// Host-lifetime pointer/keyboard injector running on its OWN thread, fed over a clonable `Send`
/// channel. The injector backend owns non-`Send` compositor state (a Wayland connection / xkb / EIS
/// socket), so it must live on a single thread; both the GameStream control plane and the native
/// punktfunk/1 plane forward their decoded keyboard/mouse events here instead of injecting inline, so
/// a slow inject (a portal stall, a desktop switch) never head-blocks the network thread's
/// keepalive/retransmit servicing.
pub(crate) struct InjectorService {
tx: std::sync::mpsc::Sender<InputEvent>,
}
impl InjectorService {
pub(crate) fn start() -> InjectorService {
let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-injector".into())
.spawn(move || injector_service_thread(rx))
{
tracing::error!(error = %e, "injector service thread spawn failed — pointer/keyboard input disabled");
}
InjectorService { tx }
}
/// A sender a session/plane forwards its pointer/keyboard events to. Cloned per caller; dropping a
/// clone does NOT stop the service (it runs while any sender — incl. the service's own — lives).
pub(crate) fn sender(&self) -> std::sync::mpsc::Sender<InputEvent> {
self.tx.clone()
}
}
/// Backoff between reopen attempts after the injector backend fails to open or its worker dies, so a
/// persistently-unavailable portal isn't hammered once per event.
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
/// The host-lifetime injector worker: lazily open the pointer/keyboard backend, then inject every
/// forwarded event. Reopen (after [`INJECTOR_REOPEN_BACKOFF`]) on open failure, on a backend change
/// (input follows the active session), or if the backend's worker dies mid-stream. Exits only when
/// every sender has dropped (host shutdown), which drops the injector and closes its portal session.
///
/// Each wake drains the whole backlog and [`coalesce`]s redundant motion before injecting, so a slow
/// backend never builds up a queue of stale relative-mouse/scroll events (latency) — while button,
/// key, and absolute-move ordering is preserved exactly.
fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
let mut injector: Option<Box<dyn InputInjector>> = None;
let mut open_backend: Option<Backend> = None;
let mut last_failed: Option<std::time::Instant> = None;
while let Ok(first) = rx.recv() {
// Drain everything already queued behind `first` so we coalesce a whole burst at once.
let mut batch = vec![first];
while let Ok(ev) = rx.try_recv() {
batch.push(ev);
}
// The resolved input backend (PUNKTFUNK_INPUT_BACKEND, set per connect / mid-stream session
// switch) may have changed since we opened. Reopen against it so input FOLLOWS the active
// session instead of injecting into a stale, still-warm backend (e.g. the managed gamescope's
// EIS socket after the user switched to the KDE desktop).
let want = default_backend();
if injector.is_some() && open_backend != Some(want) {
tracing::info!(
?open_backend,
?want,
"input: backend changed — reopening injector for the active session"
);
injector = None;
last_failed = None; // re-resolve immediately
}
if injector.is_none() {
// Open on the first event; after a failure wait out the backoff before retrying (a few
// events drop during setup — acceptable, input is lossy).
let ready = last_failed.is_none_or(|t| t.elapsed() >= INJECTOR_REOPEN_BACKOFF);
if ready {
match open(want) {
Ok(i) => {
tracing::info!(backend = ?want, "input injector ready (host-lifetime)");
injector = Some(i);
open_backend = Some(want);
last_failed = None;
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "pointer/keyboard injection unavailable — will retry");
last_failed = Some(std::time::Instant::now());
}
}
}
}
if let Some(inj) = injector.as_mut() {
for ev in coalesce(batch) {
if let Err(e) = inj.inject(&ev) {
// The backend's worker (portal session / EIS socket) died — drop it and reopen on
// a later event (covers a gamescope EIS socket that respawns with its session).
tracing::warn!(error = %format!("{e:#}"), "inject failed — reopening injector");
injector = None;
open_backend = None;
last_failed = Some(std::time::Instant::now());
break; // abandon the rest of this batch; the next one reopens
}
}
}
}
tracing::debug!("injector service stopped (host shutting down)");
}
/// Coalesce a drained burst: sum consecutive relative-mouse deltas and consecutive same-axis scroll
/// deltas (identical net effect, far fewer injects), passing buttons, keys, absolute moves, and any
/// type change through untouched and in order. Only *adjacent* same-type events merge, so a button
/// or key between two moves flushes the accumulated motion first — ordering is never reshuffled.
fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
let mut out: Vec<InputEvent> = Vec::with_capacity(events.len());
for ev in events {
match out.last_mut() {
Some(last) if last.kind == InputKind::MouseMove && ev.kind == InputKind::MouseMove => {
last.x = last.x.saturating_add(ev.x);
last.y = last.y.saturating_add(ev.y);
}
Some(last)
if last.kind == InputKind::MouseScroll
&& ev.kind == InputKind::MouseScroll
&& last.code == ev.code =>
{
last.x = last.x.saturating_add(ev.x);
}
_ => out.push(ev),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::input::{InputEvent, InputKind};
fn mk(kind: InputKind, code: u32, x: i32, y: i32) -> InputEvent {
InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags: 0,
}
}
#[test]
fn coalesce_sums_adjacent_motion_and_preserves_order() {
let events = vec![
mk(InputKind::MouseMove, 0, 1, 2),
mk(InputKind::MouseMove, 0, 3, -1), // → summed with the previous move
mk(InputKind::KeyDown, 30, 0, 0), // flushes the move, passes through verbatim
mk(InputKind::MouseMove, 0, 5, 5), // a NEW run after the key (not merged across it)
mk(InputKind::MouseScroll, 0, 1, 0),
mk(InputKind::MouseScroll, 0, 2, 0), // same axis (code 0) → summed
mk(InputKind::MouseScroll, 1, 1, 0), // different axis (code 1) → separate
];
let out = coalesce(events);
assert_eq!(out.len(), 5);
assert_eq!(
(out[0].kind, out[0].x, out[0].y),
(InputKind::MouseMove, 4, 1)
);
assert_eq!(out[1].kind, InputKind::KeyDown);
assert_eq!(
(out[2].kind, out[2].x, out[2].y),
(InputKind::MouseMove, 5, 5)
);
assert_eq!(
(out[3].kind, out[3].code, out[3].x),
(InputKind::MouseScroll, 0, 3)
);
assert_eq!(
(out[4].kind, out[4].code, out[4].x),
(InputKind::MouseScroll, 1, 1)
);
}
#[test]
fn coalesce_handles_empty_and_singleton() {
assert!(coalesce(vec![]).is_empty());
assert_eq!(coalesce(vec![mk(InputKind::MouseMove, 0, 7, 8)]).len(), 1);
}
}
@@ -6,10 +6,10 @@
//! Windows XUSB) write frames straight through with no state vec / heartbeat / rich plane, so they //! Windows XUSB) write frames straight through with no state vec / heartbeat / rich plane, so they
//! use [`PadSlots`] directly instead. //! use [`PadSlots`] directly instead.
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame, MAX_PADS}; use crate::inject::hidout_dedup::HidoutDedup;
use crate::inject::dualsense_proto::HidoutDedup;
use crate::inject::pad_slots::PadSlots; use crate::inject::pad_slots::PadSlots;
use anyhow::Result; use anyhow::Result;
use punktfunk_core::input::{GamepadEvent, GamepadFrame, MAX_PADS};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -44,7 +44,7 @@ impl PadProto for DsEdgeWinProto {
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the /// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits /// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
/// (rebuilt from every button frame, so no extra persistence). /// (rebuilt from every button frame, so no extra persistence).
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState { fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
let mut s = DsState::from_gamepad( let mut s = DsState::from_gamepad(
f.buttons, f.buttons,
f.ls_x, f.ls_x,
@@ -438,7 +438,7 @@ impl PadProto for DsWinProto {
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich- /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
/// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does. /// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState { fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
// Steam back grips have no DualSense slot — fold them onto standard buttons per the // Steam back grips have no DualSense slot — fold them onto standard buttons per the
// configured policy (default drop) so they aren't silently lost. // configured policy (default drop) so they aren't silently lost.
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
@@ -187,7 +187,7 @@ impl PadProto for Ds4WinProto {
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich- /// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
/// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does. /// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState { fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured // Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
// policy (default drop) so they aren't silently lost. // policy (default drop) so they aren't silently lost.
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
@@ -13,9 +13,9 @@
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path. //! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use crate::inject::pad_slots::PadSlots; use crate::inject::pad_slots::PadSlots;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use punktfunk_core::input::{GamepadEvent, MAX_PADS};
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{fence, AtomicU32, Ordering}; use std::sync::atomic::{fence, AtomicU32, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -179,7 +179,7 @@ impl PadProto for DeckWinProto {
fn merge_frame( fn merge_frame(
&self, &self,
prev: &SteamState, prev: &SteamState,
f: &crate::gamestream::gamepad::GamepadFrame, f: &punktfunk_core::input::GamepadFrame,
) -> SteamState { ) -> SteamState {
use super::steam_proto::btn; use super::steam_proto::btn;
let mut s = SteamState::from_gamepad( let mut s = SteamState::from_gamepad(
+5
View File
@@ -95,6 +95,11 @@ pub struct GameEntry {
/// How the host would launch it, when known. /// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>, pub launch: Option<LaunchSpec>,
/// The external provider owning this entry (custom-store entries synced by a provider
/// plugin, RFC §8) — `None` for installed-store titles and manual custom entries. The
/// console uses it for attribution; `GET /library?provider=` filters on it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
} }
/// A store that contributes titles to the library. The trait is the extension point for future /// A store that contributes titles to the library. The trait is the extension point for future
+315 -25
View File
@@ -14,6 +14,20 @@ pub struct CustomEntry {
pub art: Artwork, pub art: Artwork,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>, pub launch: Option<LaunchSpec>,
/// Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each
/// `undo` at session end in reverse order (see [`crate::hooks::run_prep`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prep: Vec<crate::hooks::PrepCmd>,
/// The external provider owning this entry (RFC §8), set ONLY by the provider reconcile
/// API — `None` = a manual entry, which no provider operation ever touches, and which the
/// manual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned
/// entries, so ownership is never ambiguous).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// The provider's own stable key for this title — the reconcile diff key, so the
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
} }
/// Request body to create or replace a custom entry (no `id` — the host owns it). /// Request body to create or replace a custom entry (no `id` — the host owns it).
@@ -24,6 +38,25 @@ pub struct CustomInput {
pub art: Artwork, pub art: Artwork,
#[serde(default)] #[serde(default)]
pub launch: Option<LaunchSpec>, pub launch: Option<LaunchSpec>,
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
}
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
/// provider's required stable key.
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct ProviderEntryInput {
/// The provider's stable id for this title (the reconcile diff key).
pub external_id: String,
pub title: String,
#[serde(default)]
pub art: Artwork,
#[serde(default)]
pub launch: Option<LaunchSpec>,
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
} }
impl From<CustomEntry> for GameEntry { impl From<CustomEntry> for GameEntry {
@@ -34,6 +67,7 @@ impl From<CustomEntry> for GameEntry {
title: c.title, title: c.title,
art: c.art, art: c.art,
launch: c.launch, launch: c.launch,
provider: c.provider,
} }
} }
} }
@@ -81,7 +115,17 @@ fn new_id(title: &str) -> String {
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6]) hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
} }
/// Create a custom entry, returning it with its assigned id. /// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists,
/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404).
pub enum MutateOutcome<T> {
Done(T),
NotFound,
/// The entry belongs to this provider — mutate it through the provider reconcile API
/// (or remove the whole provider set); manual edits would be clobbered at the next sync.
ProviderOwned(String),
}
/// Create a custom (manual) entry, returning it with its assigned id.
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> { pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
let mut entries = load_custom(); let mut entries = load_custom();
let entry = CustomEntry { let entry = CustomEntry {
@@ -89,46 +133,177 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
title: input.title, title: input.title,
art: input.art, art: input.art,
launch: input.launch, launch: input.launch,
prep: input.prep,
provider: None,
external_id: None,
}; };
entries.push(entry.clone()); entries.push(entry.clone());
save_custom(&entries)?; save_custom(&entries)?;
emit_changed(); emit_changed("manual");
Ok(entry) Ok(entry)
} }
/// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id. /// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
pub fn update_custom(id: &str, input: CustomInput) -> Result<Option<CustomEntry>> { /// their state belongs to the provider's reconcile (RFC §8 ownership rule).
pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<CustomEntry>> {
let mut entries = load_custom(); let mut entries = load_custom();
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else { let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
return Ok(None); return Ok(MutateOutcome::NotFound);
}; };
if let Some(provider) = &slot.provider {
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
}
slot.title = input.title; slot.title = input.title;
slot.art = input.art; slot.art = input.art;
slot.launch = input.launch; slot.launch = input.launch;
slot.prep = input.prep;
let updated = slot.clone(); let updated = slot.clone();
save_custom(&entries)?; save_custom(&entries)?;
emit_changed(); emit_changed("manual");
Ok(Some(updated)) Ok(MutateOutcome::Done(updated))
} }
/// Delete a custom entry. `false` ⇒ no entry with that id. /// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
pub fn delete_custom(id: &str) -> Result<bool> { pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
let mut entries = load_custom();
let Some(entry) = entries.iter().find(|e| e.id == id) else {
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &entry.provider {
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
}
entries.retain(|e| e.id != id);
save_custom(&entries)?;
emit_changed("manual");
Ok(MutateOutcome::Done(()))
}
// ------------------------------------------------------------------ providers (RFC §8)
/// Provider ids are path segments, event sources, and console labels: keep them tame.
/// `manual` is reserved (it is the no-provider sentinel in `library.changed`).
pub fn validate_provider_name(provider: &str) -> Result<(), String> {
if provider == "manual" {
return Err("provider id `manual` is reserved".into());
}
let ok = !provider.is_empty()
&& provider.len() <= 64
&& provider
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
&& provider.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit());
if ok {
Ok(())
} else {
Err("provider id must be 164 chars of [a-z0-9._-], starting alphanumeric".into())
}
}
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
for (i, e) in inputs.iter().enumerate() {
if e.external_id.trim().is_empty() {
return Err(format!("entries[{i}]: `external_id` must not be empty"));
}
if e.title.trim().is_empty() {
return Err(format!("entries[{i}]: `title` must not be empty"));
}
if !seen.insert(e.external_id.as_str()) {
return Err(format!(
"entries[{i}]: duplicate `external_id` \"{}\"",
e.external_id
));
}
}
Ok(())
}
/// The pure reconcile (unit-tested without the filesystem): replace `provider`'s entry set
/// with `inputs` inside `entries` — keeping each surviving title's host id stable (keyed on
/// `external_id`), dropping the provider's orphans, and never touching manual entries or
/// other providers'. Returns the provider's resulting entries, payload order.
fn reconcile_entries(
entries: &mut Vec<CustomEntry>,
provider: &str,
inputs: Vec<ProviderEntryInput>,
) -> Vec<CustomEntry> {
// The provider's current entries, keyed by its own stable id.
let mut existing: std::collections::HashMap<String, CustomEntry> = entries
.iter()
.filter(|e| e.provider.as_deref() == Some(provider))
.filter_map(|e| e.external_id.clone().map(|x| (x, e.clone())))
.collect();
// Everything the provider does NOT own survives untouched.
entries.retain(|e| e.provider.as_deref() != Some(provider));
let mut result = Vec::with_capacity(inputs.len());
for input in inputs {
let id = existing
.remove(&input.external_id)
.map(|prev| prev.id) // same title as last sync → keep its host id
.unwrap_or_else(|| new_id(&format!("{provider}:{}", input.external_id)));
result.push(CustomEntry {
id,
title: input.title,
art: input.art,
launch: input.launch,
prep: input.prep,
provider: Some(provider.to_string()),
external_id: Some(input.external_id),
});
}
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
entries.extend(result.iter().cloned());
result
}
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`).
/// The caller validates the name and payload first. Emits `library.changed` with the provider
/// as the source.
pub fn reconcile_provider(
provider: &str,
inputs: Vec<ProviderEntryInput>,
) -> Result<Vec<CustomEntry>> {
let mut entries = load_custom();
let result = reconcile_entries(&mut entries, provider, inputs);
save_custom(&entries)?;
emit_changed(provider);
Ok(result)
}
/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the
/// clean-uninstall path). Returns how many were removed; no event when nothing was.
pub fn delete_provider(provider: &str) -> Result<usize> {
let mut entries = load_custom(); let mut entries = load_custom();
let before = entries.len(); let before = entries.len();
entries.retain(|e| e.id != id); entries.retain(|e| e.provider.as_deref() != Some(provider));
if entries.len() == before { let removed = before - entries.len();
return Ok(false); if removed > 0 {
save_custom(&entries)?;
emit_changed(provider);
} }
save_custom(&entries)?; Ok(removed)
emit_changed();
Ok(true)
} }
/// The custom-entry mutations are the only library writes today, all operator-driven — hence /// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands). /// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
fn emit_changed() { pub fn prep_for(library_id: &str) -> Vec<crate::hooks::PrepCmd> {
let Some(id) = library_id.strip_prefix("custom:") else {
return Vec::new();
};
load_custom()
.into_iter()
.find(|e| e.id == id)
.map(|e| e.prep)
.unwrap_or_default()
}
/// Every library mutation announces itself (RFC §4): `source` is `"manual"` for the operator
/// CRUD, the provider id for a reconcile/uninstall — hooks and the SDK filter on it.
fn emit_changed(source: &str) {
crate::events::emit(crate::events::EventKind::LibraryChanged { crate::events::emit(crate::events::EventKind::LibraryChanged {
source: "manual".to_string(), source: source.to_string(),
}); });
} }
@@ -144,16 +319,131 @@ pub(crate) fn valid_steam_appid(value: &str) -> bool {
mod tests { mod tests {
use super::*; use super::*;
#[test] fn manual(id: &str, title: &str) -> CustomEntry {
fn custom_entry_maps_to_game_entry() { CustomEntry {
let g: GameEntry = CustomEntry { id: id.into(),
id: "abc123".into(), title: title.into(),
title: "My ROM".into(),
art: Artwork::default(), art: Artwork::default(),
launch: None, launch: None,
prep: Vec::new(),
provider: None,
external_id: None,
} }
.into(); }
fn input(external_id: &str, title: &str) -> ProviderEntryInput {
ProviderEntryInput {
external_id: external_id.into(),
title: title.into(),
art: Artwork::default(),
launch: None,
prep: Vec::new(),
}
}
#[test]
fn custom_entry_maps_to_game_entry_with_provider() {
let g: GameEntry = manual("abc123", "My ROM").into();
assert_eq!(g.id, "custom:abc123"); assert_eq!(g.id, "custom:abc123");
assert_eq!(g.store, "custom"); assert_eq!(g.store, "custom");
assert_eq!(g.provider, None);
let mut e = manual("def456", "Synced");
e.provider = Some("romm".into());
e.external_id = Some("rom-1".into());
let g: GameEntry = e.into();
assert_eq!(g.provider.as_deref(), Some("romm"));
}
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
/// orphans drop, and neither manual entries nor other providers are ever touched.
#[test]
fn reconcile_is_declarative_with_stable_ids() {
let mut entries = vec![manual("man1", "Hand-added")];
// Another provider's entry must survive every romm reconcile.
let mut other = manual("oth1", "Other title");
other.provider = Some("itch".into());
other.external_id = Some("x1".into());
entries.push(other);
// First sync: two titles appear.
let r1 = reconcile_entries(
&mut entries,
"romm",
vec![input("rom-a", "Game A"), input("rom-b", "Game B")],
);
assert_eq!(r1.len(), 2);
assert!(r1.iter().all(|e| e.provider.as_deref() == Some("romm")));
let id_a = r1[0].id.clone();
assert_eq!(entries.len(), 4);
// Second sync: A renamed, B gone, C new — A's host id must be STABLE.
let r2 = reconcile_entries(
&mut entries,
"romm",
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(r2.len(), 2);
assert_eq!(r2[0].id, id_a, "same external_id keeps its host id");
assert_eq!(r2[0].title, "Game A (v2)");
assert_ne!(r2[1].id, id_a);
assert!(
!entries
.iter()
.any(|e| e.external_id.as_deref() == Some("rom-b")),
"orphan dropped"
);
// Idempotence: an identical re-PUT changes nothing.
let snapshot: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
let r3 = reconcile_entries(
&mut entries,
"romm",
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(
r3.iter().map(|e| &e.id).collect::<Vec<_>>(),
r2.iter().map(|e| &e.id).collect::<Vec<_>>()
);
assert_eq!(
entries.iter().map(|e| e.id.clone()).collect::<Vec<_>>(),
snapshot
);
// The bystanders never moved.
assert!(entries
.iter()
.any(|e| e.id == "man1" && e.provider.is_none()));
assert!(entries
.iter()
.any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch")));
// Empty payload = remove everything the provider owns (same as DELETE).
let r4 = reconcile_entries(&mut entries, "romm", Vec::new());
assert!(r4.is_empty());
assert_eq!(
entries.len(),
2,
"only the manual + other-provider entries remain"
);
}
#[test]
fn provider_name_and_payload_validation() {
assert!(validate_provider_name("romm").is_ok());
assert!(validate_provider_name("my-provider.v2").is_ok());
assert!(validate_provider_name("manual").is_err(), "reserved");
assert!(validate_provider_name("").is_err());
assert!(validate_provider_name("Bad/Name").is_err());
assert!(validate_provider_name("-lead").is_err());
assert!(validate_provider_name(&"x".repeat(65)).is_err());
assert!(validate_provider_payload(&[input("a", "A")]).is_ok());
assert!(validate_provider_payload(&[input("", "A")]).is_err());
assert!(validate_provider_payload(&[input("a", " ")]).is_err());
assert!(
validate_provider_payload(&[input("a", "A"), input("a", "B")]).is_err(),
"duplicate external_id"
);
} }
} }
@@ -89,6 +89,7 @@ fn epic_entry(
app_name.clone() app_name.clone()
}; };
Some(GameEntry { Some(GameEntry {
provider: None,
id: format!("epic:{app_name}"), id: format!("epic:{app_name}"),
store: "epic".into(), store: "epic".into(),
title, title,
+1
View File
@@ -53,6 +53,7 @@ fn gog_games() -> Vec<GameEntry> {
// whatever it has cached (title-only until warmed). // whatever it has cached (title-only until warmed).
let art = cached_art(&id).unwrap_or_default(); let art = cached_art(&id).unwrap_or_default();
out.push(GameEntry { out.push(GameEntry {
provider: None,
id, id,
store: "gog".into(), store: "gog".into(),
title, title,
@@ -106,6 +106,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
logo: http("art_logo"), logo: http("art_logo"),
}; };
games.push(GameEntry { games.push(GameEntry {
provider: None,
id: format!("heroic:{runner}:{app_name}"), id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(), store: "heroic".into(),
title, title,
@@ -70,6 +70,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
let mut games = Vec::new(); let mut games = Vec::new();
for (id, slug, name) in rows.flatten() { for (id, slug, name) in rows.flatten() {
games.push(GameEntry { games.push(GameEntry {
provider: None,
id: format!("lutris:{id}"), id: format!("lutris:{id}"),
store: "lutris".into(), store: "lutris".into(),
title: name, title: name,
@@ -25,6 +25,7 @@ impl LibraryProvider for SteamProvider {
.into_iter() .into_iter()
.filter(|(appid, name)| !is_steam_tool(*appid, name)) .filter(|(appid, name)| !is_steam_tool(*appid, name))
.map(|(appid, title)| GameEntry { .map(|(appid, title)| GameEntry {
provider: None,
id: format!("steam:{appid}"), id: format!("steam:{appid}"),
store: "steam".into(), store: "steam".into(),
title, title,
@@ -61,6 +61,7 @@ fn xbox_games() -> Vec<GameEntry> {
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId). // background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
let art = cached_art(&id).unwrap_or_default(); let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry { games.push(GameEntry {
provider: None,
id, id,
store: "xbox".into(), store: "xbox".into(),
title, title,
+17 -474
View File
@@ -1,14 +1,17 @@
//! Minimal CUDA Driver API FFI for the zero-copy path. No Rust crate exposes the GL-interop //! CUDA driver-side state for the zero-copy path, layered over the raw driver-API FFI in [`ffi`]
//! driver calls we need (`cuGraphicsGLRegisterImage` & co.), so we hand-roll exactly those and //! (the `dlopen`'d `libcuda.so.1` symbol table — hand-rolled because no Rust crate exposes the
//! `dlopen` `libcuda.so.1` at runtime (the driver library — NOT `libcudart`; NOT a link-time //! GL-interop calls, and runtime-loaded so one binary runs on NVIDIA *and* on AMD/Intel where
//! `#[link]`, so one binary runs on NVIDIA and on AMD/Intel where `libcuda` is absent — see //! `libcuda` is absent). This facade owns the higher-level pieces on top of that layer:
//! [`CudaApi`]). Symbol names verified against
//! `cust_raw` + `cudaGL.h`: the context/mem ops use the `_v2` ABI suffix; the graphics-interop
//! ops are unsuffixed. (We use GL interop, not EGL interop: `cuGraphicsEGLRegisterImage` is
//! Tegra-only on the desktop driver — see [`super::egl`].)
//! //!
//! One process-wide `CUcontext` is created lazily and shared by the EGL importer (capture //! * one process-wide `CUcontext`, created lazily and shared by the EGL importer (capture thread)
//! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use. //! and ffmpeg's `hevc_nvenc` (encode thread) each thread makes it current before use;
//! * device memory: pitched allocations, the reusable `BufferPool`/`DeviceBuffer`, IPC
//! export/import, host readback, and the plane copies;
//! * GL / external-memory interop (`RegisteredTexture`, `ExternalDmabuf`); and
//! * the CUDA cursor-blend kernel (`CursorBlend`).
//!
//! (We use GL interop, not EGL interop: `cuGraphicsEGLRegisterImage` is Tegra-only on the desktop
//! driver — see [`super::egl`].)
#![allow(non_camel_case_types, non_snake_case)] #![allow(non_camel_case_types, non_snake_case)]
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
@@ -16,472 +19,12 @@
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::ffi::CStr; use std::ffi::CStr;
use std::os::raw::{c_char, c_int, c_uint, c_void}; use std::os::raw::{c_uint, c_void};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
pub type CUresult = c_uint; // CUDA_SUCCESS == 0 #[path = "cuda/ffi.rs"]
pub type CUdevice = c_int; mod ffi;
pub type CUcontext = *mut c_void; // opaque CUctx_st* pub(crate) use ffi::*;
pub type CUstream = *mut c_void; // opaque CUstream_st*
pub type CUdeviceptr = u64;
pub type CUgraphicsResource = *mut c_void;
pub type CUarray = *mut c_void;
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
pub type CUmodule = *mut c_void; // opaque CUmod_st*
pub type CUfunction = *mut c_void; // opaque CUfunc_st*
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
pub const CU_MEMORYTYPE_ARRAY: c_uint = 3;
/// `CUctx_flags` (cuda.h): block the CPU on an OS primitive while waiting for the GPU instead of
/// busy-spinning. On this shared box (compositor + send thread on the same cores) spinning a core
/// to detect copy completion steals CPU from the very threads we want scheduled; BLOCKING_SYNC
/// frees it. Default (`CU_CTX_SCHED_AUTO=0`) heuristically picks SPIN vs YIELD by core count.
const CU_CTX_SCHED_BLOCKING_SYNC: c_uint = 0x04;
/// `cuStreamCreateWithPriority` flag: don't implicitly synchronize with the legacy NULL stream.
const CU_STREAM_NON_BLOCKING: c_uint = 0x01;
/// `CUDA_MEMCPY2D` (cuda.h, `_v2` ABI). Field order is load-bearing.
#[repr(C)]
#[derive(Default)]
pub struct CUDA_MEMCPY2D {
pub srcXInBytes: usize,
pub srcY: usize,
pub srcMemoryType: c_uint,
pub srcHost: *const c_void,
pub srcDevice: CUdeviceptr,
pub srcArray: CUarray,
pub srcPitch: usize,
pub dstXInBytes: usize,
pub dstY: usize,
pub dstMemoryType: c_uint,
pub dstHost: *mut c_void,
pub dstDevice: CUdeviceptr,
pub dstArray: CUarray,
pub dstPitch: usize,
pub WidthInBytes: usize,
pub Height: usize,
}
/// `CUDA_EXTERNAL_MEMORY_HANDLE_DESC` (cuda.h, 64-bit layout). `handle` is a union whose
/// largest member is the win32 two-pointer struct (16 bytes, align 8); for the OPAQUE_FD type
/// only the first 4 bytes (the `int fd`) are read.
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_MEMORY_HANDLE_DESC {
pub type_: c_uint, // CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD = 1
_pad: u32,
pub handle: [u64; 2], // union { int fd; {void*,void*} win32; void* nvSciBufObject }
pub size: u64,
pub flags: c_uint,
reserved: [c_uint; 16],
_pad2: u32,
}
/// `CUDA_EXTERNAL_MEMORY_BUFFER_DESC` (cuda.h, 64-bit layout).
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
pub offset: u64,
pub size: u64,
pub flags: c_uint,
reserved: [c_uint; 16],
_pad: u32,
}
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
/// `struct { char reserved[64]; }`. Plain bytes — safe to ship over a socket.
pub const CU_IPC_HANDLE_SIZE: usize = 64;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUipcMemHandle {
pub reserved: [u8; CU_IPC_HANDLE_SIZE],
}
/// `CUipcMem_flags`: lazily enable peer access on open (the documented flag for
/// `cuIpcOpenMemHandle`; a no-op for a same-device open, which is our only case).
const CU_IPC_MEM_LAZY_ENABLE_PEER_ACCESS: c_uint = 0x1;
/// CUDA Driver API entry points, resolved at runtime from `libcuda.so.1` via `dlopen` rather than
/// a link-time `#[link(name = "cuda")]`. This is what lets ONE host binary run on NVIDIA
/// (zero-copy via CUDA → NVENC) *and* on AMD/Intel (VAAPI, where the NVIDIA driver — and thus
/// `libcuda` — is absent): with a hard link the loader would refuse to start the binary at all.
/// Every `cu*` call below goes through a same-named wrapper fn that forwards to this table; when
/// the driver isn't present the table is `None` and the wrappers return a non-zero `CUresult`, so
/// `context()` fails cleanly and the capturer falls back to the CPU path. The `cuda_api()` loader
/// is memoised; the library handle is intentionally leaked (process-lifetime, like the context).
struct CudaApi {
cuInit: unsafe extern "C" fn(c_uint) -> CUresult,
cuDeviceGet: unsafe extern "C" fn(*mut CUdevice, c_int) -> CUresult,
cuCtxCreate_v2: unsafe extern "C" fn(*mut CUcontext, c_uint, CUdevice) -> CUresult,
cuCtxDestroy_v2: unsafe extern "C" fn(CUcontext) -> CUresult,
cuCtxSetCurrent: unsafe extern "C" fn(CUcontext) -> CUresult,
cuMemAllocPitch_v2:
unsafe extern "C" fn(*mut CUdeviceptr, *mut usize, usize, usize, c_uint) -> CUresult,
cuMemFree_v2: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
cuMemcpy2DAsync_v2: unsafe extern "C" fn(*const CUDA_MEMCPY2D, CUstream) -> CUresult,
cuStreamSynchronize: unsafe extern "C" fn(CUstream) -> CUresult,
cuCtxGetStreamPriorityRange: unsafe extern "C" fn(*mut c_int, *mut c_int) -> CUresult,
cuStreamCreateWithPriority: unsafe extern "C" fn(*mut CUstream, c_uint, c_int) -> CUresult,
cuGraphicsGLRegisterImage:
unsafe extern "C" fn(*mut CUgraphicsResource, c_uint, c_uint, c_uint) -> CUresult,
cuGraphicsMapResources:
unsafe extern "C" fn(c_uint, *mut CUgraphicsResource, *mut c_void) -> CUresult,
cuGraphicsUnmapResources:
unsafe extern "C" fn(c_uint, *mut CUgraphicsResource, *mut c_void) -> CUresult,
cuGraphicsSubResourceGetMappedArray:
unsafe extern "C" fn(*mut CUarray, CUgraphicsResource, c_uint, c_uint) -> CUresult,
cuGraphicsUnregisterResource: unsafe extern "C" fn(CUgraphicsResource) -> CUresult,
cuImportExternalMemory: unsafe extern "C" fn(
*mut CUexternalMemory,
*const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
) -> CUresult,
cuExternalMemoryGetMappedBuffer: unsafe extern "C" fn(
*mut CUdeviceptr,
CUexternalMemory,
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult,
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
// Cursor-overlay blend: a linear device alloc + a PTX module with the blend kernels launched
// over the cursor's small rectangle (see [`CursorBlend`]).
cuMemAlloc_v2: unsafe extern "C" fn(*mut CUdeviceptr, usize) -> CUresult,
cuModuleLoadData: unsafe extern "C" fn(*mut CUmodule, *const c_void) -> CUresult,
cuModuleUnload: unsafe extern "C" fn(CUmodule) -> CUresult,
cuModuleGetFunction: unsafe extern "C" fn(*mut CUfunction, CUmodule, *const c_char) -> CUresult,
#[allow(clippy::type_complexity)]
cuLaunchKernel: unsafe extern "C" fn(
CUfunction,
c_uint,
c_uint,
c_uint,
c_uint,
c_uint,
c_uint,
c_uint,
CUstream,
*mut *mut c_void,
*mut *mut c_void,
) -> CUresult,
}
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
// value with no interior mutability and no thread affinity. Moving the table to another thread
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
unsafe impl Send for CudaApi {}
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
// points they address are themselves thread-safe.
unsafe impl Sync for CudaApi {}
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
/// the existing `ck()`/`!= 0` checks treat it as an ordinary driver error; distinct from any real
/// `CUDA_ERROR_*` (all < 1000). Never produced by the actual driver.
const CU_ERROR_NOT_LOADED: CUresult = 999;
static CUDA_API: OnceLock<Option<CudaApi>> = OnceLock::new();
/// Resolve `libcuda.so.1` and its symbols once. `None` when the NVIDIA driver isn't installed
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
fn cuda_api() -> Option<&'static CudaApi> {
CUDA_API
// SAFETY: `Library::new` runs `libcuda.so.1`'s initializers — it is the trusted NVIDIA
// driver library, so loading has no unexpected effects; `?`/`None` handle its absence.
// Each `lib.get::<T>(name)` asserts the symbol's real ABI equals `T`: every NUL-terminated
// name is a documented CUDA Driver API entry point and `T` is the exact
// `unsafe extern "C" fn(..)` signature from cuda.h/cudaGL.h (`_v2` for ctx/mem ops). Each
// `Symbol` only borrows `lib` until the end of the struct-literal statement; we deref-copy
// the raw fn-pointer out first, then `forget(lib)` leaks the mapping so those addresses
// stay valid for the whole process. Runs once under the `OnceLock` init — no aliasing.
.get_or_init(|| unsafe {
let lib = libloading::Library::new("libcuda.so.1")
.or_else(|_| libloading::Library::new("libcuda.so"))
.map_err(|e| {
tracing::debug!(error = %e, "libcuda not loadable — CUDA zero-copy unavailable (expected on AMD/Intel)");
})
.ok()?;
// Resolve all symbols; the field types drive `get`'s inference. `lib` is leaked after
// construction so the fn pointers stay valid for the process lifetime (the temporary
// `Symbol` borrows end with the struct-literal statement, before the forget).
let api = CudaApi {
cuInit: *lib.get(b"cuInit\0").ok()?,
cuDeviceGet: *lib.get(b"cuDeviceGet\0").ok()?,
cuCtxCreate_v2: *lib.get(b"cuCtxCreate_v2\0").ok()?,
cuCtxDestroy_v2: *lib.get(b"cuCtxDestroy_v2\0").ok()?,
cuCtxSetCurrent: *lib.get(b"cuCtxSetCurrent\0").ok()?,
cuMemAllocPitch_v2: *lib.get(b"cuMemAllocPitch_v2\0").ok()?,
cuMemFree_v2: *lib.get(b"cuMemFree_v2\0").ok()?,
cuMemcpy2DAsync_v2: *lib.get(b"cuMemcpy2DAsync_v2\0").ok()?,
cuStreamSynchronize: *lib.get(b"cuStreamSynchronize\0").ok()?,
cuCtxGetStreamPriorityRange: *lib.get(b"cuCtxGetStreamPriorityRange\0").ok()?,
cuStreamCreateWithPriority: *lib.get(b"cuStreamCreateWithPriority\0").ok()?,
cuGraphicsGLRegisterImage: *lib.get(b"cuGraphicsGLRegisterImage\0").ok()?,
cuGraphicsMapResources: *lib.get(b"cuGraphicsMapResources\0").ok()?,
cuGraphicsUnmapResources: *lib.get(b"cuGraphicsUnmapResources\0").ok()?,
cuGraphicsSubResourceGetMappedArray: *lib
.get(b"cuGraphicsSubResourceGetMappedArray\0")
.ok()?,
cuGraphicsUnregisterResource: *lib.get(b"cuGraphicsUnregisterResource\0").ok()?,
cuImportExternalMemory: *lib.get(b"cuImportExternalMemory\0").ok()?,
cuExternalMemoryGetMappedBuffer: *lib
.get(b"cuExternalMemoryGetMappedBuffer\0")
.ok()?,
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
cuIpcOpenMemHandle: *lib
.get(b"cuIpcOpenMemHandle_v2\0")
.or_else(|_| lib.get(b"cuIpcOpenMemHandle\0"))
.ok()?,
cuIpcCloseMemHandle: *lib.get(b"cuIpcCloseMemHandle\0").ok()?,
cuMemAlloc_v2: *lib.get(b"cuMemAlloc_v2\0").ok()?,
cuModuleLoadData: *lib.get(b"cuModuleLoadData\0").ok()?,
cuModuleUnload: *lib.get(b"cuModuleUnload\0").ok()?,
cuModuleGetFunction: *lib.get(b"cuModuleGetFunction\0").ok()?,
cuLaunchKernel: *lib.get(b"cuLaunchKernel\0").ok()?,
};
std::mem::forget(lib); // keep libcuda mapped for the fn pointers' lifetime (process)
Some(api)
})
.as_ref()
}
// Same-named wrappers so the call sites below are unchanged. Each forwards through the dlopen'd
// table, or returns `CU_ERROR_NOT_LOADED` when the driver is absent (AMD/Intel) — which the
// `CUresult` checks already handle. Only `context()` is reachable before the driver is confirmed
// present; every other entry runs after `context()` succeeded, so its wrapper always hits `Some`.
unsafe fn cuInit(flags: c_uint) -> CUresult {
match cuda_api() {
Some(a) => (a.cuInit)(flags),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuDeviceGet(device: *mut CUdevice, ordinal: c_int) -> CUresult {
match cuda_api() {
Some(a) => (a.cuDeviceGet)(device, ordinal),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuCtxCreate_v2(pctx: *mut CUcontext, flags: c_uint, dev: CUdevice) -> CUresult {
match cuda_api() {
Some(a) => (a.cuCtxCreate_v2)(pctx, flags, dev),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuCtxDestroy_v2(ctx: CUcontext) -> CUresult {
match cuda_api() {
Some(a) => (a.cuCtxDestroy_v2)(ctx),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuCtxSetCurrent(ctx: CUcontext) -> CUresult {
match cuda_api() {
Some(a) => (a.cuCtxSetCurrent)(ctx),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuMemAllocPitch_v2(
dptr: *mut CUdeviceptr,
pitch: *mut usize,
width_bytes: usize,
height: usize,
element_size: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuMemAllocPitch_v2)(dptr, pitch, width_bytes, height, element_size),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuMemFree_v2(dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuMemFree_v2)(dptr),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuMemAlloc_v2(dptr: *mut CUdeviceptr, size: usize) -> CUresult {
match cuda_api() {
Some(a) => (a.cuMemAlloc_v2)(dptr, size),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuModuleLoadData(m: *mut CUmodule, image: *const c_void) -> CUresult {
match cuda_api() {
Some(a) => (a.cuModuleLoadData)(m, image),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuModuleUnload(m: CUmodule) -> CUresult {
match cuda_api() {
Some(a) => (a.cuModuleUnload)(m),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuModuleGetFunction(f: *mut CUfunction, m: CUmodule, name: *const c_char) -> CUresult {
match cuda_api() {
Some(a) => (a.cuModuleGetFunction)(f, m, name),
None => CU_ERROR_NOT_LOADED,
}
}
#[allow(clippy::too_many_arguments)]
unsafe fn cuLaunchKernel(
f: CUfunction,
gx: c_uint,
gy: c_uint,
gz: c_uint,
bx: c_uint,
by: c_uint,
bz: c_uint,
shmem: c_uint,
stream: CUstream,
params: *mut *mut c_void,
extra: *mut *mut c_void,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuLaunchKernel)(f, gx, gy, gz, bx, by, bz, shmem, stream, params, extra),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuMemcpy2DAsync_v2(copy: *const CUDA_MEMCPY2D, stream: CUstream) -> CUresult {
match cuda_api() {
Some(a) => (a.cuMemcpy2DAsync_v2)(copy, stream),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuStreamSynchronize(stream: CUstream) -> CUresult {
match cuda_api() {
Some(a) => (a.cuStreamSynchronize)(stream),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuCtxGetStreamPriorityRange(least: *mut c_int, greatest: *mut c_int) -> CUresult {
match cuda_api() {
Some(a) => (a.cuCtxGetStreamPriorityRange)(least, greatest),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuStreamCreateWithPriority(
stream: *mut CUstream,
flags: c_uint,
priority: c_int,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuStreamCreateWithPriority)(stream, flags, priority),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuGraphicsGLRegisterImage(
resource: *mut CUgraphicsResource,
texture: c_uint,
target: c_uint,
flags: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsGLRegisterImage)(resource, texture, target, flags),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuGraphicsMapResources(
count: c_uint,
resources: *mut CUgraphicsResource,
stream: *mut c_void,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsMapResources)(count, resources, stream),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuGraphicsUnmapResources(
count: c_uint,
resources: *mut CUgraphicsResource,
stream: *mut c_void,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsUnmapResources)(count, resources, stream),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuGraphicsSubResourceGetMappedArray(
array: *mut CUarray,
resource: CUgraphicsResource,
array_index: c_uint,
mip_level: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsSubResourceGetMappedArray)(array, resource, array_index, mip_level),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuGraphicsUnregisterResource(resource: CUgraphicsResource) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsUnregisterResource)(resource),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuImportExternalMemory(
ext_mem_out: *mut CUexternalMemory,
mem_handle_desc: *const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuImportExternalMemory)(ext_mem_out, mem_handle_desc),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuExternalMemoryGetMappedBuffer(
dev_ptr: *mut CUdeviceptr,
ext_mem: CUexternalMemory,
buffer_desc: *const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuExternalMemoryGetMappedBuffer)(dev_ptr, ext_mem, buffer_desc),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult {
match cuda_api() {
Some(a) => (a.cuDestroyExternalMemory)(ext_mem),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuIpcOpenMemHandle(
dptr: *mut CUdeviceptr,
handle: CUipcMemHandle,
flags: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcOpenMemHandle)(dptr, handle, flags),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuIpcCloseMemHandle(dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcCloseMemHandle)(dptr),
None => CU_ERROR_NOT_LOADED,
}
}
#[inline]
fn ck(r: CUresult, what: &str) -> Result<()> {
if r == 0 {
Ok(())
} else {
bail!("CUDA driver error {r} in {what}")
}
}
/// Copy a pitched device plane `(src_ptr, src_pitch)` down to a tightly-packed host buffer of /// Copy a pitched device plane `(src_ptr, src_pitch)` down to a tightly-packed host buffer of
/// `width_bytes`×`height` (no row padding). Synchronous on the priority stream. Used by the NV12 /// `width_bytes`×`height` (no row padding). Synchronous on the priority stream. Used by the NV12
@@ -0,0 +1,488 @@
//! Raw CUDA Driver API FFI (plan §W4, carved out of the zero-copy CUDA facade): the opaque handle
//! typedefs + struct/const definitions, the `dlopen`'d `libcuda.so.1` symbol table ([`CudaApi`] +
//! [`cuda_api`]), the `unsafe` `cuXxx` wrappers, and the `ck` result check. No higher-level state —
//! the shared `CUcontext`, device buffers, GL/dmabuf interop, and cursor blend all live in [`super`]
//! and drive this layer.
#![allow(non_camel_case_types, non_snake_case)]
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, Result};
use std::os::raw::{c_char, c_int, c_uint, c_void};
use std::sync::OnceLock;
pub type CUresult = c_uint; // CUDA_SUCCESS == 0
pub type CUdevice = c_int;
pub type CUcontext = *mut c_void; // opaque CUctx_st*
pub type CUstream = *mut c_void; // opaque CUstream_st*
pub type CUdeviceptr = u64;
pub type CUgraphicsResource = *mut c_void;
pub type CUarray = *mut c_void;
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
pub type CUmodule = *mut c_void; // opaque CUmod_st*
pub type CUfunction = *mut c_void; // opaque CUfunc_st*
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
pub const CU_MEMORYTYPE_ARRAY: c_uint = 3;
/// `CUctx_flags` (cuda.h): block the CPU on an OS primitive while waiting for the GPU instead of
/// busy-spinning. On this shared box (compositor + send thread on the same cores) spinning a core
/// to detect copy completion steals CPU from the very threads we want scheduled; BLOCKING_SYNC
/// frees it. Default (`CU_CTX_SCHED_AUTO=0`) heuristically picks SPIN vs YIELD by core count.
pub(crate) const CU_CTX_SCHED_BLOCKING_SYNC: c_uint = 0x04;
/// `cuStreamCreateWithPriority` flag: don't implicitly synchronize with the legacy NULL stream.
pub(crate) const CU_STREAM_NON_BLOCKING: c_uint = 0x01;
/// `CUDA_MEMCPY2D` (cuda.h, `_v2` ABI). Field order is load-bearing.
#[repr(C)]
#[derive(Default)]
pub struct CUDA_MEMCPY2D {
pub srcXInBytes: usize,
pub srcY: usize,
pub srcMemoryType: c_uint,
pub srcHost: *const c_void,
pub srcDevice: CUdeviceptr,
pub srcArray: CUarray,
pub srcPitch: usize,
pub dstXInBytes: usize,
pub dstY: usize,
pub dstMemoryType: c_uint,
pub dstHost: *mut c_void,
pub dstDevice: CUdeviceptr,
pub dstArray: CUarray,
pub dstPitch: usize,
pub WidthInBytes: usize,
pub Height: usize,
}
/// `CUDA_EXTERNAL_MEMORY_HANDLE_DESC` (cuda.h, 64-bit layout). `handle` is a union whose
/// largest member is the win32 two-pointer struct (16 bytes, align 8); for the OPAQUE_FD type
/// only the first 4 bytes (the `int fd`) are read.
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_MEMORY_HANDLE_DESC {
pub type_: c_uint, // CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD = 1
pub(crate) _pad: u32,
pub handle: [u64; 2], // union { int fd; {void*,void*} win32; void* nvSciBufObject }
pub size: u64,
pub flags: c_uint,
pub(crate) reserved: [c_uint; 16],
pub(crate) _pad2: u32,
}
/// `CUDA_EXTERNAL_MEMORY_BUFFER_DESC` (cuda.h, 64-bit layout).
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
pub offset: u64,
pub size: u64,
pub flags: c_uint,
pub(crate) reserved: [c_uint; 16],
pub(crate) _pad: u32,
}
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
/// `struct { char reserved[64]; }`. Plain bytes — safe to ship over a socket.
pub const CU_IPC_HANDLE_SIZE: usize = 64;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUipcMemHandle {
pub reserved: [u8; CU_IPC_HANDLE_SIZE],
}
/// `CUipcMem_flags`: lazily enable peer access on open (the documented flag for
/// `cuIpcOpenMemHandle`; a no-op for a same-device open, which is our only case).
pub(crate) const CU_IPC_MEM_LAZY_ENABLE_PEER_ACCESS: c_uint = 0x1;
/// CUDA Driver API entry points, resolved at runtime from `libcuda.so.1` via `dlopen` rather than
/// a link-time `#[link(name = "cuda")]`. This is what lets ONE host binary run on NVIDIA
/// (zero-copy via CUDA → NVENC) *and* on AMD/Intel (VAAPI, where the NVIDIA driver — and thus
/// `libcuda` — is absent): with a hard link the loader would refuse to start the binary at all.
/// Every `cu*` call below goes through a same-named wrapper fn that forwards to this table; when
/// the driver isn't present the table is `None` and the wrappers return a non-zero `CUresult`, so
/// `context()` fails cleanly and the capturer falls back to the CPU path. The `cuda_api()` loader
/// is memoised; the library handle is intentionally leaked (process-lifetime, like the context).
pub(crate) struct CudaApi {
cuInit: unsafe extern "C" fn(c_uint) -> CUresult,
cuDeviceGet: unsafe extern "C" fn(*mut CUdevice, c_int) -> CUresult,
cuCtxCreate_v2: unsafe extern "C" fn(*mut CUcontext, c_uint, CUdevice) -> CUresult,
cuCtxDestroy_v2: unsafe extern "C" fn(CUcontext) -> CUresult,
cuCtxSetCurrent: unsafe extern "C" fn(CUcontext) -> CUresult,
cuMemAllocPitch_v2:
unsafe extern "C" fn(*mut CUdeviceptr, *mut usize, usize, usize, c_uint) -> CUresult,
cuMemFree_v2: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
cuMemcpy2DAsync_v2: unsafe extern "C" fn(*const CUDA_MEMCPY2D, CUstream) -> CUresult,
cuStreamSynchronize: unsafe extern "C" fn(CUstream) -> CUresult,
cuCtxGetStreamPriorityRange: unsafe extern "C" fn(*mut c_int, *mut c_int) -> CUresult,
cuStreamCreateWithPriority: unsafe extern "C" fn(*mut CUstream, c_uint, c_int) -> CUresult,
cuGraphicsGLRegisterImage:
unsafe extern "C" fn(*mut CUgraphicsResource, c_uint, c_uint, c_uint) -> CUresult,
cuGraphicsMapResources:
unsafe extern "C" fn(c_uint, *mut CUgraphicsResource, *mut c_void) -> CUresult,
cuGraphicsUnmapResources:
unsafe extern "C" fn(c_uint, *mut CUgraphicsResource, *mut c_void) -> CUresult,
cuGraphicsSubResourceGetMappedArray:
unsafe extern "C" fn(*mut CUarray, CUgraphicsResource, c_uint, c_uint) -> CUresult,
cuGraphicsUnregisterResource: unsafe extern "C" fn(CUgraphicsResource) -> CUresult,
cuImportExternalMemory: unsafe extern "C" fn(
*mut CUexternalMemory,
*const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
) -> CUresult,
cuExternalMemoryGetMappedBuffer: unsafe extern "C" fn(
*mut CUdeviceptr,
CUexternalMemory,
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult,
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
// Cursor-overlay blend: a linear device alloc + a PTX module with the blend kernels launched
// over the cursor's small rectangle (see [`CursorBlend`]).
cuMemAlloc_v2: unsafe extern "C" fn(*mut CUdeviceptr, usize) -> CUresult,
cuModuleLoadData: unsafe extern "C" fn(*mut CUmodule, *const c_void) -> CUresult,
cuModuleUnload: unsafe extern "C" fn(CUmodule) -> CUresult,
cuModuleGetFunction: unsafe extern "C" fn(*mut CUfunction, CUmodule, *const c_char) -> CUresult,
#[allow(clippy::type_complexity)]
cuLaunchKernel: unsafe extern "C" fn(
CUfunction,
c_uint,
c_uint,
c_uint,
c_uint,
c_uint,
c_uint,
c_uint,
CUstream,
*mut *mut c_void,
*mut *mut c_void,
) -> CUresult,
}
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
// value with no interior mutability and no thread affinity. Moving the table to another thread
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
unsafe impl Send for CudaApi {}
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
// points they address are themselves thread-safe.
unsafe impl Sync for CudaApi {}
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
/// the existing `ck()`/`!= 0` checks treat it as an ordinary driver error; distinct from any real
/// `CUDA_ERROR_*` (all < 1000). Never produced by the actual driver.
pub(crate) const CU_ERROR_NOT_LOADED: CUresult = 999;
pub(crate) static CUDA_API: OnceLock<Option<CudaApi>> = OnceLock::new();
/// Resolve `libcuda.so.1` and its symbols once. `None` when the NVIDIA driver isn't installed
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
pub(crate) fn cuda_api() -> Option<&'static CudaApi> {
CUDA_API
// SAFETY: `Library::new` runs `libcuda.so.1`'s initializers — it is the trusted NVIDIA
// driver library, so loading has no unexpected effects; `?`/`None` handle its absence.
// Each `lib.get::<T>(name)` asserts the symbol's real ABI equals `T`: every NUL-terminated
// name is a documented CUDA Driver API entry point and `T` is the exact
// `unsafe extern "C" fn(..)` signature from cuda.h/cudaGL.h (`_v2` for ctx/mem ops). Each
// `Symbol` only borrows `lib` until the end of the struct-literal statement; we deref-copy
// the raw fn-pointer out first, then `forget(lib)` leaks the mapping so those addresses
// stay valid for the whole process. Runs once under the `OnceLock` init — no aliasing.
.get_or_init(|| unsafe {
let lib = libloading::Library::new("libcuda.so.1")
.or_else(|_| libloading::Library::new("libcuda.so"))
.map_err(|e| {
tracing::debug!(error = %e, "libcuda not loadable — CUDA zero-copy unavailable (expected on AMD/Intel)");
})
.ok()?;
// Resolve all symbols; the field types drive `get`'s inference. `lib` is leaked after
// construction so the fn pointers stay valid for the process lifetime (the temporary
// `Symbol` borrows end with the struct-literal statement, before the forget).
let api = CudaApi {
cuInit: *lib.get(b"cuInit\0").ok()?,
cuDeviceGet: *lib.get(b"cuDeviceGet\0").ok()?,
cuCtxCreate_v2: *lib.get(b"cuCtxCreate_v2\0").ok()?,
cuCtxDestroy_v2: *lib.get(b"cuCtxDestroy_v2\0").ok()?,
cuCtxSetCurrent: *lib.get(b"cuCtxSetCurrent\0").ok()?,
cuMemAllocPitch_v2: *lib.get(b"cuMemAllocPitch_v2\0").ok()?,
cuMemFree_v2: *lib.get(b"cuMemFree_v2\0").ok()?,
cuMemcpy2DAsync_v2: *lib.get(b"cuMemcpy2DAsync_v2\0").ok()?,
cuStreamSynchronize: *lib.get(b"cuStreamSynchronize\0").ok()?,
cuCtxGetStreamPriorityRange: *lib.get(b"cuCtxGetStreamPriorityRange\0").ok()?,
cuStreamCreateWithPriority: *lib.get(b"cuStreamCreateWithPriority\0").ok()?,
cuGraphicsGLRegisterImage: *lib.get(b"cuGraphicsGLRegisterImage\0").ok()?,
cuGraphicsMapResources: *lib.get(b"cuGraphicsMapResources\0").ok()?,
cuGraphicsUnmapResources: *lib.get(b"cuGraphicsUnmapResources\0").ok()?,
cuGraphicsSubResourceGetMappedArray: *lib
.get(b"cuGraphicsSubResourceGetMappedArray\0")
.ok()?,
cuGraphicsUnregisterResource: *lib.get(b"cuGraphicsUnregisterResource\0").ok()?,
cuImportExternalMemory: *lib.get(b"cuImportExternalMemory\0").ok()?,
cuExternalMemoryGetMappedBuffer: *lib
.get(b"cuExternalMemoryGetMappedBuffer\0")
.ok()?,
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
cuIpcOpenMemHandle: *lib
.get(b"cuIpcOpenMemHandle_v2\0")
.or_else(|_| lib.get(b"cuIpcOpenMemHandle\0"))
.ok()?,
cuIpcCloseMemHandle: *lib.get(b"cuIpcCloseMemHandle\0").ok()?,
cuMemAlloc_v2: *lib.get(b"cuMemAlloc_v2\0").ok()?,
cuModuleLoadData: *lib.get(b"cuModuleLoadData\0").ok()?,
cuModuleUnload: *lib.get(b"cuModuleUnload\0").ok()?,
cuModuleGetFunction: *lib.get(b"cuModuleGetFunction\0").ok()?,
cuLaunchKernel: *lib.get(b"cuLaunchKernel\0").ok()?,
};
std::mem::forget(lib); // keep libcuda mapped for the fn pointers' lifetime (process)
Some(api)
})
.as_ref()
}
// Same-named wrappers so the call sites below are unchanged. Each forwards through the dlopen'd
// table, or returns `CU_ERROR_NOT_LOADED` when the driver is absent (AMD/Intel) — which the
// `CUresult` checks already handle. Only `context()` is reachable before the driver is confirmed
// present; every other entry runs after `context()` succeeded, so its wrapper always hits `Some`.
pub(crate) unsafe fn cuInit(flags: c_uint) -> CUresult {
match cuda_api() {
Some(a) => (a.cuInit)(flags),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuDeviceGet(device: *mut CUdevice, ordinal: c_int) -> CUresult {
match cuda_api() {
Some(a) => (a.cuDeviceGet)(device, ordinal),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuCtxCreate_v2(
pctx: *mut CUcontext,
flags: c_uint,
dev: CUdevice,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuCtxCreate_v2)(pctx, flags, dev),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuCtxDestroy_v2(ctx: CUcontext) -> CUresult {
match cuda_api() {
Some(a) => (a.cuCtxDestroy_v2)(ctx),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuCtxSetCurrent(ctx: CUcontext) -> CUresult {
match cuda_api() {
Some(a) => (a.cuCtxSetCurrent)(ctx),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuMemAllocPitch_v2(
dptr: *mut CUdeviceptr,
pitch: *mut usize,
width_bytes: usize,
height: usize,
element_size: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuMemAllocPitch_v2)(dptr, pitch, width_bytes, height, element_size),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuMemFree_v2(dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuMemFree_v2)(dptr),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuMemAlloc_v2(dptr: *mut CUdeviceptr, size: usize) -> CUresult {
match cuda_api() {
Some(a) => (a.cuMemAlloc_v2)(dptr, size),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuModuleLoadData(m: *mut CUmodule, image: *const c_void) -> CUresult {
match cuda_api() {
Some(a) => (a.cuModuleLoadData)(m, image),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuModuleUnload(m: CUmodule) -> CUresult {
match cuda_api() {
Some(a) => (a.cuModuleUnload)(m),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuModuleGetFunction(
f: *mut CUfunction,
m: CUmodule,
name: *const c_char,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuModuleGetFunction)(f, m, name),
None => CU_ERROR_NOT_LOADED,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) unsafe fn cuLaunchKernel(
f: CUfunction,
gx: c_uint,
gy: c_uint,
gz: c_uint,
bx: c_uint,
by: c_uint,
bz: c_uint,
shmem: c_uint,
stream: CUstream,
params: *mut *mut c_void,
extra: *mut *mut c_void,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuLaunchKernel)(f, gx, gy, gz, bx, by, bz, shmem, stream, params, extra),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuMemcpy2DAsync_v2(copy: *const CUDA_MEMCPY2D, stream: CUstream) -> CUresult {
match cuda_api() {
Some(a) => (a.cuMemcpy2DAsync_v2)(copy, stream),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuStreamSynchronize(stream: CUstream) -> CUresult {
match cuda_api() {
Some(a) => (a.cuStreamSynchronize)(stream),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuCtxGetStreamPriorityRange(
least: *mut c_int,
greatest: *mut c_int,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuCtxGetStreamPriorityRange)(least, greatest),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuStreamCreateWithPriority(
stream: *mut CUstream,
flags: c_uint,
priority: c_int,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuStreamCreateWithPriority)(stream, flags, priority),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuGraphicsGLRegisterImage(
resource: *mut CUgraphicsResource,
texture: c_uint,
target: c_uint,
flags: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsGLRegisterImage)(resource, texture, target, flags),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuGraphicsMapResources(
count: c_uint,
resources: *mut CUgraphicsResource,
stream: *mut c_void,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsMapResources)(count, resources, stream),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuGraphicsUnmapResources(
count: c_uint,
resources: *mut CUgraphicsResource,
stream: *mut c_void,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsUnmapResources)(count, resources, stream),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuGraphicsSubResourceGetMappedArray(
array: *mut CUarray,
resource: CUgraphicsResource,
array_index: c_uint,
mip_level: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsSubResourceGetMappedArray)(array, resource, array_index, mip_level),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuGraphicsUnregisterResource(resource: CUgraphicsResource) -> CUresult {
match cuda_api() {
Some(a) => (a.cuGraphicsUnregisterResource)(resource),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuImportExternalMemory(
ext_mem_out: *mut CUexternalMemory,
mem_handle_desc: *const CUDA_EXTERNAL_MEMORY_HANDLE_DESC,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuImportExternalMemory)(ext_mem_out, mem_handle_desc),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuExternalMemoryGetMappedBuffer(
dev_ptr: *mut CUdeviceptr,
ext_mem: CUexternalMemory,
buffer_desc: *const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuExternalMemoryGetMappedBuffer)(dev_ptr, ext_mem, buffer_desc),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult {
match cuda_api() {
Some(a) => (a.cuDestroyExternalMemory)(ext_mem),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuIpcOpenMemHandle(
dptr: *mut CUdeviceptr,
handle: CUipcMemHandle,
flags: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcOpenMemHandle)(dptr, handle, flags),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuIpcCloseMemHandle(dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcCloseMemHandle)(dptr),
None => CU_ERROR_NOT_LOADED,
}
}
#[inline]
pub(crate) fn ck(r: CUresult, what: &str) -> Result<()> {
if r == 0 {
Ok(())
} else {
bail!("CUDA driver error {r} in {what}")
}
}
+4 -168
View File
@@ -16,7 +16,7 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, DeviceBuffer}; use super::cuda::{self, DeviceBuffer};
use anyhow::{bail, ensure, Context as _, Result}; use anyhow::{ensure, Context as _, Result};
use khronos_egl as egl; use khronos_egl as egl;
use std::os::raw::{c_int, c_void}; use std::os::raw::{c_int, c_void};
@@ -30,173 +30,9 @@ const EGL_DMA_BUF_PLANE0_PITCH_EXT: egl::Attrib = 0x3274;
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: egl::Attrib = 0x3443; const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: egl::Attrib = 0x3443;
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: egl::Attrib = 0x3444; const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: egl::Attrib = 0x3444;
const GL_TEXTURE_2D: u32 = 0x0DE1; #[path = "egl/gl.rs"]
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801; mod gl;
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800; use gl::*;
const GL_LINEAR: c_int = 0x2601;
const GL_NEAREST: c_int = 0x2600;
const GL_RGBA8: u32 = 0x8058;
// Single/dual-channel 8-bit formats for the NV12 convert targets: R8 luma (full-res),
// RG8 interleaved chroma (half-res). The `_RED`/`_RG` enums are the matching client formats.
const GL_R8: u32 = 0x8229;
const GL_RG8: u32 = 0x822B;
// Client pixel format/type for texture uploads (self-test only): RGBA bytes.
const GL_RGBA: u32 = 0x1908;
const GL_UNSIGNED_BYTE: u32 = 0x1401;
const GL_FRAMEBUFFER: u32 = 0x8D40;
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
const GL_TEXTURE0: u32 = 0x84C0;
const GL_TRIANGLES: u32 = 0x0004;
const GL_VERTEX_SHADER: u32 = 0x8B31;
const GL_FRAGMENT_SHADER: u32 = 0x8B30;
const GL_COMPILE_STATUS: u32 = 0x8B81;
const GL_LINK_STATUS: u32 = 0x8B82;
// libglvnd's libGL dispatches these to the NVIDIA driver based on the current EGL/GL context.
#[link(name = "GL")]
extern "C" {
fn glGenTextures(n: c_int, textures: *mut u32);
fn glBindTexture(target: u32, texture: u32);
fn glTexParameteri(target: u32, pname: u32, param: c_int);
fn glDeleteTextures(n: c_int, textures: *const u32);
fn glTexStorage2D(target: u32, levels: c_int, internalformat: u32, width: c_int, height: c_int);
fn glGetError() -> u32;
fn glGenFramebuffers(n: c_int, framebuffers: *mut u32);
fn glDeleteFramebuffers(n: c_int, framebuffers: *const u32);
fn glBindFramebuffer(target: u32, framebuffer: u32);
fn glFramebufferTexture2D(
target: u32,
attachment: u32,
textarget: u32,
texture: u32,
level: c_int,
);
fn glCheckFramebufferStatus(target: u32) -> u32;
fn glViewport(x: c_int, y: c_int, width: c_int, height: c_int);
fn glGenVertexArrays(n: c_int, arrays: *mut u32);
fn glDeleteVertexArrays(n: c_int, arrays: *const u32);
fn glBindVertexArray(array: u32);
fn glDrawArrays(mode: u32, first: c_int, count: c_int);
fn glActiveTexture(texture: u32);
fn glUseProgram(program: u32);
fn glFlush();
fn glCreateShader(shader_type: u32) -> u32;
fn glShaderSource(shader: u32, count: c_int, string: *const *const i8, length: *const c_int);
fn glCompileShader(shader: u32);
fn glGetShaderiv(shader: u32, pname: u32, params: *mut c_int);
fn glDeleteShader(shader: u32);
fn glCreateProgram() -> u32;
fn glAttachShader(program: u32, shader: u32);
fn glLinkProgram(program: u32);
fn glGetProgramiv(program: u32, pname: u32, params: *mut c_int);
fn glGetUniformLocation(program: u32, name: *const i8) -> c_int;
fn glUniform1i(location: c_int, v0: c_int);
fn glDeleteProgram(program: u32);
fn glTexSubImage2D(
target: u32,
level: c_int,
xoffset: c_int,
yoffset: c_int,
width: c_int,
height: c_int,
format: u32,
type_: u32,
pixels: *const c_void,
);
}
#[link(name = "gbm")]
extern "C" {
fn gbm_create_device(fd: c_int) -> *mut c_void;
fn gbm_device_destroy(device: *mut c_void);
}
/// `glEGLImageTargetTexture2DOES(target, EGLImage)` — loaded via `eglGetProcAddress`.
type EglImageTargetFn = unsafe extern "system" fn(u32, *mut c_void);
// Fullscreen-triangle blit: sample the dmabuf EGLImage texture and write it (swizzled to BGRA,
// to match the BGRx the encoder expects) into a normal GL_RGBA8 texture that CUDA *can* register.
const VERT_SRC: &[u8] = b"#version 330 core\nout vec2 v_tex;\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));v_tex=p;gl_Position=vec4(p*2.0-1.0,0.0,1.0);}\n";
const FRAG_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){o_color=texture(image,v_tex).bgra;}\n";
// NV12 BT.709 LIMITED-range convert from full-range RGB in [0,1]. Two passes share `VERT_SRC` and
// the same source texture (the de-tiled dmabuf):
// Y pass → GL_R8 luma, full-res: Y = (16 + 219·(0.2126R+0.7152G+0.0722B))/255
// UV pass → GL_RG8 chroma, half-res (GL_LINEAR averages the 2×2 footprint):
// U = (128 + 224·(-0.1146R-0.3854G+0.5000B))/255 → R channel
// V = (128 + 224·( 0.5000R-0.4542G-0.0458B))/255 → G channel
// RG8's (R=U, G=V) byte order matches NV12's interleaved [U,V]. All outputs clamped to [0,1].
// Matches the Windows VideoConverter (BT.709, limited/studio range) so the two hosts look identical.
const FRAG_Y_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;float Y=(16.0+219.0*(0.2126*c.r+0.7152*c.g+0.0722*c.b))/255.0;o_color=vec4(clamp(Y,0.0,1.0),0.0,0.0,1.0);}\n";
const FRAG_UV_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;float U=(128.0+224.0*(-0.1146*c.r-0.3854*c.g+0.5000*c.b))/255.0;float V=(128.0+224.0*(0.5000*c.r-0.4542*c.g-0.0458*c.b))/255.0;o_color=vec4(clamp(U,0.0,1.0),clamp(V,0.0,1.0),0.0,1.0);}\n";
/// The three planar-YUV444 convert shaders (full-res `R8` target each) — the [`Yuv444Blit`]
/// analogue of `FRAG_Y_SRC`/`FRAG_UV_SRC` with NO subsampling (4:4:4 keeps every chroma sample).
/// Same BT.709 coefficients; `full_range` flips the quantization from studio (16+219 / 128±112)
/// to the full 0..255 swing — the encoder flips the VUI (`PUNKTFUNK_444_FULLRANGE`, read by both
/// processes from the same inherited environment) in lockstep, so pixels and signaling agree.
fn yuv444_frag_sources(full_range: bool) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (y_scale, y_off, c_scale) = if full_range {
("255.0", "0.0", "255.0")
} else {
("219.0", "16.0", "224.0")
};
let head = "#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;";
let y = format!(
"{head}float Y=({y_off}+{y_scale}*(0.2126*c.r+0.7152*c.g+0.0722*c.b))/255.0;o_color=vec4(clamp(Y,0.0,1.0),0.0,0.0,1.0);}}\n"
);
let u = format!(
"{head}float U=(128.0+{c_scale}*(-0.1146*c.r-0.3854*c.g+0.5000*c.b))/255.0;o_color=vec4(clamp(U,0.0,1.0),0.0,0.0,1.0);}}\n"
);
let v = format!(
"{head}float V=(128.0+{c_scale}*(0.5000*c.r-0.4542*c.g-0.0458*c.b))/255.0;o_color=vec4(clamp(V,0.0,1.0),0.0,0.0,1.0);}}\n"
);
(y.into_bytes(), u.into_bytes(), v.into_bytes())
}
unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> {
let sh = glCreateShader(kind);
ensure!(sh != 0, "glCreateShader failed");
let ptr = src.as_ptr() as *const i8;
let len = src.len() as c_int;
glShaderSource(sh, 1, &ptr, &len);
glCompileShader(sh);
let mut ok: c_int = 0;
glGetShaderiv(sh, GL_COMPILE_STATUS, &mut ok);
if ok == 0 {
glDeleteShader(sh);
bail!("GL shader compile failed");
}
Ok(sh)
}
/// Compile+link the fullscreen-triangle program with fragment source `frag` and bind its `image`
/// sampler to texture unit 0.
unsafe fn compile_program_with(frag: &[u8]) -> Result<u32> {
let vs = compile_shader(GL_VERTEX_SHADER, VERT_SRC)?;
let fs = compile_shader(GL_FRAGMENT_SHADER, frag)?;
let prog = glCreateProgram();
glAttachShader(prog, vs);
glAttachShader(prog, fs);
glLinkProgram(prog);
glDeleteShader(vs);
glDeleteShader(fs);
let mut ok: c_int = 0;
glGetProgramiv(prog, GL_LINK_STATUS, &mut ok);
ensure!(ok != 0, "GL program link failed");
glUseProgram(prog);
let loc = glGetUniformLocation(prog, c"image".as_ptr());
if loc >= 0 {
glUniform1i(loc, 0); // sampler -> texture unit 0
}
glUseProgram(0);
Ok(prog)
}
unsafe fn compile_program() -> Result<u32> {
compile_program_with(FRAG_SRC)
}
/// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture. /// Per-size GL machinery to blit a dmabuf EGLImage into a CUDA-registrable `GL_RGBA8` texture.
struct GlBlit { struct GlBlit {
@@ -0,0 +1,191 @@
//! GL plumbing for the EGL zero-copy blit (plan §W4, carved out of the EGL facade): the GL enum
//! constants, the `#[link]`'d libGL / libgbm entry points, the fullscreen-triangle shader sources
//! (BGRA swizzle + the NV12 / YUV444 BT.709 convert passes), and the shader/program compile
//! helpers. The de-tiling blit passes and the EGLDisplay importer that drive this all live in
//! [`super`].
#![allow(non_upper_case_globals)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{bail, ensure, Result};
use std::os::raw::{c_int, c_void};
pub(crate) const GL_TEXTURE_2D: u32 = 0x0DE1;
pub(crate) const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
pub(crate) const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
pub(crate) const GL_LINEAR: c_int = 0x2601;
pub(crate) const GL_NEAREST: c_int = 0x2600;
pub(crate) const GL_RGBA8: u32 = 0x8058;
// Single/dual-channel 8-bit formats for the NV12 convert targets: R8 luma (full-res),
// RG8 interleaved chroma (half-res). The `_RED`/`_RG` enums are the matching client formats.
pub(crate) const GL_R8: u32 = 0x8229;
pub(crate) const GL_RG8: u32 = 0x822B;
// Client pixel format/type for texture uploads (self-test only): RGBA bytes.
pub(crate) const GL_RGBA: u32 = 0x1908;
pub(crate) const GL_UNSIGNED_BYTE: u32 = 0x1401;
pub(crate) const GL_FRAMEBUFFER: u32 = 0x8D40;
pub(crate) const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
pub(crate) const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
pub(crate) const GL_TEXTURE0: u32 = 0x84C0;
pub(crate) const GL_TRIANGLES: u32 = 0x0004;
pub(crate) const GL_VERTEX_SHADER: u32 = 0x8B31;
pub(crate) const GL_FRAGMENT_SHADER: u32 = 0x8B30;
pub(crate) const GL_COMPILE_STATUS: u32 = 0x8B81;
pub(crate) const GL_LINK_STATUS: u32 = 0x8B82;
// libglvnd's libGL dispatches these to the NVIDIA driver based on the current EGL/GL context.
#[link(name = "GL")]
extern "C" {
pub(crate) fn glGenTextures(n: c_int, textures: *mut u32);
pub(crate) fn glBindTexture(target: u32, texture: u32);
pub(crate) fn glTexParameteri(target: u32, pname: u32, param: c_int);
pub(crate) fn glDeleteTextures(n: c_int, textures: *const u32);
pub(crate) fn glTexStorage2D(
target: u32,
levels: c_int,
internalformat: u32,
width: c_int,
height: c_int,
);
pub(crate) fn glGetError() -> u32;
pub(crate) fn glGenFramebuffers(n: c_int, framebuffers: *mut u32);
pub(crate) fn glDeleteFramebuffers(n: c_int, framebuffers: *const u32);
pub(crate) fn glBindFramebuffer(target: u32, framebuffer: u32);
pub(crate) fn glFramebufferTexture2D(
target: u32,
attachment: u32,
textarget: u32,
texture: u32,
level: c_int,
);
pub(crate) fn glCheckFramebufferStatus(target: u32) -> u32;
pub(crate) fn glViewport(x: c_int, y: c_int, width: c_int, height: c_int);
pub(crate) fn glGenVertexArrays(n: c_int, arrays: *mut u32);
pub(crate) fn glDeleteVertexArrays(n: c_int, arrays: *const u32);
pub(crate) fn glBindVertexArray(array: u32);
pub(crate) fn glDrawArrays(mode: u32, first: c_int, count: c_int);
pub(crate) fn glActiveTexture(texture: u32);
pub(crate) fn glUseProgram(program: u32);
pub(crate) fn glFlush();
pub(crate) fn glCreateShader(shader_type: u32) -> u32;
pub(crate) fn glShaderSource(
shader: u32,
count: c_int,
string: *const *const i8,
length: *const c_int,
);
pub(crate) fn glCompileShader(shader: u32);
pub(crate) fn glGetShaderiv(shader: u32, pname: u32, params: *mut c_int);
pub(crate) fn glDeleteShader(shader: u32);
pub(crate) fn glCreateProgram() -> u32;
pub(crate) fn glAttachShader(program: u32, shader: u32);
pub(crate) fn glLinkProgram(program: u32);
pub(crate) fn glGetProgramiv(program: u32, pname: u32, params: *mut c_int);
pub(crate) fn glGetUniformLocation(program: u32, name: *const i8) -> c_int;
pub(crate) fn glUniform1i(location: c_int, v0: c_int);
pub(crate) fn glDeleteProgram(program: u32);
pub(crate) fn glTexSubImage2D(
target: u32,
level: c_int,
xoffset: c_int,
yoffset: c_int,
width: c_int,
height: c_int,
format: u32,
type_: u32,
pixels: *const c_void,
);
}
#[link(name = "gbm")]
extern "C" {
pub(crate) fn gbm_create_device(fd: c_int) -> *mut c_void;
pub(crate) fn gbm_device_destroy(device: *mut c_void);
}
/// `glEGLImageTargetTexture2DOES(target, EGLImage)` — loaded via `eglGetProcAddress`.
pub(crate) type EglImageTargetFn = unsafe extern "system" fn(u32, *mut c_void);
// Fullscreen-triangle blit: sample the dmabuf EGLImage texture and write it (swizzled to BGRA,
// to match the BGRx the encoder expects) into a normal GL_RGBA8 texture that CUDA *can* register.
pub(crate) const VERT_SRC: &[u8] = b"#version 330 core\nout vec2 v_tex;\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));v_tex=p;gl_Position=vec4(p*2.0-1.0,0.0,1.0);}\n";
pub(crate) const FRAG_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){o_color=texture(image,v_tex).bgra;}\n";
// NV12 BT.709 LIMITED-range convert from full-range RGB in [0,1]. Two passes share `VERT_SRC` and
// the same source texture (the de-tiled dmabuf):
// Y pass → GL_R8 luma, full-res: Y = (16 + 219·(0.2126R+0.7152G+0.0722B))/255
// UV pass → GL_RG8 chroma, half-res (GL_LINEAR averages the 2×2 footprint):
// U = (128 + 224·(-0.1146R-0.3854G+0.5000B))/255 → R channel
// V = (128 + 224·( 0.5000R-0.4542G-0.0458B))/255 → G channel
// RG8's (R=U, G=V) byte order matches NV12's interleaved [U,V]. All outputs clamped to [0,1].
// Matches the Windows VideoConverter (BT.709, limited/studio range) so the two hosts look identical.
pub(crate) const FRAG_Y_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;float Y=(16.0+219.0*(0.2126*c.r+0.7152*c.g+0.0722*c.b))/255.0;o_color=vec4(clamp(Y,0.0,1.0),0.0,0.0,1.0);}\n";
pub(crate) const FRAG_UV_SRC: &[u8] = b"#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;float U=(128.0+224.0*(-0.1146*c.r-0.3854*c.g+0.5000*c.b))/255.0;float V=(128.0+224.0*(0.5000*c.r-0.4542*c.g-0.0458*c.b))/255.0;o_color=vec4(clamp(U,0.0,1.0),clamp(V,0.0,1.0),0.0,1.0);}\n";
/// The three planar-YUV444 convert shaders (full-res `R8` target each) — the [`Yuv444Blit`]
/// analogue of `FRAG_Y_SRC`/`FRAG_UV_SRC` with NO subsampling (4:4:4 keeps every chroma sample).
/// Same BT.709 coefficients; `full_range` flips the quantization from studio (16+219 / 128±112)
/// to the full 0..255 swing — the encoder flips the VUI (`PUNKTFUNK_444_FULLRANGE`, read by both
/// processes from the same inherited environment) in lockstep, so pixels and signaling agree.
pub(crate) fn yuv444_frag_sources(full_range: bool) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (y_scale, y_off, c_scale) = if full_range {
("255.0", "0.0", "255.0")
} else {
("219.0", "16.0", "224.0")
};
let head = "#version 330 core\nuniform sampler2D image;\nin vec2 v_tex;\nout vec4 o_color;\nvoid main(){vec3 c=texture(image,v_tex).rgb;";
let y = format!(
"{head}float Y=({y_off}+{y_scale}*(0.2126*c.r+0.7152*c.g+0.0722*c.b))/255.0;o_color=vec4(clamp(Y,0.0,1.0),0.0,0.0,1.0);}}\n"
);
let u = format!(
"{head}float U=(128.0+{c_scale}*(-0.1146*c.r-0.3854*c.g+0.5000*c.b))/255.0;o_color=vec4(clamp(U,0.0,1.0),0.0,0.0,1.0);}}\n"
);
let v = format!(
"{head}float V=(128.0+{c_scale}*(0.5000*c.r-0.4542*c.g-0.0458*c.b))/255.0;o_color=vec4(clamp(V,0.0,1.0),0.0,0.0,1.0);}}\n"
);
(y.into_bytes(), u.into_bytes(), v.into_bytes())
}
pub(crate) unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> {
let sh = glCreateShader(kind);
ensure!(sh != 0, "glCreateShader failed");
let ptr = src.as_ptr() as *const i8;
let len = src.len() as c_int;
glShaderSource(sh, 1, &ptr, &len);
glCompileShader(sh);
let mut ok: c_int = 0;
glGetShaderiv(sh, GL_COMPILE_STATUS, &mut ok);
if ok == 0 {
glDeleteShader(sh);
bail!("GL shader compile failed");
}
Ok(sh)
}
/// Compile+link the fullscreen-triangle program with fragment source `frag` and bind its `image`
/// sampler to texture unit 0.
pub(crate) unsafe fn compile_program_with(frag: &[u8]) -> Result<u32> {
let vs = compile_shader(GL_VERTEX_SHADER, VERT_SRC)?;
let fs = compile_shader(GL_FRAGMENT_SHADER, frag)?;
let prog = glCreateProgram();
glAttachShader(prog, vs);
glAttachShader(prog, fs);
glLinkProgram(prog);
glDeleteShader(vs);
glDeleteShader(fs);
let mut ok: c_int = 0;
glGetProgramiv(prog, GL_LINK_STATUS, &mut ok);
ensure!(ok != 0, "GL program link failed");
glUseProgram(prog);
let loc = glGetUniformLocation(prog, c"image".as_ptr());
if loc >= 0 {
glUniform1i(loc, 0); // sampler -> texture unit 0
}
glUseProgram(0);
Ok(prog)
}
pub(crate) unsafe fn compile_program() -> Result<u32> {
compile_program_with(FRAG_SRC)
}
+1
View File
@@ -50,6 +50,7 @@ mod gpu;
#[path = "linux/gpuclocks.rs"] #[path = "linux/gpuclocks.rs"]
mod gpuclocks; mod gpuclocks;
mod hdr; mod hdr;
mod hooks;
mod inject; mod inject;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/install.rs"] #[path = "windows/install.rs"]
+11 -1
View File
@@ -32,7 +32,9 @@ use utoipa_scalar::{Scalar, Servable};
mod auth; mod auth;
mod clients; mod clients;
mod display; mod display;
mod events;
mod gpu; mod gpu;
mod hooks;
mod host; mod host;
mod library; mod library;
mod native; mod native;
@@ -205,6 +207,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
library::update_custom_game, library::update_custom_game,
library::delete_custom_game library::delete_custom_game
)) ))
.routes(routes!(
library::reconcile_provider_entries,
library::delete_provider_entries
))
.routes(routes!(library::get_library_art)) .routes(routes!(library::get_library_art))
.routes(routes!(stats::stats_capture_start)) .routes(routes!(stats::stats_capture_start))
.routes(routes!(stats::stats_capture_stop)) .routes(routes!(stats::stats_capture_stop))
@@ -215,7 +221,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
stats::stats_recording_get, stats::stats_recording_get,
stats::stats_recording_delete stats::stats_recording_delete
)) ))
.routes(routes!(stats::logs_get)), .routes(routes!(stats::logs_get))
.routes(routes!(events::stream_events))
.routes(routes!(hooks::get_hooks, hooks::set_hooks)),
) )
.split_for_parts() .split_for_parts()
} }
@@ -251,6 +259,8 @@ pub fn openapi_json() -> String {
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"), (name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"), (name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"), (name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
) )
)] )]
struct ApiDoc; struct ApiDoc;
+231
View File
@@ -0,0 +1,231 @@
//! `GET /api/v1/events` — the host lifecycle event stream (scripting-and-hooks RFC §5, M1).
//!
//! Server-Sent Events over the existing HTTPS serve loop: each event goes out as one SSE frame
//! with `id:` = the event's `seq`, `event:` = its kind (`stream.started`, …), and `data:` = the
//! [`crate::events::HostEvent`] JSON. Consumers resume with the standard `Last-Event-ID` header
//! (or its `?since=` query twin); a consumer that fell off the catch-up ring gets a synthetic
//! `event: dropped` frame first and should resync via the REST snapshots (`/status`, `/clients`,
//! …). `?kinds=` filters server-side (exact kinds or `domain.*` prefixes, comma-separated).
//!
//! Bounds (RFC §9.6): at most [`MAX_EVENT_STREAMS`] concurrent streams (503 beyond — the
//! consumer retries; SSE clients reconnect by themselves), and a consumer too slow for the
//! live tail (broadcast lag) is **disconnected**, never buffered unboundedly — its reconnect
//! resumes from the ring via `Last-Event-ID`.
//!
//! Auth: deliberately NOT on the mTLS read-only allowlist (`auth::cert_may_access`) — the
//! stream is part of the loopback + bearer admin lane in v1 (RFC §5; revisit when paired
//! clients want an activity feed).
use super::shared::*;
use axum::http::HeaderMap;
use axum::response::sse::{Event, KeepAlive, Sse};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::broadcast;
/// Concurrent SSE connection cap. Operators run a handful of consumers (console feed, a couple
/// of scripts/plugins); 32 is generous headroom while bounding a runaway reconnect loop.
const MAX_EVENT_STREAMS: usize = 32;
/// SSE keep-alive comment interval — detects a dead peer and keeps middleboxes from idling
/// the connection out between (low-rate) lifecycle events.
const KEEP_ALIVE: Duration = Duration::from_secs(15);
static LIVE_STREAMS: AtomicUsize = AtomicUsize::new(0);
/// RAII slot in the connection cap: taken before the stream starts, released when the SSE body
/// is dropped (client disconnect, slow-consumer cut, server shutdown). `pub(crate)` only for
/// the [`test_support`] saturation helper's return type.
pub(crate) struct StreamSlot;
fn try_acquire_slot() -> Option<StreamSlot> {
LIVE_STREAMS
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
(n < MAX_EVENT_STREAMS).then_some(n + 1)
})
.ok()
.map(|_| StreamSlot)
}
impl Drop for StreamSlot {
fn drop(&mut self) {
LIVE_STREAMS.fetch_sub(1, Ordering::SeqCst);
}
}
/// `?kinds=stream.*,pairing.pending` — exact kind names or `domain.*` prefixes. `None` = all.
struct KindFilter(Option<Vec<String>>);
impl KindFilter {
fn parse(kinds: Option<&str>) -> Self {
let pats: Option<Vec<String>> = kinds.map(|s| {
s.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect()
});
KindFilter(pats.filter(|p| !p.is_empty()))
}
fn matches(&self, kind: &str) -> bool {
match &self.0 {
None => true,
Some(pats) => pats.iter().any(|p| crate::events::kind_matches(p, kind)),
}
}
}
#[derive(Deserialize)]
pub(crate) struct EventsQuery {
/// Resume cursor: stream events with `seq > since`. The `Last-Event-ID` header (what an SSE
/// client sends on auto-reconnect) takes precedence when both are present.
since: Option<u64>,
/// Comma-separated kind filter (`stream.*,pairing.pending`).
kinds: Option<String>,
}
/// One [`crate::events::HostEvent`] as an SSE frame: `id:` = seq (drives `Last-Event-ID`),
/// `event:` = kind, `data:` = the full event JSON (kind included — the frame is self-contained
/// for consumers that read `data` only).
fn sse_event(ev: &crate::events::HostEvent) -> Event {
Event::default()
.id(ev.seq.to_string())
.event(ev.kind.name())
.data(serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string()))
}
/// Everything the poll loop owns; dropping it (the response body going away) releases the
/// connection-cap slot and the broadcast receiver.
struct StreamState {
/// The dropped marker (when applicable) + the filtered catch-up, served before the live tail.
pending: VecDeque<Event>,
rx: broadcast::Receiver<crate::events::HostEvent>,
filter: KindFilter,
_slot: StreamSlot,
}
/// Stream host lifecycle events (SSE)
///
/// Server-Sent Events stream of the host's lifecycle events: client connect/disconnect, session
/// and stream start/end, pairing decisions, display create/release, library changes, host
/// start/stop — both protocol planes. Frames carry `id:` = the event's monotonic `seq`,
/// `event:` = its kind, and `data:` = the event JSON (schema-versioned, additive-only).
///
/// Resume: standard `Last-Event-ID` (or `?since=`) replays from the in-memory ring; a consumer
/// that fell off the ring receives an `event: dropped` frame first and should resync via the
/// REST snapshots. Keep-alive comments are sent every 15 s.
#[utoipa::path(
get,
path = "/events",
tag = "events",
operation_id = "streamEvents",
params(
("since" = Option<u64>, Query, description = "Resume cursor: only events with `seq` greater than this are sent (the ring keeps the newest ~1024). `Last-Event-ID` takes precedence."),
("kinds" = Option<String>, Query, description = "Comma-separated server-side kind filter: exact kinds (`pairing.pending`) or `domain.*` prefixes (`stream.*`)."),
("Last-Event-ID" = Option<u64>, Header, description = "SSE auto-reconnect cursor — the `id:` of the last received frame."),
),
responses(
(status = OK, description = "SSE stream; each frame's `data:` is one HostEvent", body = crate::events::HostEvent, content_type = "text/event-stream"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Concurrent event-stream cap reached — retry shortly", body = ApiError),
)
)]
pub(crate) async fn stream_events(Query(q): Query<EventsQuery>, headers: HeaderMap) -> Response {
let Some(slot) = try_acquire_slot() else {
return api_error(
StatusCode::SERVICE_UNAVAILABLE,
"event-stream connection cap reached — close an existing stream or retry",
);
};
// The header is what an SSE client re-sends on auto-reconnect, so it is always the newer
// cursor when both it and the original URL's `?since=` are present.
let since = headers
.get("last-event-id")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok())
.or(q.since)
.unwrap_or(0);
let filter = KindFilter::parse(q.kinds.as_deref());
let sub = crate::events::bus().subscribe(since);
let mut pending = VecDeque::new();
if sub.dropped {
// The consumer's cursor precedes the ring: tell it so (the `LogPage.dropped` contract)
// — its move is a REST resync, not trust in a complete replay.
pending.push_back(
Event::default()
.event("dropped")
.data(r#"{"dropped":true}"#),
);
}
pending.extend(
sub.catch_up
.iter()
.filter(|ev| filter.matches(ev.kind.name()))
.map(sse_event),
);
let state = StreamState {
pending,
rx: sub.rx,
filter,
_slot: slot,
};
let stream = futures_util::stream::unfold(state, |mut st| async move {
loop {
if let Some(ev) = st.pending.pop_front() {
return Some((Ok::<_, std::convert::Infallible>(ev), st));
}
match st.rx.recv().await {
Ok(ev) => {
if st.filter.matches(ev.kind.name()) {
return Some((Ok(sse_event(&ev)), st));
}
}
// Lagged = this consumer is too slow for the live tail: cut it loose (bounded
// memory, RFC §9.6) — its auto-reconnect resumes from the ring, which flags
// `dropped` if it also fell off that. Closed = host shutdown.
Err(broadcast::error::RecvError::Lagged(_))
| Err(broadcast::error::RecvError::Closed) => return None,
}
}
});
Sse::new(stream)
.keep_alive(KeepAlive::new().interval(KEEP_ALIVE))
.into_response()
}
#[cfg(test)]
pub(crate) mod test_support {
/// Fill the connection cap and hand the slots to a test ([`Drop`] frees them). The events
/// tests serialize on a lock so the full cap can't 503 an unrelated concurrent test.
pub(crate) fn saturate_slots() -> Vec<super::StreamSlot> {
std::iter::from_fn(super::try_acquire_slot).collect()
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn kind_filter_semantics() {
let all = KindFilter::parse(None);
assert!(all.matches("stream.started"));
let f = KindFilter::parse(Some("stream.*, pairing.pending"));
assert!(f.matches("stream.started"));
assert!(f.matches("stream.stopped"));
assert!(f.matches("pairing.pending"));
assert!(!f.matches("pairing.completed"));
assert!(!f.matches("client.connected"));
// A prefix pattern must match on the dot boundary, not raw text.
assert!(!f.matches("streamx.started"));
// Empty/blank filter strings mean "no filter", not "nothing matches".
assert!(KindFilter::parse(Some("")).matches("host.started"));
assert!(KindFilter::parse(Some(" , ")).matches("host.started"));
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Hooks management endpoints (scripting-and-hooks RFC §6): read and replace the operator's
//! `hooks.json` — validated on write, applied immediately (the runner reads the store per
//! event, so no restart is needed).
use super::shared::*;
/// Get the hook configuration
///
/// The operator's `hooks.json`: commands and webhooks fired on host lifecycle events. Empty
/// when unconfigured.
#[utoipa::path(
get,
path = "/hooks",
tag = "hooks",
operation_id = "getHooks",
responses(
(status = OK, description = "The stored hook configuration", body = crate::hooks::HooksConfig),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_hooks() -> Json<crate::hooks::HooksConfig> {
Json(crate::hooks::store().get())
}
/// Replace the hook configuration
///
/// Validates and persists a full `hooks.json` document (this is a whole-document PUT, not a
/// patch). Applies from the next event — no restart. Hook commands run as the host user
/// (interactive user session on Windows): treat this configuration as operator-privileged.
#[utoipa::path(
put,
path = "/hooks",
tag = "hooks",
operation_id = "setHooks",
request_body = crate::hooks::HooksConfig,
responses(
(status = OK, description = "Configuration stored; the new state", body = crate::hooks::HooksConfig),
(status = BAD_REQUEST, description = "Structurally invalid configuration", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Configuration could not be persisted", body = ApiError),
)
)]
pub(crate) async fn set_hooks(ApiJson(cfg): ApiJson<crate::hooks::HooksConfig>) -> Response {
if let Err(e) = cfg.validate() {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::hooks::store().set(cfg) {
Ok(()) => {
tracing::info!("management API: hook configuration updated");
Json(crate::hooks::store().get()).into_response()
}
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist hooks.json: {e:#}"),
),
}
}
+122 -7
View File
@@ -4,23 +4,39 @@
use super::shared::*; use super::shared::*;
use axum::http::header; use axum::http::header;
#[derive(Deserialize)]
pub(crate) struct LibraryQuery {
/// Only entries owned by this external provider (RFC §8).
provider: Option<String>,
}
/// List the game library /// List the game library
/// ///
/// Every installed-store title (Steam, read from the host's local files — no Steam API key) /// Every installed-store title (Steam, read from the host's local files — no Steam API key)
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client /// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
/// fetches directly (the public Steam CDN for Steam titles). /// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
/// entries a given external provider owns.
#[utoipa::path( #[utoipa::path(
get, get,
path = "/library", path = "/library",
tag = "library", tag = "library",
operation_id = "getLibrary", operation_id = "getLibrary",
params(
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
),
responses( responses(
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]), (status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
) )
)] )]
pub(crate) async fn get_library() -> Json<Vec<crate::library::GameEntry>> { pub(crate) async fn get_library(
Json(crate::library::all_games()) Query(q): Query<LibraryQuery>,
) -> Json<Vec<crate::library::GameEntry>> {
let mut games = crate::library::all_games();
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
}
Json(games)
} }
/// Add a custom library entry /// Add a custom library entry
@@ -75,9 +91,16 @@ pub(crate) async fn update_custom_game(
if input.title.trim().is_empty() { if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty"); return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
} }
use crate::library::MutateOutcome;
match crate::library::update_custom(&id, input) { match crate::library::update_custom(&id, input) {
Ok(Some(entry)) => Json(entry).into_response(), Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"), Ok(MutateOutcome::NotFound) => {
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
}
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
StatusCode::CONFLICT,
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
} }
} }
@@ -97,9 +120,101 @@ pub(crate) async fn update_custom_game(
) )
)] )]
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response { pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
use crate::library::MutateOutcome;
match crate::library::delete_custom(&id) { match crate::library::delete_custom(&id) {
Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(MutateOutcome::Done(())) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"), Ok(MutateOutcome::NotFound) => {
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
}
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
StatusCode::CONFLICT,
&format!(
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// The count envelope a provider uninstall returns.
#[derive(Serialize, ToSchema)]
pub(crate) struct ProviderRemoved {
/// How many entries the provider owned (and were removed).
removed: usize,
}
/// Replace a provider's library entries (declarative reconcile)
///
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
/// provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
/// entries or other providers'. An empty array removes everything the provider owns. Emits
/// `library.changed` with the provider as `source`.
#[utoipa::path(
put,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "reconcileProviderEntries",
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
request_body = Vec<crate::library::ProviderEntryInput>,
responses(
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn reconcile_provider_entries(
Path(provider): Path<String>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::library::reconcile_provider(&provider, inputs) {
Ok(entries) => {
tracing::info!(
provider,
count = entries.len(),
"library provider reconciled"
);
Json(entries).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Remove a provider's library entries
///
/// Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin
/// (RFC §8). Emits `library.changed` when anything was removed.
#[utoipa::path(
delete,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "deleteProviderEntries",
params(("provider" = String, Path, description = "The provider id")),
responses(
(status = OK, description = "How many entries were removed", body = ProviderRemoved),
(status = BAD_REQUEST, description = "Invalid provider id", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::library::delete_provider(&provider) {
Ok(removed) => {
if removed > 0 {
tracing::info!(provider, removed, "library provider entries removed");
}
Json(ProviderRemoved { removed }).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
} }
} }
+313
View File
@@ -946,3 +946,316 @@ async fn logs_endpoint_pages_by_cursor() {
assert!(json["entries"].as_array().unwrap().is_empty()); assert!(json["entries"].as_array().unwrap().is_empty());
assert_eq!(json["next"].as_u64().unwrap(), after); assert_eq!(json["next"].as_u64().unwrap(), after);
} }
// ------------------------------------------------------------------ events (SSE)
/// Serializes the events-route tests: they share the process-global event bus and the
/// connection-cap counter, so the cap test must never 503 a concurrently running stream test.
static EVENTS_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// `get_req` + the default test bearer, pre-attached (these tests read streaming bodies
/// directly instead of going through `send`).
fn events_req(path: &str) -> axum::http::Request<Body> {
let mut req = get_req(path);
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_static("Bearer test-secret"),
);
req
}
/// The next SSE frame as text, or `None` when the stream ended / nothing arrived in time.
async fn next_sse_chunk(body: &mut Body) -> Option<String> {
match tokio::time::timeout(std::time::Duration::from_secs(5), body.frame()).await {
Ok(Some(Ok(frame))) => frame
.into_data()
.ok()
.map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
/// Every `data:` payload in accumulated SSE text, parsed as JSON.
fn sse_data_events(text: &str) -> Vec<serde_json::Value> {
text.lines()
.filter_map(|l| l.strip_prefix("data: "))
.filter_map(|d| serde_json::from_str(d).ok())
.collect()
}
#[tokio::test]
async fn events_stream_requires_bearer() {
let app = test_app(test_state(), None);
let mut req = get_req("/api/v1/events");
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_static("Bearer wrong"),
);
let resp = app.clone().oneshot(req).await.expect("infallible");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
/// The full consumer contract on one route: ring catch-up, the server-side kind filter, the
/// live tail on the same connection, `?since=`/`Last-Event-ID` resume, and the `dropped`
/// marker for a cursor that fell off the ring.
#[tokio::test]
async fn events_stream_catch_up_filter_resume_tail_and_dropped() {
use crate::events::EventKind;
let _l = EVENTS_TEST_LOCK.lock().await;
let app = test_app(test_state(), None);
let uniq = format!("evt-{}-{:p}", std::process::id(), &0u8 as *const u8);
let m1 = format!("{uniq}-one");
// Noise of a different kind (must be filtered out), then our marker.
crate::events::emit(EventKind::DisplayReleased { count: 424_242 });
crate::events::emit(EventKind::LibraryChanged { source: m1.clone() });
let resp = app
.clone()
.oneshot(events_req("/api/v1/events?kinds=library.changed"))
.await
.expect("infallible");
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
ctype.starts_with("text/event-stream"),
"content-type: {ctype}"
);
// Catch-up must deliver m1 (other tests' library.changed events may interleave — scan).
let mut body = resp.into_body();
let mut seen = String::new();
while !seen.contains(&m1) {
let chunk = next_sse_chunk(&mut body)
.await
.expect("catch-up delivers the marker event");
seen.push_str(&chunk);
}
assert!(
!seen.contains("event: display.released"),
"kind filter must drop other kinds: {seen}"
);
assert!(
seen.contains("event: library.changed"),
"frame kind: {seen}"
);
let m1_seq = sse_data_events(&seen)
.iter()
.find(|e| e["source"] == m1.as_str())
.and_then(|e| e["seq"].as_u64())
.expect("marker frame carries the full event JSON with its seq");
// Live tail on the SAME connection. If a concurrent test floods the broadcast channel the
// slow-consumer cut ends this stream — then the documented client move (reconnect with the
// last seen id) must deliver m2 instead, so follow it rather than flaking.
let m2 = format!("{uniq}-two");
crate::events::emit(EventKind::LibraryChanged { source: m2.clone() });
let mut tail = String::new();
loop {
match next_sse_chunk(&mut body).await {
Some(chunk) => {
tail.push_str(&chunk);
if tail.contains(&m2) {
break;
}
}
None => {
let resp = app
.clone()
.oneshot(events_req(&format!(
"/api/v1/events?since={m1_seq}&kinds=library.changed"
)))
.await
.expect("infallible");
body = resp.into_body();
}
}
}
drop(body);
// Resume from m1's seq: m2 is caught up, m1 is not.
let resp = app
.clone()
.oneshot(events_req(&format!(
"/api/v1/events?since={m1_seq}&kinds=library.changed"
)))
.await
.expect("infallible");
let mut body = resp.into_body();
let mut resumed = String::new();
while !resumed.contains(&m2) {
let chunk = next_sse_chunk(&mut body)
.await
.expect("resume catch-up delivers m2");
resumed.push_str(&chunk);
}
assert!(!resumed.contains(&m1), "since-cursor must exclude m1");
drop(body);
// Last-Event-ID beats ?since (it is the newer cursor on an SSE auto-reconnect).
let mut req = events_req("/api/v1/events?since=0&kinds=library.changed");
req.headers_mut().insert(
"last-event-id",
axum::http::HeaderValue::from_str(&m1_seq.to_string()).unwrap(),
);
let resp = app.clone().oneshot(req).await.expect("infallible");
let mut body = resp.into_body();
let mut resumed = String::new();
while !resumed.contains(&m2) {
let chunk = next_sse_chunk(&mut body)
.await
.expect("header-resume catch-up delivers m2");
resumed.push_str(&chunk);
}
assert!(!resumed.contains(&m1), "Last-Event-ID must exclude m1");
drop(body);
// A cursor that fell off the ring gets the dropped marker first. Flood the ring past
// capacity, then resume from seq 1.
for _ in 0..1100 {
crate::events::emit(EventKind::DisplayReleased { count: 1 });
}
let resp = app
.clone()
.oneshot(events_req("/api/v1/events?since=1"))
.await
.expect("infallible");
let mut body = resp.into_body();
let first = next_sse_chunk(&mut body).await.expect("dropped marker");
assert!(first.contains("event: dropped"), "first frame: {first}");
assert!(
first.contains(r#"{"dropped":true}"#),
"marker data: {first}"
);
}
#[tokio::test]
async fn events_stream_connection_cap() {
let _l = EVENTS_TEST_LOCK.lock().await;
let app = test_app(test_state(), None);
let slots = super::events::test_support::saturate_slots();
let resp = app
.clone()
.oneshot(events_req("/api/v1/events"))
.await
.expect("infallible");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
drop(slots);
let resp = app
.clone()
.oneshot(events_req("/api/v1/events"))
.await
.expect("infallible");
assert_eq!(resp.status(), StatusCode::OK, "cap frees with the slots");
}
// ------------------------------------------------------------------ hooks
/// GET returns the (empty-when-unconfigured) config; PUT validation rejects structural errors
/// with the reason. A *successful* PUT is deliberately not exercised through the route — it
/// would write the developer's real config dir; persistence is unit-tested in `crate::hooks`
/// against a temp path.
#[tokio::test]
async fn hooks_get_shape_and_put_validation() {
let app = test_app(test_state(), None);
let (s, json) = send(&app, get_req("/api/v1/hooks")).await;
assert_eq!(s, StatusCode::OK);
assert!(json["hooks"].is_array());
let put = |body: serde_json::Value| {
axum::http::Request::put("/api/v1/hooks")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Structurally invalid: an entry with no action.
let (s, json) = send(
&app,
put(serde_json::json!({"hooks": [{"on": "stream.started"}]})),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(
json["error"].as_str().unwrap().contains("run"),
"error names the problem: {json}"
);
// Non-http(s) webhook.
let (s, _) = send(
&app,
put(serde_json::json!({"hooks": [{"on": "pairing.*", "webhook": "ftp://x"}]})),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// Wrong bearer → 401 (the hooks surface is admin-lane).
let mut req = get_req("/api/v1/hooks");
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_static("Bearer wrong"),
);
let resp = app.clone().oneshot(req).await.expect("infallible");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ------------------------------------------------------------------ library providers
/// Provider reconcile validation (the write path itself is unit-tested in `library::custom`
/// against pure functions — a successful PUT here would touch the developer's real catalog).
#[tokio::test]
async fn provider_reconcile_validation() {
let app = test_app(test_state(), None);
let put = |provider: &str, body: serde_json::Value| {
axum::http::Request::put(format!("/api/v1/library/provider/{provider}"))
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Reserved / malformed provider ids.
let (s, json) = send(&app, put("manual", serde_json::json!([]))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("reserved"));
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!([]))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// Payload rules: empty external_id, duplicate external_id.
let (s, _) = send(
&app,
put(
"romm",
serde_json::json!([{"external_id": "", "title": "X"}]),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let (s, json) = send(
&app,
put(
"romm",
serde_json::json!([
{"external_id": "a", "title": "A"},
{"external_id": "a", "title": "B"}
]),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("duplicate"));
// DELETE validates the name too.
let del = axum::http::Request::delete("/api/v1/library/provider/manual")
.body(Body::empty())
.unwrap();
let (s, _) = send(&app, del).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
File diff suppressed because it is too large Load Diff
+206
View File
@@ -0,0 +1,206 @@
//! The native `punktfunk/1` mid-stream control task (plan §W1 — carved out of [`super`]'s
//! `serve_session`). After the handshake the control stream stays open for renegotiation and
//! speed tests; this task multiplexes the inbound client requests (`Reconfigure` /
//! `RequestKeyframe` / `RfiRequest` / `LossReport` / `SetBitrate` / `ProbeRequest` / `ClockProbe`)
//! with the outbound probe-result and mode-correction channels, handing every validated change to
//! the data-plane thread over the session's mpsc bridges.
use super::*;
/// Run the control task for one live session. Owns the control streams (`serve_session` hands them
/// off after negotiation) plus every channel end that bridges to the data-plane thread. Returns
/// when the control stream closes or a data-plane channel drops.
#[allow(clippy::too_many_arguments)]
pub(super) async fn run(
mut ctrl_send: quinn::SendStream,
mut ctrl_recv: quinn::RecvStream,
initial_mode: punktfunk_core::Mode,
codec: crate::encode::Codec,
live_reconfig_ok: bool,
adaptive_fec: bool,
session_bitrate_kbps: u32,
fec_target_ctl: Arc<AtomicU8>,
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
keyframe_tx: std::sync::mpsc::Sender<()>,
rfi_tx: std::sync::mpsc::Sender<(u32, u32)>,
bitrate_tx: std::sync::mpsc::Sender<u32>,
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
) {
let mut active = initial_mode;
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
let mut last_accepted_switch: Option<std::time::Instant> = None;
loop {
tokio::select! {
msg = io::read_msg(&mut ctrl_recv) => {
let Ok(msg) = msg else { break }; // stream closed
if let Ok(req) = Reconfigure::decode(&msg) {
let now = std::time::Instant::now();
let valid = req.mode.refresh_hz > 0
&& crate::encode::validate_dimensions(
codec,
req.mode.width,
req.mode.height,
)
.is_ok();
let too_soon = last_accepted_switch
.is_some_and(|t| now.duration_since(t) < MIN_SWITCH_INTERVAL);
let ok = if !live_reconfig_ok {
// Backend can't live-reconfigure (gamescope / synthetic /
// per-client-mode identity — see the gate above): honest downgrade,
// the client keeps scaling client-side.
tracing::info!(mode = ?req.mode,
"mode switch rejected (backend cannot live-reconfigure)");
false
} else if !valid {
tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)");
false
} else if too_soon {
tracing::warn!(mode = ?req.mode, "mode switch rejected (rate-limited)");
false
} else {
true
};
if ok {
active = req.mode;
last_accepted_switch = Some(now);
tracing::info!(mode = ?req.mode, "mode switch accepted");
}
let ack = Reconfigured { accepted: ok, mode: active };
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
break;
}
if ok && reconfig_tx.send(req.mode).is_err() {
break; // data plane gone
}
} else if RequestKeyframe::decode(&msg).is_ok() {
// Client recovery: its decoder wedged — force the next encoded frame to
// be an IDR. Coalesced in the encode loop (a wedge fires several before
// the IDR lands); a send error just means the data plane is gone.
tracing::debug!("client requested keyframe (decode recovery)");
if keyframe_tx.send(()).is_err() {
break; // data plane gone
}
} else if let Ok(req) = RfiRequest::decode(&msg) {
// Client LTR-RFI recovery: it lost the frame range `[first, last]` and asks
// the encoder to re-reference a known-good older frame instead of paying for
// a full IDR. The encode loop attempts `invalidate_ref_frames`, falling back
// to a coalesced keyframe when the encoder can't (range too old / no RFI).
tracing::debug!(
first = req.first_frame,
last = req.last_frame,
"client requested reference-frame invalidation (loss recovery)"
);
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
break; // data plane gone
}
} else if let Ok(rep) = LossReport::decode(&msg) {
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
// is pinned via PUNKTFUNK_FEC_PCT.
if adaptive_fec {
// Fast attack, slow decay: jump straight to what the reported loss
// needs, but come DOWN only one point per clean report (~750 ms). The
// memoryless controller ping-ponged on periodic burst loss (Wi-Fi
// scans / BT coexistence, a burst every few seconds): a single clean
// window dropped FEC back to the floor, so every next burst hit an
// unprotected stream — an unrecoverable frame, a freeze, and a
// recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps
// the stream covered across the gap while still converging to FEC_MIN
// on a genuinely clean link.
let prev = fec_target_ctl.load(Ordering::Relaxed);
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
fec_target_ctl.store(target, Ordering::Relaxed);
if prev != target {
tracing::debug!(
loss_ppm = rep.loss_ppm,
fec_pct = target,
prev_fec_pct = prev,
"adaptive FEC adjusted"
);
}
}
} else if let Ok(req) = SetBitrate::decode(&msg) {
// Mid-stream bitrate renegotiation (adaptive bitrate): clamp exactly like
// the Hello request, ack the resolved value, then hand it to the data-plane
// thread, which rebuilds the encoder in place at the same mode — the fresh
// encoder's first frame is an IDR with in-band parameter sets, so the
// client's decoder follows without a reconnect.
// PyroWave: the rate is PINNED (§4.6 — quality collapses under rate
// descent; recovery pressure is answered by codec fallback, not AIMD).
// Our client controller is off for this codec; this guards older or
// foreign clients by acking the unchanged session rate.
let resolved = if codec == crate::encode::Codec::PyroWave {
tracing::info!(
requested_kbps = req.bitrate_kbps,
pinned_kbps = session_bitrate_kbps,
"PyroWave session: mid-stream bitrate retarget refused (pinned)"
);
session_bitrate_kbps
} else {
resolve_bitrate_kbps(req.bitrate_kbps)
};
tracing::debug!(
requested_kbps = req.bitrate_kbps,
resolved_kbps = resolved,
"mid-stream bitrate change requested"
);
let ack = BitrateChanged {
bitrate_kbps: resolved,
};
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
break;
}
if bitrate_tx.send(resolved).is_err() {
break; // data plane gone
}
} else if let Ok(req) = ProbeRequest::decode(&msg) {
tracing::info!(
target_kbps = req.target_kbps,
duration_ms = req.duration_ms,
"speed-test probe requested"
);
if probe_tx.send(req).is_err() {
break; // data plane gone
}
} else if let Ok(probe) = ClockProbe::decode(&msg) {
// Wall-clock skew handshake: echo the client's t1 with our receive (t2) and
// send (t3) stamps, both in the host clock the AU pts_ns uses. Answered
// inline on the control stream — cheap, no data-plane involvement.
let t2_ns = now_ns();
let echo = ClockEcho {
t1_ns: probe.t1_ns,
t2_ns,
t3_ns: now_ns(),
};
if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() {
break;
}
} else {
tracing::warn!("unknown control message — ignoring");
}
}
result = probe_result_rx.recv() => {
let Some(result) = result else { break }; // data plane gone
if io::write_msg(&mut ctrl_send, &result.encode()).await.is_err() {
break;
}
}
correction = reconfig_result_rx.recv() => {
// H2 rollback/correction ack: the data plane reports the mode ACTUALLY live
// after a rebuild that failed (stayed at the old mode) or that the backend
// honored at a different refresh. Track it so a later rejection's
// `mode: active` echo is truthful too.
let Some(ack) = correction else { break }; // data plane gone
active = ack.mode;
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
break;
}
}
}
}
}
@@ -246,7 +246,8 @@ pub(super) async fn negotiate(
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host // today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only // negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
// topology, and 4:4:4 routed to DDA, which was removed.) // topology, and 4:4:4 routed to DDA, which was removed.)
let capture_supports_444 = crate::capture::capturer_supports_444(); let capture_supports_444 =
crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444());
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the // The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when // compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
// the cheap gates already pass. The result is cached process-wide (a negative latches until // the cheap gates already pass. The result is cached process-wide (a negative latches until
+8 -10
View File
@@ -56,8 +56,8 @@ impl PadState {
self.rs_y = s.rs_y; self.rs_y = s.rs_y;
} }
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame { fn frame(&self, index: usize, active_mask: u16) -> punktfunk_core::input::GamepadFrame {
crate::gamestream::gamepad::GamepadFrame { punktfunk_core::input::GamepadFrame {
index: index as i16, index: index as i16,
active_mask, active_mask,
buttons: self.buttons, buttons: self.buttons,
@@ -192,8 +192,8 @@ impl Pads {
self.kinds[idx] = resolved; self.kinds[idx] = resolved;
} }
fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) { fn handle(&mut self, ev: &punktfunk_core::input::GamepadEvent) {
use crate::gamestream::gamepad::GamepadEvent; use punktfunk_core::input::GamepadEvent;
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the // Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
// removal frame emitted by the native detach path (`GamepadRemove`). // removal frame emitted by the native detach path (`GamepadRemove`).
let (idx, present) = match ev { let (idx, present) = match ev {
@@ -212,7 +212,7 @@ impl Pads {
} }
/// Dispatch a decoded event to the manager for `kind`, creating it lazily. /// Dispatch a decoded event to the manager for `kind`, creating it lazily.
fn route_handle(&mut self, kind: GamepadPref, ev: &crate::gamestream::gamepad::GamepadEvent) { fn route_handle(&mut self, kind: GamepadPref, ev: &punktfunk_core::input::GamepadEvent) {
match kind { match kind {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
GamepadPref::DualSense => self GamepadPref::DualSense => self
@@ -682,7 +682,7 @@ pub(super) fn input_thread(
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) { if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
pad_mask |= 1 << idx; pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask); let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame)); pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
} }
} }
InputKind::GamepadState => { InputKind::GamepadState => {
@@ -704,9 +704,7 @@ pub(super) fn input_thread(
if first || pad_state[idx] != before { if first || pad_state[idx] != before {
pad_mask |= 1 << idx; pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask); let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State( pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
frame,
));
} }
} }
} }
@@ -729,7 +727,7 @@ pub(super) fn input_thread(
pad_mask &= !(1 << idx); pad_mask &= !(1 << idx);
pad_state[idx] = PadState::default(); pad_state[idx] = PadState::default();
let frame = pad_state[idx].frame(idx, pad_mask); let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame)); pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
tracing::info!(pad = idx, "gamepad unplugged (native detach)"); tracing::info!(pad = idx, "gamepad unplugged (native detach)");
} }
// Fresh feedback bookkeeping so a later re-plug on this index inherits no // Fresh feedback bookkeeping so a later re-plug on this index inherits no
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -108,7 +108,7 @@ pub fn run(opts: Options) -> Result<()> {
.context("create virtual output")?; .context("create virtual output")?;
capture::capture_virtual_output( capture::capture_virtual_output(
vout, vout,
capture::OutputFormat::resolve(false), capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()),
crate::session_plan::CaptureBackend::resolve(), crate::session_plan::CaptureBackend::resolve(),
) )
.context("capture virtual output")? .context("capture virtual output")?
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,204 @@
//! Virtual-display backend contract (plan §W3 — the trait facade carved out of [`super`]).
//! [`DisplayOwnership`] declares who owns an output's lifecycle, [`VirtualOutput`] is the created
//! output (PipeWire node + RAII keepalive), and [`VirtualDisplay`] is the per-compositor backend
//! trait `super::open` returns boxed. The per-backend `impl`s and the factory stay in `super`.
use super::*;
/// Who owns a [`VirtualOutput`]'s lifecycle — the honest declaration that lets the registry
/// (`design/gamemode-and-dedicated-sessions.md` Part A1) pool **only what it owns** instead of
/// keeping outputs whose real lifecycle lives elsewhere (the gamescope managed/attach paths, which
/// are governed by the gamescope module's own session machinery). Extends the CLAUDE.md invariant
/// "the registry owns display lifecycle" with its converse: what the registry does not own, it must
/// not pretend to keep.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DisplayOwnership {
/// The registry owns the lifecycle: it may pool, linger, pin, and tear this display down (KWin,
/// Mutter, wlroots, gamescope **bare spawn**, and the Windows manager-delegated monitor). The
/// default — a backend that says nothing is registry-owned.
#[default]
Owned,
/// Someone else's display, merely mirrored: no keep-alive, no topology, no reuse (gamescope
/// **attach** to a foreign session). Codifies the design-doc §7 "attach = unmanaged pass-through"
/// row.
External,
/// A box-level session the gamescope module manages (the managed `gamescope-session-plus` /
/// SteamOS takeover). Passed through by the registry (its restore lifecycle is the gamescope
/// module's until Part A3 hands the registry a real keepalive + restore duty).
SessionManaged,
}
/// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop
/// tears the output down (releases the compositor-side resource).
///
/// Allowed dead on non-Linux: the backends that construct it are all `cfg(target_os = "linux")`.
#[allow(dead_code)]
pub struct VirtualOutput {
/// PipeWire node id of the output's screencast stream.
pub node_id: u32,
/// Portal/remote PipeWire fd when the node lives on a sandboxed remote (e.g. Mutter's
/// RemoteDesktop+ScreenCast). `None` means the node is on the user's default PipeWire daemon
/// (KWin `zkde_screencast`), captured by connecting to that daemon directly.
#[cfg(target_os = "linux")]
pub remote_fd: Option<OwnedFd>,
/// `(width, height, refresh_hz)` to prefer in the PipeWire format negotiation. KWin and
/// gamescope outputs are created at the exact size, so this just confirms it; **Mutter sizes
/// its virtual monitor FROM the negotiation**, so here it's what makes the client's mode real.
pub preferred_mode: Option<(u32, u32, u32)>,
/// Windows capture identity (DXGI adapter LUID + GDI output name) for the pf-vdisplay backend —
/// what [`crate::capture::capture_virtual_output`] needs to duplicate the right output.
#[cfg(target_os = "windows")]
pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>,
/// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown.
pub keepalive: Box<dyn Send>,
/// Who owns this display's lifecycle (`design/gamemode-and-dedicated-sessions.md` A1). The
/// registry pools/keep-alives only [`DisplayOwnership::Owned`] outputs; `External`/`SessionManaged`
/// pass through (the capturer holds the keepalive, teardown on drop). Defaults to `Owned`.
pub ownership: DisplayOwnership,
/// `Some(gen)` when [`registry::acquire`](crate::vdisplay::registry::acquire) handed this back as a
/// **reused** kept display (`design/gamemode-and-dedicated-sessions.md` A2), so the pipeline builder
/// can [`registry::mark_failed(gen)`](crate::vdisplay::registry::mark_failed) if the first frame
/// fails on it — tearing the corpse down so the retry loop's next acquire creates fresh instead of
/// re-wedging on the same dead node. `None` on a fresh create / non-poolable output. Linux-only (the
/// keep-alive pool is Linux).
#[cfg(target_os = "linux")]
pub reused_gen: Option<u64>,
/// The registry pool generation of this display (fresh AND reused — unlike `reused_gen`), so a
/// mid-stream mode-switch rebuild can [`registry::retire`](crate::vdisplay::registry::retire) the
/// display it supersedes instead of leaving it to accumulate under a linger/forever keep-alive
/// policy (`design/midstream-resolution-resize.md` H4). `None` for non-poolable outputs.
/// Linux-only (the keep-alive pool is Linux).
#[cfg(target_os = "linux")]
pub pool_gen: Option<u64>,
}
impl VirtualOutput {
/// A registry-[owned](DisplayOwnership::Owned) output — the common case (KWin/Mutter/wlroots,
/// gamescope bare-spawn, Windows). Fills `ownership: Owned`; the caller sets the platform fields.
pub fn owned(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
keepalive: Box<dyn Send>,
) -> VirtualOutput {
VirtualOutput {
node_id,
#[cfg(target_os = "linux")]
remote_fd: None,
preferred_mode,
#[cfg(target_os = "windows")]
win_capture: None,
keepalive,
ownership: DisplayOwnership::Owned,
#[cfg(target_os = "linux")]
reused_gen: None,
#[cfg(target_os = "linux")]
pool_gen: None,
}
}
}
/// Pluggable virtual-output creation, per compositor.
pub trait VirtualDisplay: Send {
/// Human-readable backend name (e.g. `"kwin"`, `"wlroots"`, `"mutter"`).
fn name(&self) -> &'static str;
/// Create a virtual output of the given mode. Teardown is RAII: drop the returned
/// [`VirtualOutput`]'s `keepalive`.
fn create(&mut self, mode: Mode) -> Result<VirtualOutput>;
/// Set the per-session command this display should launch into its nested output (the resolved
/// app/game). Carried on the backend instance — NOT a process-global env var — so concurrent
/// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
fn set_launch_command(&mut self, _cmd: Option<String>) {}
/// Set the connecting client's cert fingerprint so the backend can give that client a STABLE virtual
/// monitor identity across reconnects and its saved per-monitor config (notably DPI scaling) is
/// reapplied — via the OS (Windows EDID serial), the compositor (KWin per-slot output name), or
/// host-side persistence (Mutter, whose virtual monitors can't carry a stable identity). Carried on
/// the backend instance; set once before [`create`](Self::create). Default: no-op (wlroots/gamescope
/// have no per-client identity). `None` = anonymous/unpaired/GameStream → the backend's auto
/// (slot-based/shared) identity.
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
/// Hand the backend the session's deliberate-quit flag (set when the client closes with the QUIT
/// application code — a user "stop", not a network drop) so the last lease's drop can tear the
/// display down IMMEDIATELY, skipping the keep-alive linger — the Windows analogue of the Linux
/// registry's `Linger::Immediate` path. Carried on the backend instance; set once before
/// [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay backend needs it (its
/// leases live in the `VirtualDisplayManager`, which the registry's quit plumbing does not reach;
/// Linux backends get the flag through `registry::acquire`).
fn set_quit_flag(&mut self, _quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {}
/// Hand the backend the CLIENT display's HDR colour volume (`Hello::display_hdr` — primaries /
/// white point / luminance range as reported by the client OS), so a freshly created virtual
/// output can advertise the client's REAL panel in its EDID (pf-vdisplay codes the luminance
/// into the CTA-861.3 HDR static-metadata block) — host apps and the OS then tone-map to the
/// panel the stream actually lands on instead of a built-in placeholder volume. Carried on the
/// backend instance; set once before [`create`](Self::create). `None` = unknown/SDR client →
/// the backend's default EDID. Default: no-op — only the Windows pf-vdisplay backend can mint
/// per-monitor EDIDs today (the Linux compositors' virtual outputs take no EDID from us).
fn set_client_hdr(&mut self, _hdr: Option<punktfunk_core::quic::HdrMeta>) {}
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
/// registry reads it right after `create` to key the display's group **arrangement** (manual
/// per-slot positions) and to label the mgmt `/display/state` slot. Default `None`: a backend
/// with no per-client identity (wlroots/gamescope) always auto-rows. KWin (per-slot output
/// naming) and Mutter (host-persisted per-client scale) report a real slot on Linux.
fn last_identity_slot(&self) -> Option<u32> {
None
}
/// Place the most-recently-[created](Self::create) output at `(x, y)` in the desktop coordinate
/// space (design `display-management.md` §6.2 — layout). The registry, which owns the display
/// **group**, computes the position from the whole group (auto-row or the console's manual
/// arrangement) and calls this right after `create`. Default no-op: only backends that can position
/// an output (KWin) implement it; the registry never calls it for the desktop origin `(0, 0)`, so a
/// single-display / first-of-group session issues no positioning at all. Best-effort — a failure
/// leaves the compositor's default placement.
fn apply_position(&mut self, _x: i32, _y: i32) {}
/// Take the topology **restore** action this [`create`](Self::create) prepared — the work that
/// un-does an `exclusive`/`primary` topology change (e.g. re-enable the physical outputs KWin
/// disabled). The registry lifts it into the display **group** so it runs **once, when the group's
/// last display is torn down** (design §6.1 — per-group restore), not when this one session's
/// display drops: a sibling `exclusive` session must not have the physical re-enabled under it.
/// Called right after `create`; the backend must not also run it itself. Default `None` — a backend
/// whose topology auto-reverts (Mutter `APPLY_TEMPORARY`) or that changes nothing has nothing to
/// hand off.
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
None
}
/// Tell the backend whether this create will be the **first** display in its group — i.e. no
/// sibling of the same backend is already live (design §6.1). A backend that *establishes* the
/// group's topology (Mutter's sole-monitor `exclusive` `ApplyMonitorsConfig`) applies it only when
/// first; a later sibling **extends** into the already-exclusive desktop instead of re-clobbering it
/// (a fresh sole-monitor config would disable the first session's virtual output). Set by the
/// registry right before [`create`](Self::create). Default no-op: KWin recognises siblings at
/// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display
/// backends never have a sibling.
fn set_first_in_group(&mut self, _first: bool) {}
/// Will a [`create`](Self::create) for the CURRENT request produce a registry-poolable
/// ([`DisplayOwnership::Owned`], keep-alive-able) display? The registry consults this **before**
/// its keep-alive reuse lookup, so it never hands a kept display of one flavor to a request of
/// another — specifically a gamescope managed/attach acquire must not reuse a kept **bare-spawn**
/// (they share the backend name `"gamescope"`). Default `true`; only gamescope overrides it,
/// returning `false` when the env selects attach/managed (consistent with the `ownership` its
/// `create` will report). See `design/gamemode-and-dedicated-sessions.md` A1.
fn poolable_now(&self) -> bool {
true
}
/// The resolved launch command carried on this backend instance (set via
/// [`set_launch_command`](Self::set_launch_command)). The registry reads it to key keep-alive reuse
/// on `(backend, mode, launch)` (`design/gamemode-and-dedicated-sessions.md` A2) — a kept display
/// running game A must never be handed to a session that asked to launch game B. Default `None`
/// (backends that never nest a command); only gamescope reports its `cmd`.
fn launch_command(&self) -> Option<String> {
None
}
/// Is the kept display's `node_id` still live, checked **before** the registry REUSES it on a
/// reconnect (`design/gamemode-and-dedicated-sessions.md` A2)? A `false` tells the registry to tear
/// the dead entry down and create fresh instead of handing back a corpse (which would then fail
/// capture and burn a retry). Default `true` (honest optimism — the [`mark_failed`] path is the
/// backstop for a display that dies between this check and first frame). Only gamescope overrides
/// it (its nested session dies when the game exits, independently of any compositor); KWin/Mutter
/// nodes die only with their compositor, which the session-epoch invalidation (A4) already reaps.
///
/// [`mark_failed`]: crate::vdisplay::registry::mark_failed
fn kept_display_alive(&mut self, _node_id: u32) -> bool {
true
}
}
@@ -19,6 +19,14 @@ use anyhow::{anyhow, bail, Context, Result};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[path = "gamescope/discovery.rs"]
mod discovery;
use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
gamescope_node_present, poll_managed_node, wait_for_node,
};
pub(crate) use discovery::{game_session_exited, is_available};
/// The gamescope virtual-display driver. Three modes by env, in precedence order: /// The gamescope virtual-display driver. Three modes by env, in precedence order:
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session /// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
/// (full Steam-Deck-UI polish) headless at the CLIENT's mode; relaunch it when the mode changes. /// (full Steam-Deck-UI polish) headless at the CLIENT's mode; relaunch it when the mode changes.
@@ -1436,258 +1444,6 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
.context("spawn gamescope (is it installed? `apt install gamescope`)") .context("spawn gamescope (is it installed? `apt install gamescope`)")
} }
/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
/// — the adapter and the inner stream — and only the advertised id is the correct capture
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
/// node stays absent across the window.
pub fn game_session_exited(node_id: u32) -> bool {
let deadline = Instant::now() + Duration::from_millis(1500);
loop {
if gamescope_node_present(node_id) {
return false; // OUR node is (still) present → not an exit (transient loss)
}
if Instant::now() >= deadline {
return true; // our node stayed gone across the window → the game exited
}
std::thread::sleep(Duration::from_millis(250));
}
}
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
fn poll_managed_node(timeout: Duration) -> Option<u32> {
let deadline = Instant::now() + timeout;
loop {
if let Some(id) = find_gamescope_node() {
return Some(id);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(300));
}
}
fn wait_for_node(timeout: Duration, log: &std::path::Path, child_pid: u32) -> Option<u32> {
let deadline = Instant::now() + timeout;
loop {
if let Some(id) = node_from_log(log) {
return Some(id);
}
if Instant::now() >= deadline {
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
// node isn't picked by mistake.
return find_gamescope_node_scoped(Some(child_pid));
}
std::thread::sleep(Duration::from_millis(300));
}
}
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
fn node_from_log(log: &std::path::Path) -> Option<u32> {
let log = std::fs::read_to_string(log).ok()?;
for line in log.lines().rev() {
if let Some(pos) = line.find("stream available on node ID:") {
let tail = &line[pos + "stream available on node ID:".len()..];
let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect();
if let Ok(id) = digits.parse() {
return Some(id);
}
}
}
None
}
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
fn gamescope_node_present(node_id: u32) -> bool {
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
return true;
};
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
return true;
};
dump.as_array()
.map(|objs| {
objs.iter().any(|o| {
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
})
})
.unwrap_or(true)
}
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
///
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
fn find_gamescope_node() -> Option<u32> {
find_gamescope_node_scoped(None)
}
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
let out = Command::new("pw-dump").output().ok()?;
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let nodes = dump.as_array()?;
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
return None;
}
let id = obj.get("id").and_then(|i| i.as_u64())? as u32;
let props = obj.get("info").and_then(|i| i.get("props"));
let name = props
.and_then(|p| p.get("node.name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let class = props
.and_then(|p| p.get("media.class"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
// PipeWire records the owning process id as a string or an int depending on version.
let pid = props
.and_then(|p| p.get("application.process.id"))
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.map(|n| n as u32)
});
Some((id, name, class, pid))
};
// A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
// per-instance log is the primary addressing (design §7 risk note).
let in_scope = |pid: Option<u32>| -> bool {
match scope {
None => true,
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
}
};
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
for obj in nodes {
if let Some((id, name, class, pid)) = node_props(obj) {
if class == "Video/Source"
&& (name == "gamescope" || name.contains("gamescope"))
&& in_scope(pid)
{
return Some(id);
}
}
}
// Fallback: a node literally named "gamescope" with no usable class tag, in scope.
for obj in nodes {
if let Some((id, name, _, pid)) = node_props(obj) {
if name == "gamescope" && in_scope(pid) {
tracing::warn!(
node_id = id,
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
);
return Some(id);
}
}
}
None
}
/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing
/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file).
///
/// gamescope names its EIS socket `gamescope-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
/// `gamescope-<display>` wayland socket). Stale sockets from dead sessions linger, so we don't
/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the
/// most recently created (the live session). Returns the bare socket *name* (the injector
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
fn find_gamescope_eis_socket() -> Option<String> {
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
// The EIS socket itself, not its `.lock` sidecar or the bare wayland socket.
if !(name.starts_with("gamescope-") && name.ends_with("-ei")) {
continue;
}
// Connectable == a live listener is behind it (a dead session's socket refuses).
if std::os::unix::net::UnixStream::connect(entry.path()).is_err() {
continue;
}
let mtime = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
live.push((mtime, name));
}
live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first
live.into_iter().next().map(|(_, n)| n)
}
/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
/// create path); just checks the binary executes.
pub fn is_available() -> bool {
std::process::Command::new("gamescope")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
/// gate. Returns the parsed version when it could read one.
fn check_gamescope_version() -> Option<(u32, u32, u32)> {
let out = Command::new("gamescope").arg("--version").output().ok()?;
// gamescope prints the version banner to stderr on some builds, stdout on others.
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let ver = parse_version(&text)?;
if ver < MIN_GAMESCOPE {
tracing::warn!(
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2),
"gamescope is older than the minimum for reliable headless capture — expect a \
capture deadlock against PipeWire 1.6 (a wedged link head-blocks the daemon); \
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
);
}
Some(ver)
}
/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`).
fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
let mut parts = token.split('.');
let (a, b, c) = (parts.next()?, parts.next(), parts.next());
let (Some(b), Some(c)) = (b, c) else { continue };
if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) {
return Some((a, b, c));
}
}
None
}
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual /// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
/// output down. /// output down.
struct GamescopeProc { struct GamescopeProc {
@@ -1709,10 +1465,7 @@ impl Drop for GamescopeProc {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{cgroup_is_punktfunk_owned, is_steam_launch, shape_dedicated_command};
cgroup_is_punktfunk_owned, is_steam_launch, parse_version, shape_dedicated_command,
MIN_GAMESCOPE,
};
#[test] #[test]
fn steam_launch_detection() { fn steam_launch_detection() {
@@ -1768,27 +1521,4 @@ mod tests {
)); ));
assert!(!cgroup_is_punktfunk_owned("")); assert!(!cgroup_is_punktfunk_owned(""));
} }
#[test]
fn parses_version_banner() {
assert_eq!(
parse_version("gamescope version 3.16.22"),
Some((3, 16, 22))
);
assert_eq!(
parse_version("gamescope: version v3.15.9 (no PipeWire)"),
Some((3, 15, 9))
);
assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20)));
assert_eq!(parse_version("no version here"), None);
assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple
}
#[test]
fn flags_known_bad_versions() {
// The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock).
assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE);
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
}
} }
@@ -0,0 +1,290 @@
//! gamescope **discovery + probes** (plan §W3, carved out of the backend): finding the compositor's
//! PipeWire node (log line first, then a scoped `pw-dump` fallback), locating its live EIS/libei
//! socket, the version gate, and the dedicated-session game-exit check. Pure read-side plumbing — it
//! observes gamescope, never spawns or tears it down (that stays in [`super`]).
use super::*;
/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
/// — the adapter and the inner stream — and only the advertised id is the correct capture
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
/// node stays absent across the window.
pub(crate) fn game_session_exited(node_id: u32) -> bool {
let deadline = Instant::now() + Duration::from_millis(1500);
loop {
if gamescope_node_present(node_id) {
return false; // OUR node is (still) present → not an exit (transient loss)
}
if Instant::now() >= deadline {
return true; // our node stayed gone across the window → the game exited
}
std::thread::sleep(Duration::from_millis(250));
}
}
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
let deadline = Instant::now() + timeout;
loop {
if let Some(id) = find_gamescope_node() {
return Some(id);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(300));
}
}
pub(super) fn wait_for_node(
timeout: Duration,
log: &std::path::Path,
child_pid: u32,
) -> Option<u32> {
let deadline = Instant::now() + timeout;
loop {
if let Some(id) = node_from_log(log) {
return Some(id);
}
if Instant::now() >= deadline {
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
// node isn't picked by mistake.
return find_gamescope_node_scoped(Some(child_pid));
}
std::thread::sleep(Duration::from_millis(300));
}
}
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
fn node_from_log(log: &std::path::Path) -> Option<u32> {
let log = std::fs::read_to_string(log).ok()?;
for line in log.lines().rev() {
if let Some(pos) = line.find("stream available on node ID:") {
let tail = &line[pos + "stream available on node ID:".len()..];
let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect();
if let Ok(id) = digits.parse() {
return Some(id);
}
}
}
None
}
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
pub(super) fn gamescope_node_present(node_id: u32) -> bool {
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
return true;
};
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
return true;
};
dump.as_array()
.map(|objs| {
objs.iter().any(|o| {
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
})
})
.unwrap_or(true)
}
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
///
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
pub(super) fn find_gamescope_node() -> Option<u32> {
find_gamescope_node_scoped(None)
}
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
let out = Command::new("pw-dump").output().ok()?;
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let nodes = dump.as_array()?;
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
return None;
}
let id = obj.get("id").and_then(|i| i.as_u64())? as u32;
let props = obj.get("info").and_then(|i| i.get("props"));
let name = props
.and_then(|p| p.get("node.name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let class = props
.and_then(|p| p.get("media.class"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
// PipeWire records the owning process id as a string or an int depending on version.
let pid = props
.and_then(|p| p.get("application.process.id"))
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.map(|n| n as u32)
});
Some((id, name, class, pid))
};
// A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
// per-instance log is the primary addressing (design §7 risk note).
let in_scope = |pid: Option<u32>| -> bool {
match scope {
None => true,
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
}
};
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
for obj in nodes {
if let Some((id, name, class, pid)) = node_props(obj) {
if class == "Video/Source"
&& (name == "gamescope" || name.contains("gamescope"))
&& in_scope(pid)
{
return Some(id);
}
}
}
// Fallback: a node literally named "gamescope" with no usable class tag, in scope.
for obj in nodes {
if let Some((id, name, _, pid)) = node_props(obj) {
if name == "gamescope" && in_scope(pid) {
tracing::warn!(
node_id = id,
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
);
return Some(id);
}
}
}
None
}
/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing
/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file).
///
/// gamescope names its EIS socket `gamescope-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
/// `gamescope-<display>` wayland socket). Stale sockets from dead sessions linger, so we don't
/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the
/// most recently created (the live session). Returns the bare socket *name* (the injector
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
pub(super) fn find_gamescope_eis_socket() -> Option<String> {
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
// The EIS socket itself, not its `.lock` sidecar or the bare wayland socket.
if !(name.starts_with("gamescope-") && name.ends_with("-ei")) {
continue;
}
// Connectable == a live listener is behind it (a dead session's socket refuses).
if std::os::unix::net::UnixStream::connect(entry.path()).is_err() {
continue;
}
let mtime = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
live.push((mtime, name));
}
live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first
live.into_iter().next().map(|(_, n)| n)
}
/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
/// create path); just checks the binary executes.
pub(crate) fn is_available() -> bool {
std::process::Command::new("gamescope")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
/// gate. Returns the parsed version when it could read one.
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
let out = Command::new("gamescope").arg("--version").output().ok()?;
// gamescope prints the version banner to stderr on some builds, stdout on others.
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let ver = parse_version(&text)?;
if ver < MIN_GAMESCOPE {
tracing::warn!(
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2),
"gamescope is older than the minimum for reliable headless capture — expect a \
capture deadlock against PipeWire 1.6 (a wedged link head-blocks the daemon); \
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
);
}
Some(ver)
}
/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`).
fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
let mut parts = token.split('.');
let (a, b, c) = (parts.next()?, parts.next(), parts.next());
let (Some(b), Some(c)) = (b, c) else { continue };
if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) {
return Some((a, b, c));
}
}
None
}
#[cfg(test)]
mod tests {
use super::{parse_version, MIN_GAMESCOPE};
#[test]
fn parses_version_banner() {
assert_eq!(
parse_version("gamescope version 3.16.22"),
Some((3, 16, 22))
);
assert_eq!(
parse_version("gamescope: version v3.15.9 (no PipeWire)"),
Some((3, 15, 9))
);
assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20)));
assert_eq!(parse_version("no version here"), None);
assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple
}
#[test]
fn flags_known_bad_versions() {
// The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock).
assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE);
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
}
}
@@ -0,0 +1,269 @@
//! Gamescope-session routing (plan §W3 — carved out of [`super`]): mode selection
//! ([`pick_gamescope_mode`]), input-env routing ([`apply_input_env`]), dedicated-game-session
//! decisions/launch ([`wants_dedicated_game_session`], [`launch_into_gamescope_session`]), and the
//! managed-session restore workers.
use super::*;
/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`],
/// written into the env knobs `GamescopeDisplay::create` dispatches on.
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GamescopeMode {
/// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode.
Managed,
/// Attach to an already-running gamescope (capture + inject, no lifecycle ownership).
Attach,
/// Bare-spawn a headless gamescope per session, nesting the session's launch command.
Spawn,
}
/// Pure sub-mode ladder for gamescope (unit-testable — the env/probe inputs are parameters):
/// explicit `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed; explicit ATTACH/NODE forces attach; an
/// operator-set `PUNKTFUNK_GAMESCOPE_SESSION` keeps managed; otherwise managed only **when the box
/// actually has the session infrastructure** (gamescope-session-plus / SteamOS — the old code
/// defaulted to managed unconditionally and then bailed on a plain distro, killing the session);
/// a foreign (not host-spawned) gamescope on an infra-less box is attached to; and the final
/// default is a per-session bare spawn — the path that nests the client's launch command.
#[cfg(target_os = "linux")]
fn pick_gamescope_mode(
dedicated_launch: bool,
force_managed: bool,
attach_env: bool,
node_env: bool,
session_env: bool,
managed_infra: bool,
foreign_gamescope: bool,
) -> GamescopeMode {
if force_managed {
GamescopeMode::Managed
} else if attach_env || node_env {
GamescopeMode::Attach
} else if dedicated_launch {
// A dedicated game session always spawns its own headless gamescope at the client's mode,
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
GamescopeMode::Spawn
} else if session_env || managed_infra {
GamescopeMode::Managed
} else if foreign_gamescope {
GamescopeMode::Attach
} else {
GamescopeMode::Spawn
}
}
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
/// tears the TV's autologin down on connect, restored on a debounced idle; only where
/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
/// forces managed over all of it.
#[cfg(target_os = "linux")]
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backend = match chosen {
Compositor::Gamescope => "gamescope",
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
// dialog (headless, the krdpserver path), authorized by the host's shipped .desktop.
Compositor::Kwin => "kwin",
// GNOME has neither fake_input nor the wlr protocols → RemoteDesktop portal via libei.
Compositor::Mutter => "libei",
// Hyprland kept `zwlr_virtual_pointer_v1` + `zwp_virtual_keyboard_v1` (D4) — same wlr
// injector as sway/river, no code change.
Compositor::Wlroots | Compositor::Hyprland => "wlr",
};
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
if chosen == Compositor::Gamescope {
let mode = pick_gamescope_mode(
dedicated_launch,
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(),
gamescope::managed_session_available(),
gamescope::foreign_gamescope_running(),
);
tracing::info!(?mode, "gamescope sub-mode");
match mode {
GamescopeMode::Attach => {
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
}
}
GamescopeMode::Managed => {
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
}
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
}
GamescopeMode::Spawn => {
// Bare spawn: `create` must fall through to the spawn path, so neither knob may
// linger from an earlier connect's managed/attach selection.
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
}
}
}
}
#[cfg(not(target_os = "linux"))]
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
use policy::GameSession;
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
return false;
}
#[cfg(target_os = "linux")]
{
if gamescope::is_available() {
true
} else {
tracing::warn!(
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
);
false
}
}
#[cfg(not(target_os = "linux"))]
{
false // Windows: a launching session opens into the one desktop (no gamescope)
}
}
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope
/// sub-mode (the env knobs are that resolution's output).
#[cfg(target_os = "linux")]
pub fn launch_is_nested(compositor: Compositor) -> bool {
compositor == Compositor::Gamescope
&& with_env_lock(|| {
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
})
}
/// Launch `cmd` into the live gamescope session (managed/attach — see
/// [`gamescope::launch_into_session`]). Split out so `library.rs` doesn't reach into the private
/// backend module.
#[cfg(target_os = "linux")]
pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
gamescope::launch_into_session(cmd)
}
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
/// exit (review #4/#8). Always `false` off Linux.
#[cfg(target_os = "linux")]
pub fn dedicated_game_exited(node_id: u32) -> bool {
gamescope::game_session_exited(node_id)
}
#[cfg(not(target_os = "linux"))]
pub fn dedicated_game_exited(_node_id: u32) -> bool {
false
}
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
#[cfg(target_os = "linux")]
pub fn cancel_pending_tv_restore() {
gamescope::cancel_pending_restore();
}
#[cfg(not(target_os = "linux"))]
pub fn cancel_pending_tv_restore() {}
/// Path of the file where the gamescope backend relays the nested session's `LIBEI_SOCKET`
/// (gamescope's EIS server) for the input injector. Under `$XDG_RUNTIME_DIR` (per-user 0700).
#[cfg(target_os = "linux")]
pub fn gamescope_ei_socket_file() -> std::path::PathBuf {
gamescope::ei_socket_file()
}
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
/// (which reuses the warm session, avoiding the per-connect gamescope stop/relaunch that leaked GPU
/// context on F44). No-op on other compositors / when nothing was taken. Needs [`start_restore_worker`]
/// running to actually fire.
#[cfg(target_os = "linux")]
pub fn restore_managed_session() {
gamescope::schedule_restore_tv_session();
}
#[cfg(not(target_os = "linux"))]
pub fn restore_managed_session() {}
/// Start the host-lifetime worker that fires debounced [`restore_managed_session`] restores once a
/// client has been gone long enough. Hold the returned handle for the host's lifetime; dropping it
/// stops the worker. Call once from `serve()`.
#[cfg(target_os = "linux")]
pub fn start_restore_worker() -> std::sync::Arc<()> {
gamescope::start_restore_worker()
}
#[cfg(not(target_os = "linux"))]
pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(())
}
/// Recover a stranded TV takeover from a crashed previous host instance
/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside
/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start).
#[cfg(target_os = "linux")]
pub fn restore_takeover_on_startup() {
gamescope::restore_takeover_on_startup();
}
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_on_startup() {}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
#[test]
fn gamescope_mode_ladder() {
use GamescopeMode::*;
let pick = pick_gamescope_mode;
// (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
// Bazzite/SteamOS (session infra present): managed, as validated live.
assert_eq!(
pick(false, false, false, false, false, true, false),
Managed
);
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
// Foreign gamescope on an infra-less box: attach and mirror it.
assert_eq!(pick(false, false, false, false, false, false, true), Attach);
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
assert_eq!(
pick(false, false, false, false, true, false, false),
Managed
);
// Explicit attach/node wins over infra…
assert_eq!(pick(false, false, true, false, false, true, false), Attach);
assert_eq!(pick(false, false, false, true, true, true, false), Attach);
// …and force-managed wins over everything.
assert_eq!(pick(false, true, true, true, false, false, false), Managed);
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
// …but the explicit operator overrides still win over dedicated.
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
}
}
@@ -0,0 +1,521 @@
//! Live graphical-session detection + session-epoch + process-env retargeting (plan §W3 — the
//! self-contained subsystem carved out of [`super`]). Detects the active compositor/session
//! ([`detect_active_session`]), tracks the session epoch so pooled displays never outlive their
//! compositor instance, and retargets the process env at the live session ([`apply_session_env`],
//! [`settle_desktop_portal`]) under `super::ENV_LOCK`.
use super::*;
/// The **session epoch** — bumped whenever session detection observes a different compositor
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still
/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a
/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4).
static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries
/// and gate reuse) and by its linger timer (to reap dead-epoch zombies).
pub fn session_epoch() -> u64 {
SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
}
/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor
/// instance (kind change, or same-kind new PID). Returns the new value.
pub fn bump_session_epoch() -> u64 {
SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
}
/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a
/// genuine instance change from a stable re-detect.
static LAST_INSTANCE: std::sync::Mutex<Option<(ActiveKind, Option<u32>)>> =
std::sync::Mutex::new(None);
/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor
/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind
/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session
/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept
/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per
/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex
/// read); the registry lock is taken only on an actual change. Call from every site that detects the
/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect).
pub fn observe_session_instance(active: &ActiveSession) {
let cur = (active.kind, active.compositor_pid);
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(prev) = *last {
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
if is_desktop_kind(prev.0) {
if let Some(old) = compositor_for_kind(prev.0) {
registry::invalidate_backend(old.id());
}
}
let epoch = bump_session_epoch();
tracing::info!(
from = ?prev.0,
to = ?cur.0,
epoch,
"desktop compositor instance changed — session epoch bumped"
);
}
}
*last = Some(cur);
}
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
fn is_desktop_kind(kind: ActiveKind) -> bool {
matches!(
kind,
ActiveKind::DesktopKde
| ActiveKind::DesktopGnome
| ActiveKind::DesktopWlroots
| ActiveKind::DesktopHyprland
)
}
/// The kind of graphical session live for our uid *right now* — the basis for per-connect backend
/// selection on a box that flips between Steam Gaming Mode and a KDE/GNOME desktop (Bazzite,
/// SteamOS). Detected by probing which compositor process is actually running, not by a static
/// env var, so the host follows the box as the user switches sessions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ActiveKind {
/// A `gamescope` session is live (Steam Gaming Mode / `gamescope-session-plus`).
Gaming,
/// A KWin / Plasma desktop is live.
DesktopKde,
/// A GNOME / Mutter desktop is live.
DesktopGnome,
/// A wlroots-proper (Sway / River) desktop is live.
DesktopWlroots,
/// A Hyprland desktop is live (distinct from [`DesktopWlroots`](ActiveKind::DesktopWlroots):
/// its own `hyprctl` IPC + xdph portal, though it shares the wlr virtual-input path).
DesktopHyprland,
/// No recognized graphical session is running for our uid.
None,
}
/// The session environment that points a backend at the [detected](detect_active_session) active
/// session: the Wayland socket (for the Wayland-protocol backends), the runtime dir + session bus
/// (for PipeWire capture + D-Bus / portal input), and the desktop name (for portal routing). The
/// host serves one session at a time, so [`apply_session_env`] writes these into the process env
/// per connect and every backend that reads them then opens against the live session.
#[derive(Clone, Debug, Default)]
pub struct SessionEnv {
/// `WAYLAND_DISPLAY` of the live compositor (`None` for Gaming-attach / Mutter, which are
/// PipeWire-node / D-Bus driven and don't talk Wayland to us).
pub wayland_display: Option<String>,
/// `/run/user/<uid>` — the trustworthy anchor (the default PipeWire daemon + bus live here).
pub xdg_runtime_dir: String,
/// `DBUS_SESSION_BUS_ADDRESS` (defaults to `unix:path=<runtime>/bus`).
pub dbus_session_bus_address: String,
/// `XDG_CURRENT_DESKTOP` to advertise (KDE/GNOME/sway/Hyprland/gamescope) — drives portal/EIS
/// routing (xdph keys its Hyprland-specific behavior off `Hyprland`).
pub xdg_current_desktop: Option<String>,
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor.
pub hyprland_signature: Option<String>,
}
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
pub struct ActiveSession {
pub kind: ActiveKind,
pub env: SessionEnv,
/// PID of the winning compositor process (`None` when nothing live). The session watcher compares
/// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session
/// epoch — a fresh instance's node-id space is unrelated to the old one's (A4).
pub compositor_pid: Option<u32>,
}
impl ActiveSession {
/// A "nothing live" result carrying just the runtime-dir anchor.
fn none() -> ActiveSession {
ActiveSession {
kind: ActiveKind::None,
env: SessionEnv {
xdg_runtime_dir: default_runtime_dir(),
dbus_session_bus_address: default_bus(&default_runtime_dir()),
..Default::default()
},
compositor_pid: None,
}
}
}
/// The concrete backend that drives a given live-session kind. `None` for [`ActiveKind::None`].
pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
match kind {
ActiveKind::Gaming => Some(Compositor::Gamescope),
ActiveKind::DesktopKde => Some(Compositor::Kwin),
ActiveKind::DesktopGnome => Some(Compositor::Mutter),
ActiveKind::DesktopWlroots => Some(Compositor::Wlroots),
ActiveKind::DesktopHyprland => Some(Compositor::Hyprland),
ActiveKind::None => None,
}
}
#[cfg(target_os = "linux")]
fn default_runtime_dir() -> String {
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no
// memory — it just returns the calling process's real uid. Nothing is aliased or freed.
let uid = unsafe { libc::getuid() };
format!("/run/user/{uid}")
})
}
#[cfg(not(target_os = "linux"))]
fn default_runtime_dir() -> String {
std::env::var("XDG_RUNTIME_DIR").unwrap_or_default()
}
fn default_bus(runtime: &str) -> String {
std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap_or_else(|_| format!("unix:path={runtime}/bus"))
}
/// Detect the graphical session live for our uid right now (cheap, side-effect-free: a `/proc`
/// scan plus a runtime-dir socket scan — well under the handshake timeout). The authority is the
/// running compositor process; a desktop compositor outranks a lingering gamescope. Used to route
/// each connect to the correct backend, and to derive the [`SessionEnv`] that targets it.
#[cfg(target_os = "linux")]
pub fn detect_active_session() -> ActiveSession {
use std::os::unix::fs::MetadataExt;
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory —
// it just returns the calling process's real uid. Nothing is aliased or freed.
let uid = unsafe { libc::getuid() };
let xdg_runtime_dir = default_runtime_dir();
let dbus = default_bus(&xdg_runtime_dir);
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
let mut kind = ActiveKind::None;
let mut best = 0u8;
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
// session epoch (A4), not just a kind change.
let mut winning_pid: Option<u32> = None;
if let Ok(entries) = std::fs::read_dir("/proc") {
for e in entries.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let pid_path = e.path();
let Ok(md) = std::fs::metadata(&pid_path) else {
continue;
};
if md.uid() != uid {
continue;
}
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
continue;
};
let (k, prio) = match comm.trim() {
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
// Hyprland is its own backend (hyprctl + xdph) — split it out of the sway/river
// wlroots-proper family (design/hyprland-support.md D1).
"Hyprland" | "hyprland" => (ActiveKind::DesktopHyprland, 4),
"sway" | "river" => (ActiveKind::DesktopWlroots, 4),
_ => continue,
};
let pid = name.parse::<u32>().ok();
if prio > best {
best = prio;
kind = k;
winning_pid = pid;
} else if prio == best {
// Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a
// duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with
// `/proc` enumeration order — which `observe_session_instance` would misread as a
// compositor restart and tear a live display down (re-review low-severity note).
if let (Some(p), Some(w)) = (pid, winning_pid) {
if p < w {
kind = k;
winning_pid = Some(p);
}
}
}
}
}
// Wayland-protocol backends (KWin, wlroots, Hyprland) need the live socket for input (the wlr
// virtual pointer/keyboard client connects to it); Gaming-attach and Mutter are node/D-Bus
// driven and don't.
let wayland_display = match kind {
ActiveKind::DesktopKde | ActiveKind::DesktopWlroots | ActiveKind::DesktopHyprland => {
find_wayland_socket(&xdg_runtime_dir, uid)
}
_ => None,
};
let xdg_current_desktop = match kind {
ActiveKind::DesktopKde => Some("KDE".to_string()),
ActiveKind::DesktopGnome => Some("GNOME".to_string()),
ActiveKind::DesktopWlroots => Some("sway".to_string()),
// G4: advertise the real desktop so portal routing (portals.conf `[Hyprland]`) and xdph's
// own Hyprland checks work — NOT the old blanket `sway`.
ActiveKind::DesktopHyprland => Some("Hyprland".to_string()),
ActiveKind::Gaming => Some("gamescope".to_string()),
ActiveKind::None => None,
};
// Discover the Hyprland instance signature so `hyprctl` can reach the compositor even when the
// host runs as a systemd `--user` service that never inherited the session env.
let hyprland_signature = match kind {
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
_ => None,
};
ActiveSession {
kind,
env: SessionEnv {
wayland_display,
xdg_runtime_dir,
dbus_session_bus_address: dbus,
xdg_current_desktop,
hyprland_signature,
},
compositor_pid: winning_pid,
}
}
/// Find the live Hyprland instance signature (`HYPRLAND_INSTANCE_SIGNATURE`) for our uid. Trust a
/// valid inherited value first (the host launched inside the session); otherwise pick the
/// newest-mtime instance directory under `$XDG_RUNTIME_DIR/hypr/` that we own and that still has a
/// live `.socket.sock` — the same "newest wins" heuristic as [`find_wayland_socket`]. A desktop
/// normally exposes exactly one. (Phase-2 refinement: match the instance to `compositor_pid` via
/// `hyprctl instances` when several coexist — `design/hyprland-support.md` §Phase-1.1.)
#[cfg(target_os = "linux")]
fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
use std::os::unix::fs::MetadataExt;
let hypr = std::path::Path::new(runtime).join("hypr");
if let Ok(sig) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
if !sig.is_empty() && hypr.join(&sig).join(".socket.sock").exists() {
return Some(sig);
}
}
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
for e in std::fs::read_dir(&hypr).ok()?.flatten() {
let Ok(md) = e.metadata() else { continue };
if !md.is_dir() || md.uid() != uid {
continue;
}
if !e.path().join(".socket.sock").exists() {
continue;
}
let name = e.file_name().to_string_lossy().into_owned();
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
cands.push((mtime, name));
}
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
cands.into_iter().next().map(|(_, n)| n)
}
#[cfg(not(target_os = "linux"))]
pub fn detect_active_session() -> ActiveSession {
ActiveSession::none()
}
/// Find the live `wayland-*` socket in `runtime` for our uid (skipping `.lock` sidecars). Trust a
/// valid inherited `WAYLAND_DISPLAY` first; otherwise take the newest-mtime socket we own (a
/// desktop session normally exposes exactly one).
#[cfg(target_os = "linux")]
fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
use std::os::unix::fs::MetadataExt;
if let Ok(w) = std::env::var("WAYLAND_DISPLAY") {
if !w.is_empty() {
let p = if w.starts_with('/') {
std::path::PathBuf::from(&w)
} else {
std::path::Path::new(runtime).join(&w)
};
if p.exists() {
return Some(w);
}
}
}
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
for e in std::fs::read_dir(runtime).ok()?.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.starts_with("wayland-") || name.ends_with(".lock") {
continue;
}
let Ok(md) = e.metadata() else { continue };
if md.uid() != uid {
continue;
}
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
cands.push((mtime, name));
}
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
cands.into_iter().next().map(|(_, n)| n)
}
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
/// re-applies.
#[cfg(target_os = "linux")]
pub fn apply_session_env(active: &ActiveSession) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let e = &active.env;
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
if let Some(w) = &e.wayland_display {
std::env::set_var("WAYLAND_DISPLAY", w);
}
if let Some(d) = &e.xdg_current_desktop {
std::env::set_var("XDG_CURRENT_DESKTOP", d);
}
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live compositor
// (fixes G4 for the systemd `--user` host, which never inherited it). Only set when detection
// found a Hyprland session; a stale value from a previous connect is cleared otherwise so a
// Hyprland→sway switch can't leave `hyprctl` pointed at a dead instance.
match &e.hyprland_signature {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
}
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
// graphical session" handshake error. Clear them so `available()` reports the truth and the
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
if active.kind == ActiveKind::None {
std::env::remove_var("XDG_CURRENT_DESKTOP");
std::env::remove_var("WAYLAND_DISPLAY");
}
// 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) {}
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
/// connected while NO graphical session is live for this uid — the state a compositor crash
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
/// a recovery is underway (just launched, or launched within the debounce window), letting the
/// handshake error tell the client to simply retry.
#[cfg(target_os = "linux")]
pub fn try_recover_session() -> bool {
let Some(cmd) = crate::config::config().recover_session_cmd.clone() else {
return false;
};
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
return true; // a launch is already in flight — the retry lands in the recovered session
}
match std::process::Command::new("/bin/sh")
.arg("-c")
.arg(&cmd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => {
*last = Some(std::time::Instant::now());
tracing::warn!(cmd = %cmd,
"no live graphical session — launched the operator's session-recovery command");
// Reap off-thread so the finished child never lingers as a zombie.
std::thread::spawn(move || {
let _ = child.wait();
});
true
}
Err(e) => {
tracing::error!(cmd = %cmd, error = %e,
"session-recovery command failed to launch");
false
}
}
}
#[cfg(not(target_os = "linux"))]
pub fn try_recover_session() -> bool {
false
}
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
/// against a half-stale env — it accepts events but they don't reach the compositor until a
/// reconnect. Push the live session env into the systemd/D-Bus activation environment and (for KWin,
/// whose input rides the xdg RemoteDesktop portal) restart the portal so it re-reads it — the same
/// settling a fresh desktop login does. Best-effort; mirrors the wlroots portal restart. GNOME uses
/// Mutter's *direct* EIS (no xdg portal), so it only needs the env push.
#[cfg(target_os = "linux")]
pub fn settle_desktop_portal(chosen: Compositor) {
const VARS: &[&str] = &[
"WAYLAND_DISPLAY",
"XDG_CURRENT_DESKTOP",
"DBUS_SESSION_BUS_ADDRESS",
"XDG_RUNTIME_DIR",
];
// Push our (correct) env into the systemd --user manager + the D-Bus activation environment so a
// re-activated portal/backend inherits the live session.
let _ = std::process::Command::new("systemctl")
.args(["--user", "import-environment"])
.args(VARS)
.status();
let _ = std::process::Command::new("dbus-update-activation-environment")
.arg("--systemd")
.args(VARS)
.status();
// KWin input goes through the xdg RemoteDesktop portal; the frontend routes RemoteDesktop to a
// backend by its OWN startup XDG_CURRENT_DESKTOP, so restart it (+ the KDE backend) to re-read
// the now-live session, then let it settle before the injector reopens against it.
if chosen == Compositor::Kwin {
let _ = std::process::Command::new("systemctl")
.args([
"--user",
"try-restart",
"xdg-desktop-portal-kde.service",
"xdg-desktop-portal.service",
])
.status();
std::thread::sleep(std::time::Duration::from_millis(600));
}
// Hyprland capture rides the xdg ScreenCast portal serviced by xdph (G5): on a mid-stream switch
// xdph may still hold the old session's Wayland/instance env, so restart it (+ the frontend) to
// re-read the now-live session, mirroring the KWin settling above.
if chosen == Compositor::Hyprland {
let _ = std::process::Command::new("systemctl")
.args([
"--user",
"try-restart",
"xdg-desktop-portal-hyprland.service",
"xdg-desktop-portal.service",
])
.status();
std::thread::sleep(std::time::Duration::from_millis(600));
}
tracing::info!(
compositor = chosen.id(),
"settled desktop portal env for the switched-to session"
);
}
#[cfg(not(target_os = "linux"))]
pub fn settle_desktop_portal(_chosen: Compositor) {}
@@ -38,68 +38,18 @@ use crate::win_display::{
restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig, restore_displays_ccd, 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 #[path = "manager/driver.rs"]
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id. mod driver;
#[derive(Clone, Copy)] pub(crate) use driver::{AddedMonitor, MonitorKey, VdisplayDriver};
pub(crate) enum MonitorKey {
Guid(windows::core::GUID),
Session(u64),
}
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the #[path = "manager/instance.rs"]
/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target) + the monitor id the mod instance;
/// driver actually resolved (the per-client stable id when honored; diagnostics on the slot). use instance::claim_instance;
pub(crate) struct AddedMonitor { pub(crate) use instance::claim_instance_eagerly;
pub key: MonitorKey,
pub target_id: u32,
pub luid: LUID,
pub wudf_pid: u32,
pub resolved_monitor_id: u32,
}
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay. #[path = "manager/knobs.rs"]
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in mod knobs;
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a use knobs::{keep_alive_forever, linger_ms, topology_action};
/// `&'static` singleton reached from the pinger + linger threads.
pub(crate) trait VdisplayDriver: Send + Sync {
fn name(&self) -> &'static str;
/// Find + open the control device, validate it (version handshake), and read the watchdog
/// timeout. `reap_orphans` (the FIRST open of the process only) additionally `CLEAR_ALL`s
/// monitors orphaned by a crashed previous host — a REOPEN (after a dead handle was retired)
/// must NOT, since sessions this process still considers live may be racing it. Returns the
/// owned handle + watchdog seconds.
///
/// # Safety
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr`
/// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the
/// driver's built-in defaults). Returns the REMOVE key + target id + the IddCx DISPLAY adapter
/// LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the
/// driver reports its render adapter only in the shared frame header).
///
/// # Safety
/// `dev` must be the live control handle from [`open`](Self::open).
unsafe fn add_monitor(
&self,
dev: HANDLE,
mode: Mode,
render_luid: Option<LUID>,
preferred_monitor_id: u32,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
) -> Result<AddedMonitor>;
/// REMOVE the monitor identified by `key`.
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>;
/// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread).
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
}
/// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by /// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by
/// any session). No `Drop` impl — [`teardown_removed`](VirtualDisplayManager::teardown_removed) must be /// any session). No `Drop` impl — [`teardown_removed`](VirtualDisplayManager::teardown_removed) must be
@@ -308,70 +258,6 @@ pub(crate) fn control_device_handle() -> Option<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle) VDM.get().and_then(VirtualDisplayManager::device_handle)
} }
/// True when an IOCTL failure means the CONTROL DEVICE itself is gone (driver upgrade, WUDFHost
/// restart, device disable) — the cached handle can only keep failing and must be retired so the
/// next use reopens. The root `windows` error survives anyhow `.context` chains via `downcast_ref`.
/// NOTE: 0x80070490 (ERROR_NOT_FOUND, the ADD slot-exhaustion wedge) is deliberately NOT here — it
/// has its own reap-and-retry handling and the device is alive when it fires.
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
/// first-comer-wins, and a lazily-claiming service could otherwise lose its own machine's driver to
/// a stray second host started while the service sat idle (observed on-glass). A failed claim is NOT
/// memoized: once the other instance exits, the next attempt succeeds.
static INSTANCE: Mutex<Option<OwnedHandle>> = Mutex::new(None);
/// Claim (or re-verify) the cross-process single-instance guard. Idempotent; retries after failure.
fn claim_instance() -> Result<()> {
let mut g = INSTANCE.lock().unwrap();
if g.is_none() {
*g = Some(acquire_single_instance()?);
}
Ok(())
}
/// Eager startup claim for the serve/service path (Windows): reserves this process as THE
/// pf-vdisplay manager before any client connects. Failure is a loud warning, not fatal — sessions
/// then fail with the same clear in-use error until the other instance exits.
pub(crate) fn claim_instance_eagerly() {
if let Err(e) = claim_instance() {
tracing::warn!("pf-vdisplay single-instance claim failed at startup: {e:#}");
}
}
/// The cross-process single-instance guard for pf-vdisplay management. A SECOND host process's
/// first device open used to fire `IOCTL_CLEAR_ALL` and raze the live host's monitors mid-stream —
/// an admin footgun (run `punktfunk-host serve` while the SCM service streams), masked afterwards
/// because both processes' pings satisfy the shared driver watchdog. The named mutex makes the
/// second process fail its vdisplay open LOUDLY instead. Held, never released, for the process
/// lifetime; the OS reclaims it (and frees the name) when the process exits, however it exits.
fn acquire_single_instance() -> Result<OwnedHandle> {
const IN_USE: &str = "another punktfunk-host process is already managing pf-vdisplay on this \
machine refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
service stop`) first.";
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
unsafe {
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
Ok(h) => h,
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
Err(e) => {
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
}
};
let already = GetLastError() == ERROR_ALREADY_EXISTS;
let owned = OwnedHandle::from_raw_handle(h.0 as _);
if already {
anyhow::bail!("{IN_USE}");
}
Ok(owned)
}
}
/// Best-effort "is this WUDFHost pid still alive?" — the monitor-liveness probe for the JOIN path. /// Best-effort "is this WUDFHost pid still alive?" — the monitor-liveness probe for the JOIN path.
/// `OpenProcess` failing (pid reaped) or the process being signaled ⇒ dead. Pid reuse could /// `OpenProcess` failing (pid reaped) or the process being signaled ⇒ dead. Pid reuse could
/// theoretically alias a fresh process and read "alive"; the joining session then just retries into /// theoretically alias a fresh process and read "alive"; the joining session then just retries into
@@ -392,6 +278,11 @@ fn wudf_alive(pid: u32) -> bool {
} }
} }
/// True when an IOCTL failure means the CONTROL DEVICE itself is gone (driver upgrade, WUDFHost
/// restart, device disable) — the cached handle can only keep failing and must be retired so the
/// next use reopens. The root `windows` error survives anyhow `.context` chains via `downcast_ref`.
/// NOTE: 0x80070490 (ERROR_NOT_FOUND, the ADD slot-exhaustion wedge) is deliberately NOT here — it
/// has its own reap-and-retry handling and the device is alive when it fires.
fn is_device_gone(e: &anyhow::Error) -> bool { fn is_device_gone(e: &anyhow::Error) -> bool {
let Some(w) = e.downcast_ref::<windows::core::Error>() else { let Some(w) = e.downcast_ref::<windows::core::Error>() else {
return false; return false;
@@ -1700,55 +1591,3 @@ pub(crate) fn snapshot() -> Vec<ManagedInfo> {
pub(crate) fn force_release(slot: Option<u64>) -> usize { pub(crate) fn force_release(slot: Option<u64>) -> usize {
VDM.get().map(|m| m.force_release(slot)).unwrap_or(0) VDM.get().map(|m| m.force_release(slot)).unwrap_or(0)
} }
/// 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,
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
// check) — fall back to the default rather than a huge linger.
Linger::Forever => 10_000,
};
}
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
}
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of
/// lingering. Unconfigured hosts are never forever (default is a short linger).
fn keep_alive_forever() -> bool {
use crate::vdisplay::policy::{prefs, Linger};
prefs()
.configured_effective()
.map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever))
.unwrap_or(false)
}
/// 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
}
}
@@ -0,0 +1,69 @@
//! The backend-specific virtual-display **seam** (SudoVDA vs pf-vdisplay), carved out of the manager
//! (plan §W3): the REMOVE-key type, the `add_monitor` reply, and the IOCTL trait. This is the ONLY
//! thing that differs between the two Windows backends — the refcount machine, linger, pinger, and
//! CCD/GDI glue are all backend-neutral in [`super::VirtualDisplayManager`].
use super::*;
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id.
#[derive(Clone, Copy)]
pub(crate) enum MonitorKey {
Guid(windows::core::GUID),
Session(u64),
}
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the
/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target) + the monitor id the
/// driver actually resolved (the per-client stable id when honored; diagnostics on the slot).
pub(crate) struct AddedMonitor {
pub key: MonitorKey,
pub target_id: u32,
pub luid: LUID,
pub wudf_pid: u32,
pub resolved_monitor_id: u32,
}
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a
/// `&'static` singleton reached from the pinger + linger threads.
pub(crate) trait VdisplayDriver: Send + Sync {
fn name(&self) -> &'static str;
/// Find + open the control device, validate it (version handshake), and read the watchdog
/// timeout. `reap_orphans` (the FIRST open of the process only) additionally `CLEAR_ALL`s
/// monitors orphaned by a crashed previous host — a REOPEN (after a dead handle was retired)
/// must NOT, since sessions this process still considers live may be racing it. Returns the
/// owned handle + watchdog seconds.
///
/// # Safety
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr`
/// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the
/// driver's built-in defaults). Returns the REMOVE key + target id + the IddCx DISPLAY adapter
/// LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the
/// driver reports its render adapter only in the shared frame header).
///
/// # Safety
/// `dev` must be the live control handle from [`open`](Self::open).
unsafe fn add_monitor(
&self,
dev: HANDLE,
mode: Mode,
render_luid: Option<LUID>,
preferred_monitor_id: u32,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
) -> Result<AddedMonitor>;
/// REMOVE the monitor identified by `key`.
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>;
/// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread).
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
}
@@ -0,0 +1,64 @@
//! The cross-process single-instance guard for pf-vdisplay management (plan §W3, carved out of the
//! manager). A named mutex makes a SECOND host process fail its vdisplay open loudly instead of firing
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
use super::*;
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
/// first-comer-wins, and a lazily-claiming service could otherwise lose its own machine's driver to
/// a stray second host started while the service sat idle (observed on-glass). A failed claim is NOT
/// memoized: once the other instance exits, the next attempt succeeds.
static INSTANCE: Mutex<Option<OwnedHandle>> = Mutex::new(None);
/// Claim (or re-verify) the cross-process single-instance guard. Idempotent; retries after failure.
pub(super) fn claim_instance() -> Result<()> {
let mut g = INSTANCE.lock().unwrap();
if g.is_none() {
*g = Some(acquire_single_instance()?);
}
Ok(())
}
/// Eager startup claim for the serve/service path (Windows): reserves this process as THE
/// pf-vdisplay manager before any client connects. Failure is a loud warning, not fatal — sessions
/// then fail with the same clear in-use error until the other instance exits.
pub(crate) fn claim_instance_eagerly() {
if let Err(e) = claim_instance() {
tracing::warn!("pf-vdisplay single-instance claim failed at startup: {e:#}");
}
}
/// The cross-process single-instance guard for pf-vdisplay management. A SECOND host process's
/// first device open used to fire `IOCTL_CLEAR_ALL` and raze the live host's monitors mid-stream —
/// an admin footgun (run `punktfunk-host serve` while the SCM service streams), masked afterwards
/// because both processes' pings satisfy the shared driver watchdog. The named mutex makes the
/// second process fail its vdisplay open LOUDLY instead. Held, never released, for the process
/// lifetime; the OS reclaims it (and frees the name) when the process exits, however it exits.
fn acquire_single_instance() -> Result<OwnedHandle> {
const IN_USE: &str = "another punktfunk-host process is already managing pf-vdisplay on this \
machine refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
service stop`) first.";
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
unsafe {
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
Ok(h) => h,
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
Err(e) => {
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
}
};
let already = GetLastError() == ERROR_ALREADY_EXISTS;
let owned = OwnedHandle::from_raw_handle(h.0 as _);
if already {
anyhow::bail!("{IN_USE}");
}
Ok(owned)
}
}
@@ -0,0 +1,55 @@
//! Runtime display-management knobs read from the console policy (with legacy env-var fallbacks),
//! carved out of the manager (plan §W3): the linger window, the keep-alive-forever pin, and the
//! per-monitor topology action. Pure readers of [`crate::vdisplay::policy`] + env — no manager state.
/// 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.
pub(super) 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,
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
// check) — fall back to the default rather than a huge linger.
Linger::Forever => 10_000,
};
}
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
}
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of
/// lingering. Unconfigured hosts are never forever (default is a short linger).
pub(super) fn keep_alive_forever() -> bool {
use crate::vdisplay::policy::{prefs, Linger};
prefs()
.configured_effective()
.map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever))
.unwrap_or(false)
}
/// 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.
pub(super) 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
}
}
+5 -1
View File
@@ -104,7 +104,11 @@ impl TrayStatus {
} }
/// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine — /// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine —
/// unsupported side-by-side. Drives the tray's attention state. /// unsupported side-by-side. Drives the Linux (ksni) backend's `NeedsAttention` state; the
/// Windows backend surfaces the same conflict through the tooltip `headline()` instead (it has
/// no distinct attention icon), so this accessor is unused there — allow it per-platform rather
/// than gate the shared API out.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn has_conflicts(&self) -> bool { pub fn has_conflicts(&self) -> bool {
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty()) matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
} }
+153
View File
@@ -0,0 +1,153 @@
---
title: Events & hooks
description: React to what the host does — lifecycle events over SSE, hook commands and webhooks, per-app prep/undo — for notifications, DND toggles, Home Assistant, and more.
---
The host emits a **lifecycle event** for the things you'd want to react to: a client connects or
disconnects, a stream starts or stops, a pairing request arrives, a virtual display is created,
the library changes, the host starts or shuts down. Two ways to consume them:
- **Hooks** — zero-code: entries in `~/.config/punktfunk/hooks.json` run a **command** or POST a
**webhook** when a matching event fires. This covers the common automation: Do-Not-Disturb
during a stream, a phone notification on a pairing request, pausing downloads while playing.
- **The event stream** — code: `GET /api/v1/events` on the management API is a standard
[Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
stream of the same events, for scripts and integrations that want to *decide* things (e.g.
auto-approve pairing from a known subnet by calling the approve endpoint).
Hooks **observe** — they can never veto or delay a connection, a stream, or a pairing decision,
and nothing you configure here runs anywhere near the streaming path.
## The events
| Kind | Fires when | Carries |
|---|---|---|
| `client.connected` / `client.disconnected` | a client session is admitted / goes away | device name, cert fingerprint, plane (`native`/`gamestream`); disconnect adds `reason`: `quit` (user stop), `timeout` (vanished), `error` |
| `session.started` / `session.ended` | an A/V session registers / ends | session id, client label, mode (`3840x2160@120`), HDR |
| `stream.started` / `stream.stopped` | video actually starts / stops | mode, HDR, client name, launched app id/title (when one was requested), plane |
| `pairing.pending` | an unpaired device knocks (once per device, not per retry) | device name, fingerprint, plane |
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
version (additive-only — fields get added, never renamed), and the fields above. Example:
```json
{ "seq": 42, "ts_ms": 1784227449526, "schema": 1,
"kind": "stream.started",
"stream": { "mode": "2560x1440@120", "hdr": true,
"client": "Living Room TV", "app": "steam:570", "plane": "native" } }
```
## Hooks: `hooks.json`
Create `~/.config/punktfunk/hooks.json` (Windows: `%ProgramData%\punktfunk\hooks.json`), or PUT
the same document to `/api/v1/hooks` from a script — changes apply immediately, no restart:
```json
{
"hooks": [
{ "on": "stream.started", "run": "~/.config/punktfunk/scripts/on-stream.sh" },
{ "on": "stream.stopped", "run": "~/.config/punktfunk/scripts/off-stream.sh" },
{ "on": "client.connected", "filter": { "client": "Living Room TV" },
"run": "kscreen-doctor output.HDMI-A-1.mode.3840x2160@60" },
{ "on": "pairing.pending",
"webhook": "https://ha.local/api/webhook/punktfunk",
"hmac_secret_file": "/home/me/.config/punktfunk/webhook-secret" }
]
}
```
Each entry:
| Field | Meaning |
|---|---|
| `on` | Which events fire it: an exact kind (`stream.started`) or a `domain.*` prefix (`pairing.*`). |
| `run` | A shell command (`sh -c` on Linux). Gets the event JSON on **stdin** and flat **`PF_EVENT_*`** env vars. |
| `webhook` | A URL the event JSON is POSTed to. TLS-verified, redirects are never followed, no punktfunk credentials attached. |
| `filter` | Optional exact-match constraints: `client` (device name), `fingerprint`, `plane` (`native`/`gamestream`), `app`. All present fields must match. |
| `timeout_s` | Command timeout (default 30, max 600) — on expiry the whole process group is killed. |
| `debounce_ms` | Minimum interval between firings of this hook (0 = every event). |
| `hmac_secret_file` | File with a secret; the webhook gains `X-Punktfunk-Signature: sha256=<hex HMAC-SHA256 of the body>` so your receiver can authenticate the host. |
A `run` command's shell one-liner vocabulary — the event flattened to env, values sanitized:
```sh
#!/bin/sh
# PF_EVENT_KIND=stream.started PF_EVENT_SEQ=42
# PF_EVENT_STREAM_MODE=2560x1440@120 PF_EVENT_STREAM_HDR=true
# PF_EVENT_STREAM_CLIENT='Living Room TV' PF_EVENT_STREAM_APP=steam:570
# PF_EVENT_STREAM_PLANE=native PF_EVENT_JSON='{…the whole event…}'
[ "$PF_EVENT_KIND" = stream.started ] && makoctl mode -a do-not-disturb
```
Richer payloads (and the full document) are on stdin — `jq` away. On a Windows host running as
the service, the command runs **in your interactive session** (never as SYSTEM); that path can't
carry per-process env or stdin, so the event JSON's path is appended as the command's last
argument instead.
Verify a signed webhook (Python):
```python
import hmac, hashlib
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(request.headers["X-Punktfunk-Signature"], expected)
```
**Rules of the road:** hooks are fire-and-forget and bounded — at most 8 in flight (extra
firings are dropped with a log line, never queued), and a command that outlives its timeout is
killed. Because hook commands run as the host user, `hooks.json` is operator-privileged config;
a hook **script** must be owned by you (or root) and not group/world-writable, or the host
refuses to run it — loudly, in the log.
The two simplest cases also exist as plain [host.env](/docs/configuration) settings, no
`hooks.json` needed: `PUNKTFUNK_ON_CONNECT_CMD` and `PUNKTFUNK_ON_DISCONNECT_CMD`.
## Per-app prep/undo
For per-title setup (HDR toggle, MangoHud, a VRR tweak), attach `prep` steps to a GameStream
`apps.json` entry or a custom library entry — each `do` runs **before** the title launches
(synchronously — the launch waits), each `undo` runs at session end in **reverse order**,
best-effort, even if the session crashed:
```json
{ "id": 2, "title": "Steam", "compositor": "gamescope", "cmd": "steam -gamepadui",
"prep": [
{ "do": "~/bin/hdr on", "undo": "~/bin/hdr off" },
{ "do": "pactl set-default-sink game_sink", "undo": "pactl set-default-sink desk_sink" }
] }
```
A `do` that fails logs, keeps going, and its own `undo` is skipped (it never took effect).
## The event stream (`GET /api/v1/events`)
For code, subscribe to the SSE stream on the management API (loopback + bearer token — the
same credentials as the rest of the admin surface):
```sh
curl -Nk -H "Authorization: Bearer $(cat ~/.config/punktfunk/mgmt-token)" \
"https://127.0.0.1:47990/api/v1/events?kinds=pairing.*,stream.*"
```
- Frames carry `id:` (the event's `seq`), `event:` (the kind), `data:` (the event JSON).
- Reconnect with the standard `Last-Event-ID` header (or `?since=<seq>`) and the host replays
what you missed from its in-memory ring (~1024 events); if you fell off the ring you get one
`event: dropped` frame first — resync from the REST snapshots (`/status`, `/clients`, …).
- `?kinds=` filters server-side: exact kinds or `domain.*` prefixes, comma-separated.
## Scripts, plugins, and the runner
For anything beyond a `curl` one-liner there is **`@punktfunk/host`** — the TypeScript SDK
(`sdk/` in the repo): typed events with automatic reconnect/resume, the REST surface, and a
plugin convention (`punktfunk-plugin-*`). Its **runner** (`punktfunk-scripting`) supervises a
directory of scripts and installed plugins as one service: crash-restarts with backoff, and a
`systemctl stop` that interrupts plugins structurally so their cleanup runs. See the SDK README
for the five-line quickstart and unit templates.
The canonical "decide, don't just observe" pattern — approve pairing from your phone: watch
`pairing.pending`, send yourself a notification, and call
`POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at
[`/api/docs`](/api) on your host.
+2
View File
@@ -80,6 +80,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_RECOVER_SESSION_CMD` | command | Operator hook fired (debounced) when a client connects while **no graphical session is live** for the host's user — the state a compositor crash leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login is once-per-boot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or `systemctl restart display-manager` under a polkit rule; with auto-login enabled the restart brings the desktop back and the client's automatic retry lands in it. Unset/empty = disabled (the default). | | `PUNKTFUNK_RECOVER_SESSION_CMD` | command | Operator hook fired (debounced) when a client connects while **no graphical session is live** for the host's user — the state a compositor crash leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login is once-per-boot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or `systemctl restart display-manager` under a polkit rule; with auto-login enabled the restart brings the desktop back and the client's automatic retry lands in it. Unset/empty = disabled (the default). |
| `PUNKTFUNK_ON_CONNECT_CMD` | command | Fired (detached) when a client connects, on either plane — the event JSON on stdin plus `PF_EVENT_*` env vars. The zero-config little sibling of [hooks.json](/docs/automation), which adds filters, webhooks, and debounce. |
| `PUNKTFUNK_ON_DISCONNECT_CMD` | command | The `client.disconnected` counterpart of `PUNKTFUNK_ON_CONNECT_CMD` (its `PF_EVENT_REASON` is `quit`, `timeout`, or `error`). |
## Video quality ## Video quality
+1
View File
@@ -26,6 +26,7 @@
"virtual-displays", "virtual-displays",
"pyrowave", "pyrowave",
"host-cli", "host-cli",
"automation",
"---Connecting---", "---Connecting---",
"clients", "clients",
"install-client", "install-client",
+3 -3
View File
@@ -42,7 +42,7 @@
let let
pkgs = pkgsFor system; pkgs = pkgsFor system;
in in
pkgs.callPackage ./nix/packages.nix { pkgs.callPackage ./packaging/nix/packages.nix {
craneLib = craneLibFor pkgs; craneLib = craneLibFor pkgs;
src = self; src = self;
inherit version; inherit version;
@@ -128,10 +128,10 @@
formatter = forAllSystems (system: (pkgsFor system).nixfmt-rfc-style); formatter = forAllSystems (system: (pkgsFor system).nixfmt-rfc-style);
# NixOS integration — see nix/nixos-module.nix and nix/README.md. # NixOS integration — see packaging/nix/nixos-module.nix and packaging/nix/README.md.
# imports = [ punktfunk.nixosModules.default ]; # imports = [ punktfunk.nixosModules.default ];
# services.punktfunk.host.enable = true; # services.punktfunk.host.enable = true;
nixosModules.default = import ./nix/nixos-module.nix self; nixosModules.default = import ./packaging/nix/nixos-module.nix self;
nixosModules.punktfunk = self.nixosModules.default; nixosModules.punktfunk = self.nixosModules.default;
}; };
} }
+1 -1
View File
@@ -27,7 +27,7 @@ The other packaging targets have their own READMEs: [`debian/`](debian/README.md
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer + [`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
drivers), plus `kde/` and `linux/` helpers. **NixOS / Nix** users get a flake (`flake.nix` at the drivers), plus `kde/` and `linux/` helpers. **NixOS / Nix** users get a flake (`flake.nix` at the
repo root) with reproducible host + client packages and a `services.punktfunk` NixOS module — repo root) with reproducible host + client packages and a `services.punktfunk` NixOS module —
see [`../nix/README.md`](../nix/README.md). see [`nix/README.md`](nix/README.md).
## What's needed beyond base Fedora ## What's needed beyond base Fedora
@@ -177,7 +177,7 @@ in
# build needs the full gn/ninja/python toolchain + network-fetched third-party. The `ui` feature # build needs the full gn/ninja/python toolchain + network-fetched third-party. The `ui` feature
# is explicitly droppable (clients/session/Cargo.toml: "same streaming, stats on stdout only"), # is explicitly droppable (clients/session/Cargo.toml: "same streaming, stats on stdout only"),
# so build the session without it. The GTK shell (punktfunk-client-linux) is skia-free and full. # so build the session without it. The GTK shell (punktfunk-client-linux) is skia-free and full.
# Re-adding the Skia OSD under Nix is tracked in nix/README.md. # Re-adding the Skia OSD under Nix is tracked in packaging/nix/README.md.
cargoExtraArgs = cargoExtraArgs =
"--locked -p punktfunk-client-linux -p punktfunk-client-session " "--locked -p punktfunk-client-linux -p punktfunk-client-session "
+ "--no-default-features --features punktfunk-client-session/pyrowave"; + "--no-default-features --features punktfunk-client-session/pyrowave";
+172
View File
@@ -0,0 +1,172 @@
# @punktfunk/host
TypeScript SDK for the [punktfunk](https://git.unom.io/unom/punktfunk) streaming host: a typed
management-API client plus the host's lifecycle **event stream** (client connect/disconnect,
stream start/stop, pairing, displays, library) — built on [Effect](https://effect.website).
Two surfaces, one core:
- **`@punktfunk/host`** — the Promise facade, the front door. `connect()`, `await`, `.on()`.
You never need to know Effect exists.
- **`@punktfunk/host/effect`** — the Effect-native surface for plugins and composed programs:
the `PunktfunkHost` service + layer, `Stream`-based events, typed errors
(`AuthError | ApiError | TransportError | VersionSkew`), and every wire shape as an
`effect/Schema` (REST shapes generated from the host's OpenAPI spec; event shapes mirroring
the host's snapshot-tested wire format).
## Quickstart
```ts
import { connect } from "@punktfunk/host";
const pf = await connect(); // zero config on the host box
pf.events.on("stream.started", (e) => {
console.log(`${e.stream.client} started ${e.stream.mode}${e.stream.hdr ? " HDR" : ""}`);
});
pf.events.on("pairing.pending", async (e) => {
// notify your phone, then decide through the API:
// await pf.request("POST", `/native/pending/${id}/approve`);
});
```
The same, Effect-native:
```ts
import { Effect, Stream } from "effect";
import { events, PunktfunkHostLive } from "@punktfunk/host/effect";
const program = events().pipe(
Stream.filter((e) => e.kind === "stream.started"),
Stream.runForEach((e) => Effect.log(`stream: ${e.stream.mode}`)),
);
Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));
```
## Connection resolution
`connect()` / `PunktfunkHostLive()` resolve, in order:
| What | Source |
|---|---|
| URL | `{ url }``PUNKTFUNK_MGMT_URL``https://127.0.0.1:47990` |
| Token | `{ token }``PUNKTFUNK_MGMT_TOKEN``<config_dir>/mgmt-token` |
| TLS pin | `{ ca }``PUNKTFUNK_MGMT_CA` (path) → `<config_dir>/cert.pem` |
`<config_dir>` is `~/.config/punktfunk` (Linux/macOS) or `%ProgramData%\punktfunk` (Windows) —
so a script running on the host box needs **zero configuration**. The TLS pin trusts exactly
the host's self-signed identity cert (chain-verified; the hostname check is waived — the cert
is deliberately CN-only, native clients pin its fingerprint). Bun and Node are first-class;
other runtimes fall back to system trust (point your runtime's CA option at `cert.pem`).
The bearer token is the host's **admin** credential and is honored from loopback only — run
scripts on the host box (or through an SSH tunnel).
## Events
- Reconnects automatically (exponential backoff + jitter, capped) and resumes with
`Last-Event-ID` — the host replays what you missed from its ring.
- Default is **live tail only** (a fresh notify script must not re-fire on history);
pass `{ since: 0 }` on the Effect surface to replay the host's full ring, or `since: N`
to resume after a seq you persisted.
- `on()` patterns: exact kinds (`"stream.started"`, typed callback), `"domain.*"` prefixes,
`"*"`, plus `"dropped"` (your cursor fell off the ring — resync via REST) and `"unknown"`
(an event kind newer than this SDK — the additive-only wire at work).
- Effect surface: `events()` is a `Stream<HostEvent, EventStreamError>`; `eventsRaw()` carries
every SSE frame verbatim.
## Plugins (`punktfunk-plugin-*`)
```ts
import { definePlugin } from "@punktfunk/host";
import { Effect } from "effect";
import { PunktfunkHost } from "@punktfunk/host/effect";
export default definePlugin({
name: "romm-library",
main: Effect.gen(function* () {
const pf = yield* PunktfunkHost;
// subscribe, sync, reconcile — scoped finalizers run on shutdown/interruption
}),
// …or the simple shape: main: async (pf) => { … }
});
```
In v1 a plugin is a script you run (see below); the managed runner package is a later step.
## The runner: `punktfunk-scripting`
Instead of one unit file per script, run everything under the managed runner — it discovers
your units and supervises them:
```sh
bun src/runner-cli.ts # runs <config_dir>/scripts/* + installed punktfunk-plugin-*
bun src/runner-cli.ts --list # show what it found
```
- **Plugins** (a `definePlugin` default export, from the scripts dir or a
`punktfunk-plugin-*` package installed under `<config_dir>/plugins/`): supervised — a crash
restarts them with capped exponential backoff; a clean return completes them.
- **Bare scripts**: importing them is the run — one-shot, no restart (export a plugin to be
supervised).
- **Shutdown is structural**: SIGINT/SIGTERM interrupt every unit's fiber — Effect plugins'
scoped finalizers run (release the preset, deregister cleanly) and facade clients close
before the process exits. This is what makes `systemctl stop` clean.
- The sshd rule applies: a group/world-writable unit file is refused loudly.
systemd user unit for the runner (`~/.config/systemd/user/punktfunk-scripting.service`):
```ini
[Unit]
Description=punktfunk script/plugin runner
After=punktfunk-host.service
[Service]
ExecStart=/usr/bin/bun /path/to/sdk/src/runner-cli.ts
Restart=on-failure
RestartSec=5
# SIGTERM (the default KillSignal) triggers the runner's structured shutdown.
[Install]
WantedBy=default.target
```
## Running a single script as a service
systemd user unit (`~/.config/systemd/user/punktfunk-myscript.service`):
```ini
[Unit]
Description=punktfunk automation: myscript
After=punktfunk-host.service
[Service]
ExecStart=/usr/bin/bun /home/me/punktfunk-scripts/myscript.ts
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
```
Windows Task Scheduler: a task triggered *At log on* running
`bun C:\Users\me\punktfunk-scripts\myscript.ts` (the SDK reads
`%ProgramData%\punktfunk\mgmt-token` — run the task as an account that can).
## Compatibility
- SDK **majors** track the management-API major; an event `schema` bump or an `effect` major
is an SDK major too.
- The wire is **additive-only** within a major: an older SDK keeps working against a newer
host (unknown response keys are ignored; unknown event kinds ride the `"unknown"` channel).
- A 2xx response that doesn't match its schema surfaces as `VersionSkew` on the Effect
surface — a typed nudge to update, not an `undefined` three frames later.
## Development
```sh
bun install
bun run gen # regenerate src/gen/schemas.ts from ../api/openapi.json
bun run typecheck
bun test
```
+303
View File
@@ -0,0 +1,303 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@punktfunk/host",
"dependencies": {
"effect": "^3.19.0",
},
"devDependencies": {
"@types/bun": "^1.3.0",
"orval": "^8.20.0",
"typescript": "^5.9.3",
},
"optionalDependencies": {
"undici": "^7.0.0",
},
},
},
"packages": {
"@commander-js/extra-typings": ["@commander-js/extra-typings@15.0.0", "", { "peerDependencies": { "commander": "~15.0.0" } }, "sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="],
"@orval/angular": ["@orval/angular@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-lSvaNj+VHGIWBQOG104HfPNrk2xGjpArwy67u6ZBPzHo/OtDE5Tm1t+ARYseCOElFr4z3i3A62qjFLFy6zj00Q=="],
"@orval/axios": ["@orval/axios@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-fFu0UgTbpI9a1ayM7qCs55MMy6MlVgOpE3BBXGukCTBSwNwFLN47WdzQev/x0mAcs58pNKYT0KL4R9MXGnmgfg=="],
"@orval/core": ["@orval/core@8.22.0", "", { "dependencies": { "@scalar/openapi-types": "0.8.0", "acorn": "^8.15.0", "compare-versions": "^6.1.1", "debug": "^4.4.3", "esbuild": "^0.28.0", "esutils": "2.0.3", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "jsesc": "^3.0.0", "remeda": "^2.33.6", "tinyglobby": "^0.2.16", "typedoc": "^0.28.19" }, "peerDependencies": { "@faker-js/faker": ">=10" }, "optionalPeers": ["@faker-js/faker"] }, "sha512-uKYi7+Smg6oQ2MxE0AS2FNI7bwssoxGLh391Uk0FU+DcTcSv9q2GmvoM8uwd827Hok29kD7AegHE8Mhmsa5uWg=="],
"@orval/effect": ["@orval/effect@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "remeda": "^2.33.6" } }, "sha512-DACiw2+0ZsPJPKQbYlb9qFFFppaCBaT/HgIq2S1asH9ZCOOTZKm9VrRXpeCWINHC2w1BpdSATpytv+61UT5Y/A=="],
"@orval/fetch": ["@orval/fetch@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-G0r6hOdZG963H/8S4Vk1kWfU/hkQC/cwpDZL+mzcXgzrdG9NDloshTqmge/jP5lPsFAwfcRUmmts10OmuIE4BQ=="],
"@orval/hono": ["@orval/hono@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/zod": "8.22.0", "fs-extra": "^11.3.2" }, "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-GMCpGZqCuGYSu10KTt2q3WYzCqEcTGxr18z3HgHv9dX22sv/V76dlLBh3SE72xp8Z7/jmoq3GKBdgU9k98ju0w=="],
"@orval/mcp": ["@orval/mcp@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0", "@orval/zod": "8.22.0" } }, "sha512-ySa4EenAF8YMCpbmddJGO3lItTPC8Uf/iT+P2ezaowVgxOCPX/fjG7dd0fnVmrr0vaphi53yWx5o5DlT17FzLg=="],
"@orval/mock": ["@orval/mock@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "remeda": "^2.33.6" } }, "sha512-ZCEFpdi4z+YOCg0Tsgz6DO/M1TSfYxE5urDWJgYNUKeD8XDEHFb2IgTiiaAwJunqWnRQuiMfGp9G81+u10Kx6w=="],
"@orval/query": ["@orval/query@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0", "remeda": "^2.33.6" } }, "sha512-IH9049z860CLUeGEezGv+yOvUvcAyDnd9doDe1Ryi0WxsDul2Vh3LjCRUEQf4JZiyZezadWYYGzs0lwnCn/afA=="],
"@orval/solid-start": ["@orval/solid-start@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-Q1ZCWOrA6/VJnyj62XpPxXti9NpAA4lDZVqPL2uQ5gbxv+T+azEaazKqYIpSBJXbl1aEezujdcyg/DVUakWKEw=="],
"@orval/swr": ["@orval/swr@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0" } }, "sha512-OSeWX3Af8ESapKIo3XpbOaTMaG8oeEtuucFyOUauqg6NyC6/9Ikcf2csBdF0AuIKA0QTcjcXxra/ccj1f7KnRg=="],
"@orval/zod": ["@orval/zod@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "jsesc": "^3.0.0", "remeda": "^2.33.6" } }, "sha512-briHtUTz79fvCeIFfGW3IbmUIF9DOotUoWFa/sKUjRUG8PMGDWSjQzO8QdLzLoETVGo9g4TfGwp5Xjcs81GZTA=="],
"@scalar/helpers": ["@scalar/helpers@0.9.1", "", {}, "sha512-UKSLIPfN++f+zzbsZ+F6I0lNrR4yX4PtokjHgGwGiHapxT5VJqAF4zFTIP2KCd27XG7KnAIA7qq62O4xRBxc6w=="],
"@scalar/json-magic": ["@scalar/json-magic@0.12.18", "", { "dependencies": { "@scalar/helpers": "0.9.1", "pathe": "^2.0.3", "yaml": "^2.8.3" } }, "sha512-3oZr+jUUiwD3C+x2CROBtlyIUnwt9ScRhFfczuOTPZlD7Pnb5cyTTfkiHbxxU4qd66ij0Z3JA4X/Nj0OADyEiA=="],
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.28.9", "", { "dependencies": { "@scalar/helpers": "0.9.1", "@scalar/json-magic": "0.12.18", "@scalar/openapi-types": "0.9.2", "@scalar/openapi-upgrader": "0.2.10", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.3" } }, "sha512-AYmqO7dR6zc1TSVhW5BpwY0nUMfG5JuF85/8YXn5ZXcCvjgoJu8dbxX52vIGK9rRwSn07HXCdKk7KPIF4xfOTA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.8.0", "", {}, "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.2.10", "", { "dependencies": { "@scalar/openapi-types": "0.9.2" } }, "sha512-FRYwCl4IXRi7yAF5/3Jho0jc2akTZfr/vRTYMjhm+96Fq0LaEuHJAvPcSkkU+j3IWGie6LaCQMZTngcSjoIzgA=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
"@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
"@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
"@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"effect": ["effect@3.22.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-jhYFe0zTlIRqYFrKTS+6luhmS/Tm0f+JLo0K9KUxvtFab1SUGEszQi2ehOP6QzAZvy831lDmTwwzvVDZSPNz3g=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"find-up": ["find-up@8.0.0", "", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="],
"fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
"linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="],
"locate-path": ["locate-path@8.0.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg=="],
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
"markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"orval": ["orval@8.22.0", "", { "dependencies": { "@commander-js/extra-typings": "^15.0.0", "@orval/angular": "8.22.0", "@orval/axios": "8.22.0", "@orval/core": "8.22.0", "@orval/effect": "8.22.0", "@orval/fetch": "8.22.0", "@orval/hono": "8.22.0", "@orval/mcp": "8.22.0", "@orval/mock": "8.22.0", "@orval/query": "8.22.0", "@orval/solid-start": "8.22.0", "@orval/swr": "8.22.0", "@orval/zod": "8.22.0", "@scalar/json-magic": "^0.12.16", "@scalar/openapi-parser": "^0.28.7", "@scalar/openapi-types": "0.8.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "get-tsconfig": "^4.14.0", "jiti": "^2.6.1", "js-yaml": "4.2.0", "remeda": "^2.33.6", "string-argv": "^0.3.2", "typedoc": "^0.28.19", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-N8UmB4DOhW+Z2SzVq4oS/pN3DsRPq+msrRU8NyRldP1i0yiqT6qo8mUxHPk2umqYjyY0FlR2mvPbi/Jf5CiP7A=="],
"p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
"p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"remeda": ["remeda@2.39.0", "", {}, "sha512-3Ki8dU1o3OVu4dwIQ2Pj+yiuP7OnEbmWAGmJ3yDRqopily5jsj8NWzPvbS89H85d6UdONKEcUnrfuHY6jN9vyw=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="],
"typedoc-plugin-coverage": ["typedoc-plugin-coverage@4.0.3", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-baim3wyMkqpX7rBzL/6iZ7wzKJuSr9ffP16RHOsdTUNoHUZeXLIZHSUBtUhXmNHaUNRgfqdmKLBwyggbJjGdeQ=="],
"typedoc-plugin-markdown": ["typedoc-plugin-markdown@4.12.0", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"@scalar/openapi-parser/@scalar/openapi-types": ["@scalar/openapi-types@0.9.2", "", {}, "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g=="],
"@scalar/openapi-upgrader/@scalar/openapi-types": ["@scalar/openapi-types@0.9.2", "", {}, "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
}
}
+20
View File
@@ -0,0 +1,20 @@
// The RFC §7 quickstart, Effect-native: when the living-room TV starts a stream, apply a
// display preset — a composed, typed, interruptible program.
import { Effect, Stream } from "effect";
import { events, PunktfunkHost, PunktfunkHostLive } from "../src/effect.js";
const program = Effect.gen(function* () {
const pf = yield* PunktfunkHost;
yield* events().pipe(
Stream.filter(
(e) => e.kind === "stream.started" && e.stream.client === "Living Room TV",
),
Stream.runForEach(() =>
pf
.request("PUT", "/display/settings", { mode: "preset", preset: "couch" })
.pipe(Effect.catchAll((e) => Effect.logWarning(`preset failed: ${e}`))),
),
);
});
Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive()))).catch(console.error);
+15
View File
@@ -0,0 +1,15 @@
// The flagship hook-with-a-decision pattern (Promise facade): watch pairing requests, notify,
// and approve/deny THROUGH the API — asynchronously, the punktfunk way (hooks never veto).
import { connect } from "../src/index.js";
const pf = await connect();
console.log("watching for pairing requests…");
pf.events.on("pairing.pending", async (e) => {
console.log(`pairing request: ${e.device.name} (${e.device.fingerprint})`);
// Wire your real notifier here (ntfy, Pushover, Home Assistant, …), then decide:
// const pending = await pf.request("GET", "/native/pending") as { id: number }[];
// await pf.request("POST", `/native/pending/${pending[0].id}/approve`);
});
pf.events.on("pairing.completed", (e) => console.log(`paired: ${e.device.name}`));
pf.events.on("pairing.denied", (e) => console.log(`denied: ${e.device.name}`));
+26
View File
@@ -0,0 +1,26 @@
// The external game-library provider pattern (RFC §8): compute your desired title list and
// declaratively PUT it — the host diffs by your `external_id`, keeps host ids stable across
// syncs, drops orphans, and never touches manual entries. Uninstall = one DELETE.
import { connect } from "../src/index.js";
const PROVIDER = "romm"; // your punktfunk-plugin-* name
const pf = await connect();
pf.events.on("library.changed", (e) => {
if (e.source === PROVIDER) console.log("library synced");
});
// Fetch your source of truth (a ROM manager, itch.io, a curated list…), then reconcile:
const desired = [
{ external_id: "rom-1", title: "Chrono Trigger", launch: { command: "retroarch ..." } },
{ external_id: "rom-2", title: "Super Metroid", launch: { command: "retroarch ..." } },
];
const entries = (await pf.request("PUT", `/library/provider/${PROVIDER}`, desired)) as {
id: string;
title: string;
}[];
console.log(`synced ${entries.length} titles:`, entries.map((e) => `${e.title} (custom:${e.id})`));
// …run on a schedule, or keep watching your source. Clean uninstall:
// await pf.request("DELETE", `/library/provider/${PROVIDER}`);
pf.close();
+13
View File
@@ -0,0 +1,13 @@
// Tail the host's lifecycle events — the SDK "hello world" (Promise facade).
//
// bun examples/tail-events.ts (on the host box: zero config)
// PUNKTFUNK_MGMT_URL=… PUNKTFUNK_MGMT_TOKEN=… bun examples/tail-events.ts
import { connect } from "../src/index.js";
const pf = await connect();
const host = (await pf.request("GET", "/host")) as { hostname?: string };
console.log(`connected to ${host.hostname ?? "host"} — tailing events (^C to stop)`);
pf.events.on("*", (e) => console.log(`[${e.seq}] ${e.kind}`, JSON.stringify(e)));
pf.events.on("unknown", (e) => console.log("[unknown kind]", JSON.stringify(e)));
pf.events.on("dropped", () => console.log("[cursor fell off the ring — resync]"));
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "orval";
// Generates the SDK's Effect Schemas from the host's checked-in OpenAPI document — the same
// single source of truth the web console's react-query client is generated from (RFC §7: the
// chain is Rust structs (utoipa) → OpenAPI → Effect Schemas → inferred TS types). Regenerate
// after any management-API change: `bun run gen` (CI drift-tests the output).
export default defineConfig({
punktfunk: {
input: {
target: "../api/openapi.json",
},
output: {
mode: "single",
target: "./src/gen/schemas.ts",
client: "effect",
clean: true,
},
},
});
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@punktfunk/host",
"version": "0.1.0",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts",
"./effect": "./src/effect.ts"
},
"scripts": {
"gen": "orval --config ./orval.config.ts",
"typecheck": "tsc --noEmit",
"test": "bun test",
"runner": "bun src/runner-cli.ts"
},
"dependencies": {
"effect": "^3.19.0"
},
"devDependencies": {
"orval": "^8.20.0",
"typescript": "^5.9.3",
"@types/bun": "^1.3.0"
},
"optionalDependencies": {
"undici": "^7.0.0"
},
"bin": {
"punktfunk-scripting": "./src/runner-cli.ts"
}
}
+168
View File
@@ -0,0 +1,168 @@
// The Effect-native surface (RFC §7): the `PunktfunkHost` service — a typed management-API
// client plus the lifecycle-event `Stream` — provided by [`PunktfunkHostLive`]. Wire shapes
// are Effect Schemas (generated for REST in ./gen/schemas.ts, hand-mirrored for events in
// ./wire.ts); API responses are validated by default, so host/SDK version skew surfaces as a
// typed [`VersionSkew`] instead of an `undefined` three frames later.
import {
Context,
Data,
Effect,
Layer,
Option,
Schema as S,
Stream,
} from "effect";
import {
type ConnectOptions,
type ResolvedConfig,
resolveConfig,
} from "./config.js";
import { HttpStatusError, httpRequest } from "./core.js";
import {
type EventStreamOptions,
type SseFrame,
SseAuthError,
sseFrames,
} from "./sse.js";
import { decodeHostEvent, type HostEvent } from "./wire.js";
/** Bad credentials — the token (or paired cert) was rejected. */
export class AuthError extends Data.TaggedError("AuthError")<{
message: string;
}> {}
/** The host answered with a non-2xx (the message is its `ApiError` envelope). */
export class ApiError extends Data.TaggedError("ApiError")<{
status: number;
message: string;
}> {}
/** The request never completed (connection refused, TLS, abort). */
export class TransportError extends Data.TaggedError("TransportError")<{
cause: unknown;
}> {}
/** A 2xx body did not match its schema — host and SDK disagree on the wire shape. */
export class VersionSkew extends Data.TaggedError("VersionSkew")<{
path: string;
issue: string;
}> {}
/** The event stream failed unrecoverably (auth) — transient trouble self-heals via reconnect. */
export class EventStreamError extends Data.TaggedError("EventStreamError")<{
cause: unknown;
}> {}
export type RequestError = AuthError | ApiError | TransportError;
export interface PunktfunkHostService {
readonly config: ResolvedConfig;
/** One management-API request under `/api/v1`; the parsed JSON body. */
readonly request: (
method: string,
path: string,
body?: unknown,
) => Effect.Effect<unknown, RequestError>;
/** GET + schema-validate (the generated schemas from `@punktfunk/host/effect`'s `api`). */
readonly get: <A, I>(
path: string,
schema: S.Schema<A, I>,
) => Effect.Effect<A, RequestError | VersionSkew>;
/**
* The lifecycle-event stream: decoded [`HostEvent`]s with automatic reconnect +
* `Last-Event-ID` resume. Unknown kinds and the `dropped` marker surface on
* [`eventsRaw`] (and the warning callback), never as a failure here.
*/
readonly events: (
opts?: EventStreamOptions,
) => Stream.Stream<HostEvent, EventStreamError>;
/** Every SSE frame verbatim — the `dropped` marker and unknown kinds included. */
readonly eventsRaw: (
opts?: EventStreamOptions,
) => Stream.Stream<SseFrame, EventStreamError>;
}
export class PunktfunkHost extends Context.Tag("@punktfunk/host/PunktfunkHost")<
PunktfunkHost,
PunktfunkHostService
>() {}
const toRequestError = (path: string, cause: unknown): RequestError => {
if (cause instanceof HttpStatusError) {
return cause.status === 401
? new AuthError({ message: cause.message })
: new ApiError({ status: cause.status, message: cause.message });
}
return new TransportError({ cause });
};
export const makeService = (cfg: ResolvedConfig): PunktfunkHostService => {
const request = (method: string, path: string, body?: unknown) =>
Effect.tryPromise({
try: () => httpRequest(cfg, method, path, body),
catch: (cause) => toRequestError(path, cause),
});
const get = <A, I>(path: string, schema: S.Schema<A, I>) =>
request("GET", path).pipe(
Effect.flatMap((body) =>
S.decodeUnknown(schema)(body).pipe(
Effect.mapError(
(e) => new VersionSkew({ path, issue: String(e) }),
),
),
),
);
const eventsRaw = (opts?: EventStreamOptions) =>
// suspend: each run must get a FRESH generator (a generator is single-use).
Stream.suspend(() =>
Stream.fromAsyncIterable(
sseFrames(cfg, opts),
(cause) => new EventStreamError({ cause }),
),
);
const events = (opts?: EventStreamOptions) => {
const warn =
opts?.onWarning ?? ((m: string) => console.warn(`[punktfunk] ${m}`));
return eventsRaw(opts).pipe(
Stream.filterMap((frame) => {
if (frame.event === "dropped") {
warn(
"event cursor fell off the host's ring — resync via the REST snapshots",
);
return Option.none();
}
let json: unknown;
try {
json = JSON.parse(frame.data);
} catch {
warn(`unparseable event frame (${frame.event})`);
return Option.none();
}
const decoded = decodeHostEvent(json);
if (decoded._tag === "Left") {
// An unknown kind from a NEWER host is expected (additive-only wire) —
// it rides the raw channel; a consumer that wants it uses eventsRaw.
warn(`unknown/undecodable event kind "${frame.event}"`);
return Option.none();
}
return Option.some(decoded.right);
}),
);
};
return { config: cfg, request, get, events, eventsRaw };
};
/**
* The live layer: resolves URL/token/CA (env host files) and provides [`PunktfunkHost`].
*/
export const layer = (
options?: ConnectOptions,
): Layer.Layer<PunktfunkHost, TransportError> =>
Layer.effect(
PunktfunkHost,
Effect.tryPromise({
try: () => resolveConfig(options),
catch: (cause) => new TransportError({ cause }),
}).pipe(Effect.map(makeService)),
);
/** RFC-spelled alias of [`layer`]. */
export const PunktfunkHostLive = layer;
export { SseAuthError };
+128
View File
@@ -0,0 +1,128 @@
// Connection resolution (RFC §7): loopback URL + bearer token + the host's self-signed
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
// needs zero configuration.
//
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
//
// The CA is the host's own identity certificate — trusting exactly it (not the system roots)
// IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch,
// Node (undici) takes a dispatcher with a CA-carrying TLS connector; anything else falls back
// to plain fetch (document PUNKTFUNK_MGMT_CA + NODE_EXTRA_CA_CERTS there).
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export interface ConnectOptions {
/** Management API base URL (default `https://127.0.0.1:47990`). */
url?: string;
/** Bearer token (default: `PUNKTFUNK_MGMT_TOKEN`, else the host's `mgmt-token` file). */
token?: string;
/** PEM of the CA to trust — the host's identity cert (default: `PUNKTFUNK_MGMT_CA`, else `cert.pem`). */
ca?: string;
}
export interface ResolvedConfig {
url: string;
token: string;
ca?: string;
/** A fetch honoring `ca` on this runtime. */
fetch: typeof fetch;
}
/** The host's config dir — the same resolution the host itself uses. */
export const configDir = (): string => {
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
if (explicit) return explicit;
if (process.platform === "win32") {
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
return path.join(base, "punktfunk");
}
const base =
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
return path.join(base, "punktfunk");
};
const readIfExists = (p: string): string | undefined => {
try {
return fs.readFileSync(p, "utf8");
} catch {
return undefined;
}
};
/** First token-looking line of the mgmt-token file (tolerates `TOKEN=`-style and blank lines). */
const parseTokenFile = (raw: string): string | undefined => {
for (const line of raw.split(/\r?\n/)) {
const t = line.trim();
if (t.length === 0 || t.startsWith("#")) continue;
return t.includes("=") ? t.slice(t.indexOf("=") + 1).trim() : t;
}
return undefined;
};
export const resolveConfig = async (
options?: ConnectOptions,
): Promise<ResolvedConfig> => {
const url = (
options?.url ??
process.env.PUNKTFUNK_MGMT_URL ??
"https://127.0.0.1:47990"
).replace(/\/+$/, "");
const token =
options?.token ??
process.env.PUNKTFUNK_MGMT_TOKEN ??
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? "");
if (!token) {
throw new Error(
"no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
);
}
const caPath = process.env.PUNKTFUNK_MGMT_CA;
const ca =
options?.ca ??
(caPath ? readIfExists(caPath) : undefined) ??
(url.startsWith("https://")
? readIfExists(path.join(configDir(), "cert.pem"))
: undefined);
return { url, token, ca, fetch: await makeFetch(ca) };
};
/**
* A fetch that PINS `ca` the host's self-signed identity cert on this runtime.
*
* The pin is chain verification against exactly that certificate (nothing else can pass),
* with the HOSTNAME check waived: the host identity cert is deliberately CN-only/no-SAN
* (native clients pin its fingerprint; see `web/nitro-entry/bun-https.mjs` for the same
* finding), so standard SAN matching would always fail and it adds nothing when the chain
* already admits only the one pinned cert.
*/
const makeFetch = async (ca: string | undefined): Promise<typeof fetch> => {
if (!ca) return fetch;
const skipHostname = { checkServerIdentity: () => undefined };
// Bun: fetch takes node-compatible `tls` options.
if (typeof (globalThis as Record<string, unknown>).Bun !== "undefined") {
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
fetch(input, {
...init,
tls: { ca, ...skipHostname },
} as RequestInit)) as typeof fetch;
}
// Node: global fetch is undici — a per-request dispatcher carries the pin.
try {
// Optional dependency — declared in package.json optionalDependencies; absent on
// runtimes that don't need it (the catch below falls back).
const { Agent } = (await import("undici" as string)) as {
Agent: new (opts: unknown) => unknown;
};
const dispatcher = new Agent({ connect: { ca, ...skipHostname } });
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
fetch(input, { ...init, dispatcher } as RequestInit)) as typeof fetch;
} catch {
// Unknown runtime: plain fetch (system trust) — PUNKTFUNK_MGMT_CA via the runtime's
// own CA mechanism (e.g. NODE_EXTRA_CA_CERTS / --cert) is the documented fallback.
return fetch;
}
};
+47
View File
@@ -0,0 +1,47 @@
// The one HTTP implementation both surfaces share (RFC §7: two surfaces, one core): the
// Effect service wraps these with typed errors; the Promise facade calls them directly.
import type { ResolvedConfig } from "./config.js";
/** A non-2xx response, with the host's `ApiError` envelope message when present. */
export class HttpStatusError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
/**
* One management-API request under `/api/v1`. Returns the parsed JSON body (or `undefined`
* for 204/empty). Throws [`HttpStatusError`] on a non-2xx (401 included callers type it).
*/
export const httpRequest = async (
cfg: ResolvedConfig,
method: string,
apiPath: string,
body?: unknown,
): Promise<unknown> => {
const headers: Record<string, string> = {
authorization: `Bearer ${cfg.token}`,
};
if (body !== undefined) headers["content-type"] = "application/json";
const resp = await cfg.fetch(`${cfg.url}/api/v1${apiPath}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
let message = `HTTP ${resp.status}`;
try {
const err = (await resp.json()) as { error?: string };
if (typeof err.error === "string") message = err.error;
} catch {
// non-JSON error body — keep the status message
}
throw new HttpStatusError(resp.status, message);
}
if (resp.status === 204) return undefined;
const text = await resp.text();
return text.length === 0 ? undefined : JSON.parse(text);
};
+70
View File
@@ -0,0 +1,70 @@
// `@punktfunk/host/effect` — the Effect-native surface (RFC §7): the `PunktfunkHost` service
// tag + live layer, the wire schemas, typed errors, and Stream accessors. The package root
// (`@punktfunk/host`) is the Promise facade; this entry is for plugins and Effect programs.
//
// import { Effect, Stream } from "effect";
// import { PunktfunkHost, PunktfunkHostLive, events } from "@punktfunk/host/effect";
//
// const program = Effect.gen(function* () {
// yield* events().pipe(
// Stream.filter((e) => e.kind === "stream.started"),
// Stream.runForEach((e) => Effect.log(`stream started: ${e.stream.mode}`)),
// );
// });
// Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));
import { Effect, type Schema as S, Stream } from "effect";
import {
EventStreamError,
PunktfunkHost,
type RequestError,
type VersionSkew,
} from "./client.js";
import type { EventStreamOptions, SseFrame } from "./sse.js";
import type { HostEvent } from "./wire.js";
export {
ApiError,
AuthError,
EventStreamError,
layer,
makeService,
PunktfunkHost,
type PunktfunkHostService,
PunktfunkHostLive,
type RequestError,
SseAuthError,
TransportError,
VersionSkew,
} from "./client.js";
export { type ConnectOptions, configDir, resolveConfig } from "./config.js";
export type { EventStreamOptions, SseFrame } from "./sse.js";
export * from "./wire.js";
/** The generated REST wire schemas (orval `client: 'effect'` over `api/openapi.json`). */
export * as api from "./gen/schemas.js";
/** The decoded lifecycle-event stream of the ambient [`PunktfunkHost`]. */
export const events = (
opts?: EventStreamOptions,
): Stream.Stream<HostEvent, EventStreamError, PunktfunkHost> =>
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.events(opts)));
/** Every SSE frame verbatim (the `dropped` marker + unknown kinds included). */
export const eventsRaw = (
opts?: EventStreamOptions,
): Stream.Stream<SseFrame, EventStreamError, PunktfunkHost> =>
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.eventsRaw(opts)));
/** One management-API request under `/api/v1` on the ambient service. */
export const request = (
method: string,
path: string,
body?: unknown,
): Effect.Effect<unknown, RequestError, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.request(method, path, body));
/** GET + schema-validate on the ambient service (schemas from [`api`]). */
export const get = <A, I>(
path: string,
schema: S.Schema<A, I>,
): Effect.Effect<A, RequestError | VersionSkew, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.get(path, schema));
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
// `@punktfunk/host` — the Promise facade, the package's front door (RFC §7): `connect()`,
// `await`, `.on()` — a thin veneer over the same core the Effect surface uses (config
// resolution, one HTTP implementation, one reconnecting SSE source). Effect is the substrate
// and the power surface (`@punktfunk/host/effect`), never a prerequisite:
//
// import { connect } from "@punktfunk/host";
//
// const pf = await connect();
// pf.events.on("pairing.pending", async (e) => {
// await notifyPhone(`Pairing request from ${e.device.name}`);
// });
import type { Effect } from "effect";
import type { PunktfunkHost } from "./client.js";
import {
type ConnectOptions,
type ResolvedConfig,
resolveConfig,
} from "./config.js";
import { HttpStatusError, httpRequest } from "./core.js";
import { type SseFrame, sseFrames } from "./sse.js";
import {
decodeHostEvent,
type EventOf,
type HostEvent,
type HostEventKind,
kindMatches,
} from "./wire.js";
export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js";
export type {
ClientRef,
DeviceRef,
DisconnectReason,
EventOf,
HostEvent,
HostEventKind,
Plane,
SessionRef,
StreamRef,
} from "./wire.js";
/** `on()` also accepts these beyond the typed kinds. */
type SpecialPattern = "*" | "dropped" | "unknown" | (string & {});
interface Listener {
pattern: string;
cb: (ev: never) => unknown;
}
export interface PunktfunkEvents {
/**
* Subscribe to lifecycle events. `pattern` is an exact kind (`"stream.started"` the
* callback is typed to it), a `domain.*` prefix, `"*"` (every known event), `"dropped"`
* (the fell-off-the-ring marker), or `"unknown"` (kinds this SDK doesn't know a newer
* host). Returns the unsubscribe function. Callback errors are caught and warned, never
* fatal to the stream.
*/
on<K extends HostEventKind>(
pattern: K,
cb: (ev: EventOf<K>) => unknown,
): () => void;
on(pattern: SpecialPattern, cb: (ev: HostEvent) => unknown): () => void;
}
export interface Punktfunk {
/** Resolved connection (URL/token/CA). */
readonly config: ResolvedConfig;
/** One management-API request under `/api/v1` (`request("GET", "/status")`). */
request(method: string, path: string, body?: unknown): Promise<unknown>;
/** The lifecycle-event subscription surface. */
readonly events: PunktfunkEvents;
/** Stop the event stream and release the connection. */
close(): void;
}
/**
* Connect to the host's management API: resolves the URL, bearer token, and the host's
* self-signed identity cert from the environment/host files (zero config on the host box),
* verifies the credentials with a `/health`-adjacent probe, and returns the client.
*/
export const connect = async (options?: ConnectOptions): Promise<Punktfunk> => {
const cfg = await resolveConfig(options);
// Fail fast on bad credentials/URL: one cheap authenticated probe.
await httpRequest(cfg, "GET", "/host");
const listeners = new Set<Listener>();
let pump: AsyncGenerator<SseFrame> | undefined;
let closed = false;
const warn = (m: string) => console.warn(`[punktfunk] ${m}`);
const dispatch = (pattern: string, ev: unknown) => {
for (const l of listeners) {
if (l.pattern === pattern || (pattern !== "dropped" && pattern !== "unknown" && (l.pattern === "*" || kindMatches(l.pattern, pattern)))) {
try {
(l.cb as (e: unknown) => unknown)(ev);
} catch (e) {
warn(`listener for "${l.pattern}" threw: ${e}`);
}
}
}
};
const startPump = () => {
if (pump || closed) return;
pump = sseFrames(cfg, { onWarning: warn });
void (async () => {
try {
for await (const frame of pump) {
if (closed) break;
if (frame.event === "dropped") {
dispatch("dropped", frame);
continue;
}
let json: unknown;
try {
json = JSON.parse(frame.data);
} catch {
continue;
}
const decoded = decodeHostEvent(json);
if (decoded._tag === "Left") {
dispatch("unknown", json);
continue;
}
dispatch(decoded.right.kind, decoded.right);
}
} catch (e) {
if (!closed) warn(`event stream stopped: ${e}`);
}
})();
};
return {
config: cfg,
request: (method, path, body) => httpRequest(cfg, method, path, body),
events: {
on(pattern: string, cb: (ev: never) => unknown) {
const l: Listener = { pattern, cb };
listeners.add(l);
startPump();
return () => listeners.delete(l);
},
} as PunktfunkEvents,
close() {
closed = true;
pump?.return(undefined).catch(() => {});
pump = undefined;
},
};
};
// ------------------------------------------------------------------- plugins (RFC §8)
/**
* A plugin's `main`: either a plain async function receiving the connected client, or an
* Effect requiring the `PunktfunkHost` service (`@punktfunk/host/effect`) the shape that
* makes it well-behaved under the managed runner's supervision (structured interruption,
* scoped finalizers).
*/
export type PluginMain =
| ((pf: Punktfunk) => Promise<unknown> | unknown)
| Effect.Effect<unknown, unknown, PunktfunkHost>;
export interface PluginDef {
/** Package-convention name (`punktfunk-plugin-*` drops the prefix): kebab-case. */
name: string;
main: PluginMain;
}
/**
* Declare a plugin (the `punktfunk-plugin-*` convention, RFC §8). In v1 a plugin is a script
* the operator runs; the managed runner (a later, optional package) discovers this default
* export and supervises `main`.
*/
export const definePlugin = (def: PluginDef): PluginDef => {
if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
throw new Error(
`plugin name "${def.name}" must be kebab-case ([a-z][a-z0-9-]*)`,
);
}
return def;
};
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bun
// `punktfunk-scripting` — run the operator's scripts and punktfunk-plugin-* packages under
// supervision (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree structurally, so
// every plugin's finalizers run before exit (the systemd-stop story).
//
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list]
import { Effect, Fiber } from "effect";
import { discoverUnits, runner } from "./runner.js";
const arg = (flag: string): string | undefined => {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
};
const options = {
scriptsDir: arg("--scripts"),
pluginsDir: arg("--plugins"),
};
if (process.argv.includes("--list")) {
for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`);
process.exit(0);
}
const fiber = Effect.runFork(runner(options));
let stopping = false;
const shutdown = (signal: string) => {
if (stopping) return process.exit(1); // second signal = get out now
stopping = true;
console.log(`${new Date().toISOString()} [runner] ${signal} — interrupting units…`);
void Effect.runPromise(Fiber.interrupt(fiber)).finally(() => process.exit(0));
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
await Effect.runPromise(Fiber.await(fiber));
+226
View File
@@ -0,0 +1,226 @@
// The managed script/plugin runner (RFC §8, M5) — what the `punktfunk-scripting` package runs:
// discover the operator's units, supervise them as Effect fibers, shut down structurally.
//
// Units:
// - **Plugins** — a file whose default export is a [`PluginDef`] (`definePlugin`), from the
// scripts dir or an installed `punktfunk-plugin-*` package. Supervised: a failure restarts
// it with capped exponential backoff; a clean return completes it. The Effect `main` shape
// runs with the `PunktfunkHost` layer provided and is interrupted STRUCTURALLY on shutdown
// (scoped finalizers run — release the preset, deregister cleanly); the async-fn shape gets
// a connected facade client whose close is guaranteed by the same scope.
// - **Bare scripts** — any other `.ts`/`.js` file in the scripts dir: importing it IS the run
// (top-level await). One-shot: completion logs, failure logs — no restart (a bare script's
// background work is invisible to supervision; export a plugin to be supervised).
//
// Trust model (RFC §9.4): a unit is code the operator chose to run — no sandbox is pretended.
// The same sshd rule as hooks applies: a world-writable unit file is refused loudly.
import {
Cause,
Duration,
Effect,
Schedule,
} from "effect";
import * as fs from "node:fs";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import { layer as hostLayer, PunktfunkHost } from "./client.js";
import { type ConnectOptions, configDir } from "./config.js";
import { connect, type PluginDef } from "./index.js";
export interface RunnerOptions {
/** Where loose scripts live. Default `<config_dir>/scripts`. */
scriptsDir?: string;
/**
* Where plugin packages are installed (`<pluginsDir>/node_modules/punktfunk-plugin-*`,
* i.e. the operator runs `bun add punktfunk-plugin-x` there). Default `<config_dir>/plugins`.
*/
pluginsDir?: string;
/** Connection overrides handed to every unit's client/layer. */
connect?: ConnectOptions;
/** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */
restartBase?: Duration.DurationInput;
/** Line sink. Default: stamped stdout. */
log?: (line: string) => void;
}
export interface Unit {
/** Display name: the file stem, or the plugin package name. */
name: string;
/** Absolute path of the module to import. */
file: string;
}
const defaultLog = (line: string) =>
console.log(`${new Date().toISOString()} ${line}`);
/** The sshd rule (RFC §9.1/§9.4): refuse group/world-writable unit files, loudly. */
const fileIsSafe = (file: string, log: (l: string) => void): boolean => {
if (process.platform === "win32") return true; // config dir is DACL'd; ACL check is a follow-up
try {
const mode = fs.statSync(file).mode & 0o022;
if (mode !== 0) {
log(
`[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`,
);
return false;
}
} catch {
return false;
}
return true;
};
const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs"]);
/** Enumerate the operator's units: loose scripts plus installed plugin packages. */
export const discoverUnits = (
options: RunnerOptions = {},
log: (l: string) => void = options.log ?? defaultLog,
): Unit[] => {
const units: Unit[] = [];
const scriptsDir = options.scriptsDir ?? path.join(configDir(), "scripts");
const pluginsDir = options.pluginsDir ?? path.join(configDir(), "plugins");
try {
for (const entry of fs.readdirSync(scriptsDir).sort()) {
const file = path.join(scriptsDir, entry);
if (!SCRIPT_EXTENSIONS.has(path.extname(entry))) continue;
if (!fs.statSync(file).isFile()) continue;
if (!fileIsSafe(file, log)) continue;
units.push({ name: path.basename(entry, path.extname(entry)), file });
}
} catch {
// no scripts dir — fine
}
const modules = path.join(pluginsDir, "node_modules");
try {
for (const pkg of fs.readdirSync(modules).sort()) {
if (!pkg.startsWith("punktfunk-plugin-")) continue;
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(modules, pkg, "package.json"), "utf8"),
) as { main?: string; module?: string };
const rel = manifest.module ?? manifest.main ?? "index.js";
const file = path.join(modules, pkg, rel);
if (!fileIsSafe(file, log)) continue;
units.push({ name: pkg, file });
} catch (e) {
log(`[runner] skipping ${pkg}: unreadable package.json (${e})`);
}
}
} catch {
// no plugins dir — fine
}
return units;
};
const isPluginDef = (v: unknown): v is PluginDef =>
typeof v === "object" &&
v !== null &&
typeof (v as PluginDef).name === "string" &&
(v as PluginDef).main !== undefined;
/** One attempt at a unit: import (cache-busted per attempt) and run whatever it exports. */
const attemptUnit = (
unit: Unit,
attempt: number,
options: RunnerOptions,
log: (l: string) => void,
): Effect.Effect<"plugin" | "script", unknown> =>
Effect.gen(function* () {
const mod = (yield* Effect.tryPromise(
() => import(`${pathToFileURL(unit.file).href}?attempt=${attempt}`),
)) as { default?: unknown };
if (!isPluginDef(mod.default)) {
return "script" as const; // the import WAS the run (top-level await)
}
const def = mod.default;
if (Effect.isEffect(def.main)) {
// The well-behaved shape: interruption reaches it structurally, its scoped
// finalizers run on shutdown.
yield* (def.main as Effect.Effect<unknown, unknown, PunktfunkHost>).pipe(
Effect.provide(hostLayer(options.connect)),
);
} else {
// The simple shape: a facade client whose close is guaranteed by the scope —
// on completion, failure, OR interruption (shutdown).
const main = def.main as (pf: unknown) => Promise<unknown> | unknown;
yield* Effect.scoped(
Effect.gen(function* () {
const pf = yield* Effect.acquireRelease(
Effect.tryPromise(() => connect(options.connect)),
(client) => Effect.sync(() => client.close()),
);
yield* Effect.tryPromise(async () => await main(pf));
}),
);
}
return "plugin" as const;
});
/**
* A unit under supervision: plugins restart on failure (capped exponential backoff, jittered);
* a clean completion ends the unit; bare scripts are one-shot either way. Never fails the
* runner every outcome is logged.
*/
export const superviseUnit = (
unit: Unit,
options: RunnerOptions = {},
): Effect.Effect<void> => {
const log = options.log ?? defaultLog;
const restart = Schedule.exponential(options.restartBase ?? "1 second").pipe(
Schedule.union(Schedule.spaced("60 seconds")), // cap
Schedule.jittered,
);
let attempt = 0;
const once = Effect.suspend(() => {
attempt += 1;
if (attempt > 1) log(`[${unit.name}] restarting (attempt ${attempt})`);
return attemptUnit(unit, attempt, options, log);
});
return once.pipe(
Effect.tap((kind) =>
Effect.sync(() =>
log(
kind === "script"
? `[${unit.name}] script completed`
: `[${unit.name}] plugin completed`,
),
),
),
Effect.tapErrorCause((cause) =>
Effect.sync(() =>
log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`),
),
),
Effect.retry(restart),
Effect.catchAllCause((cause) =>
// A retry schedule that gives up (it doesn't, but stay total) — log and end.
Effect.sync(() => log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`)),
),
Effect.asVoid,
);
};
/**
* The runner: discover units, supervise each as a fiber, run until interrupted at which
* point every unit is interrupted STRUCTURALLY (scoped finalizers run: facade clients close,
* Effect plugins release what they acquired).
*/
export const runner = (options: RunnerOptions = {}): Effect.Effect<void> => {
const log = options.log ?? defaultLog;
return Effect.scoped(
Effect.gen(function* () {
const units = discoverUnits(options, log);
if (units.length === 0) {
log(
"[runner] nothing to run — add scripts to the scripts dir or install punktfunk-plugin-* packages",
);
}
for (const unit of units) {
log(`[runner] starting ${unit.name} (${unit.file})`);
yield* Effect.forkScoped(superviseUnit(unit, options));
}
yield* Effect.never; // interruption (shutdown) collapses the scope → all units
}),
);
};
+172
View File
@@ -0,0 +1,172 @@
// The SSE half of the SDK: a spec-shaped parser plus one shared reconnecting frame source both
// surfaces consume — the Effect `Stream` wraps it, the Promise facade iterates it directly, so
// there is exactly one implementation of connect/parse/resume (RFC §7: "two surfaces, one
// core"). Reconnects carry `Last-Event-ID` (the host replays from its ring); backoff is
// exponential + jittered, capped, and resets after a healthy connection.
import type { ResolvedConfig } from "./config.js";
/** One parsed SSE frame. */
export interface SseFrame {
/** The `event:` name — an event kind, or `dropped`, or `message` when absent. */
event: string;
/** The joined `data:` payload. */
data: string;
/** The `id:` field (the host sets it to the event's `seq`). */
id?: string;
}
/** Incremental SSE parser (the WHATWG dispatch rules the host's frames need). */
export class SseParser {
private buf = "";
private event = "";
private data: string[] = [];
private id: string | undefined;
/** Feed a chunk; returns the frames it completed. */
push(chunk: string): SseFrame[] {
this.buf += chunk;
const frames: SseFrame[] = [];
for (;;) {
const nl = this.buf.indexOf("\n");
if (nl < 0) break;
let line = this.buf.slice(0, nl);
this.buf = this.buf.slice(nl + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line.length === 0) {
// Blank line = dispatch.
if (this.data.length > 0 || this.event.length > 0) {
frames.push({
event: this.event.length > 0 ? this.event : "message",
data: this.data.join("\n"),
id: this.id,
});
}
this.event = "";
this.data = [];
continue;
}
if (line.startsWith(":")) continue; // comment (the host's keep-alives)
const colon = line.indexOf(":");
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
switch (field) {
case "event":
this.event = value;
break;
case "data":
this.data.push(value);
break;
case "id":
this.id = value;
break;
default: // unknown fields are ignored per spec (retry: handled by our own backoff)
}
}
return frames;
}
}
export interface EventStreamOptions {
/**
* Resume cursor (`?since=`); reconnects use the newer `Last-Event-ID` automatically.
* **Omitted = live tail only** (events from now on a fresh notify script must not
* re-fire on the host's replayed history); `0` = replay the host's full ring first;
* `N` = resume after seq N.
*/
since?: number;
/** Server-side kind filter (`["stream.*", "pairing.pending"]`). */
kinds?: string[];
/** Called on transient trouble (reconnects, decode warnings). Default: console.warn. */
onWarning?: (message: string) => void;
}
/**
* The "live tail only" cursor: beyond any realistic seq, so the host's catch-up is empty and
* (being > 0 yet the ring head) it never trips the `dropped` marker. The first received
* frame's real id replaces it for reconnects.
*/
const LIVE_ONLY_CURSOR = Number.MAX_SAFE_INTEGER;
/** Thrown when the stream cannot ever work (bad credentials) — retrying would loop 401s. */
export class SseAuthError extends Error {
readonly _tag = "SseAuthError";
}
const BACKOFF_INITIAL_MS = 500;
const BACKOFF_CAP_MS = 15_000;
/** A connection that lived this long resets the backoff (it was healthy, not flapping). */
const HEALTHY_MS = 30_000;
/**
* The shared frame source: connect parse yield frames, forever reconnecting with
* `Last-Event-ID` on any hiccup. Ends only by consumer break/return (both surfaces cancel by
* dropping the iterator, which aborts the in-flight request) or throws [`SseAuthError`].
*/
export async function* sseFrames(
cfg: ResolvedConfig,
opts: EventStreamOptions = {},
): AsyncGenerator<SseFrame> {
const warn = opts.onWarning ?? ((m) => console.warn(`[punktfunk] ${m}`));
let lastId: number = opts.since ?? LIVE_ONLY_CURSOR;
let backoff = BACKOFF_INITIAL_MS;
const abort = new AbortController();
try {
for (;;) {
const url = new URL(`${cfg.url}/api/v1/events`);
if (opts.kinds && opts.kinds.length > 0)
url.searchParams.set("kinds", opts.kinds.join(","));
const headers: Record<string, string> = {
authorization: `Bearer ${cfg.token}`,
accept: "text/event-stream",
};
if (lastId !== 0) headers["last-event-id"] = String(lastId);
const connectedAt = Date.now();
try {
const resp = await cfg.fetch(url, {
headers,
signal: abort.signal,
});
if (resp.status === 401) throw new SseAuthError("invalid credentials");
if (!resp.ok || !resp.body)
throw new Error(`event stream HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
const parser = new SseParser();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break; // server closed (slow-consumer cut / shutdown) → reconnect
for (const frame of parser.push(
decoder.decode(value, { stream: true }),
)) {
if (frame.id !== undefined) {
const id = Number(frame.id);
if (Number.isFinite(id)) lastId = id;
}
yield frame;
}
}
} finally {
reader.cancel().catch(() => {});
}
throw new Error("event stream ended");
} catch (e) {
if (e instanceof SseAuthError) throw e;
if (abort.signal.aborted) return;
if (Date.now() - connectedAt >= HEALTHY_MS) backoff = BACKOFF_INITIAL_MS;
const jittered = backoff * (0.5 + Math.random());
warn(
`event stream reconnecting in ${Math.round(jittered)}ms (${
e instanceof Error ? e.message : String(e)
})`,
);
await new Promise((r) => setTimeout(r, jittered));
backoff = Math.min(backoff * 2, BACKOFF_CAP_MS);
}
}
} finally {
abort.abort(); // consumer went away — tear down any in-flight request
}
}
+160
View File
@@ -0,0 +1,160 @@
// The lifecycle-event wire schemas (RFC §4/§7) — hand-written as a discriminated union on
// `kind` for precise types and decode errors. The REST surface is generated (./gen/schemas.ts);
// the event stream's `text/event-stream` payload is not expressible there, and the host's
// Rust-side JSON snapshot tests (crates/punktfunk-host/src/events.rs) are the wire's source of
// truth — this file mirrors them. Additive-only within `schema: 1`: decoding tolerates unknown
// keys (Effect's default), and an unknown `kind` surfaces on the raw channel, never a throw.
import { Schema as S } from "effect";
export const Plane = S.Literal("native", "gamestream");
export type Plane = S.Schema.Type<typeof Plane>;
export const DisconnectReason = S.Literal("quit", "timeout", "error");
export type DisconnectReason = S.Schema.Type<typeof DisconnectReason>;
export const ClientRef = S.Struct({
name: S.String,
fingerprint: S.optional(S.String),
plane: Plane,
});
export type ClientRef = S.Schema.Type<typeof ClientRef>;
export const SessionRef = S.Struct({
id: S.Number,
client: S.String,
mode: S.String,
hdr: S.Boolean,
});
export type SessionRef = S.Schema.Type<typeof SessionRef>;
export const StreamRef = S.Struct({
mode: S.String,
hdr: S.Boolean,
client: S.String,
app: S.optional(S.String),
plane: Plane,
});
export type StreamRef = S.Schema.Type<typeof StreamRef>;
export const DeviceRef = S.Struct({
name: S.String,
fingerprint: S.String,
plane: Plane,
});
export type DeviceRef = S.Schema.Type<typeof DeviceRef>;
/** The `{seq, ts_ms, schema}` envelope every event carries. */
const envelope = {
seq: S.Number,
ts_ms: S.Number,
schema: S.Number,
} as const;
export const ClientConnected = S.Struct({
...envelope,
kind: S.Literal("client.connected"),
client: ClientRef,
});
export const ClientDisconnected = S.Struct({
...envelope,
kind: S.Literal("client.disconnected"),
client: ClientRef,
reason: DisconnectReason,
});
export const SessionStarted = S.Struct({
...envelope,
kind: S.Literal("session.started"),
session: SessionRef,
});
export const SessionEnded = S.Struct({
...envelope,
kind: S.Literal("session.ended"),
session: SessionRef,
});
export const StreamStarted = S.Struct({
...envelope,
kind: S.Literal("stream.started"),
stream: StreamRef,
});
export const StreamStopped = S.Struct({
...envelope,
kind: S.Literal("stream.stopped"),
stream: StreamRef,
});
export const PairingPending = S.Struct({
...envelope,
kind: S.Literal("pairing.pending"),
device: DeviceRef,
});
export const PairingCompleted = S.Struct({
...envelope,
kind: S.Literal("pairing.completed"),
device: DeviceRef,
});
export const PairingDenied = S.Struct({
...envelope,
kind: S.Literal("pairing.denied"),
device: DeviceRef,
});
export const DisplayCreated = S.Struct({
...envelope,
kind: S.Literal("display.created"),
backend: S.String,
mode: S.String,
});
export const DisplayReleased = S.Struct({
...envelope,
kind: S.Literal("display.released"),
count: S.Number,
});
export const LibraryChanged = S.Struct({
...envelope,
kind: S.Literal("library.changed"),
source: S.String,
});
export const HostStarted = S.Struct({
...envelope,
kind: S.Literal("host.started"),
version: S.String,
gamestream: S.Boolean,
});
export const HostStopping = S.Struct({
...envelope,
kind: S.Literal("host.stopping"),
});
/** Every known lifecycle event — discriminated on `kind`. */
export const HostEvent = S.Union(
ClientConnected,
ClientDisconnected,
SessionStarted,
SessionEnded,
StreamStarted,
StreamStopped,
PairingPending,
PairingCompleted,
PairingDenied,
DisplayCreated,
DisplayReleased,
LibraryChanged,
HostStarted,
HostStopping,
);
export type HostEvent = S.Schema.Type<typeof HostEvent>;
/** The known event kinds (for filters and the facade's `on()`). */
export type HostEventKind = HostEvent["kind"];
/** Narrow a HostEvent by kind: `EventOf<"stream.started">`. */
export type EventOf<K extends HostEventKind> = Extract<HostEvent, { kind: K }>;
export const decodeHostEvent = S.decodeUnknownEither(HostEvent);
/**
* Does `pattern` select `kind`? Exact kinds (`stream.started`) or `domain.*` prefixes on the
* dot boundary the same vocabulary as the host's SSE `?kinds=` filter and hooks `on:` field.
*/
export const kindMatches = (pattern: string, kind: string): boolean =>
pattern.endsWith(".*")
? kind.startsWith(pattern.slice(0, -1)) // "stream.*" → prefix "stream."
: pattern === kind;
+233
View File
@@ -0,0 +1,233 @@
// The runner's contract: discovery (scripts + plugin packages + the sshd refusal), both plugin
// shapes running against a mock host, crash → supervised restart, one-shot scripts, and the
// M5 heart — structured interruption running scoped finalizers on shutdown.
import { afterAll, describe, expect, test } from "bun:test";
import { Effect, Fiber } from "effect";
import * as fs from "node:fs";
import * as path from "node:path";
import { discoverUnits, runner, superviseUnit } from "../src/runner.js";
const TOKEN = "runner-token";
// Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK.
const ROOT = path.join(import.meta.dir, "..", `.runner-fixtures-${process.pid}`);
fs.mkdirSync(ROOT, { recursive: true });
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const mkdirs = (name: string) => {
const dir = path.join(ROOT, name);
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
fs.mkdirSync(path.join(dir, "plugins", "node_modules"), { recursive: true });
return {
scriptsDir: path.join(dir, "scripts"),
pluginsDir: path.join(dir, "plugins"),
dir,
};
};
const write = (file: string, content: string) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
fs.chmodSync(file, 0o644);
};
const mockHost = () =>
Bun.serve({
port: 0,
fetch(req) {
if (req.headers.get("authorization") !== `Bearer ${TOKEN}`)
return Response.json({ error: "no" }, { status: 401 });
const p = new URL(req.url).pathname;
if (p === "/api/v1/host") return Response.json({ hostname: "mock" });
if (p === "/api/v1/status") return Response.json({ ok: true });
return Response.json({ error: "nf" }, { status: 404 });
},
});
const waitFor = async (predicate: () => boolean, ms = 5000) => {
const deadline = Date.now() + ms;
while (!predicate()) {
if (Date.now() > deadline) throw new Error("condition never became true");
await new Promise((r) => setTimeout(r, 25));
}
};
describe("discovery", () => {
test("finds scripts + plugin packages, refuses world-writable files", () => {
const d = mkdirs("discover");
write(path.join(d.scriptsDir, "b-script.ts"), "export {};");
write(path.join(d.scriptsDir, "a-script.ts"), "export {};");
write(path.join(d.scriptsDir, "notes.txt"), "not code");
const evil = path.join(d.scriptsDir, "evil.ts");
write(evil, "export {};");
fs.chmodSync(evil, 0o777);
write(
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-x", "package.json"),
JSON.stringify({ name: "punktfunk-plugin-x", main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-x", "index.js"),
"export default { name: 'x', main: async () => {} };",
);
write(
path.join(d.pluginsDir, "node_modules", "unrelated-pkg", "package.json"),
JSON.stringify({ name: "unrelated-pkg" }),
);
const logs: string[] = [];
const units = discoverUnits(d, (l) => logs.push(l));
expect(units.map((u) => u.name)).toEqual([
"a-script",
"b-script",
"punktfunk-plugin-x",
]);
expect(logs.join("\n")).toContain("REFUSING");
expect(logs.join("\n")).toContain("evil.ts");
});
});
describe("supervision", () => {
test("async-fn plugin runs with a facade client; clean return completes", async () => {
const server = mockHost();
const d = mkdirs("fn-plugin");
const out = path.join(d.dir, "out.txt");
write(
path.join(d.scriptsDir, "fn.ts"),
`export default { name: "fn", main: async (pf) => {
const host = await pf.request("GET", "/host");
require("node:fs").writeFileSync(${JSON.stringify(out)}, host.hostname);
}};`,
);
const logs: string[] = [];
try {
const fiber = Effect.runFork(
runner({
...d,
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
log: (l) => logs.push(l),
}),
);
await waitFor(() => fs.existsSync(out));
expect(fs.readFileSync(out, "utf8")).toBe("mock");
await waitFor(() => logs.some((l) => l.includes("[fn] plugin completed")));
await Effect.runPromise(Fiber.interrupt(fiber));
} finally {
server.stop(true);
}
});
test("a crashing plugin is restarted with backoff", async () => {
const server = mockHost();
const d = mkdirs("crashy");
const counter = path.join(d.dir, "count.txt");
write(
path.join(d.scriptsDir, "crashy.ts"),
`import * as fs from "node:fs";
export default { name: "crashy", main: async () => {
const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, "utf8")) : 0;
fs.writeFileSync(${JSON.stringify(counter)}, String(n + 1));
throw new Error("boom " + n);
}};`,
);
const logs: string[] = [];
try {
const fiber = Effect.runFork(
runner({
...d,
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
restartBase: "20 millis",
log: (l) => logs.push(l),
}),
);
await waitFor(() => {
try {
return Number(fs.readFileSync(counter, "utf8")) >= 3;
} catch {
return false;
}
});
expect(logs.some((l) => l.includes("[crashy] failed: "))).toBe(true);
expect(logs.some((l) => l.includes("restarting (attempt 2)"))).toBe(true);
await Effect.runPromise(Fiber.interrupt(fiber));
} finally {
server.stop(true);
}
});
test("a bare script is one-shot: runs on import, never restarts", async () => {
const d = mkdirs("bare");
const counter = path.join(d.dir, "ran.txt");
write(
path.join(d.scriptsDir, "once.ts"),
`import * as fs from "node:fs";
const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, "utf8")) : 0;
fs.writeFileSync(${JSON.stringify(counter)}, String(n + 1));`,
);
const logs: string[] = [];
const fiber = Effect.runFork(
runner({ ...d, restartBase: "20 millis", log: (l) => logs.push(l) }),
);
await waitFor(() => logs.some((l) => l.includes("[once] script completed")));
await new Promise((r) => setTimeout(r, 200)); // would have restarted by now
expect(fs.readFileSync(counter, "utf8")).toBe("1");
await Effect.runPromise(Fiber.interrupt(fiber));
});
test("shutdown interrupts an Effect plugin STRUCTURALLY — its finalizer runs", async () => {
const server = mockHost();
const d = mkdirs("finalizer");
const acquired = path.join(d.dir, "acquired.txt");
const released = path.join(d.dir, "released.txt");
write(
path.join(d.scriptsDir, "holder.ts"),
`import { Effect } from "effect";
import * as fs from "node:fs";
export default { name: "holder", main: Effect.scoped(Effect.gen(function* () {
yield* Effect.acquireRelease(
Effect.sync(() => fs.writeFileSync(${JSON.stringify(acquired)}, "1")),
() => Effect.sync(() => fs.writeFileSync(${JSON.stringify(released)}, "1")),
);
yield* Effect.never; // hold the resource for the plugin's lifetime
})) };`,
);
try {
const fiber = Effect.runFork(
runner({
...d,
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
log: () => {},
}),
);
await waitFor(() => fs.existsSync(acquired));
expect(fs.existsSync(released)).toBe(false);
await Effect.runPromise(Fiber.interrupt(fiber)); // the SIGTERM path
await waitFor(() => fs.existsSync(released));
} finally {
server.stop(true);
}
});
test("supervised unit ends cleanly when its plugin completes (no spin)", async () => {
const server = mockHost();
const d = mkdirs("done");
write(
path.join(d.scriptsDir, "done.ts"),
`export default { name: "done", main: async () => {} };`,
);
const logs: string[] = [];
try {
await Effect.runPromise(
superviseUnit(
{ name: "done", file: path.join(d.scriptsDir, "done.ts") },
{
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
log: (l) => logs.push(l),
restartBase: "10 millis",
},
),
);
expect(logs.filter((l) => l.includes("restarting")).length).toBe(0);
} finally {
server.stop(true);
}
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test";
import { SseAuthError, SseParser, sseFrames } from "../src/sse.js";
import type { ResolvedConfig } from "../src/config.js";
describe("SseParser", () => {
test("parses frames split across arbitrary chunks, skipping comments", () => {
const p = new SseParser();
let frames = p.push("id: 4\nevent: library.ch");
expect(frames.length).toBe(0);
frames = p.push('anged\ndata: {"seq":4}\n\n: keep-alive\n\nid: 5\n');
expect(frames.length).toBe(1);
expect(frames[0]).toEqual({ event: "library.changed", data: '{"seq":4}', id: "4" });
frames = p.push("data: x\n\n");
expect(frames.length).toBe(1);
expect(frames[0]?.id).toBe("5");
expect(frames[0]?.event).toBe("message");
});
test("joins multi-line data and handles CRLF", () => {
const p = new SseParser();
const frames = p.push("data: a\r\ndata: b\r\n\r\n");
expect(frames[0]?.data).toBe("a\nb");
});
});
const cfgFor = (port: number, token = "t"): ResolvedConfig => ({
url: `http://127.0.0.1:${port}`,
token,
fetch,
});
describe("sseFrames", () => {
test("reads frames, reconnects with Last-Event-ID after a server close", async () => {
const lastEventIds: Array<string | null> = [];
let connection = 0;
const server = Bun.serve({
port: 0,
fetch(req) {
lastEventIds.push(req.headers.get("last-event-id"));
connection += 1;
const first = connection === 1;
const body = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
if (first) {
controller.enqueue(enc.encode('id: 1\nevent: library.changed\ndata: {"seq":1}\n\n'));
controller.close(); // server closes → client must reconnect
} else {
controller.enqueue(enc.encode('id: 2\nevent: library.changed\ndata: {"seq":2}\n\n'));
// stay open
}
},
});
return new Response(body, { headers: { "content-type": "text/event-stream" } });
},
});
try {
const gen = sseFrames(cfgFor(server.port as number), { onWarning: () => {} });
const f1 = await gen.next();
expect(f1.value?.id).toBe("1");
const f2 = await gen.next(); // spans the reconnect
expect(f2.value?.id).toBe("2");
await gen.return(undefined);
// No `since` = live-tail-only: the first connect carries the beyond-tip cursor,
// the reconnect carries the last REAL id.
expect(lastEventIds[0]).toBe(String(Number.MAX_SAFE_INTEGER));
expect(lastEventIds[1]).toBe("1");
} finally {
server.stop(true);
}
});
test("401 is terminal (no retry loop)", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("{}", { status: 401 }),
});
try {
const gen = sseFrames(cfgFor(server.port as number), { onWarning: () => {} });
await expect(gen.next()).rejects.toBeInstanceOf(SseAuthError);
} finally {
server.stop(true);
}
});
});

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