Compare commits

...

24 Commits

Author SHA1 Message Date
enricobuehler 5f9a06d51f fix(packaging): install punktfunk-host on Ubuntu 24.04 LTS via a noble builder that bundles FFmpeg 8
The host .deb was built on the Ubuntu 26.04 rust-ci image, so dpkg-shlibdeps
baked in `Depends: libavcodec62` (FFmpeg 8) and a glibc-2.41 floor — making it
uninstallable on Ubuntu 24.04 LTS (FFmpeg 6.1 / libavcodec60, glibc 2.39; apt
reports the deps as "too recent"). The source floor (ffmpeg-next 8, libavcodec
>=61 APIs) means a straight 24.04 rebuild would fail too.

Build the host on Ubuntu 24.04 instead — lowering the glibc floor to 2.39 so one
binary runs on 24.04 -> 26.04 — and bundle a from-source LGPL FFmpeg 8 into the
package so it no longer depends on the distro libav*. Everything else the host
links is soname-compatible on 24.04 (opus is vendored via cmake; NVENC/libcuda
are dlopen-only, never link-time), and the only FFmpeg encoders used are
*_nvenc / *_vaapi (software H.264 fallback is the BSD-2 openh264 crate, not
FFmpeg libx264), so an LGPL build keeps the bundle license-clean.

- ci/rust-ci-noble.Dockerfile (new): ubuntu:24.04 builder; nv-codec-headers +
  FFmpeg 8 (--enable-nvenc --enable-vaapi, shared) -> /opt/ffmpeg; PKG_CONFIG_PATH.
- packaging/debian/build-deb.sh: BUNDLE_FFMPEG=1 copies libav*/libsw*/libpostproc
  into /usr/lib/punktfunk-host, patchelf-sets the rpath ($ORIGIN per-lib + binary
  --force-rpath), feeds them to dpkg-shlibdeps (captures libva2/libdrm2), and drops
  the libav* sonames from Depends. Normal (non-bundle) path unchanged.
- .gitea/workflows/deb.yml: split into build-publish (client/web/scripting on the
  26.04 image) and build-publish-host (noble image, BUNDLE_FFMPEG=1); parallel,
  identical version step, same apt distribution -> one universal host .deb.
- .gitea/workflows/docker.yml: build+push punktfunk-rust-ci-noble.
- packaging/debian/README.md: document the 24.04 LTS path + bundled local build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:08:55 +02:00
enricobuehler 7f639f7cf5 docs: link every client in the clients-page chooser + correctness sweep
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 56s
android / android (push) Successful in 13m53s
arch / build-publish (push) Successful in 12m20s
decky / build-publish (push) Successful in 19s
ci / bench (push) Successful in 5m50s
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 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 29s
deb / build-publish (push) Successful in 11m25s
ci / rust (push) Successful in 24m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m8s
The "Which should I use?" table on the clients page listed most client
names as bold text, not links — only the Decky plugin and pf-webos were
clickable — so the client references appeared broken. Link each client to
its section (or dedicated page), and fix a stale Windows headless command.

Repo-wide docs correctness/staleness pass against the code:
- steam-deck: client-not-found -> flatpak-not-found (the real backend code)
- install: host cert is punktfunk-host-windows_<ver>.cer, not ..._setup.cer
- configuration: GPU_PRIORITY_CLASS default is auto; 10BIT/444 are default-on
- how-it-works/index: GameStream/Moonlight is opt-in (--gamestream)
- roadmap: clipboard sync is shipped, not planned
- install-client: MSIX/cert artifacts are arch-suffixed (_x64/_arm64)
- requirements: fix garbled 22H2/IddCx sentence
- status: Linux encode also covers AMD/Intel (VAAPI/Vulkan Video)
- automation: add the plugins.changed event
- windows-host: note the optional bundled VB-CABLE virtual mic
- sway: PUNKTFUNK_COMPOSITOR=hyprland is a wlroots-family alias
- running-as-a-service: punktfunk-probe is a source-build-only dev tool

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:50:29 +02:00
enricobuehler 2c0aee3979 chore(plugins): regenerate OpenAPI spec + SDK client for /api/v1/plugins
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m13s
apple / swift (push) Successful in 1m22s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
ci / bench (push) Successful in 6m40s
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 15s
apple / screenshots (push) Successful in 6m26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m52s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m57s
deb / build-publish (push) Successful in 13m13s
windows-host / package (push) Successful in 15m54s
arch / build-publish (push) Successful in 17m25s
android / android (push) Successful in 18m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m50s
ci / rust (push) Successful in 27m22s
docker / deploy-docs (push) Successful in 23s
Regenerated api/openapi.json (host built on Linux) with the plugins tag —
registerPlugin/listPlugins/deregisterPlugin/getPluginUiCredential + the
PluginRegistration/PluginSummary/PluginUi/PluginUiPublic/UiCredential schemas —
and the SDK's generated client. Drift test + 44 mgmt/events host tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:48:37 +02:00
enricobuehler ec84b30eae feat(plugins): console-hosted plugin UI surface (host registry + SDK servePluginUi + console proxy/nav)
Implements planning/design/plugin-ui-surface.md (U1-U3):

- host: in-memory lease-based plugin registry (mgmt/plugins.rs) — PUT/GET/DELETE
  /api/v1/plugins + GET /plugins/{id}/ui-credential; bearer+loopback only (not on
  the mTLS read-only allowlist); plugins.changed event; port-only registration
  (proxy always dials 127.0.0.1); secret never in the listing.
- sdk: servePluginUi — loopback ephemeral bind + per-boot secret + constant-time
  check + /__health + static/SPA-fallback + register/renew(30s)/deregister via
  pf.request (skew-proof, D7). Example + tests.
- console: /plugin-ui/{id}/** reverse proxy (server-side secret injection, cookie
  strip, SSE streaming, stale-secret 401-retry) + credential cache; BFF denylist
  for the credential endpoint; dynamic Plugins nav (desktop + mobile) fed by a
  polled list; iframe-in-shell page with health probe, offline card, open-in-tab,
  deep-link sync. Dev-mode /plugin-ui middleware in vite.config.ts.

OpenAPI regen for the new endpoints follows in the next commit (built on Linux).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:48:37 +02:00
enricobuehler d579cd318e ci(windows-host): lint host + tray in --release so clippy reuses the release build
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m13s
docker / deploy-docs (push) Successful in 12s
ci / rust (push) Successful in 19m29s
deb / build-publish (push) Successful in 11m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m54s
apple / swift (push) Successful in 1m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m34s
windows-host / package (push) Successful in 16m22s
apple / screenshots (push) Successful in 6m18s
ci / web (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m38s
android / android (push) Successful in 13m16s
ci / bench (push) Successful in 6m0s
arch / build-publish (push) Successful in 11m57s
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 17s
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 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
The clippy step ran in the default (debug) profile, compiling the whole
dep tree into a second target dir (C:\t\debug) — a second full build of
openh264-sys2's vendored C++ (pf-encode's software-H.264 fallback) on top
of the release copy the Build steps already produced. That second cc-rs
cl.exe fan-out tips the self-hosted runner into
`cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)`
— environmental disk/temp exhaustion, not a source error: the identical
file compiles fine in the release build minutes earlier, and openh264 is
untouched here. Linting in --release reuses the release build-script
artifacts (no openh264 rebuild) and keeps everything in one C:\t\release
tree. Matches pf-vkhdr-layer's clippy, which already runs --release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 02:12:43 +02:00
enricobuehler bb755ef7d2 fix(gamestream): generate the RSA host identity via the rsa crate, not rcgen (ring can't RSA-keygen)
apple / screenshots (push) Successful in 6m30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m54s
android / android (push) Successful in 13m24s
windows-host / package (push) Successful in 18m37s
deb / build-publish (push) Successful in 12m42s
arch / build-publish (push) Successful in 16m12s
ci / rust (push) Successful in 27m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m29s
docker / deploy-docs (push) Successful in 27s
ci / web (push) Successful in 1m22s
apple / swift (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m29s
decky / build-publish (push) Successful in 26s
ci / bench (push) Successful in 6m16s
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 12s
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 14s
The workspace went ring-only in 0.13.0 (aws-lc-sys breaks Windows CI), and
`ring` can sign with an existing RSA key but cannot *generate* one —
rcgen's ring backend returns `KeyGenerationUnavailable` for
`generate_for(&PKCS_RSA_SHA256)`. So `ServerIdentity::{load_or_create,
ephemeral}` panics with "rcgen RSA keygen / There is no support for
generating keys for the given algorithm" on any host without an existing
`cert.pem`. This is the shared trust root for both the GameStream TLS cert
and the QUIC identity clients pin, so a *fresh* 0.13.0 install can't start
the host at all (existing dev boxes survive only because they already have
a cert on disk); in CI every test that builds a `ServerIdentity` (all of
`mgmt::tests`, `gamestream::nvhttp`, and the `native::tests` synthetic host
via `serve()`) failed — 30 failures from this one cause.

Moonlight requires an RSA-2048 identity, so generate the key with the
pure-Rust `rsa` crate (already a dependency for the pairing signer) and
hand its PKCS#8 PEM to rcgen, whose ring backend *can* load and self-sign
with an externally supplied RSA key (`from_pkcs8_pem_and_sign_algo` →
`RsaKeyPair::from_pkcs8`). Return that same PEM so it stays byte-identical
to what `from_pems` re-parses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:59:28 +02:00
enricobuehler 08a397cf08 fix(transport): treat Windows WSAENOBUFS as a transient send drop, not a stream teardown
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 9m17s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 57s
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 21s
apple / swift (push) Successful in 1m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
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 11s
ci / bench (push) Successful in 7m7s
flatpak / build-publish (push) Failing after 8m7s
docker / deploy-docs (push) Successful in 22s
release / apple (push) Successful in 9m3s
windows-host / package (push) Failing after 11m41s
deb / build-publish (push) Successful in 13m34s
ci / rust (push) Failing after 14m2s
arch / build-publish (push) Successful in 14m59s
android / android (push) Successful in 16m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m16s
apple / screenshots (push) Successful in 6m38s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m38s
`is_transient_io` only recognized `ENOBUFS` on unix; on Windows the
`#[cfg(not(unix))]` arm returned `false`, so `WSAENOBUFS` (10055) — which
Rust maps to `ErrorKind::Uncategorized`, missing the `WouldBlock` arm —
propagated out of the USO `send_gso` path through `Session::send_sealed`
and killed the session with `native::stream` "send failed — stopping
stream". A high-bitrate keyframe burst (one `WSASendMsg` USO super-buffer
is up to ~512 segments) momentarily exhausts the socket send buffer / AFD
non-paged pool; it's a lossy drop that FEC + the next frame recover,
exactly like the unix `ath11k` `ENOBUFS` case the classifier already
handles. Fires independently of client platform (Windows/macOS clients
both saw the crash) because it's the Windows *host's* send socket.

Add a `#[cfg(windows)]` arm matching `WSAENOBUFS` plus the
`WSAENET*`/`WSAEHOST*` network-path family (the Windows counterparts of
the droppable unix set), and extend the classifier test with per-platform
raw-errno coverage so the Windows CI runner exercises the 10055 path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:43:15 +02:00
enricobuehler 11cca33300 fix(host/hooks): honor hand-edits to hooks.json without a restart
android / android (push) Successful in 19m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m51s
windows-host / package (push) Successful in 18m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m58s
ci / rust (push) Failing after 14m6s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m21s
decky / build-publish (push) Successful in 20s
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 12s
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 9s
ci / bench (push) Successful in 5m56s
apple / screenshots (push) Successful in 6m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 12m33s
arch / build-publish (push) Successful in 18m31s
docs/automation.md promises 'changes apply immediately, no restart' for BOTH
config paths, but the store loaded hooks.json once (OnceLock) — only PUT
/api/v1/hooks applied live, and a hand-edited file silently did nothing until
the next host start. Since the file is the accessible path most users will
take, make it real: get() re-stats the file per event (mtime + length) and
re-reads it on change, with the same lenient contract as startup (missing =
no hooks, invalid = disabled loudly). set() records the identity it wrote so
the API path doesn't trigger a spurious reload.

Validated live on the 0.13.0 host (.21): full connect/disconnect lifecycle
fires exec hooks (client.*/session.*/stream.*) — the reload gap was found
setting that test up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:53:25 +02:00
enricobuehler 4d2632a845 fix(client/deps): ring-only rustls in pf-client-core — finish dropping aws-lc-sys
audit / cargo-audit (push) Successful in 2m30s
deb / build-publish (push) Successful in 13m42s
web-screenshots / screenshots (push) Successful in 2m47s
flatpak / build-publish (push) Successful in 6m2s
ci / bench (push) Successful in 6m1s
android-screenshots / screenshots (push) Successful in 3m59s
apple / screenshots (push) Successful in 6m27s
decky / build-publish (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m46s
docker / deploy-docs (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m2s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 9m18s
ci / rust (push) Failing after 17m36s
windows-host / package (push) Successful in 20m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m5s
linux-client-screenshots / screenshots (push) Successful in 7m54s
arch / build-publish (push) Successful in 13m5s
android / android (push) Successful in 13m44s
release / apple (push) Successful in 10m22s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m17s
apple / swift (push) Successful in 1m25s
audit / bun-audit (push) Successful in 16s
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 57s
Companion to the host ring fix: pf-client-core declared rustls with
features=["ring"] but WITHOUT default-features=false, so rustls's default
aws-lc-rs backend came along too (feature tree: aws-lc-rs <- rustls default <-
pf-client-core default) — pulling aws-lc-sys 0.41.0 into the Windows client +
session + presenter builds, the same C dep that fails on the CI runner. Pin
default-features=false + [ring, logging, std, tls12] (matching the tray/host/core
pattern). aws-lc-sys now gone from EVERY windows-built crate (host, client-windows,
client-session, tray — all 'cargo tree -i aws-lc-sys' => no match). Code already
used rustls::crypto::ring::default_provider (library.rs); no behaviour change.

Validated: cargo tree (all 4 windows crates aws-lc-free) + .21 clippy -D warnings
(pf-client-core, pf-presenter, punktfunk-client-session) clean.
2026-07-17 22:26:04 +02:00
enricobuehler 17a262aace fix(host/deps): ring-only crypto — drop aws-lc-sys (fails to build on the Windows CI)
android / android (push) Has been cancelled
audit / bun-audit (push) Successful in 14s
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
decky / build-publish (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (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 Windows host clippy CI broke building aws-lc-sys 0.41.0 (a heavy C dep that
cl.exe fails to compile on the runner). The host was the ONLY thing pulling it:
its rcgen used features=["aws_lc_rs"], and bare `rustls`/`tokio-rustls` take
the default (aws-lc-rs) backend, and gamestream/{mod,tls}.rs installed the
aws_lc_rs crypto provider explicitly — a pre-existing deviation from the
project's documented ring-only design (punktfunk-core + the client are ring).

Align the host to ring: rcgen/rustls/tokio-rustls pinned to the `ring` feature
(keeping tls12 for Moonlight/GameStream TLS-1.2 clients) and the two GameStream
provider installs switched to `rustls::crypto::ring::default_provider()`. Both
providers offer the same standard TLS 1.2/1.3 suites, so it's transparent to the
TLS paths; aws-lc-sys/aws-lc-rs leave the tree entirely (`cargo tree -i
aws-lc-sys` => no match). Not caused by the preceding log fix (that touched no
crypto deps / the lock).

Validated: cargo tree (aws-lc-sys gone, windows target, amf-qsv,qsv features) +
.21 host clippy -D warnings (--features nvenc) clean. Owed: GameStream on-glass
re-check with a real Moonlight client (low risk — same TLS suites, client is
already ring).
2026-07-17 22:21:23 +02:00
enricobuehler 205e5c5a59 fix(apple): recover dropped macOS client features lost in the W7/W8 refactor
apple / swift (push) Successful in 1m20s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Successful in 24s
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 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (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
The W7/W8 client refactor (extracting FullscreenController/ApprovalRequest out of
ContentView, splitting Settings) landed the DefaultsKeys + scaffolding but dropped
the feature WIRING, which was uncommitted WIP shelved in a stash during the
16:28 clipboard/v4 reconcile. Recovered from that stash (+ its untracked-files
parent for the whole new ModifierLayout.swift) and re-ported onto the refactored
tree:

- invertScroll: applied in InputCapture.sendScroll (the one scroll sink) + Settings toggle
- modifierLayout (Cmd/Option switch): restored ModifierLayout.swift enum + KeyMaps
  .applyModifierLayout + InputCapture.emitKey wire-boundary + Settings picker
- fullscreen shortcut (Ctrl+Cmd+F): InputCapture ⌃⌘F interception + onToggleFullscreen
  + StreamView wiring + Stream-menu item (the .punktfunkToggleFullscreen sink +
  FullscreenController observer already survived on main)
- render scale: Settings picker + MatchWindowFollower renderScale/maxDimension
  application + StreamView/StreamViewIOS plumbing (RenderScale.swift already on main)

StreamCommands re-ported by hand (the stash hunk deleted the since-landed clipboard
item — kept it, added the fullscreen item alongside). Recovered ModifierLayout +
RenderScale tests. swift build green; 15 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:20:46 +02:00
enricobuehler 3584b47fb8 fix(host/log): drop the wasapi crate's once-a-second DEBUG device-poll from the logs
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 5m16s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m15s
arch / build-publish (push) Has been cancelled
ci / rust (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 / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
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) Has been cancelled
android / android (push) Successful in 16m4s
The `wasapi` audio crate polls the default output device ~1×/s and
`log::debug!`s it (`wasapi::api default device Ok(...)`), flooding host.log
even at RUST_LOG=info: those `log`-crate records reach tracing through the
tracing-log bridge, and at *filter* time they carry the bridge's "log" shim
target, so neither a bare-info EnvFilter nor a `wasapi=warn` directive on the
file layer matches them (the ring already drops them post-hoc via
normalized_metadata + NOISY_DEBUG_TARGETS — the fmt layer filters pre-event and
can't).

Fix at the source: a shared `log_capture::install_global` replaces
SubscriberInitExt::init in both init paths (service file log + interactive
stderr) and inits the tracing-log bridge with `ignore_crate("wasapi")`, so the
poll records are dropped before they ever become tracing events — both sinks go
quiet, while every other log-crate dependency still reaches the console ring
(bridge max-level stays DEBUG). `wasapi` also added to NOISY_DEBUG_TARGETS as
belt-and-suspenders, and `log` moved dev-dep -> dep for LevelFilter.

Validated: .21 clippy (host --features nvenc) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:06:14 +02:00
enricobuehler de651c0f1a chore(gitignore): ignore Gradle build output in the vendored pyrowave Granite Android platform
apple / swift (push) Successful in 1m24s
apple / screenshots (push) Successful in 6m21s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 5m14s
release / apple (push) Successful in 11m7s
windows-host / package (push) Failing after 12m38s
android-screenshots / screenshots (push) Successful in 3m23s
decky / build-publish (push) Successful in 23s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m22s
ci / rust (push) Successful in 29m2s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m41s
linux-client-screenshots / screenshots (push) Successful in 7m50s
android / android (push) Successful in 13m43s
web-screenshots / screenshots (push) Successful in 3m16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 20s
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 (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 16s
docker / deploy-docs (push) Successful in 30s
arch / build-publish (push) Successful in 14m35s
deb / build-publish (push) Successful in 15m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m18s
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:36:49 +02:00
enricobuehler e7a8cf2bba feat(packaging): nix web console + plugin/script runner everywhere; fix tray zbus/tokio crash
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m24s
ci / docs-site (push) Successful in 59s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 17s
decky / build-publish (push) Successful in 25s
windows-host / package (push) Successful in 19m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
android / android (push) Successful in 16m12s
arch / build-publish (push) Successful in 12m14s
ci / bench (push) Successful in 5m27s
apple / screenshots (push) Successful in 6m32s
ci / rust (push) Successful in 19m26s
deb / build-publish (push) Successful in 12m58s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m51s
Ship the management web console and the plugin/script runner across the packaging
matrix, and fix a Linux tray startup crash.

- nix: add punktfunk-web (Nitro SSR on bun) and punktfunk-scripting (Effect runner),
  with services.punktfunk.{web,scripting} modules. Both bun node_modules come from
  fixed-output derivations pinned to real deps hashes; patchShebangs the web
  node_modules so orval/paraglide/vite run in the sandbox. Validated on x86_64-linux
  (nix build .#punktfunk-{web,scripting,tray,host} all green).

- punktfunk-scripting now ships for deb/rpm/arch/nix/windows, mirroring the web-console
  mechanics (vendored bun on deb/rpm/arch, pkgs.bun on nix; one self-contained
  `bun build --target=bun` bundle). Opt-in: host Recommends it, but the systemd --user
  unit ships un-started (Windows registers its scheduled task disabled).

- fix(tray): build punktfunk-tray in its OWN cargo invocation on nix/rpm/arch. Cargo
  unifies features across one `cargo build`, so co-building the tray with the host pulls
  the host's ashpd->zbus/tokio onto the tray's shared zbus and it panics at startup
  ("there is no reactor running, must be called from the context of a Tokio 1.x
  runtime"). Isolated, the tray's zbus stays on async-io. The .deb already did this;
  runtime-verified on nix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:16:34 +02:00
enricobuehler fb92dcd1c2 fix(release): regen managed dev profiles at archive (App Groups + widgets); widgets iOS target 27→17
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
apple / swift (push) Successful in 1m15s
decky / build-publish (push) Successful in 22s
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 14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / bench (push) Successful in 6m36s
docker / deploy-docs (push) Successful in 26s
release / apple (push) Successful in 9m13s
arch / build-publish (push) Successful in 12m10s
android / android (push) Successful in 14m2s
apple / screenshots (push) Successful in 6m27s
deb / build-publish (push) Successful in 17m9s
ci / rust (push) Successful in 20m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m3s
The iOS/tvOS/macOS-AppStore archive steps sign development-automatic OFFLINE
(no -allowProvisioningUpdates), so they depend on Xcode's cached managed dev
profiles. Adding the App Groups capability (group.io.unom.punktfunk, shared with
the Widget/Live-Activity extension) invalidated the cached "iOS Team Provisioning
Profile: io.unom.punktfunk" (it carries no application-groups entitlement) and
there is no managed dev profile for the embedded io.unom.punktfunk.widgets at all
— so the archive fails with "doesn't include the App Groups capability" and
"No profiles for io.unom.punktfunk.widgets".

Add -allowProvisioningUpdates + the ASC auth key (asc.p8 already written to
RUNNER_TEMP) to all three archive commands so Xcode syncs the App ID capabilities
and regenerates/creates the managed *development* profiles. This is development
signing against the Apple Development cert already in the keychain — no cert
creation, so the App-Manager ASC key suffices; the manual distribution export
steps are untouched.

Also drop the widgets IPHONEOS_DEPLOYMENT_TARGET from 27.0 (an iOS-27-beta-era
value, out of range on the runner's iPhoneOS26.5 SDK) to 17.0, matching the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:14:23 +02:00
enricobuehler 8e75f3d8bf perf(latency): adaptive IDD pipeline depth — depth-1 default, escalate on contention
ci / web (push) Successful in 55s
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
apple / swift (push) Successful in 1m17s
decky / build-publish (push) Successful in 25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
ci / bench (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 25s
apple / screenshots (push) Successful in 6m22s
arch / build-publish (push) Successful in 11m59s
android / android (push) Successful in 14m53s
deb / build-publish (push) Successful in 16m23s
windows-host / package (push) Successful in 19m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m33s
ci / rust (push) Successful in 21m0s
From the 2026-07-17 on-glass finding (.173 RTX 4090): the IDD-push capturer's
depth-2 pipeline measured ~13ms of glass-to-glass latency over depth-1 at
60fps (17ms → 4ms) — the AU is ready in µs but depth-2 holds it a whole frame
interval unpolled while N+1 is submitted. Depth-2 only earns its keep under
GPU contention (it overlaps the convert of N+1 with the encode of N, avoiding
the depth-1 ~50fps collapse). So the native encode loop now runs depth-1 by
default and escalates to the capturer's max ONLY when it can't hold cadence at
depth-1 (a leaky-bucket over 'the frame's work overran its pacing deadline',
with a startup warmup skip), then holds there for the session (no oscillation;
de-escalation is a v2 item). PUNKTFUNK_IDD_ADAPTIVE=0 pins the capturer's full
depth (pre-adaptive behaviour); no effect where the capturer's max is already
1 (every non-IDD backend). GameStream plane untouched.

Pairs with the shipped REALTIME auto-gate (1bc156aa): uncontended sessions get
the ~13ms latency cut, genuinely GPU-bound ones keep the depth-2 overlap AND
the REALTIME priority. Validated: .21 clippy -D warnings (host --features nvenc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:48:55 +02:00
enricobuehler 5e1e64e50b perf(latency): T2.5b — NV12 compute CSC on the LINEAR/gamescope zero-copy path
apple / swift (push) Successful in 1m21s
apple / screenshots (push) Successful in 6m23s
ci / web (push) Successful in 54s
arch / build-publish (push) Successful in 10m52s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 35s
android / android (push) Successful in 13m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 6m8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m56s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m44s
docker / deploy-docs (push) Successful in 24s
deb / build-publish (push) Successful in 11m52s
ci / rust (push) Successful in 27m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m20s
design/latency-reduction-2026-07.md T2.5's Linux half: the LINEAR dmabuf path
(gamescope's only offer) fed NVENC RGB, paying its internal RGB->YUV CSC on
the SM the game is saturating — the exact contention §5.A removed everywhere
else. The Vulkan bridge now carries a buffer-to-buffer RGB->NV12 compute
shader (rgb2nv12_buf.comp, BT.709 limited, coefficient-identical to
pf-encode's rgb2yuv.comp; whole-word writes so no 8-bit-storage feature is
needed): import dmabuf -> dispatch CSC into the exportable buffer -> CUDA
de-strides both planes into a pooled two-plane NV12 buffer. PUNKTFUNK_NV12
(default-on) now covers LINEAR; a CSC failure latches RGB for the stream
(mid-frame fallback, no dropped frame); 4:4:4 LINEAR sessions stay RGB (never
silently subsample). New ImportKind::LinearNv12 rides the existing worker IPC
(appended last per the wire-tag rule); cursor stays downstream (blend_nv12).

Validated: .21 clippy -D warnings (pf-zerocopy/pf-capture/host+nvenc) + 17
zero-copy tests. Owed: on-glass gamescope session (visual + dmon sm% check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:14:20 +02:00
enricobuehler fbe1e62ef2 Merge commit '0c9d3ee3' into HEAD
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
2026-07-17 20:11:08 +02:00
enricobuehler df04c2c54f Merge commit '9da38b8d' into HEAD
apple / swift (push) Successful in 1m21s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 52s
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 20s
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 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 5m14s
windows-host / package (push) Failing after 8m11s
apple / screenshots (push) Successful in 5m22s
android / android (push) Has been cancelled
docker / deploy-docs (push) Successful in 30s
arch / build-publish (push) Successful in 16m33s
ci / rust (push) Successful in 19m17s
deb / build-publish (push) Successful in 18m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m9s
2026-07-17 20:01:13 +02:00
enricobuehler 1bc156aab0 perf(latency): T2.2 Linux NVENC two-thread retrieve + T2.3 REALTIME auto-gate
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
design/latency-reduction-2026-07.md tier 2, the two code-side halves:

- T2.2: the Linux direct-NVENC backend gains the two-thread retrieve
  (PUNKTFUNK_NVENC_ASYNC, the same opt-in knob as Windows): the session stays
  sync-mode (async events are Windows-only) but the blocking lock_bitstream
  moves to a dedicated pf-nvenc-out thread — the NVENC guide's sanctioned
  submit-thread/output-thread split. poll() drains completions non-blocking,
  submit() backpressures at PUNKTFUNK_NVENC_ASYNC_DEPTH (default 4) in-flight;
  map/unmap and every other session call stay on the encode thread; teardown
  joins the thread before destroying the session. Under a GPU-saturating game
  completed frames queue instead of serializing capture on the encode wait.
- T2.3: PUNKTFUNK_GPU_PRIORITY_CLASS gains 'auto' AND IT IS THE NEW DEFAULT
  (gpu-contention §5.C): HIGH immediately, then REALTIME where the documented
  NVIDIA+HAGS+near-full-VRAM NVENC hang cannot bite — HAGS probed once via
  D3DKMT WDDM_2_7_CAPS (off => REALTIME outright); HAGS on => a pf-gpu-prio
  monitor flips REALTIME<->HIGH on LOCAL-segment VRAM headroom (downgrade
  >92% of budget, restore <=85% for 3x2s polls). 'high' restores the old
  static default; 'realtime' pins it (operator owns the hazard).

Validated: .21 clippy -D warnings (punktfunk-host --features nvenc) against
the QSV-merged main; .133 Windows cargo check of pf-frame + punktfunk-host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:57:38 +02:00
enricobuehler 399ea9a0f1 merge(qsv): on-glass Arc validation round — MT-protect-before-SetHandle + live test matrix
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / docs-site (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (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
8/8 live tests green on the Arc Pro laptop (VPL 2.16): H264/HEVC SDR, HEVC
Main10+HDR metadata, AV1 10-bit, LTR-RFI clean recovery (no IDR; driver honors
mfxExtRefListCtrl on H264/HEVC/AV1), no-IDR bitrate retarget. Zero-copy
GetNativeHandle path confirmed (single textures, no array-slice hazard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:56:51 +02:00
enricobuehler 6f409caac7 merge(qsv): land the native QSV backend onto main
apple / swift (push) Successful in 1m22s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (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) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Reconciles the qsv line (libvpl-sys + qsv.rs + dispatch/probe wiring, verified
on the .133 Windows box: check + clippy + dispatcher link test + unit tests)
with the concurrent clipboard JPEG/GIF pass-through commits; Cargo.lock union
re-adds the libvpl-sys entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:49:12 +02:00
enricobuehler 055b95a181 fix(pf-clipboard): winfmt layout + wayland map test for the JPEG/GIF pass-through
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m23s
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 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 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
ci / bench (push) Successful in 6m25s
arch / build-publish (push) Successful in 11m18s
android / android (push) Successful in 13m57s
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / screenshots (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:33:51 +02:00
enricobuehler f4b52d0bf5 feat(clipboard): pass original JPEG/GIF through verbatim beside the PNG floor
android / android (push) Has been cancelled
audit / bun-audit (push) Successful in 14s
audit / cargo-audit (push) Successful in 2m29s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
apple / swift (push) Successful in 1m23s
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 13s
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 11s
ci / bench (push) Successful in 5m58s
ci / rust (push) Failing after 6m54s
release / apple (push) Successful in 5m52s
arch / build-publish (push) Failing after 11m7s
flatpak / build-publish (push) Failing after 8m7s
deb / build-publish (push) Successful in 13m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m31s
docker / deploy-docs (push) Successful in 24s
apple / screenshots (push) Successful in 6m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m5s
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Transcoding every image to PNG was the Phase-1 floor, but it bloats lossy
originals (a copied JPEG re-encoded lossless is 5-10x the bytes for zero
quality gain — feeding the render-timeout on constrained links) and flattens
GIFs to one frame. The wire already carries kind LISTS, so offer the original
format beside the portable fallback and let the destination pick:

- New wire kinds image/jpeg + image/gif (§3.5 extension; strings only, no
  protocol change). image/png stays the universal floor every peer accepts.
- macOS: public.jpeg / com.compuserve.gif rows pass through verbatim both
  directions; the PNG floor is announced whenever any image is present.
- Windows: registered JFIF/GIF formats read + promised verbatim; every image
  offer still promises CF_DIB, whose delayed render now fetches the richest
  offered kind (png > jpeg > gif) through the generalized image_to_dib.
- Linux maps gain the matching rows (native MIME pass-through).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:32:04 +02:00
97 changed files with 5006 additions and 360 deletions
+5 -4
View File
@@ -1,4 +1,4 @@
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from # Build the punktfunk-host / punktfunk-client / punktfunk-web / punktfunk-scripting pacman packages from
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes # packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree). # get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container # Arch is rolling, so the packages build against whatever the archlinux:base-devel container
@@ -45,8 +45,9 @@ jobs:
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \ git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \ gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1); # bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# it's AUR-only on Arch, so bootstrap the official binary. # their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
# the official binary.
command -v bun >/dev/null || { command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
@@ -105,7 +106,7 @@ jobs:
sudo -u builder git config --global --add safe.directory "$PWD" sudo -u builder git config --global --add safe.directory "$PWD"
mkdir -p dist && chown builder: dist mkdir -p dist && chown builder: dist
cd packaging/arch cd packaging/arch
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \ sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \ PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \ CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
makepkg -f -d --holdver makepkg -f -d --holdver
+121 -18
View File
@@ -1,8 +1,18 @@
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian # Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs # boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the # apt distribution/component:
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the #
# target boxes run. # build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
#
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
# host and client packages always share a version line. The release-attach helpers are race-safe
# (ensure_release create-or-fetch; upsert_asset only conflicts on same-name), so the jobs run parallel.
# #
# Registry (public, unom org): https://git.unom.io/unom/-/packages # Registry (public, unom org): https://git.unom.io/unom/-/packages
# Box setup (once): see packaging/debian/README.md # Box setup (once): see packaging/debian/README.md
@@ -83,22 +93,15 @@ jobs:
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}- restore-keys: cargo-target-v3-${{ env.rustc }}-
- name: Build release host + client - name: Build release clients
env: env:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs) PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: | run: |
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect — # punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
# both client binaries must ship (build-client-deb.sh installs both). # both client binaries must ship (build-client-deb.sh installs both). The HOST is built
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on # separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
- name: Build + smoke-boot web console (bun preset) - name: Build + smoke-boot web console (bun preset)
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset # Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
@@ -128,10 +131,12 @@ jobs:
- name: Build .debs - name: Build .debs
run: | run: |
export PATH="$HOME/.bun/bin:$PATH" export PATH="$HOME/.bun/bin:$PATH"
VERSION="$VERSION" bash packaging/debian/build-deb.sh # host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading. # Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
- name: Publish to the Gitea apt registry - name: Publish to the Gitea apt registry
env: env:
@@ -166,3 +171,101 @@ jobs:
for DEB in dist/*.deb; do for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB" upsert_asset "$RID" "$DEB"
done done
# ---------------------------------------------------------------------------------------------
# The HOST .deb — built on Ubuntu 24.04 (noble) with a from-source FFmpeg 8 BUNDLED, so it installs
# on Ubuntu 24.04 LTS through 26.04 (see the file header + packaging/debian/README.md). Runs in
# parallel with build-publish and publishes to the SAME distribution/component; the version step is
# byte-identical so host and clients share a version line.
build-publish-host:
runs-on: ubuntu-24.04
container:
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Version + channel
run: |
git config --global --add safe.directory "$PWD"
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "host package version $V -> apt distribution '$DIST'"
# dpkg-shlibdeps/dpkg-deb + patchelf are baked into rust-ci-noble; python3 is for the
# release-attach helper. Re-install defensively so the job stays green against the PREVIOUS
# image on the same push (docker.yml bootstrap lag) — a no-op once the image ships them.
- name: dpkg-dev + patchelf + python3
run: |
apt-get update
apt-get install -y --no-install-recommends dpkg-dev patchelf python3
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
with:
path: target
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
# ci.yml's 26.04 target cache (mixing would poison both).
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
- name: Build release host
env:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
run: |
git config --global --add safe.directory "$PWD"
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
# via PKG_CONFIG_PATH (set in rust-ci-noble).
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-tray
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
# binary's rpath, so there is no `Depends: libavcodec62` to block install on 24.04. FFMPEG_PREFIX
# defaults to /opt/ffmpeg (the image's ENV also sets it).
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for DEB in dist/*.deb; do
echo "uploading $DEB"
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done
echo "published host to $OWNER/debian $DISTRIBUTION/$COMPONENT"
- name: Attach the host .deb to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
+6
View File
@@ -39,6 +39,12 @@ jobs:
- image: punktfunk-rust-ci - image: punktfunk-rust-ci
dockerfile: ci/rust-ci.Dockerfile dockerfile: ci/rust-ci.Dockerfile
context: ci context: ci
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
- image: punktfunk-rust-ci-noble
dockerfile: ci/rust-ci-noble.Dockerfile
context: ci
- image: punktfunk-fedora-rpm - image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile dockerfile: ci/fedora-rpm.Dockerfile
context: ci context: ci
+33 -11
View File
@@ -258,9 +258,12 @@ jobs:
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support # sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves # provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox # the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs # entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile # regenerate the managed *development* profile — needed because the App Groups capability
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below # (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
# step below
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store # (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
# distribution profile that export needs. # distribution profile that export needs.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
@@ -271,6 +274,10 @@ jobs:
-destination 'generic/platform=macOS' \ -destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \ CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID" DEVELOPMENT_TEAM="$TEAM_ID"
@@ -308,11 +315,16 @@ jobs:
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER # license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
# this step used to set matched it and failed the archive ("does not support provisioning # this step used to set matched it and failed the archive ("does not support provisioning
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and # profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never # the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development* # capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below # AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the # capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
# manually-installed App Store distribution profile survives for export. # invalidated the cached managed dev profile, which had no widgets profile at all. This is
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
# App Store distribution profile survives for export.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk iOS App Store Distribution" PROFILE="Punktfunk iOS App Store Distribution"
@@ -325,6 +337,10 @@ jobs:
-destination 'generic/platform=iOS' \ -destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \ CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID" DEVELOPMENT_TEAM="$TEAM_ID"
@@ -366,9 +382,11 @@ jobs:
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the # resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and # sdk-scoped profile this step used to set; Automatic signing profiles only the app and
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/ # leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the # AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for # and regenerate the managed *development* profile — the tvOS app carries the App Groups key
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist). # (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
# DISTRIBUTION signing is the export step below (manual, plist).
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk tvOS App Store Distribution" PROFILE="Punktfunk tvOS App Store Distribution"
@@ -377,6 +395,10 @@ jobs:
-destination 'generic/platform=tvOS' \ -destination 'generic/platform=tvOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-skipMacroValidation -skipPackagePluginValidation \ -skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \ CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID" DEVELOPMENT_TEAM="$TEAM_ID"
+6 -4
View File
@@ -92,9 +92,10 @@ jobs:
echo "rpm $V-$R -> group '$GROUP'" echo "rpm $V-$R -> group '$GROUP'"
- name: Build RPM - name: Build RPM
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below # PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
# globs it in; the host RPM Recommends it). Needs bun (ensured in Prep). # punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh # Recommends both). Both need bun (ensured in Prep).
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md) - name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
env: env:
@@ -131,7 +132,8 @@ jobs:
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \ bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \ --out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \ dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
- name: Publish the sysext feed - name: Publish the sysext feed
env: env:
+33 -4
View File
@@ -1,7 +1,8 @@
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic # Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled # package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled # pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner # run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
# windows-amd64 runner
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's # (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below). # windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
# #
@@ -52,6 +53,7 @@ on:
- 'packaging/windows/**' - 'packaging/windows/**'
- 'scripts/windows/**' - 'scripts/windows/**'
- 'web/**' - 'web/**'
- 'sdk/**'
- 'Cargo.lock' - 'Cargo.lock'
- 'Cargo.toml' - 'Cargo.toml'
- '.gitea/workflows/windows-host.yml' - '.gitea/workflows/windows-host.yml'
@@ -143,9 +145,18 @@ jobs:
- name: Clippy (host + tray, Windows) - name: Clippy (host + tray, Windows)
shell: pwsh shell: pwsh
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
# --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
# into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
# vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
# steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
# `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
# disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
# pf-vkhdr-layer's clippy below runs --release.
run: | run: |
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" } cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" } cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh shell: pwsh
@@ -212,6 +223,24 @@ jobs:
if ($code -ne 200) { throw "web console failed to boot under bun" } if ($code -ne 200) { throw "web console failed to boot under bun" }
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build plugin/script runner bundle (bun)
shell: pwsh
# `bun build --target=bun` bundles the SDK's runner CLI to ONE self-contained JS (effect + the
# SDK inlined; the dynamic plugin import stays a runtime import). pack-host-installer.ps1 ships
# it (+ the shared bun) and registers its scheduled task DISABLED (opt-in). The SDK's deps are
# public npm (effect), so no @unom token is needed here.
run: |
$bun = $env:BUN_EXE
Push-Location sdk
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "sdk bun install failed ($LASTEXITCODE)" }
New-Item -ItemType Directory -Force -Path C:\t\scripting | Out-Null
& $bun build src/runner-cli.ts --target=bun --outfile=C:\t\scripting\runner-cli.js; if ($LASTEXITCODE) { throw "runner bundle build failed ($LASTEXITCODE)" }
Pop-Location
if (-not (Select-String -Path C:\t\scripting\runner-cli.js -Pattern 'attempt=' -Quiet)) {
throw "runner bundle missing the dynamic plugin import - wrong build"
}
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Pack + sign installer - name: Pack + sign installer
shell: pwsh shell: pwsh
env: env:
+4
View File
@@ -43,3 +43,7 @@ CLAUDE.md
/result /result
/result-* /result-*
.direnv/ .direnv/
# Gradle build output inside the vendored pyrowave Granite Android platform (regenerated, never ours to commit)
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/.gradle/
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/build/
Generated
+41 -37
View File
@@ -358,28 +358,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.9" version = "0.8.9"
@@ -762,6 +740,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -1028,12 +1012,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
@@ -1288,12 +1266,6 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -1510,6 +1482,16 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.22.6" version = "0.22.6"
@@ -2011,9 +1993,13 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"color_quant",
"gif",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png", "png",
"zune-core",
"zune-jpeg",
] ]
[[package]] [[package]]
@@ -3650,7 +3636,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [ dependencies = [
"aws-lc-rs",
"pem", "pem",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -3879,7 +3864,6 @@ version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [ dependencies = [
"aws-lc-rs",
"log", "log",
"once_cell", "once_cell",
"ring", "ring",
@@ -3944,7 +3928,6 @@ version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [ dependencies = [
"aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
@@ -5269,6 +5252,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "wide" name = "wide"
version = "0.7.33" version = "0.7.33"
@@ -6081,6 +6070,21 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.12.0" version = "5.12.0"
+329
View File
@@ -1931,6 +1931,184 @@
} }
} }
}, },
"/api/v1/plugins": {
"get": {
"tags": [
"plugins"
],
"summary": "List registered plugins",
"description": "The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry\nreports its id, title, optional version, and — for plugins that serve one — a UI descriptor\n(loopback port + icon). The console renders these as nav entries and proxies to the port; it\nfetches the secret separately, server-side.",
"operationId": "listPlugins",
"responses": {
"200": {
"description": "Live plugin registrations",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PluginSummary"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/plugins/{id}": {
"put": {
"tags": [
"plugins"
],
"summary": "Register or renew a plugin",
"description": "Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs\nthis every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console\nwill proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed\n(first registration, restart, or re-scan) — a pure renewal is silent.",
"operationId": "registerPlugin",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginRegistration"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Registered / renewed"
},
"400": {
"description": "Invalid id or registration",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"delete": {
"tags": [
"plugins"
],
"summary": "Deregister a plugin",
"description": "The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls\nthis from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was\nremoved. Idempotent — deleting an unknown/expired id is a no-op `204`.",
"operationId": "deregisterPlugin",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The plugin id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Deregistered (or already absent)"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/plugins/{id}/ui-credential": {
"get": {
"tags": [
"plugins"
],
"summary": "Fetch a plugin UI's proxy credential",
"description": "Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.\nBearer + loopback only (like every mutation), and additionally excluded from the console's browser\npassthrough: the secret never reaches a browser.",
"operationId": "getPluginUiCredential",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The plugin id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The proxy credential",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UiCredential"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No live plugin with that id, or it serves no UI",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/session": { "/api/v1/session": {
"delete": { "delete": {
"tags": [ "tags": [
@@ -3294,6 +3472,25 @@
} }
} }
}, },
{
"type": "object",
"required": [
"id",
"kind"
],
"properties": {
"id": {
"type": "string",
"description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set."
},
"kind": {
"type": "string",
"enum": [
"plugins.changed"
]
}
}
},
{ {
"type": "object", "type": "object",
"required": [ "required": [
@@ -4099,6 +4296,116 @@
"gamestream" "gamestream"
] ]
}, },
"PluginRegistration": {
"type": "object",
"description": "Register/renew body for `PUT /plugins/{id}`.",
"required": [
"title"
],
"properties": {
"title": {
"type": "string",
"description": "Human-readable title for the console nav entry (164 chars; control chars stripped)."
},
"ui": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/PluginUi",
"description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry."
}
]
},
"version": {
"type": [
"string",
"null"
],
"description": "Optional plugin version, purely informational (≤32 chars)."
}
}
},
"PluginSummary": {
"type": "object",
"description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).",
"required": [
"id",
"title"
],
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"ui": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/PluginUiPublic"
}
]
},
"version": {
"type": [
"string",
"null"
]
}
}
},
"PluginUi": {
"type": "object",
"description": "A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request\nbody, never a response ([`PluginUiPublic`] is the secret-free view).",
"required": [
"port",
"secret"
],
"properties": {
"icon": {
"type": [
"string",
"null"
],
"description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)."
},
"port": {
"type": "integer",
"format": "int32",
"description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:<port>`; a registration can never carry a hostname.",
"minimum": 0
},
"secret": {
"type": "string",
"description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts."
}
}
},
"PluginUiPublic": {
"type": "object",
"description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.",
"required": [
"port"
],
"properties": {
"icon": {
"type": [
"string",
"null"
]
},
"port": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"PortMap": { "PortMap": {
"type": "object", "type": "object",
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).", "description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
@@ -4710,6 +5017,24 @@
"primary", "primary",
"exclusive" "exclusive"
] ]
},
"UiCredential": {
"type": "object",
"description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser.",
"required": [
"port",
"secret"
],
"properties": {
"port": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"secret": {
"type": "string"
}
}
} }
}, },
"securitySchemes": { "securitySchemes": {
@@ -4772,6 +5097,10 @@
{ {
"name": "hooks", "name": "hooks",
"description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)" "description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"
},
{
"name": "plugins",
"description": "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"
} }
] ]
} }
+85
View File
@@ -0,0 +1,85 @@
# LTS builder for the punktfunk HOST .deb — Ubuntu 24.04 (noble), the current Ubuntu LTS.
#
# WHY THIS EXISTS (see packaging/debian/README.md → "Ubuntu 24.04 LTS"):
# The default builder (ci/rust-ci.Dockerfile) is Ubuntu 26.04, so the host .deb it produces bakes
# in a glibc 2.41 floor and a hard `Depends: libavcodec62, …` (FFmpeg 8). Ubuntu 24.04 LTS ships
# glibc 2.39 and FFmpeg 6.1 (libavcodec60), so that .deb is uninstallable there — apt reports the
# deps as "too recent". Building the host on 24.04 instead lowers the glibc floor to 2.39 (the
# binary then runs on 24.04 → 26.04), and the ONE library 24.04 is too old for — FFmpeg — is built
# from source here and BUNDLED into the .deb (packaging/debian/build-deb.sh, BUNDLE_FFMPEG=1), so
# the package no longer depends on the distro's libav* at all. Everything else the host links
# (PipeWire, Wayland, xkbcommon, GL/EGL/GBM, Vulkan; opus is vendored via cmake) is soname-compatible
# on 24.04, so this ONE universal host .deb replaces the 26.04-built one for every Ubuntu user.
#
# libcuda is deliberately NOT provided: the host dlopen's libcuda.so.1 at runtime (pf-zerocopy /
# pf-encode) and never link-imports it, so — unlike the full-workspace rust-ci image, which builds
# tests that DO link a cuda stub — this host-only build needs no NVIDIA driver package. NVENC/EGL
# come from whatever driver the target runs, out of band.
#
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-rust-ci-noble); consumed by the
# `build-publish-host` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci, the first deb.yml
# run after this image is added uses the image from a PRIOR docker.yml push — seed it once manually
# (docker build -f ci/rust-ci-noble.Dockerfile -t … ci && docker push) before the host job can run.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
dpkg-dev patchelf \
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
# encode backend the host auto-selects; zlib (libavformat). NVENC needs only headers (below), dlopen'd.
nasm libva-dev libdrm-dev zlib1g-dev \
# host link deps present on 24.04 with sonames compatible up to 26.04
libpipewire-0.3-dev libwayland-dev libxkbcommon-dev \
libgl-dev libegl-dev libgbm-dev libvulkan-dev \
&& rm -rf /var/lib/apt/lists/*
# --- FFmpeg 8 from source -> /opt/ffmpeg (shared libs + .pc files) ----------------------------------
# libavcodec.so.62, matching the 26.04 line's soname so the host behaves identically. This is an
# LGPL build (no --enable-gpl / --enable-nonfree) so bundling the .so's into an MIT/Apache .deb stays
# license-clean — LGPL's relink clause is satisfied by dynamic linking, and the only encoders the host
# calls (h264/hevc/av1 _nvenc + _vaapi, plus scale_vaapi/hwmap filters; software H.264 fallback is the
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
ARG FFMPEG_TAG=n8.0
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
ARG NVHDR_TAG=n12.2.72.0
RUN set -eux; \
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
make -C /tmp/nvhdr install PREFIX=/usr/local; \
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
cd /tmp/ffmpeg; \
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
--prefix=/opt/ffmpeg \
--enable-shared --disable-static \
--disable-doc --disable-programs --disable-debug \
--enable-nvenc --enable-vaapi \
--extra-cflags=-I/usr/local/include --extra-ldflags=-L/usr/local/lib; \
make -j"$(nproc)"; make install; \
cd /; rm -rf /tmp/ffmpeg /tmp/nvhdr; \
# sanity: the soname we expect to bundle (libavcodec.so.62 on FFmpeg 8)
test -e /opt/ffmpeg/lib/libavcodec.so.62
# ffmpeg-sys-next discovers FFmpeg via pkg-config; point it at the bundled build. PKG_CONFIG_PATH is
# PREPENDED to pkg-config's default dirs (not a replacement — that's PKG_CONFIG_LIBDIR), so PipeWire /
# Wayland / libva / … still resolve from the system. FFMPEG_PREFIX is read by build-deb.sh's bundler.
ENV PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig \
FFMPEG_PREFIX=/opt/ffmpeg
# Toolchain shared across CI users (jobs may run as different uids).
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
--component rustfmt,clippy \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo clippy --version && cargo fmt --version
@@ -712,7 +712,7 @@
INFOPLIST_FILE = PunktfunkWidgets/Info.plist; INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets; INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 27.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -757,7 +757,7 @@
INFOPLIST_FILE = PunktfunkWidgets/Info.plist; INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets; INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 27.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -72,6 +72,14 @@ struct StreamCommands: Commands {
} }
.keyboardShortcut("c", modifiers: [.control, .option, .shift]) .keyboardShortcut("c", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true) .disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
// Toggle the window's fullscreen. F is the macOS-standard fullscreen combo; here it's
// explicit so it's discoverable AND survives capture while streaming the stream view
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
// notification the key window's FullscreenController observes.
Button("Toggle Fullscreen") {
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
}
.keyboardShortcut("f", modifiers: [.control, .command])
#endif #endif
Divider() Divider()
Button("Disconnect") { session?.disconnect() } Button("Disconnect") { session?.disconnect() }
@@ -38,6 +38,7 @@ extension SettingsView {
} }
#endif #endif
#if !os(tvOS) #if !os(tvOS)
renderScaleRow
bitrateRows bitrateRows
#endif #endif
} header: { } header: {
@@ -54,6 +55,50 @@ extension SettingsView {
} }
} }
#if !os(tvOS)
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
@ViewBuilder var renderScaleRow: some View {
Picker("Render scale", selection: $renderScale) {
ForEach(RenderScale.presets, id: \.self) { scale in
Text(RenderScale.label(scale)).tag(scale)
}
}
// The concrete host resolution makes the cost legible. Only meaningful for the explicit mode
// (match-window derives the base from the live window, not these fields).
if renderScale != 1.0, !matchWindow {
let mode = RenderScale.apply(
baseWidth: width, baseHeight: height,
scale: renderScale,
maxDimension: RenderScale.maxDimension(codec: codec))
Text("Host renders \(Int(mode.width))×\(Int(mode.height)); this device downscales it to your display.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Keyboard & mouse forwarding applies wherever a hardware keyboard/mouse drives the stream
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
@ViewBuilder var inputSection: some View {
Section {
Picker("Modifier keys", selection: $modifierLayout) {
ForEach(ModifierLayout.allCases, id: \.self) { layout in
Text(layout.label).tag(layout.rawValue)
}
}
Toggle("Invert scroll direction", isOn: $invertScroll)
} header: {
Text("Keyboard & mouse")
} footer: {
Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail
+ " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#endif
#if os(iOS) #if os(iOS)
// MARK: - Stream mode (iOS wheel) // MARK: - Stream mode (iOS wheel)
@@ -1,5 +1,6 @@
// App settings. The host creates a native virtual output at exactly the chosen size/refresh // App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
// there is no scaling anywhere in the pipeline. // deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
// downscales supersampling for sharpness, or under-rendering for a lighter host/link).
// //
// Navigation differs per platform, but all three group the same categories (General, Display, // Navigation differs per platform, but all three group the same categories (General, Display,
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses // Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
@@ -25,6 +26,10 @@ struct SettingsView: View {
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays // windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window. // pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false @AppStorage(DefaultsKey.matchWindow) var matchWindow = false
// Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the
// presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host /
// link). 1.0 = Native (the prior behaviour).
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@@ -51,6 +56,12 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true @AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false @AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10 @AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
#if !os(tvOS)
// Keyboard & mouse forwarding (macOS + a hardware keyboard/mouse on iPad). Invert-scroll flips
// both wheel axes; modifier-layout relocates the / Alt/Super roles by physical position.
@AppStorage(DefaultsKey.invertScroll) var invertScroll = false
@AppStorage(DefaultsKey.modifierLayout) var modifierLayout = ModifierLayout.mac.rawValue
#endif
#if DEBUG && !os(tvOS) #if DEBUG && !os(tvOS)
@State var showControllerTest = false @State var showControllerTest = false
#endif #endif
@@ -112,6 +123,7 @@ struct SettingsView: View {
TabView { TabView {
Form { Form {
streamModeSection streamModeSection
inputSection
compositorSection compositorSection
wakeSection wakeSection
} }
@@ -242,6 +254,7 @@ struct SettingsView: View {
Form { Form {
streamModeSection streamModeSection
pointerSection pointerSection
inputSection
compositorSection compositorSection
wakeSection wakeSection
keepAliveSection // iOS-only content; empty on tvOS keepAliveSection // iOS-only content; empty on tvOS
@@ -334,6 +347,10 @@ struct SettingsView: View {
return ScrollView { return ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag) TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
TVSelectionRow(
title: "Render scale",
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
selection: $renderScale)
TVSelectionRow( TVSelectionRow(
title: "Bitrate", title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps), options: SettingsOptions.bitrateOptions(current: bitrateKbps),
@@ -29,6 +29,11 @@ public final class ClipboardSync: NSObject {
("text/rtf", .rtf), ("text/rtf", .rtf),
("text/html", .html), ("text/html", .html),
("image/png", .png), ("image/png", .png),
// Original image formats pass through VERBATIM beside the PNG floor a copied JPEG
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
// kind it can place.
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
] ]
/// Pasteboard marker types that must never cross the wire (password managers mark secrets /// Pasteboard marker types that must never cross the wire (password managers mark secrets
/// with these see nspasteboard.org). /// with these see nspasteboard.org).
@@ -205,11 +210,14 @@ public final class ClipboardSync: NSObject {
var kinds = Self.wireToPasteboard var kinds = Self.wireToPasteboard
.filter { types.contains($0.type) } .filter { types.contains($0.type) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) } .map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// Images: macOS image copies usually carry TIFF (browsers add WebP/AVIF/GIF, screenshots // PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// TIFF) and only sometimes PNG announce the portable `image/png` whenever ANY // native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
// convertible image type is present; `serveFetch` converts at fetch time (lazy, §3.5). // offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
// fallback costs nothing unless a peer actually pastes it.
if !kinds.contains(where: { $0.mime == "image/png" }), if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(.tiff) || types.contains(NSPasteboard.PasteboardType("public.heic")) types.contains(.tiff)
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
{ {
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png")) kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
} }
@@ -385,6 +393,8 @@ private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider
case .rtf: return "text/rtf" case .rtf: return "text/rtf"
case .html: return "text/html" case .html: return "text/html"
case .png: return "image/png" case .png: return "image/png"
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
default: return nil default: return nil
} }
} }
@@ -41,6 +41,7 @@ import UIKit
import Foundation import Foundation
import GameController import GameController
import PunktfunkCore import PunktfunkCore
import PunktfunkShared
import os import os
/// Diagnostic logging for the input path. Off by default (input is high-rate); set /// Diagnostic logging for the input path. Off by default (input is high-rate); set
@@ -125,6 +126,12 @@ public final class InputCapture {
public var onDisconnect: (() -> Void)? public var onDisconnect: (() -> Void)?
public var onCycleStats: (() -> Void)? public var onCycleStats: (() -> Void)?
/// Fired on F (macOS) toggle the streaming window in/out of fullscreen. Detected in the
/// monitor only WHILE FORWARDING, for the same reason as the combos: a captured stream view
/// swallows keys, so the Stream menu's identical F equivalent never reaches it; released, the
/// menu handles it. Main queue.
public var onToggleFullscreen: (() -> Void)?
#if os(iOS) #if os(iOS)
/// Windows VKs of the three modifier classes in the Q release chord, both L/R sides: /// Windows VKs of the three modifier classes in the Q release chord, both L/R sides:
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream. /// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
@@ -273,6 +280,14 @@ public final class InputCapture {
break break
} }
} }
// F toggles the streaming window's fullscreen. Intercepted only while forwarding (the
// captured stream view swallows the menu's identical equivalent); the F is latched so its
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
self.suppressedVK = 0x46 // VK_F the same physical F is en route via GC
self.onToggleFullscreen?()
return nil
}
return event return event
} }
#endif #endif
@@ -318,7 +333,7 @@ public final class InputCapture {
chordModifiersDown.removeAll() chordModifiersDown.removeAll()
suppressedVK = nil suppressedVK = nil
for vk in pressedVKs { for vk in pressedVKs {
connection.send(.key(vk, down: false)) emitKey(vk, down: false)
} }
for button in pressedButtons { for button in pressedButtons {
connection.send(.mouseButton(button, down: false)) connection.send(.mouseButton(button, down: false))
@@ -331,6 +346,15 @@ public final class InputCapture {
residualScrollY = 0 residualScrollY = 0
} }
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
/// active location-based modifier layout is applied in exactly one place while all internal
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
/// the physical VK. Read live from the setting so a mid-session change (rare) takes on the next
/// key without re-arming capture. Non-modifier VKs pass through untouched.
private func emitKey(_ vk: UInt32, down: Bool) {
connection.send(.key(Self.applyModifierLayout(vk, ModifierLayout.current), down: down))
}
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when /// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release /// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release /// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
@@ -399,7 +423,7 @@ public final class InputCapture {
inputLog.debug( inputLog.debug(
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent") "key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent")
} }
connection.send(.key(vk, down: down)) emitKey(vk, down: down)
} }
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp they arrive /// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp they arrive
@@ -566,8 +590,15 @@ public final class InputCapture {
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel the only delivery /// Moonlight's convention). Fed by StreamLayerView.scrollWheel the only delivery
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them). /// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away. /// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away.
public func sendScroll(dx: Float, dy: Float) { public func sendScroll(dx rawDx: Float, dy rawDy: Float) {
guard forwarding else { return } guard forwarding else { return }
// Optionally invert both axes (read live). This is the ONE scroll sink for every platform
// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel all land here so the toggle
// flips them consistently. Residuals are accumulated AFTER inversion so a direction change
// between events doesn't strand a fractional remainder of the old sign.
let invert = UserDefaults.standard.bool(forKey: DefaultsKey.invertScroll)
let dx = invert ? -rawDx : rawDx
let dy = invert ? -rawDy : rawDy
let fy = dy + residualScrollY let fy = dy + residualScrollY
let fx = dx + residualScrollX let fx = dx + residualScrollX
let iy = fy.rounded(.towardZero) let iy = fy.rounded(.towardZero)
@@ -643,7 +674,7 @@ public final class InputCapture {
} else { } else {
self.pressedVKs.remove(vk) self.pressedVKs.remove(vk)
} }
self.connection.send(.key(vk, down: pressed)) self.emitKey(vk, down: pressed)
} }
#endif #endif
} }
@@ -1,7 +1,28 @@
// InputCapture's static keymap tables: HID usage Windows VK (the GCKeyboard path on all // InputCapture's static keymap tables: HID usage Windows VK (the GCKeyboard path on all
// platforms) and, on macOS, NSEvent.keyCode Windows VK (the NSEvent key path). // platforms) and, on macOS, NSEvent.keyCode Windows VK (the NSEvent key path).
import PunktfunkShared
extension InputCapture { extension InputCapture {
/// Remap one modifier VK for the active location-based [`ModifierLayout`] just before it goes on
/// the wire. `.mac` (and every non-modifier VK) is the identity. `.windows` swaps the Alt vs
/// Super/Windows ROLE between the Option and Command keys while KEEPING the side, so a Windows
/// user's ` = Alt, = Win` muscle memory lands correctly:
/// L Command 0x5B L Alt 0xA4 · R Command 0x5C R Alt 0xA5.
/// It's applied ONLY at the send boundary (`InputCapture.emitKey`) all press/release
/// bookkeeping stays on the physical VK, so modifier direction tracking and the client-local
/// -based shortcuts are untouched. The swap is its own inverse: a key that went down remapped
/// goes up remapped, so the host never sees a stuck modifier.
static func applyModifierLayout(_ vk: UInt32, _ layout: ModifierLayout) -> UInt32 {
guard layout == .windows else { return vk }
switch vk {
case 0x5B: return 0xA4 // L Command L Alt (VK_LWIN VK_LMENU)
case 0x5C: return 0xA5 // R Command R Alt (VK_RWIN VK_RMENU)
case 0xA4: return 0x5B // L Option L Win (VK_LMENU VK_LWIN)
case 0xA5: return 0x5C // R Option R Win (VK_RMENU VK_RWIN)
default: return vk
}
}
/// HID usage (GCKeyCode raw) Windows VK (the host maps VK evdev; every VK emitted /// HID usage (GCKeyCode raw) Windows VK (the host maps VK evdev; every VK emitted
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev extend the two together). /// here exists in punktfunk-host/src/inject.rs::vk_to_evdev extend the two together).
static let hidToVK: [Int: UInt32] = { static let hidToVK: [Int: UInt32] = {
@@ -15,6 +15,7 @@
// from looping request rollback request. // from looping request rollback request.
import Foundation import Foundation
import PunktfunkShared
/// The pure, side-effect-free core of the Match-window trigger so the normalize/skip discipline /// The pure, side-effect-free core of the Match-window trigger so the normalize/skip discipline
/// is unit-tested without a live connection or a UI (`MatchWindowTests`). /// is unit-tested without a live connection or a UI (`MatchWindowTests`).
@@ -51,6 +52,11 @@ public final class MatchWindowFollower {
private weak var connection: PunktfunkConnection? private weak var connection: PunktfunkConnection?
private let debounce: TimeInterval private let debounce: TimeInterval
private let minSpacing: TimeInterval private let minSpacing: TimeInterval
/// Render-scale multiplier applied to the live window size before it's requested, so the
/// match-window path supersamples/undersamples exactly like the fixed-mode connect. 1.0 = the
/// window's native pixels (the prior behaviour). `maxDimension` is the codec's per-axis ceiling.
private let renderScale: Double
private let maxDimension: Int
private var enabled: Bool private var enabled: Bool
private var work: DispatchWorkItem? private var work: DispatchWorkItem?
@@ -74,15 +80,28 @@ public final class MatchWindowFollower {
public init( public init(
connection: PunktfunkConnection, connection: PunktfunkConnection,
enabled: Bool, enabled: Bool,
renderScale: Double = 1.0,
maxDimension: Int = 8192,
debounce: TimeInterval = 0.4, debounce: TimeInterval = 0.4,
minSpacing: TimeInterval = 1.0 minSpacing: TimeInterval = 1.0
) { ) {
self.connection = connection self.connection = connection
self.enabled = enabled self.enabled = enabled
self.renderScale = RenderScale.sanitize(renderScale)
self.maxDimension = maxDimension
self.debounce = debounce self.debounce = debounce
self.minSpacing = minSpacing self.minSpacing = minSpacing
} }
/// The host-valid mode for a live window size: the window's physical pixels × the render scale,
/// aspect-preserved, even, and clamped to the codec ceiling the match-window twin of
/// ContentView's `scaledMode()`. Reduces to `MatchWindow.normalize` when the scale is 1.0.
private func targetMode(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
RenderScale.apply(
baseWidth: widthPx, baseHeight: heightPx,
scale: renderScale, maxDimension: maxDimension)
}
/// Turn following on/off live (a mid-session settings change; off cancels a pending request). /// Turn following on/off live (a mid-session settings change; off cancels a pending request).
public func setEnabled(_ on: Bool) { public func setEnabled(_ on: Bool) {
enabled = on enabled = on
@@ -109,7 +128,7 @@ public final class MatchWindowFollower {
/// mode yet nothing to compare against, skip. /// mode yet nothing to compare against, skip.
private func reportSteering(widthPx: Int, heightPx: Int) { private func reportSteering(widthPx: Int, heightPx: Int) {
guard let connection else { return } guard let connection else { return }
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx) let target = targetMode(widthPx: widthPx, heightPx: heightPx)
let mode = connection.currentMode() let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0 else { return } guard mode.width > 0, mode.height > 0 else { return }
if target.width == mode.width, target.height == mode.height { if target.width == mode.width, target.height == mode.height {
@@ -137,7 +156,7 @@ public final class MatchWindowFollower {
schedule() schedule()
return return
} }
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height) let target = targetMode(widthPx: size.width, heightPx: size.height)
let mode = connection.currentMode() let mode = connection.currentMode()
pendingSize = nil pendingSize = nil
guard let req = MatchWindow.request( guard let req = MatchWindow.request(
@@ -621,6 +621,12 @@ public final class StreamLayerView: NSView {
guard let self, self.window?.isKeyWindow == true else { return } guard let self, self.window?.isKeyWindow == true else { return }
self.onDisconnectRequest?() self.onDisconnectRequest?()
} }
capture.onToggleFullscreen = { [weak self] in
// App-level window action: post to the key window's FullscreenController (same routing as
// the Stream menu's F item, so captured and released states hit one code path).
guard self?.window?.isKeyWindow == true else { return }
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
}
capture.onCycleStats = { [weak self] in capture.onCycleStats = { [weak self] in
guard self?.window?.isKeyWindow == true else { return } guard self?.window?.isKeyWindow == true else { return }
// Advance the shared tier setting directly every @AppStorage reader (the HUD's // Advance the shared tier setting directly every @AppStorage reader (the HUD's
@@ -671,7 +677,10 @@ public final class StreamLayerView: NSView {
// default keeps the explicit mode. // default keeps the explicit mode.
let follower = MatchWindowFollower( let follower = MatchWindowFollower(
connection: connection, connection: connection,
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false) enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
maxDimension: RenderScale.maxDimension(
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower) follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
matchFollower = follower matchFollower = follower
layoutPresenter() layoutPresenter()
@@ -385,7 +385,10 @@ public final class StreamViewController: StreamViewControllerBase {
// default keeps the explicit mode. // default keeps the explicit mode.
let follower = MatchWindowFollower( let follower = MatchWindowFollower(
connection: connection, connection: connection,
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false) enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
maxDimension: RenderScale.maxDimension(
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
follower.onResizeTarget = onResizeTarget follower.onResizeTarget = onResizeTarget
matchFollower = follower matchFollower = follower
#endif #endif
@@ -0,0 +1,52 @@
// Location-based modifier mapping which Windows VK each PHYSICAL modifier position forwards to
// the host. A Mac keyboard's bottom row is ` Control / Option / Command / space`; a Windows
// keyboard's is `Ctrl / Super / Alt / space`. So the key NEAREST the space bar is Command on a
// Mac but Alt on Windows, and the next one out is Option on a Mac but the Windows key. A user who
// grew up on Windows reaches for Alt where the Mac has Command; this setting lets them get that
// muscle memory back WITHOUT relabelling keycaps it remaps by physical position, not by a blunt
// "swap these two VKs" that would also drag Control/Shift or lose the left/right distinction.
//
// The model: keep the physical detection (which side, which key) exactly as the OS reports it, and
// swap only the Alt-vs-Super ROLE between the Option and Command keys, per side. So under `.windows`
// the position emits Alt (VK_L/RMENU) and the position emits the Windows key (VK_L/RWIN), while
// left stays left and right stays right. Control and Shift are in the same place on both keyboards,
// so they never move. Lives in PunktfunkShared because both the Settings UI (PunktfunkClient) and
// the wire-boundary remap (`InputCapture.applyModifierLayout`, PunktfunkKit) resolve it.
import Foundation
/// How the physical Option / Command keys map to host modifiers. The raw values are stable on
/// disk rename the cases freely, never the strings.
public enum ModifierLayout: String, CaseIterable, Sendable {
/// Apple positions (default): Option Alt, Command Super/Windows. The current behaviour.
case mac
/// Windows positions: the key nearest the space bar ( Command) Alt, the next one ( Option)
/// the Windows/Super key. Side (left/right) is preserved.
case windows
/// User-facing label (Settings picker).
public var label: String {
switch self {
case .mac: return "Mac (⌥ Alt · ⌘ Super)"
case .windows: return "Windows (⌘ Alt · ⌥ Super)"
}
}
/// A one-line explanation for the setting's footer.
public var detail: String {
switch self {
case .mac:
return "The ⌥ Option key sends Alt and the ⌘ Command key sends the Windows key — the Apple layout."
case .windows:
return "The key nearest the space bar sends Alt and the next one sends the Windows key, matching a PC keyboard. Client shortcuts (⌘⎋ and friends) still use the physical ⌘ key."
}
}
/// The persisted layout (default `.mac` when unset).
public static var current: ModifierLayout {
guard let raw = UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else {
return .mac
}
return ModifierLayout(rawValue: raw) ?? .mac
}
}
@@ -0,0 +1,51 @@
import XCTest
import PunktfunkShared
@testable import PunktfunkKit
/// Pins the location-based modifier remap (`InputCapture.applyModifierLayout`) the wire-boundary
/// swap that relocates the Alt/Super role between the physical / keys per side, without touching
/// Control/Shift or the physical detection upstream.
final class ModifierLayoutMappingTests: XCTestCase {
// The four physical modifier VKs the remap can touch, and everything else it must not.
private let lWin: UInt32 = 0x5B, rWin: UInt32 = 0x5C
private let lAlt: UInt32 = 0xA4, rAlt: UInt32 = 0xA5
func testMacLayoutIsIdentity() {
for vk: UInt32 in [lWin, rWin, lAlt, rAlt, 0xA0, 0xA2, 0x41, 0x1B] {
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .mac), vk)
}
}
func testWindowsLayoutSwapsAltAndSuperPerSide() {
XCTAssertEqual(InputCapture.applyModifierLayout(lWin, .windows), lAlt) // L L Alt
XCTAssertEqual(InputCapture.applyModifierLayout(rWin, .windows), rAlt) // R R Alt
XCTAssertEqual(InputCapture.applyModifierLayout(lAlt, .windows), lWin) // L L Win
XCTAssertEqual(InputCapture.applyModifierLayout(rAlt, .windows), rWin) // R R Win
}
func testWindowsLayoutKeepsSideNeverCrossesLeftRight() {
// A left key never becomes a right VK or vice-versa.
XCTAssertNotEqual(InputCapture.applyModifierLayout(lWin, .windows), rAlt)
XCTAssertNotEqual(InputCapture.applyModifierLayout(rWin, .windows), lAlt)
}
func testControlShiftAndRegularKeysNeverMove() {
for vk: UInt32 in [
0xA0, 0xA1, // L/R Shift
0xA2, 0xA3, // L/R Control
0x41, 0x5A, // A, Z
0x1B, 0x0D, // Esc, Return
] {
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .windows), vk)
}
}
func testWindowsRemapIsItsOwnInverse() {
// Down-remapped then the same key up-remapped land on the same host VK no stuck modifier.
for vk: UInt32 in [lWin, rWin, lAlt, rAlt] {
let once = InputCapture.applyModifierLayout(vk, .windows)
XCTAssertEqual(InputCapture.applyModifierLayout(once, .windows), vk)
}
}
}
@@ -0,0 +1,82 @@
import XCTest
import PunktfunkShared
/// Pins the render-scale geometry (`RenderScale`) the multiply aspect-preserve even-floor
/// codec-clamp that turns the `renderScale` setting into a host-valid `Mode`, plus the label/clamp
/// helpers the UI and the connect share.
final class RenderScaleTests: XCTestCase {
func testSanitizeClampsToRangeAndDefaultsMissing() {
XCTAssertEqual(RenderScale.sanitize(0), 1.0) // absent / zero Native
XCTAssertEqual(RenderScale.sanitize(-2), 1.0) // nonsense Native
XCTAssertEqual(RenderScale.sanitize(0.1), 0.5) // below the floor
XCTAssertEqual(RenderScale.sanitize(9), 4.0) // above the ceiling
XCTAssertEqual(RenderScale.sanitize(1.5), 1.5) // in range, untouched
}
func testMaxDimensionIsCodecAware() {
XCTAssertEqual(RenderScale.maxDimension(codec: "h264"), 4096)
XCTAssertEqual(RenderScale.maxDimension(codec: "hevc"), 8192)
XCTAssertEqual(RenderScale.maxDimension(codec: "av1"), 8192)
XCTAssertEqual(RenderScale.maxDimension(codec: "auto"), 8192)
}
func testNativeScaleIsIdentity() {
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 1.0, maxDimension: 8192)
XCTAssertEqual(m.width, 1920)
XCTAssertEqual(m.height, 1080)
}
func testSupersampleDoubles() {
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 2.0, maxDimension: 8192)
XCTAssertEqual(m.width, 3840)
XCTAssertEqual(m.height, 2160)
}
func testUnderRenderHalves() {
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 0.5, maxDimension: 8192)
XCTAssertEqual(m.width, 960)
XCTAssertEqual(m.height, 540)
}
func testResultsAreAlwaysEven() {
// 1280×720 × 1.5 = 1920×1080 (already even); 1366×768 × 1.5 = 2049×1152 2048×1152.
let m = RenderScale.apply(baseWidth: 1366, baseHeight: 768, scale: 1.5, maxDimension: 8192)
XCTAssertEqual(m.width % 2, 0)
XCTAssertEqual(m.height % 2, 0)
XCTAssertEqual(m.width, 2048)
XCTAssertEqual(m.height, 1152)
}
func testOverCeilingClampsUniformlyPreservingAspect() {
// 4K × 4 would be 15360×8640; both axes exceed 8192, so the LARGER (width) lands on the cap
// and the ratio is kept (16:9 8192×4608, even).
let m = RenderScale.apply(baseWidth: 3840, baseHeight: 2160, scale: 4.0, maxDimension: 8192)
XCTAssertLessThanOrEqual(m.width, 8192)
XCTAssertLessThanOrEqual(m.height, 8192)
XCTAssertEqual(m.width, 8192)
XCTAssertEqual(m.height, 4608)
// 16:9 preserved to within the even-floor rounding.
XCTAssertEqual(Double(m.width) / Double(m.height), 16.0 / 9.0, accuracy: 0.01)
}
func testH264CeilingIsTighter() {
// 1080p × 4 = 7680×4320; under H.264's 4096 wall the width clamps to 4096, aspect kept.
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 4.0, maxDimension: 4096)
XCTAssertEqual(m.width, 4096)
XCTAssertEqual(m.height, 2304)
}
func testMinimumFloorHonoured() {
// A tiny base under a small scale can't fall below 320×200.
let m = RenderScale.apply(baseWidth: 400, baseHeight: 300, scale: 0.5, maxDimension: 8192)
XCTAssertGreaterThanOrEqual(m.width, 320)
XCTAssertGreaterThanOrEqual(m.height, 200)
}
func testLabels() {
XCTAssertEqual(RenderScale.label(1.0), "Native (1×)")
XCTAssertEqual(RenderScale.label(2.0), "2× · supersample")
XCTAssertEqual(RenderScale.label(0.5), "0.5×")
}
}
+27 -8
View File
@@ -748,6 +748,9 @@ mod pipewire {
/// (`ImportKind::Tiled444`) and feed NVENC native full-chroma YUV — takes precedence over /// (`ImportKind::Tiled444`) and feed NVENC native full-chroma YUV — takes precedence over
/// `nv12` (a 4:4:4 session must never subsample). /// `nv12` (a 4:4:4 session must never subsample).
yuv444: bool, yuv444: bool,
/// The LINEAR (gamescope) NV12 compute CSC failed once — RGB for the rest of the stream
/// (T2.5b's per-stream fallback latch; cleared by the next stream's fresh `Ud`).
linear_nv12_failed: bool,
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`). /// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
dbg_log_n: u64, dbg_log_n: u64,
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`). /// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
@@ -1352,15 +1355,17 @@ mod pipewire {
// sample LINEAR). // sample LINEAR).
let modifier = (ud.modifier != 0).then_some(ud.modifier); let modifier = (ud.modifier != 0).then_some(ud.modifier);
if let Some(fourcc) = pf_frame::drm_fourcc(fmt) { if let Some(fourcc) = pf_frame::drm_fourcc(fmt) {
// GPU converts only on the tiled EGL/GL path (`modifier.is_some()`): a 4:4:4 // GPU converts: a 4:4:4 session gets the planar-YUV444 convert on the tiled
// session gets the planar-YUV444 convert (full chroma, takes precedence over // EGL/GL path (full chroma, takes precedence over NV12 — 4:4:4 must never
// NV12 — 4:4:4 must never subsample), otherwise `PUNKTFUNK_NV12` gets NV12 — // subsample), otherwise `PUNKTFUNK_NV12` gets NV12 — tiled via the EGL/GL
// both feed NVENC native YUV so it skips its internal RGB→YUV CSC. The // blit, LINEAR/gamescope via the Vulkan bridge's compute CSC (latency plan
// LINEAR/Vulkan (gamescope) path stays RGB — its converts aren't wired here; // T2.5b) — so NVENC encodes native YUV and skips its internal RGB→YUV CSC on
// a 4:4:4 session on LINEAR frames falls to the encoder's clear-error path // the contended SM. A 4:4:4 session on LINEAR frames has no convert and
// (`want_444` with an RGB CUDA payload) rather than silently subsampling. // stays RGB, falling to the encoder's clear-error path (`want_444` with an
// RGB CUDA payload) rather than silently subsampling. A LINEAR NV12 convert
// failure latches RGB for the stream (mid-frame fallback, no drop).
let yuv444 = ud.yuv444 && modifier.is_some(); let yuv444 = ud.yuv444 && modifier.is_some();
let nv12 = ud.nv12 && !yuv444 && modifier.is_some(); let mut nv12 = ud.nv12 && !ud.yuv444;
let imported = if let Some(m) = modifier { let imported = if let Some(m) = modifier {
if yuv444 { if yuv444 {
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m)) importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
@@ -1369,7 +1374,20 @@ mod pipewire {
} else { } else {
importer.import(&plane, w as u32, h as u32, fourcc, Some(m)) importer.import(&plane, w as u32, h as u32, fourcc, Some(m))
} }
} else if nv12 && !ud.linear_nv12_failed {
match importer.import_linear_nv12(&plane, w as u32, h as u32) {
Ok(buf) => Ok(buf),
Err(e) => {
ud.linear_nv12_failed = true;
nv12 = false;
tracing::warn!(error = %format!("{e:#}"),
"LINEAR NV12 compute CSC failed — RGB for the rest of this \
stream (NVENC does the CSC internally)");
importer.import_linear(&plane, w as u32, h as u32)
}
}
} else { } else {
nv12 = false;
importer.import_linear(&plane, w as u32, h as u32) importer.import_linear(&plane, w as u32, h as u32)
}; };
match imported { match imported {
@@ -1742,6 +1760,7 @@ mod pipewire {
vaapi_passthrough, vaapi_passthrough,
nv12: pf_zerocopy::nv12_enabled(), nv12: pf_zerocopy::nv12_enabled(),
yuv444: want_444, yuv444: want_444,
linear_nv12_failed: false,
dbg_log_n: 0, dbg_log_n: 0,
cursor: CursorState::default(), cursor: CursorState::default(),
}; };
+1 -1
View File
@@ -28,7 +28,7 @@ mdns-sd = "0.20"
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the # `ureq` is small + sync (the host uses it too) and its rustls unifies with the
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`. # workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
ureq = "2" ureq = "2"
rustls = { version = "0.23", features = ["ring"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
anyhow = "1" anyhow = "1"
+1 -1
View File
@@ -22,7 +22,7 @@ quinn = "0.11"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format. # CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host. # Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
image = { version = "0.25", default-features = false, features = ["png", "bmp"] } image = { version = "0.25", default-features = false, features = ["png", "bmp", "jpeg", "gif"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg # Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
+16 -1
View File
@@ -235,6 +235,12 @@ pub const WIRE_HTML: &str = "text/html";
pub const WIRE_RTF: &str = "text/rtf"; pub const WIRE_RTF: &str = "text/rtf";
/// Wire MIME for a PNG image. /// Wire MIME for a PNG image.
pub const WIRE_PNG: &str = "image/png"; pub const WIRE_PNG: &str = "image/png";
/// Wire MIME for a JPEG image — passed through VERBATIM when the source clipboard carries one
/// (no PNG transcode: a lossy original re-encoded lossless is pure bloat). [`WIRE_PNG`] remains
/// the universal fallback every peer must accept; JPEG/GIF are richer options beside it.
pub const WIRE_JPEG: &str = "image/jpeg";
/// Wire MIME for a GIF image — verbatim pass-through preserves animation end to end.
pub const WIRE_GIF: &str = "image/gif";
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets /// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases /// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
@@ -248,6 +254,8 @@ pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
"text/html" => Some(WIRE_HTML), "text/html" => Some(WIRE_HTML),
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF), "text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
"image/png" => Some(WIRE_PNG), "image/png" => Some(WIRE_PNG),
"image/jpeg" => Some(WIRE_JPEG),
"image/gif" => Some(WIRE_GIF),
_ => match base { _ => match base {
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT), "text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
_ => None, _ => None,
@@ -270,6 +278,8 @@ pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
WIRE_HTML => &["text/html"], WIRE_HTML => &["text/html"],
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"], WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
WIRE_PNG => &["image/png"], WIRE_PNG => &["image/png"],
WIRE_JPEG => &["image/jpeg"],
WIRE_GIF => &["image/gif"],
_ => &[], _ => &[],
} }
} }
@@ -330,6 +340,8 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
push("text/rtf"); push("text/rtf");
} }
WIRE_PNG => push("image/png"), WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
other => push(other), other => push(other),
} }
} }
@@ -355,10 +367,13 @@ mod tests {
assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML)); assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF)); assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG)); assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
// Original image formats now map to their own wire kinds (verbatim pass-through).
assert_eq!(wayland_to_wire("image/jpeg"), Some(WIRE_JPEG));
assert_eq!(wayland_to_wire("image/gif"), Some(WIRE_GIF));
// Internal targets and unsupported formats are dropped. // Internal targets and unsupported formats are dropped.
assert_eq!(wayland_to_wire("TARGETS"), None); assert_eq!(wayland_to_wire("TARGETS"), None);
assert_eq!(wayland_to_wire("TIMESTAMP"), None); assert_eq!(wayland_to_wire("TIMESTAMP"), None);
assert_eq!(wayland_to_wire("image/jpeg"), None); assert_eq!(wayland_to_wire("image/webp"), None);
} }
#[test] #[test]
+66 -11
View File
@@ -48,7 +48,9 @@ use ::windows::Win32::UI::WindowsAndMessaging::{
}; };
use super::winfmt; use super::winfmt;
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT}; use super::{
ClipEvent, PasteResponder, WIRE_GIF, WIRE_HTML, WIRE_JPEG, WIRE_PNG, WIRE_RTF, WIRE_TEXT,
};
/// Custom app message that wakes the pump to drain the [`Cmd`] channel. /// Custom app message that wakes the pump to drain the [`Cmd`] channel.
const WM_APP_CMD: u32 = WM_APP + 1; const WM_APP_CMD: u32 = WM_APP + 1;
@@ -98,6 +100,13 @@ struct WinClip {
fmt_html: u32, fmt_html: u32,
fmt_rtf: u32, fmt_rtf: u32,
fmt_png: u32, fmt_png: u32,
/// Registered `"JFIF"` — the conventional raw-JPEG clipboard format (Office/browsers).
fmt_jfif: u32,
/// Registered `"GIF"` — raw GIF bytes (animation preserved by verbatim pass-through).
fmt_gif: u32,
/// The wire MIMEs of the offer currently promised via delayed rendering — `WM_RENDERFORMAT`
/// for the synthesized `CF_DIB` picks the richest image kind among them to fetch + convert.
offered_wires: RefCell<Vec<String>>,
/// Our own message window — used for the owner-check and clipboard opens. /// Our own message window — used for the owner-check and clipboard opens.
own_hwnd: HWND, own_hwnd: HWND,
} }
@@ -137,6 +146,15 @@ impl WinClip {
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) { if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
out.push(WIRE_PNG.to_string()); out.push(WIRE_PNG.to_string());
} }
// Original lossy/animated formats offered VERBATIM beside the PNG floor — the client
// picks the richest kind it can place, so a copied JPEG never balloons into PNG and a
// GIF keeps its animation.
if avail(self.fmt_jfif) {
out.push(WIRE_JPEG.to_string());
}
if avail(self.fmt_gif) {
out.push(WIRE_GIF.to_string());
}
out out
} }
@@ -192,6 +210,7 @@ impl WinClip {
} }
} }
*self.offered.borrow_mut() = fmts; *self.offered.borrow_mut() = fmts;
*self.offered_wires.borrow_mut() = wire.to_vec();
} }
/// Drop the selection we own (empty the clipboard iff we're still its owner). /// Drop the selection we own (empty the clipboard iff we're still its owner).
@@ -265,8 +284,23 @@ impl WinClip {
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client /// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster. /// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
fn on_render_format(&self, fmt: u32) { fn on_render_format(&self, fmt: u32) {
let Some(wire) = self.wire_for_format(fmt) else { // The synthesized CF_DIB promise has no wire kind of its own: fetch the richest image
return; // kind the client offered (PNG first — lossless with alpha — then JPEG, then GIF's first
// frame) and convert. Every other format maps 1:1.
let wire: &str = if fmt == CF_DIB.0 as u32 {
let offered = self.offered_wires.borrow();
match [WIRE_PNG, WIRE_JPEG, WIRE_GIF]
.into_iter()
.find(|w| offered.iter().any(|o| o == w))
{
Some(w) => w,
None => return,
}
} else {
match self.wire_for_format(fmt) {
Some(w) => w,
None => return,
}
}; };
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>(); let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let ev = ClipEvent::Paste { let ev = ClipEvent::Paste {
@@ -281,9 +315,9 @@ impl WinClip {
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste) Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
}; };
let win_bytes = if fmt == CF_DIB.0 as u32 { let win_bytes = if fmt == CF_DIB.0 as u32 {
// The app asked for a bitmap: the client served PNG — convert. A PNG that doesn't // The app asked for a bitmap: convert whatever image kind the client served. Bytes
// decode leaves the format unrendered (empty paste), matching the timeout path. // that don't decode leave the format unrendered (empty paste), like the timeout path.
match winfmt::png_to_dib(&bytes) { match winfmt::image_to_dib(&bytes) {
Some(d) => d, Some(d) => d,
None => return, None => return,
} }
@@ -310,6 +344,8 @@ impl WinClip {
WIRE_HTML => Some(self.fmt_html), WIRE_HTML => Some(self.fmt_html),
WIRE_RTF => Some(self.fmt_rtf), WIRE_RTF => Some(self.fmt_rtf),
WIRE_PNG => Some(self.fmt_png), WIRE_PNG => Some(self.fmt_png),
WIRE_JPEG => Some(self.fmt_jfif),
WIRE_GIF => Some(self.fmt_gif),
_ => None, _ => None,
} }
} }
@@ -322,8 +358,12 @@ impl WinClip {
Some(WIRE_HTML) Some(WIRE_HTML)
} else if fmt == self.fmt_rtf { } else if fmt == self.fmt_rtf {
Some(WIRE_RTF) Some(WIRE_RTF)
} else if fmt == self.fmt_png || fmt == CF_DIB.0 as u32 { } else if fmt == self.fmt_png {
Some(WIRE_PNG) Some(WIRE_PNG)
} else if fmt == self.fmt_jfif {
Some(WIRE_JPEG)
} else if fmt == self.fmt_gif {
Some(WIRE_GIF)
} else { } else {
None None
} }
@@ -340,9 +380,12 @@ impl WinClip {
} }
} }
// Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients) // Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients)
// ask for the bitmap family, not the registered "PNG"; Windows synthesizes // ask for the bitmap family, not the registered image formats; Windows synthesizes
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` converts on demand. // CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` fetches the richest
if w == WIRE_PNG && !out.contains(&(CF_DIB.0 as u32)) { // offered image kind and converts on demand.
if matches!(w.as_str(), WIRE_PNG | WIRE_JPEG | WIRE_GIF)
&& !out.contains(&(CF_DIB.0 as u32))
{
out.push(CF_DIB.0 as u32); out.push(CF_DIB.0 as u32);
} }
} }
@@ -376,12 +419,18 @@ impl WindowsClipboard {
let fmt_html = register_format(w!("HTML Format"))?; let fmt_html = register_format(w!("HTML Format"))?;
let fmt_rtf = register_format(w!("Rich Text Format"))?; let fmt_rtf = register_format(w!("Rich Text Format"))?;
let fmt_png = register_format(w!("PNG"))?; let fmt_png = register_format(w!("PNG"))?;
let fmt_jfif = register_format(w!("JFIF"))?;
let fmt_gif = register_format(w!("GIF"))?;
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>(); let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
let cw = Arc::clone(&current_wire); let cw = Arc::clone(&current_wire);
let join = std::thread::Builder::new() let join = std::thread::Builder::new()
.name("punktfunk-clipboard-win".into()) .name("punktfunk-clipboard-win".into())
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx)) .spawn(move || {
pump_thread(
clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, fmt_jfif, fmt_gif, ready_tx,
)
})
.context("spawn windows clipboard thread")?; .context("spawn windows clipboard thread")?;
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await { let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
@@ -573,6 +622,7 @@ fn create_window() -> anyhow::Result<HWND> {
} }
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`. /// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
#[allow(clippy::too_many_arguments)]
fn pump_thread( fn pump_thread(
clip_tx: ClipTx, clip_tx: ClipTx,
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>, cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
@@ -580,6 +630,8 @@ fn pump_thread(
fmt_html: u32, fmt_html: u32,
fmt_rtf: u32, fmt_rtf: u32,
fmt_png: u32, fmt_png: u32,
fmt_jfif: u32,
fmt_gif: u32,
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>, ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
) { ) {
let hwnd = match create_window() { let hwnd = match create_window() {
@@ -598,9 +650,12 @@ fn pump_thread(
current_wire, current_wire,
cmd_rx: RefCell::new(cmd_rx), cmd_rx: RefCell::new(cmd_rx),
offered: RefCell::new(Vec::new()), offered: RefCell::new(Vec::new()),
offered_wires: RefCell::new(Vec::new()),
fmt_html, fmt_html,
fmt_rtf, fmt_rtf,
fmt_png, fmt_png,
fmt_jfif,
fmt_gif,
own_hwnd: hwnd, own_hwnd: hwnd,
}); });
let ptr = Box::into_raw(state); let ptr = Box::into_raw(state);
+84 -83
View File
@@ -158,6 +158,89 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
} }
} }
// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------
//
// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes
// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when
// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER:
// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows.
/// Image wire bytes (PNG / JPEG / GIF — any format the `image` crate sniffs) → `CF_DIB` HGLOBAL
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(bytes).ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 {
return None;
}
let mut out = Vec::with_capacity(40 + w * h * 4);
// BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows.
out.extend_from_slice(&40u32.to_le_bytes()); // biSize
out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth
out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up)
out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount
out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB
out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage
out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant
// Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned).
for row in rgba.rows().rev() {
for px in row {
let [r, g, b, a] = px.0;
out.extend_from_slice(&[b, g, r, a]);
}
}
Some(out)
}
/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects
/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on
/// any malformed input — the caller declines the fetch.
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
if dib.len() < 40 {
return None;
}
let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize;
if hdr_size < 40 || hdr_size > dib.len() {
return None;
}
let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize;
let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?);
let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize;
// Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels.
let palette = if bit_count <= 8 {
(if clr_used != 0 {
clr_used
} else {
1 << bit_count
}) * 4
} else if compression == 3 {
// BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER —
// V4/V5 headers already contain the masks within hdr_size).
if hdr_size == 40 {
12
} else {
0
}
} else {
0
};
let pixel_offset = 14 + hdr_size + palette;
let mut bmp = Vec::with_capacity(14 + dib.len());
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved
bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
bmp.extend_from_slice(dib);
let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.ok()?;
Some(png)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -265,7 +348,7 @@ mod tests {
image::DynamicImage::ImageRgba8(img.clone()) image::DynamicImage::ImageRgba8(img.clone())
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap(); .unwrap();
let dib = png_to_dib(&png).expect("png -> dib"); let dib = image_to_dib(&png).expect("png -> dib");
// BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp. // BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp.
assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40); assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40);
assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3); assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3);
@@ -282,85 +365,3 @@ mod tests {
assert!(dib_to_png(&[0xFFu8; 200]).is_none()); assert!(dib_to_png(&[0xFFu8; 200]).is_none());
} }
} }
// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------
//
// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes
// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when
// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER:
// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows.
/// PNG wire bytes → `CF_DIB` HGLOBAL bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up).
/// `None` when the PNG doesn't decode — the caller leaves the format unrendered (empty paste).
pub fn png_to_dib(png: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 {
return None;
}
let mut out = Vec::with_capacity(40 + w * h * 4);
// BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows.
out.extend_from_slice(&40u32.to_le_bytes()); // biSize
out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth
out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up)
out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount
out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB
out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage
out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant
// Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned).
for row in rgba.rows().rev() {
for px in row {
let [r, g, b, a] = px.0;
out.extend_from_slice(&[b, g, r, a]);
}
}
Some(out)
}
/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects
/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on
/// any malformed input — the caller declines the fetch.
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
if dib.len() < 40 {
return None;
}
let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize;
if hdr_size < 40 || hdr_size > dib.len() {
return None;
}
let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize;
let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?);
let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize;
// Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels.
let palette = if bit_count <= 8 {
(if clr_used != 0 {
clr_used
} else {
1 << bit_count
}) * 4
} else if compression == 3 {
// BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER —
// V4/V5 headers already contain the masks within hdr_size).
if hdr_size == 40 {
12
} else {
0
}
} else {
0
};
let pixel_offset = 14 + hdr_size + palette;
let mut bmp = Vec::with_capacity(14 + dib.len());
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved
bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
bmp.extend_from_slice(dib);
let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.ok()?;
Some(png)
}
+241 -6
View File
@@ -20,9 +20,21 @@
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is //! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is
//! zero regression versus today; true zero-copy input registration is a follow-up. //! zero regression versus today; true zero-copy input registration is a follow-up.
//! //!
//! **Sync-only.** NVENC async mode (`enableEncodeAsync` + Win32 completion events) is Windows-only, //! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows
//! so the whole two-thread async-retrieve subsystem of the Windows backend is absent here: `poll` //! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode*
//! does the blocking `lock_bitstream`, exactly like the libav path. //! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC —
//! but the NVENC guide's threading model still applies: the main thread should only *submit*
//! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an
//! internal retrieve thread owns exactly that blocking lock (+ copy + unlock); `submit` returns
//! after `encode_picture` and `poll` drains finished AUs without blocking, so under a
//! GPU-saturating game completed frames queue instead of serializing capture on the scheduler
//! wait. All input-resource calls (register/map/unmap) and every other session call stay on the
//! encode thread. Backpressure: `submit` blocks on the oldest completion at
//! `PUNKTFUNK_NVENC_ASYNC_DEPTH` (default 4) in-flight encodes. Without the flag, `poll` does
//! the blocking `lock_bitstream` on the encode thread, exactly like the libav path (unchanged
//! default). Caveat shared with the sync path: a driver wedge that hangs `lock_bitstream` hangs
//! the retrieve thread the same way it would hang the encode thread today (Linux has no
//! event-timeout escape) — no regression, just no new watchdog either.
//! //!
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and //! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly //! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
@@ -42,6 +54,7 @@ use pf_zerocopy::cuda::{self, InputSurface};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::ffi::c_void; use std::ffi::c_void;
use std::ptr; use std::ptr;
use std::sync::mpsc;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv; use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
@@ -205,11 +218,135 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
} }
} }
/// Output bitstream buffers = max in-flight encodes; equals the input-surface ring depth. The host /// Output bitstream buffers = max in-flight encodes; equals the input-surface ring depth. Must
/// loop deep-pipelines (submits several frames before locking the oldest) so this must be ≥ the /// stay ≥ the two-thread retrieve's in-flight cap ([`async_inflight_cap`], ≤ `POOL - 1`) so a
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6). /// bitstream/ring slot is never reused mid-encode.
const POOL: usize = 8; const POOL: usize = 8;
/// Whether the operator asked for the two-thread retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy — the
/// SAME knob as the Windows backend, so one env drives the split on either host OS). Opt-in
/// until on-glass validated. Unlike Windows this changes NO session parameter (Linux stays sync
/// mode; only the blocking lock moves off the encode thread), so there is no async-rejecting
/// config to fail the open.
fn async_retrieve_requested() -> bool {
std::env::var("PUNKTFUNK_NVENC_ASYNC")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
/// `2..=POOL-1` — a bitstream must never be reused mid-encode, and the input ring is the same
/// depth). Mirrors the Windows knob exactly.
fn async_inflight_cap() -> usize {
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(4)
.clamp(2, POOL - 1)
}
/// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock.
/// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before
/// the session it belongs to is destroyed).
struct RetrieveJob {
bs: usize,
}
/// A finished retrieve: the locked-and-copied AU (or the retrieve-side error) for the oldest
/// in-flight bitstream. `bs` lets the encode thread cross-check FIFO pairing with `pending`.
struct RetrieveDone {
bs: usize,
result: std::result::Result<(Vec<u8>, bool), String>,
}
/// The two-thread-retrieve runtime: the job channel feeding the retrieve thread, the completion
/// channel back, the thread handle (joined in `teardown` BEFORE the session is destroyed), and
/// AUs already absorbed by backpressure that `poll` hands out first.
struct AsyncRetrieve {
work_tx: Option<mpsc::SyncSender<RetrieveJob>>,
done_rx: mpsc::Receiver<RetrieveDone>,
join: Option<std::thread::JoinHandle<()>>,
ready: VecDeque<EncodedFrame>,
}
impl AsyncRetrieve {
fn spawn(enc: usize) -> Self {
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
let (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
let join = std::thread::Builder::new()
.name("pf-nvenc-out".into())
.spawn(move || retrieve_loop(enc, work_rx, done_tx))
.expect("spawn pf-nvenc-out");
AsyncRetrieve {
work_tx: Some(work_tx),
done_rx,
join: Some(join),
ready: VecDeque::new(),
}
}
}
/// The retrieve-thread body (latency plan T2.2, the Linux half of gpu-contention §5.B): for each
/// submitted frame, BLOCKING-lock the bitstream (sync-mode `nvEncLockBitstream` returns when the
/// encode completes — the guide's sanctioned secondary-thread surface), copy the AU out, unlock,
/// and send it back. Exits when the job channel closes (teardown drops the sender and joins
/// BEFORE destroying the session, so `enc` and every `bs` outlive their uses here).
fn retrieve_loop(
enc: usize,
work_rx: mpsc::Receiver<RetrieveJob>,
done_tx: mpsc::Sender<RetrieveDone>,
) {
pf_frame::thread_qos::boost_thread_priority(false);
// The session is bound to the shared process-wide CUDA context; make it current here the
// same way the encode thread does before its own NVENC calls.
if let Err(e) = cuda::make_current() {
tracing::warn!(error = %format!("{e:#}"), "pf-nvenc-out: cuCtxSetCurrent failed");
}
while let Ok(job) = work_rx.recv() {
// SAFETY: `job.bs` is one of the session's pool bitstreams a prior `encode_picture`
// targeted; both it and the session stay valid until `teardown`, which joins this thread
// first. `lock_bitstream` (version set, struct a live stack local for the synchronous
// call) BLOCKS until that encode finishes, then yields a CPU-readable
// `bitstreamBufferPtr`/`bitstreamSizeInBytes` valid until `unlock_bitstream`; the slice
// is copied (`to_vec`) before the unlock on the same buffer. Lock/unlock from a
// secondary thread while the encode thread submits is the NVENC guide's documented
// threading model.
let result = unsafe {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
outputBitstream: job.bs as *mut c_void,
..Default::default()
};
match (api().lock_bitstream)(enc as *mut c_void, &mut lock).nv_ok() {
Ok(()) => {
let data = std::slice::from_raw_parts(
lock.bitstreamBufferPtr as *const u8,
lock.bitstreamSizeInBytes as usize,
)
.to_vec();
let keyframe = matches!(
lock.pictureType,
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR
| nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
);
let _ = (api().unlock_bitstream)(
enc as *mut c_void,
job.bs as nv::NV_ENC_OUTPUT_PTR,
);
Ok((data, keyframe))
}
Err(e) => Err(format!(
"lock_bitstream (retrieve thread): {e:?} — {}",
nvenc_status::explain(e)
)),
}
};
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
break; // encoder side gone (teardown drains us via join)
}
}
}
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero- /// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal /// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit. /// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
@@ -300,6 +437,9 @@ pub struct NvencCudaEncoder {
/// One-shot latch for [`diagnose_failed_open`](Self::diagnose_failed_open) so a rebuild-retry /// One-shot latch for [`diagnose_failed_open`](Self::diagnose_failed_open) so a rebuild-retry
/// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt. /// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt.
diagnosed: bool, diagnosed: bool,
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
async_rt: Option<AsyncRetrieve>,
} }
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext` // SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
@@ -373,6 +513,7 @@ impl NvencCudaEncoder {
custom_vbv: false, custom_vbv: false,
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32, split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
last_rfi_range: None, last_rfi_range: None,
async_rt: None,
}) })
} }
@@ -381,6 +522,15 @@ impl NvencCudaEncoder {
if self.encoder.is_null() { if self.encoder.is_null() {
return; return;
} }
// Stop the retrieve thread FIRST: close its job channel and join. Any in-flight blocking
// lock returns once its encode completes (≤ a frame time on a live driver), so the join
// is bounded; after it no other thread can touch the session the code below destroys.
if let Some(mut rt) = self.async_rt.take() {
rt.work_tx.take();
if let Some(j) = rt.join.take() {
let _ = j.join();
}
}
// Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams. // Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams.
for (_, map, _, _) in &self.pending { for (_, map, _, _) in &self.pending {
if !map.is_null() { if !map.is_null() {
@@ -804,6 +954,15 @@ impl NvencCudaEncoder {
} }
self.inited = true; self.inited = true;
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
// session parameter differs — teardown/rebuild always stops it before destroy.
if async_retrieve_requested() {
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
tracing::info!(
depth = async_inflight_cap(),
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
);
}
tracing::info!( tracing::info!(
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps), mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
bit_depth = self.bit_depth, bit_depth = self.bit_depth,
@@ -845,6 +1004,40 @@ impl NvencCudaEncoder {
_ => cuda::copy_device_to_device(buf, base, pitch), _ => cuda::copy_device_to_device(buf, base, pitch),
} }
} }
/// Fold one retrieve-thread completion into `ready` (two-thread mode only): pop the oldest
/// in-flight entry, cross-check FIFO pairing, unmap its input HERE (the encode thread — the
/// retrieve thread never touches input resources), and queue the finished AU.
fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
bail!("NVENC retrieve: completion with no in-flight frame (pairing bug)");
};
if bs as usize != done.bs {
bail!("NVENC retrieve: completion out of order (pairing bug)");
}
// SAFETY: `map` is the mapped input `submit` recorded for exactly this now-completed
// encode; the session is live (`async_rt` exists only between `init_session` and
// `teardown`) and this runs on the encode thread — the single unmap here mirrors the
// sync path's poll-side unmap, exactly once per mapping.
unsafe {
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
}
let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
self.async_rt
.as_mut()
.expect("absorb_done is only reachable in two-thread mode")
.ready
.push_back(EncodedFrame {
data,
pts_ns,
keyframe,
recovery_anchor: anchor,
chunk_aligned: false,
});
Ok(())
}
} }
impl Encoder for NvencCudaEncoder { impl Encoder for NvencCudaEncoder {
@@ -891,6 +1084,19 @@ impl Encoder for NvencCudaEncoder {
// output slot counter (`teardown` zeroes it), NOT `pts`: `submit_indexed` pins pts to the // output slot counter (`teardown` zeroes it), NOT `pts`: `submit_indexed` pins pts to the
// wire frame index, non-zero on a mid-session rebuild's first frame. // wire frame index, non-zero on a mid-session rebuild's first frame.
let opening = self.next == 0; let opening = self.next == 0;
// Two-thread backpressure: never more than the cap in flight — block on the OLDEST
// completion first, absorbing its AU into `ready` for `poll`. Bounds the added latency
// exactly like the sync path's blocking poll, just `cap` deep instead of 1, and keeps
// this slot's bitstream/input surface free before they're reused below.
while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
let done = {
let rt = self.async_rt.as_mut().expect("checked in loop condition");
rt.done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(|_| anyhow!("NVENC retrieve stalled (5s) — encoder wedged?"))?
};
self.absorb_done(done)?;
}
let slot = self.next % POOL; let slot = self.next % POOL;
self.next += 1; self.next += 1;
@@ -1057,6 +1263,15 @@ impl Encoder for NvencCudaEncoder {
anchor, anchor,
)); ));
} }
// Two-thread mode: hand the blocking lock for this bitstream to the retrieve thread.
// The sync_channel(POOL) can never fill (in-flight is capped < POOL above).
if let Some(rt) = &self.async_rt {
if let Some(tx) = &rt.work_tx {
let _ = tx.send(RetrieveJob {
bs: self.bitstreams[slot] as usize,
});
}
}
Ok(()) Ok(())
} }
@@ -1130,6 +1345,26 @@ impl Encoder for NvencCudaEncoder {
} }
fn poll(&mut self) -> Result<Option<EncodedFrame>> { fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// Two-thread mode: drain whatever the retrieve thread has finished (non-blocking) and
// hand out the oldest ready AU. `None` = nothing completed yet — the session loop keeps
// the frame in flight and re-polls next tick; capture never blocks on the encode wait.
if self.async_rt.is_some() {
while let Ok(done) = self
.async_rt
.as_mut()
.expect("checked just above")
.done_rx
.try_recv()
{
self.absorb_done(done)?;
}
return Ok(self
.async_rt
.as_mut()
.expect("checked just above")
.ready
.pop_front());
}
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else { let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
return Ok(None); return Ok(None);
}; };
+11
View File
@@ -320,6 +320,17 @@ impl RemoteImporter {
self.import_impl(plane, ImportKind::Linear, width, height, 0, None) self.import_impl(plane, ImportKind::Linear, width, height, 0, None)
} }
/// Mirror of [`super::egl::EglImporter::import_linear_nv12`] (LINEAR dmabuf → Vulkan-bridge
/// compute CSC → two-plane NV12 buffer, latency plan T2.5b).
pub fn import_linear_nv12(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
) -> Result<DeviceBuffer> {
self.import_impl(plane, ImportKind::LinearNv12, width, height, 0, None)
}
fn import_impl( fn import_impl(
&mut self, &mut self,
plane: &DmabufPlane, plane: &DmabufPlane,
+47
View File
@@ -1386,3 +1386,50 @@ pub fn copy_pitched_to_buffer(
// within both. Wrapper → live table. // within both. Wrapper → live table.
unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(ext->dev)") } unsafe { copy_blocking(&copy, "cuMemcpy2DAsync_v2(ext->dev)") }
} }
/// De-stride an NV12 pair from an external mapping (the Vulkan bridge's exportable buffer after
/// its compute CSC — latency plan T2.5b) into a pooled two-plane NV12 [`DeviceBuffer`]: the Y
/// plane (`width` bytes × `height` rows) and the interleaved UV plane (`width` bytes × ⌈h/2⌉
/// rows), each de-strided from `src_pitch` to the pool's own plane pitches. Same contract as
/// [`copy_pitched_to_buffer`]: the shared context must be current.
pub fn copy_pitched_nv12_to_buffer(
y_src: CUdeviceptr,
uv_src: CUdeviceptr,
src_pitch: usize,
dst: &DeviceBuffer,
) -> Result<()> {
let Some((uv_ptr, uv_pitch)) = dst.uv else {
anyhow::bail!("copy_pitched_nv12_to_buffer: destination is not an NV12 buffer");
};
let y = CUDA_MEMCPY2D {
srcMemoryType: CU_MEMORYTYPE_DEVICE,
srcDevice: y_src,
srcPitch: src_pitch,
dstMemoryType: CU_MEMORYTYPE_DEVICE,
dstDevice: dst.ptr,
dstPitch: dst.pitch,
WidthInBytes: dst.width as usize,
Height: dst.height as usize,
..Default::default()
};
let uv = CUDA_MEMCPY2D {
srcMemoryType: CU_MEMORYTYPE_DEVICE,
srcDevice: uv_src,
srcPitch: src_pitch,
dstMemoryType: CU_MEMORYTYPE_DEVICE,
dstDevice: uv_ptr,
dstPitch: uv_pitch,
// W/2 interleaved UV samples × 2 bytes = `width` bytes per row.
WidthInBytes: dst.width as usize,
Height: dst.height.div_ceil(2) as usize,
..Default::default()
};
// SAFETY: same contract as `copy_pitched_to_buffer` — the caller holds the shared context
// current; both `CUDA_MEMCPY2D`s are live locals describing spans inside the caller's live
// external mapping (`y_src`/`uv_src` at `src_pitch`) and `dst`'s live pooled planes; each
// `copy_blocking` synchronizes before returning.
unsafe {
copy_blocking(&y, "cuMemcpy2DAsync_v2(ext->dev nv12 Y)")?;
copy_blocking(&uv, "cuMemcpy2DAsync_v2(ext->dev nv12 UV)")
}
}
+36
View File
@@ -504,6 +504,9 @@ pub struct EglImporter {
/// created lazily on the first LINEAR frame, + the destination pool. /// created lazily on the first LINEAR frame, + the destination pool.
vk: Option<super::vulkan::VkBridge>, vk: Option<super::vulkan::VkBridge>,
linear_pool: Option<cuda::BufferPool>, linear_pool: Option<cuda::BufferPool>,
/// NV12 twin of [`linear_pool`](Self::linear_pool) for the bridge's compute-CSC output
/// (T2.5b) — separate pools because a session may fall back RGB mid-stream.
linear_nv12_pool: Option<cuda::BufferPool>,
gbm: *mut c_void, gbm: *mut c_void,
render_fd: c_int, render_fd: c_int,
} }
@@ -647,6 +650,7 @@ impl EglImporter {
yuv444_blit: None, yuv444_blit: None,
vk: None, vk: None,
linear_pool: None, linear_pool: None,
linear_nv12_pool: None,
gbm, gbm,
render_fd, render_fd,
}) })
@@ -677,6 +681,38 @@ impl EglImporter {
) )
} }
/// Like [`import_linear`](Self::import_linear), but the bridge's compute CSC converts to a
/// two-plane **NV12** buffer (latency plan T2.5b) — the gamescope/LINEAR analogue of
/// [`import_nv12`](Self::import_nv12), so NVENC encodes native YUV on the dedicated-session
/// path too instead of paying its internal RGB→YUV CSC on the contended SM.
pub fn import_linear_nv12(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
) -> Result<DeviceBuffer> {
cuda::make_current()?;
if self
.linear_nv12_pool
.as_ref()
.map(|p| (p.width(), p.height()))
!= Some((width, height))
{
self.linear_nv12_pool = Some(cuda::BufferPool::new_nv12(width, height)?);
}
if self.vk.is_none() {
self.vk = Some(super::vulkan::VkBridge::new()?);
}
self.vk.as_mut().unwrap().import_linear_nv12(
plane.fd,
plane.offset,
plane.stride,
width,
height,
self.linear_nv12_pool.as_ref().unwrap(),
)
}
/// Drop the Vulkan bridge's cached per-fd import (see [`super::vulkan::VkBridge::forget_fd`]). /// Drop the Vulkan bridge's cached per-fd import (see [`super::vulkan::VkBridge::forget_fd`]).
/// No-op when the bridge hasn't been built (tiled-only captures). /// No-op when the bridge hasn't been built (tiled-only captures).
pub fn forget_linear_fd(&mut self, fd: i32) { pub fn forget_linear_fd(&mut self, fd: i32) {
+14
View File
@@ -166,6 +166,20 @@ impl Importer {
} }
} }
/// LINEAR dmabuf → Vulkan-bridge compute CSC → two-plane NV12 buffer (latency plan T2.5b —
/// the gamescope analogue of [`import_nv12`](Self::import_nv12)).
pub fn import_linear_nv12(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
) -> anyhow::Result<DeviceBuffer> {
match self {
Importer::Remote(r) => r.import_linear_nv12(plane, width, height),
Importer::InProc(i) => i.import_linear_nv12(plane, width, height),
}
}
/// True once the worker process is gone/wedged (every further call fails fast). Always /// True once the worker process is gone/wedged (every further call fails fast). Always
/// `false` in-process — an in-process driver fault doesn't return. /// `false` in-process — an in-process driver fault doesn't return.
pub fn dead(&self) -> bool { pub fn dead(&self) -> bool {
+4
View File
@@ -41,6 +41,10 @@ pub enum ImportKind {
/// variants' wire tags must never shift — an old worker receiving this fails the decode and /// variants' wire tags must never shift — an old worker receiving this fails the decode and
/// the import-fail machinery handles it like any other worker error. /// the import-fail machinery handles it like any other worker error.
Tiled444, Tiled444,
/// LINEAR dmabuf → Vulkan-bridge compute CSC → two-plane NV12 CUDA buffer (latency plan
/// T2.5b — the gamescope analogue of [`TiledNv12`](Self::TiledNv12)). Appended last, same
/// wire-tag rule as [`Tiled444`](Self::Tiled444).
LinearNv12,
} }
/// host → worker. /// host → worker.
@@ -0,0 +1,77 @@
#version 450
// LINEAR BGRx dmabuf bytes -> NV12 (BT.709 limited range), BUFFER to BUFFER — the Vulkan-bridge
// CSC for the gamescope path (latency plan T2.5b). The bridge deals in VkBuffers (NVIDIA's EGL
// can't sample LINEAR and CUDA rejects raw dmabuf fds), so unlike `pf-encode`'s image-based
// `rgb2yuv.comp` sibling this reads packed texels straight out of the imported dmabuf buffer and
// writes the two NV12 planes into the exportable buffer CUDA reads. Same BT.709 coefficients as
// the sibling shader — keep the two in sync.
//
// One invocation converts a 4x2 luma block: 4 Y bytes = exactly one u32 word per row, and its
// two chroma samples = one u32 word — so every store is a whole word and no two invocations
// touch the same word (an 8-bit-storage-free way to write byte planes race-free). All pitches
// and the UV offset are in WORDS and must be word-aligned (the Rust side sizes them so).
//
// Rebuild: glslc rgb2nv12_buf.comp -o rgb2nv12_buf.spv
layout(local_size_x = 8, local_size_y = 8) in;
layout(std430, binding = 0) readonly buffer Src {
uint spx[]; // packed 4-byte texels, byte order B,G,R,X (little-endian XRGB8888/ARGB8888)
};
layout(std430, binding = 1) writeonly buffer Dst {
uint dw[]; // [0, uv_off_w): Y rows at y_pitch_w; [uv_off_w, ...): interleaved UV rows
};
layout(push_constant) uniform Push {
uint width; // source pixels
uint height;
uint src_off_w; // dmabuf plane offset, in words (offset bytes / 4)
uint src_pitch_w; // dmabuf row stride, in words
uint y_pitch_w; // dst Y row pitch, in words
uint uv_off_w; // dst UV plane offset, in words
uint uv_pitch_w; // dst UV row pitch, in words
} pc;
// Edge-clamped fetch: alignment padding (x >= width from the 4-wide block, odd-height chroma)
// duplicates the last real texel instead of reading out of bounds.
vec3 texel(uint x, uint y) {
x = min(x, pc.width - 1u);
y = min(y, pc.height - 1u);
uint t = spx[pc.src_off_w + y * pc.src_pitch_w + x];
// B,G,R,X byte order: r = bits 16..23, g = 8..15, b = 0..7.
return vec3(float((t >> 16) & 0xFFu), float((t >> 8) & 0xFFu), float(t & 0xFFu)) / 255.0;
}
// BT.709 limited range — numerically identical to rgb2yuv.comp's lumaY/U/V, scaled to bytes.
uint lumaB(vec3 c) {
return uint(clamp(16.0 + 255.0 * (0.1826 * c.r + 0.6142 * c.g + 0.0620 * c.b) + 0.5, 0.0, 255.0));
}
void main() {
uint bx = gl_GlobalInvocationID.x; // 4-px column block
uint by = gl_GlobalInvocationID.y; // 2-row block
uint x0 = bx * 4u;
uint y0 = by * 2u;
if (x0 >= pc.width || y0 >= pc.height) {
return;
}
// Two Y words (4 bytes each, rows y0 and y0+1).
for (uint row = 0u; row < 2u; row++) {
uint y = y0 + row;
uint w = lumaB(texel(x0, y))
| (lumaB(texel(x0 + 1u, y)) << 8)
| (lumaB(texel(x0 + 2u, y)) << 16)
| (lumaB(texel(x0 + 3u, y)) << 24);
dw[y * pc.y_pitch_w + bx] = w;
}
// One UV word: the block's two chroma samples (each a 2x2 average).
uint uvw = 0u;
for (uint s = 0u; s < 2u; s++) {
uint cx = x0 + s * 2u;
vec3 a = (texel(cx, y0) + texel(cx + 1u, y0) + texel(cx, y0 + 1u) + texel(cx + 1u, y0 + 1u))
* 0.25;
uint U = uint(clamp(128.0 + 255.0 * (-0.1006 * a.r - 0.3386 * a.g + 0.4392 * a.b) + 0.5, 0.0, 255.0));
uint V = uint(clamp(128.0 + 255.0 * (0.4392 * a.r - 0.3989 * a.g - 0.0403 * a.b) + 0.5, 0.0, 255.0));
uvw |= (U | (V << 8)) << (16u * s);
}
dw[pc.uv_off_w + by * pc.uv_pitch_w + bx] = uvw;
}
Binary file not shown.
+281 -11
View File
@@ -40,6 +40,20 @@ struct DstBuf {
cuda: cuda::ExternalDmabuf, cuda: cuda::ExternalDmabuf,
} }
/// The lazy compute-CSC pipeline (`rgb2nv12_buf.comp`) for [`VkBridge::import_linear_nv12`].
struct Csc {
module: vk::ShaderModule,
dset_layout: vk::DescriptorSetLayout,
playout: vk::PipelineLayout,
pipeline: vk::Pipeline,
dpool: vk::DescriptorPool,
dset: vk::DescriptorSet,
}
/// The buffer-to-buffer RGB→NV12 compute shader (see `rgb2nv12_buf.comp` beside this file;
/// rebuild with `glslc rgb2nv12_buf.comp -o rgb2nv12_buf.spv`).
const CSC_SPV: &[u8] = include_bytes!("rgb2nv12_buf.spv");
pub struct VkBridge { pub struct VkBridge {
_entry: ash::Entry, _entry: ash::Entry,
instance: ash::Instance, instance: ash::Instance,
@@ -52,6 +66,9 @@ pub struct VkBridge {
mem_props: vk::PhysicalDeviceMemoryProperties, mem_props: vk::PhysicalDeviceMemoryProperties,
src_cache: HashMap<i32, SrcBuf>, src_cache: HashMap<i32, SrcBuf>,
dst: Option<DstBuf>, dst: Option<DstBuf>,
/// Built on the first [`import_linear_nv12`](Self::import_linear_nv12); RGB-only bridges
/// never pay for it.
csc: Option<Csc>,
} }
// SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a // SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a
@@ -94,18 +111,15 @@ impl VkBridge {
.ok_or_else(|| anyhow!("no NVIDIA Vulkan device"))?; .ok_or_else(|| anyhow!("no NVIDIA Vulkan device"))?;
let mem_props = instance.get_physical_device_memory_properties(phys); let mem_props = instance.get_physical_device_memory_properties(phys);
// Any queue family supporting transfer (graphics/compute imply it). // A COMPUTE-capable family (compute implies transfer): the copy path only needs
// transfer, but the NV12 CSC dispatch (T2.5b) needs compute — on every NVIDIA
// device family 0 is graphics+compute+transfer, so this picks the same family the
// old transfer-only predicate did.
let qf = instance let qf = instance
.get_physical_device_queue_family_properties(phys) .get_physical_device_queue_family_properties(phys)
.iter() .iter()
.position(|q| { .position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
q.queue_flags.intersects( .ok_or_else(|| anyhow!("no compute-capable queue family"))?
vk::QueueFlags::TRANSFER
| vk::QueueFlags::GRAPHICS
| vk::QueueFlags::COMPUTE,
)
})
.ok_or_else(|| anyhow!("no transfer-capable queue family"))?
as u32; as u32;
let exts = [ let exts = [
@@ -161,6 +175,7 @@ impl VkBridge {
mem_props, mem_props,
src_cache: HashMap::new(), src_cache: HashMap::new(),
dst: None, dst: None,
csc: None,
}) })
} }
} }
@@ -189,7 +204,11 @@ impl VkBridge {
.create_buffer( .create_buffer(
&vk::BufferCreateInfo::default() &vk::BufferCreateInfo::default()
.size(size) .size(size)
.usage(vk::BufferUsageFlags::TRANSFER_SRC) // STORAGE so the NV12 compute CSC can read it as an SSBO (T2.5b); harmless
// for the plain copy path.
.usage(
vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::STORAGE_BUFFER,
)
.push_next(&mut ext_info), .push_next(&mut ext_info),
None, None,
) )
@@ -256,7 +275,10 @@ impl VkBridge {
.create_buffer( .create_buffer(
&vk::BufferCreateInfo::default() &vk::BufferCreateInfo::default()
.size(size) .size(size)
.usage(vk::BufferUsageFlags::TRANSFER_DST) // STORAGE so the NV12 compute CSC can write it as an SSBO (T2.5b).
.usage(
vk::BufferUsageFlags::TRANSFER_DST | vk::BufferUsageFlags::STORAGE_BUFFER,
)
.push_next(&mut ext_info), .push_next(&mut ext_info),
None, None,
) )
@@ -302,6 +324,246 @@ impl VkBridge {
Ok(()) Ok(())
} }
/// Build the RGB→NV12 compute pipeline once (T2.5b): two-SSBO descriptor set + a 28-byte
/// push-constant block matching `rgb2nv12_buf.comp`'s `Push`.
unsafe fn ensure_csc(&mut self) -> Result<()> {
if self.csc.is_some() {
return Ok(());
}
let words: Vec<u32> = CSC_SPV
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect();
let module = self
.device
.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None)
.context("create CSC shader module")?;
let bindings = [
vk::DescriptorSetLayoutBinding::default()
.binding(0)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::COMPUTE),
vk::DescriptorSetLayoutBinding::default()
.binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::COMPUTE),
];
let dset_layout = self
.device
.create_descriptor_set_layout(
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
None,
)
.context("create CSC dset layout")?;
let pc = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::COMPUTE)
.size(28)];
let layouts = [dset_layout];
let playout = self
.device
.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default()
.set_layouts(&layouts)
.push_constant_ranges(&pc),
None,
)
.context("create CSC pipeline layout")?;
let entry = c"main";
let stage = vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::COMPUTE)
.module(module)
.name(entry);
let pipeline = self
.device
.create_compute_pipelines(
vk::PipelineCache::null(),
&[vk::ComputePipelineCreateInfo::default()
.stage(stage)
.layout(playout)],
None,
)
.map_err(|(_, e)| anyhow!("create CSC pipeline: {e}"))?[0];
let sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(2)];
let dpool = self
.device
.create_descriptor_pool(
&vk::DescriptorPoolCreateInfo::default()
.max_sets(1)
.pool_sizes(&sizes),
None,
)
.context("create CSC descriptor pool")?;
let dset = self
.device
.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(dpool)
.set_layouts(&layouts),
)
.context("allocate CSC descriptor set")?[0];
self.csc = Some(Csc {
module,
dset_layout,
playout,
pipeline,
dpool,
dset,
});
tracing::info!("Vulkan-bridge NV12 compute CSC ready (LINEAR path feeds NVENC native YUV)");
Ok(())
}
/// Bridge one LINEAR dmabuf frame into a pooled NV12 CUDA buffer (latency plan T2.5b):
/// instead of the plain byte copy, the compute CSC reads the imported RGB texels and writes
/// both NV12 planes into the exportable buffer, so NVENC on the gamescope path encodes
/// native YUV (its internal RGB→YUV CSC on the contended SM disappears). `pool` must be an
/// NV12 pool ([`cuda::BufferPool::new_nv12`]).
pub fn import_linear_nv12(
&mut self,
fd: i32,
offset: u32,
stride: u32,
width: u32,
height: u32,
pool: &cuda::BufferPool,
) -> Result<DeviceBuffer> {
anyhow::ensure!(
offset % 4 == 0 && stride % 4 == 0,
"LINEAR dmabuf offset/stride not word-aligned ({offset}/{stride})"
);
// Exportable-buffer NV12 layout the shader writes: 4-aligned Y pitch, UV plane (⌈h/2⌉
// rows at the same pitch) directly after the Y plane.
let y_pitch = (width as u64 + 3) & !3;
let uv_off = y_pitch * height as u64;
let dst_size = uv_off + y_pitch * height.div_ceil(2) as u64;
// SAFETY: same structure and proofs as `import_linear` — `fd` is the caller's live dmabuf
// (dup'd by `import_src`), sizes are checked (`import_src` asserts the fd covers
// `offset + stride*height`; `ensure_dst(dst_size)` makes the exportable buffer at least
// the shader's whole write range, whose last word is `dst_size - 4`). The descriptor
// update binds the live cached src buffer and the live dst buffer WHOLE_SIZE; every
// `*Info`/array is a local outliving its synchronous call; `cmd`/`queue`/`fence` are this
// bridge's own single-thread handles. The dispatch covers ⌈w/32⌉×⌈h/16⌉ groups of 8×8
// invocations, each writing only whole words inside the proven dst range (shader
// contract). The host `wait_for_fences` retires the compute pass (with a shader-write →
// memory barrier recorded before end) BEFORE CUDA reads the shared memory.
unsafe {
let span = offset as u64 + stride as u64 * height as u64;
if !self.src_cache.contains_key(&fd) {
let size = libc::lseek(fd, 0, libc::SEEK_END);
anyhow::ensure!(size > 0, "lseek(dmabuf)");
anyhow::ensure!(size as u64 >= span, "dmabuf smaller than frame span");
self.import_src(fd, size as u64)?;
}
let src_buffer = self.src_cache[&fd].buffer;
self.ensure_dst(dst_size)?;
self.ensure_csc()?;
let (dst_buffer, dst_cuda_ptr) = {
let d = self.dst.as_ref().unwrap();
(d.buffer, d.cuda.ptr)
};
let csc = self.csc.as_ref().unwrap();
let src_info = [vk::DescriptorBufferInfo::default()
.buffer(src_buffer)
.range(vk::WHOLE_SIZE)];
let dst_info = [vk::DescriptorBufferInfo::default()
.buffer(dst_buffer)
.range(vk::WHOLE_SIZE)];
let writes = [
vk::WriteDescriptorSet::default()
.dst_set(csc.dset)
.dst_binding(0)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&src_info),
vk::WriteDescriptorSet::default()
.dst_set(csc.dset)
.dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&dst_info),
];
self.device.update_descriptor_sets(&writes, &[]);
self.device
.begin_command_buffer(
self.cmd,
&vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)
.context("begin cmd")?;
self.device
.cmd_bind_pipeline(self.cmd, vk::PipelineBindPoint::COMPUTE, csc.pipeline);
self.device.cmd_bind_descriptor_sets(
self.cmd,
vk::PipelineBindPoint::COMPUTE,
csc.playout,
0,
&[csc.dset],
&[],
);
let push: [u32; 7] = [
width,
height,
offset / 4,
stride / 4,
(y_pitch / 4) as u32,
(uv_off / 4) as u32,
(y_pitch / 4) as u32,
];
let push_bytes: &[u8] = std::slice::from_raw_parts(push.as_ptr().cast(), 28);
self.device.cmd_push_constants(
self.cmd,
csc.playout,
vk::ShaderStageFlags::COMPUTE,
0,
push_bytes,
);
self.device
.cmd_dispatch(self.cmd, width.div_ceil(32), height.div_ceil(16), 1);
// Make the shader writes available before the external (CUDA) read.
let barrier = vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
.dst_access_mask(vk::AccessFlags::MEMORY_READ);
self.device.cmd_pipeline_barrier(
self.cmd,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
vk::DependencyFlags::empty(),
&[barrier],
&[],
&[],
);
self.device
.end_command_buffer(self.cmd)
.context("end cmd")?;
let cmds = [self.cmd];
let submit = vk::SubmitInfo::default().command_buffers(&cmds);
self.device
.queue_submit(self.queue, &[submit], self.fence)
.context("queue submit")?;
self.device
.wait_for_fences(&[self.fence], true, 1_000_000_000)
.context("fence wait")?;
self.device
.reset_fences(&[self.fence])
.context("reset fence")?;
// De-stride both NV12 planes from the CUDA view into a pooled two-plane buffer.
cuda::make_current()?;
let out = pool.get()?;
cuda::copy_pitched_nv12_to_buffer(
dst_cuda_ptr,
dst_cuda_ptr + uv_off,
y_pitch as usize,
&out,
)?;
Ok(out)
}
}
/// Drop the cached import for `fd` (the PipeWire buffer it wrapped is gone — pool recycle / /// Drop the cached import for `fd` (the PipeWire buffer it wrapped is gone — pool recycle /
/// renegotiation — or the caller is about to store a different dmabuf under the same slot). /// renegotiation — or the caller is about to store a different dmabuf under the same slot).
/// Without this the cache could serve a stale imported buffer for a reused fd number, or /// Without this the cache could serve a stale imported buffer for a reused fd number, or
@@ -414,6 +676,14 @@ impl Drop for VkBridge {
self.device.destroy_buffer(d.buffer, None); self.device.destroy_buffer(d.buffer, None);
self.device.free_memory(d.memory, None); self.device.free_memory(d.memory, None);
} }
if let Some(c) = self.csc.take() {
self.device.destroy_pipeline(c.pipeline, None);
self.device.destroy_pipeline_layout(c.playout, None);
self.device.destroy_descriptor_pool(c.dpool, None); // frees `c.dset` with it
self.device
.destroy_descriptor_set_layout(c.dset_layout, None);
self.device.destroy_shader_module(c.module, None);
}
self.device.destroy_fence(self.fence, None); self.device.destroy_fence(self.fence, None);
self.device.destroy_command_pool(self.cmd_pool, None); self.device.destroy_command_pool(self.cmd_pool, None);
self.device.destroy_device(None); self.device.destroy_device(None);
+3
View File
@@ -299,6 +299,9 @@ impl EglBackend {
req.modifier, req.modifier,
)?, )?,
ImportKind::Linear => self.importer.import_linear(&plane, req.width, req.height)?, ImportKind::Linear => self.importer.import_linear(&plane, req.width, req.height)?,
ImportKind::LinearNv12 => self
.importer
.import_linear_nv12(&plane, req.width, req.height)?,
}; };
// Assign / look up the buffer's id and export its CUDA IPC identity on first delivery. // Assign / look up the buffer's id and export its CUDA IPC identity on first delivery.
cuda::make_current()?; cuda::make_current()?;
+70 -2
View File
@@ -45,12 +45,20 @@ const RECV_BUF: usize = MAX_DATAGRAM_BYTES + 1;
/// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame /// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`) /// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
/// are droppable for the same reason a stale ICMP is. /// are droppable for the same reason a stale ICMP is.
/// - Windows `WSAENOBUFS` (10055): the exact analogue of unix `ENOBUFS` — a high-bitrate keyframe
/// burst (one `WSASendMsg` USO super-buffer is up to ~512 segments ≈ 700 KB) momentarily exhausts
/// the socket send buffer / AFD non-paged pool, and Winsock reports `WSAENOBUFS`, which Rust maps
/// to `ErrorKind::Uncategorized` (so the `WouldBlock` arm misses it, exactly like unix `ENOBUFS`).
/// Without treating it as transient a Windows host tears the whole session down under load
/// (observed live: `native::stream` "send failed — stopping stream" on a paced video burst). Same
/// lossy-drop contract; FEC + the next frame recover. The `WSAENET*`/`WSAEHOST*` family is the
/// Windows counterpart of the droppable unix network-path blips above.
fn is_transient_io(e: &std::io::Error) -> bool { fn is_transient_io(e: &std::io::Error) -> bool {
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock}; use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) { if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
return true; return true;
} }
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno (unix only). // `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno.
#[cfg(unix)] #[cfg(unix)]
{ {
matches!( matches!(
@@ -62,7 +70,20 @@ fn is_transient_io(e: &std::io::Error) -> bool {
| Some(libc::EHOSTDOWN) | Some(libc::EHOSTDOWN)
) )
} }
#[cfg(not(unix))] // Windows Winsock codes (WSAE*), raw like the sibling `uso_unsupported`. WSAEWOULDBLOCK (10035)
// already maps to `ErrorKind::WouldBlock` above, so it isn't repeated here.
#[cfg(windows)]
{
matches!(
e.raw_os_error(),
Some(10055) // WSAENOBUFS — tx queue / send buffer full (the dominant high-bitrate drop)
| Some(10051) // WSAENETUNREACH
| Some(10065) // WSAEHOSTUNREACH
| Some(10050) // WSAENETDOWN
| Some(10064) // WSAEHOSTDOWN
)
}
#[cfg(not(any(unix, windows)))]
{ {
false false
} }
@@ -324,6 +345,53 @@ mod tests {
} }
} }
/// The raw-errno tx-queue-full / network-blip codes have no stable `ErrorKind` (they surface as
/// `Uncategorized`), so they only get caught by the platform `raw_os_error()` arms. A burst that
/// momentarily exhausts the send buffer must stay a lossy drop, never a teardown — this is the
/// regression guard for the Windows `WSAENOBUFS` (10055) session crash and the unix `ENOBUFS`
/// wlan-driver case. Gated per platform because a code is only classified on its own OS.
#[test]
fn transient_io_covers_raw_tx_queue_and_path_codes() {
use std::io::Error;
#[cfg(unix)]
{
for code in [
libc::ENOBUFS,
libc::ENETUNREACH,
libc::EHOSTUNREACH,
libc::ENETDOWN,
libc::EHOSTDOWN,
] {
assert!(
is_transient_io(&Error::from_raw_os_error(code)),
"unix errno {code} should be transient"
);
}
// A genuine failure with no stable ErrorKind must still tear down.
assert!(
!is_transient_io(&Error::from_raw_os_error(libc::EACCES)),
"EACCES must stay fatal"
);
}
#[cfg(windows)]
{
// WSAENOBUFS / WSAENETUNREACH / WSAEHOSTUNREACH / WSAENETDOWN / WSAEHOSTDOWN.
for code in [10055, 10051, 10065, 10050, 10064] {
assert!(
is_transient_io(&Error::from_raw_os_error(code)),
"WSA code {code} should be transient"
);
}
// WSAEACCES (10013) — a real failure that must stay fatal.
assert!(
!is_transient_io(&Error::from_raw_os_error(10013)),
"WSAEACCES must stay fatal"
);
}
}
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising /// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits /// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order. /// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
+9 -5
View File
@@ -54,6 +54,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# back to the real module path, so the console's target column and the ring's noise gate see # back to the real module path, so the console's target column and the ring's noise gate see
# `mdns_sd::…` instead of "log". # `mdns_sd::…` instead of "log".
tracing-log = "0.2" tracing-log = "0.2"
# `log::LevelFilter` for the bridge's max-level in `log_capture::install_global` (which inits the
# tracing-log bridge with `ignore_crate("wasapi")`). Already in the tree transitively.
log = "0.4"
axum = "0.8" axum = "0.8"
mdns-sd = "0.20" mdns-sd = "0.20"
# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record. # Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record.
@@ -77,17 +80,20 @@ base64 = "0.22"
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled + # webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
# checked everywhere even though only the Windows GOG/Xbox providers need it today. # checked everywhere even though only the Windows GOG/Xbox providers need it today.
ureq = "2" ureq = "2"
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] } rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
x509-parser = "0.16" x509-parser = "0.16"
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over # Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that # tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene). # feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
axum-server = "0.8" axum-server = "0.8"
rustls = "0.23" # Ring backend, NOT the (default) aws-lc-rs one — matches punktfunk-core + the client so the whole
# tree stays ring-only (no aws-lc-sys: a heavy C dep that fails to build on the Windows CI runner).
# Keep `tls12` for GameStream/Moonlight clients that negotiate TLS 1.2.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a # Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the # tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
# verified fingerprint injected as a request extension. Versions match the workspace lock. # verified fingerprint injected as a request extension. Versions match the workspace lock.
tokio-rustls = "0.26" tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
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"] }
@@ -114,8 +120,6 @@ tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1" http-body-util = "0.1"
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs). # Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
tempfile = "3" tempfile = "3"
# Emit `log`-crate records through the tracing-log bridge in the log_capture tests.
log = "0.4"
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround # Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The # (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
+20
View File
@@ -164,6 +164,12 @@ pub enum EventKind {
/// API (RFC §8) lands. /// API (RFC §8) lands.
source: String, source: String,
}, },
#[serde(rename = "plugins.changed")]
PluginsChanged {
/// The plugin whose registration changed (registered, restarted, deregistered, or
/// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set.
id: String,
},
#[serde(rename = "host.started")] #[serde(rename = "host.started")]
HostStarted { HostStarted {
version: String, version: String,
@@ -190,6 +196,7 @@ impl EventKind {
EventKind::DisplayCreated { .. } => "display.created", EventKind::DisplayCreated { .. } => "display.created",
EventKind::DisplayReleased { .. } => "display.released", EventKind::DisplayReleased { .. } => "display.released",
EventKind::LibraryChanged { .. } => "library.changed", EventKind::LibraryChanged { .. } => "library.changed",
EventKind::PluginsChanged { .. } => "plugins.changed",
EventKind::HostStarted { .. } => "host.started", EventKind::HostStarted { .. } => "host.started",
EventKind::HostStopping => "host.stopping", EventKind::HostStopping => "host.stopping",
} }
@@ -495,6 +502,19 @@ mod tests {
serde_json::to_string(&ev).unwrap(), serde_json::to_string(&ev).unwrap(),
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"# r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
); );
let ev = HostEvent {
seq: 3,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::PluginsChanged {
id: "rom-manager".into(),
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"#
);
} }
#[test] #[test]
+16 -3
View File
@@ -5,7 +5,7 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use pf_paths::config_dir; use pf_paths::config_dir;
use rsa::pkcs1v15::SigningKey; use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::DecodePrivateKey; use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
use rsa::RsaPrivateKey; use rsa::RsaPrivateKey;
use sha2::Sha256; use sha2::Sha256;
use std::fs; use std::fs;
@@ -70,7 +70,20 @@ impl ServerIdentity {
} }
fn generate() -> Result<(String, String)> { fn generate() -> Result<(String, String)> {
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).context("rcgen RSA keygen")?; // The workspace is ring-only (aws-lc-sys breaks Windows CI — see the rustls/rcgen pins), and
// `ring` can *sign* with an existing RSA key but cannot *generate* one: rcgen's ring backend
// returns `KeyGenerationUnavailable` for `generate_for(&PKCS_RSA_SHA256)`. Moonlight requires an
// RSA-2048 identity, so generate the key with the pure-Rust `rsa` crate (already a dep for the
// pairing signer) and hand the PKCS#8 PEM to rcgen, whose ring backend *can* load + self-sign
// with it. Returning that same PEM keeps it byte-identical to what `from_pems` re-parses.
let mut rng = rand::thread_rng();
let priv_key = RsaPrivateKey::new(&mut rng, 2048).context("generate RSA-2048 host key")?;
let key_pem = priv_key
.to_pkcs8_pem(LineEnding::LF)
.context("encode host key as PKCS#8 PEM")?
.to_string();
let key = rcgen::KeyPair::from_pkcs8_pem_and_sign_algo(&key_pem, &rcgen::PKCS_RSA_SHA256)
.context("load RSA host key into rcgen")?;
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?; let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
params params
.distinguished_name .distinguished_name
@@ -78,7 +91,7 @@ fn generate() -> Result<(String, String)> {
params.not_before = rcgen::date_time_ymd(2020, 1, 1); params.not_before = rcgen::date_time_ymd(2020, 1, 1);
params.not_after = rcgen::date_time_ymd(2040, 1, 1); params.not_after = rcgen::date_time_ymd(2040, 1, 1);
let cert = params.self_signed(&key).context("self-sign cert")?; let cert = params.self_signed(&key).context("self-sign cert")?;
Ok((cert.pem(), key.serialize_pem())) Ok((cert.pem(), key_pem))
} }
/// Extract the X.509 `signatureValue` bytes from a cert PEM. /// Extract the X.509 `signatureValue` bytes from a cert PEM.
+1 -1
View File
@@ -239,7 +239,7 @@ pub fn serve(
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?; let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
rt.block_on(async move { rt.block_on(async move {
// 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::ring::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 // 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). // `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
+1 -1
View File
@@ -180,7 +180,7 @@ fn build_server_config(
key_pem: &str, key_pem: &str,
mandatory: bool, mandatory: bool,
) -> Result<Arc<ServerConfig>> { ) -> Result<Arc<ServerConfig>> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); let provider = Arc::new(rustls::crypto::ring::default_provider());
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses), // PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
// so we don't pull the unmaintained `rustls-pemfile`. // so we don't pull the unmaintained `rustls-pemfile`.
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes()) let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
+88 -9
View File
@@ -176,16 +176,48 @@ impl HooksConfig {
/// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe: /// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe:
/// private dir, temp-write + atomic rename, in-memory value changes only if the write succeeds. /// private dir, temp-write + atomic rename, in-memory value changes only if the write succeeds.
///
/// A hand-edited `hooks.json` is honored WITHOUT a restart (the documented contract): [`get`]
/// re-stats the file and reloads when its identity (mtime + length) moved. The stat rides the
/// per-event dispatch, so the check costs one `metadata()` call per event, and a full re-read
/// happens only when the file actually changed.
///
/// [`get`]: HooksStore::get
pub struct HooksStore { pub struct HooksStore {
path: PathBuf, path: PathBuf,
cur: Mutex<Option<HooksConfig>>, cur: Mutex<StoreState>,
}
struct StoreState {
cfg: Option<HooksConfig>,
/// Identity of the file revision `cfg` was parsed from (mtime + length); `None` = the file
/// did not exist. `get` compares against a fresh stat to detect hand edits.
file_id: Option<(std::time::SystemTime, u64)>,
} }
impl HooksStore { impl HooksStore {
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning /// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
/// (never fail host startup over a settings file). /// (never fail host startup over a settings file).
pub fn load_from(path: PathBuf) -> Self { pub fn load_from(path: PathBuf) -> Self {
let cur = match std::fs::read(&path) { let (cfg, file_id) = Self::read_disk(&path);
HooksStore {
path,
cur: Mutex::new(StoreState { cfg, file_id }),
}
}
/// The file's on-disk identity, `None` when it does not exist (or cannot be stat'd —
/// indistinguishable on purpose: both mean "no usable hooks file").
fn file_identity(path: &PathBuf) -> Option<(std::time::SystemTime, u64)> {
let meta = std::fs::metadata(path).ok()?;
Some((meta.modified().ok()?, meta.len()))
}
/// Read + validate the file. Same lenient contract as startup: missing ⇒ no hooks;
/// invalid/unreadable ⇒ no hooks with a warning naming the problem.
fn read_disk(path: &PathBuf) -> (Option<HooksConfig>, Option<(std::time::SystemTime, u64)>) {
let file_id = Self::file_identity(path);
let cfg = match std::fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) { Ok(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) {
Ok(c) => { Ok(c) => {
if let Err(e) = c.validate() { if let Err(e) = c.validate() {
@@ -204,15 +236,23 @@ impl HooksStore {
}, },
Err(_) => None, Err(_) => None,
}; };
HooksStore { (cfg, file_id)
path,
cur: Mutex::new(cur),
}
} }
/// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher. /// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher.
/// Re-reads `hooks.json` first if it changed on disk since last load, so hand edits apply
/// on the next event, no restart ("changes apply immediately" — docs/automation.md).
pub fn get(&self) -> HooksConfig { pub fn get(&self) -> HooksConfig {
self.cur.lock().unwrap().clone().unwrap_or_default() let mut st = self.cur.lock().unwrap();
let now_id = Self::file_identity(&self.path);
if now_id != st.file_id {
let (cfg, file_id) = Self::read_disk(&self.path);
tracing::info!(path = %self.path.display(), hooks = cfg.as_ref().map_or(0, |c| c.hooks.len()),
"hooks.json changed on disk — reloaded");
st.cfg = cfg;
st.file_id = file_id;
}
st.cfg.clone().unwrap_or_default()
} }
/// Persist + adopt a new configuration (caller validates first). The in-memory value /// Persist + adopt a new configuration (caller validates first). The in-memory value
@@ -224,12 +264,15 @@ impl HooksStore {
let tmp = self.path.with_extension("json.tmp"); let tmp = self.path.with_extension("json.tmp");
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?; pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
std::fs::rename(&tmp, &self.path)?; std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = Some(cfg); let mut st = self.cur.lock().unwrap();
st.file_id = Self::file_identity(&self.path);
st.cfg = Some(cfg);
Ok(()) Ok(())
} }
} }
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded once on first access. /// The process-wide hooks store (`<config_dir>/hooks.json`), loaded on first access and
/// re-loaded whenever the file changes on disk (see [`HooksStore::get`]).
pub fn store() -> &'static HooksStore { pub fn store() -> &'static HooksStore {
static STORE: OnceLock<HooksStore> = OnceLock::new(); static STORE: OnceLock<HooksStore> = OnceLock::new();
STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json"))) STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json")))
@@ -860,6 +903,42 @@ mod tests {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
#[test]
fn hand_edited_file_reloads_without_restart() {
let path = std::env::temp_dir().join(format!(
"pf-hooks-reload-test-{}-{:p}.json",
std::process::id(),
&0u8 as *const u8
));
let _ = std::fs::remove_file(&path);
let store = HooksStore::load_from(path.clone());
assert!(store.get().hooks.is_empty());
// The documented flow: the operator writes hooks.json by hand and the SAME running
// store honors it on the next event — no restart, no PUT.
std::fs::write(
&path,
br#"{"hooks":[{"on":"stream.started","run":"true"}]}"#,
)
.unwrap();
assert_eq!(store.get().hooks.len(), 1, "hand edit applies on next read");
assert_eq!(store.get().hooks[0].on, "stream.started");
// A second edit applies too (length differs, so same-second mtime granularity can't
// mask it).
std::fs::write(
&path,
br#"{"hooks":[{"on":"stream.started","run":"true"},{"on":"client.*","run":"true"}]}"#,
)
.unwrap();
assert_eq!(store.get().hooks.len(), 2, "second hand edit applies too");
// Deleting the file removes the hooks.
std::fs::remove_file(&path).unwrap();
assert!(store.get().hooks.is_empty(), "deleted file = no hooks");
}
#[test] #[test]
fn filters_constrain_and_missing_fields_never_match() { fn filters_constrain_and_missing_fields_never_match() {
let ev = sample_event(); let ev = sample_event();
+26 -6
View File
@@ -125,12 +125,13 @@ pub fn ring() -> &'static LogRing {
RING.get_or_init(LogRing::new) RING.get_or_init(LogRing::new)
} }
/// Targets whose DEBUG/TRACE output is steady-state per-packet chatter, not diagnostics — left /// Targets whose DEBUG/TRACE output is steady-state chatter, not diagnostics — left in, they evict
/// in, they evict the entire ring tail (mdns-sd DEBUG-logs every multicast packet it can't parse, /// the entire ring tail: `mdns_sd` DEBUG-logs every multicast packet it can't parse (one chatty
/// so one chatty AirPlay/HomePod device on the LAN floods thousands of entries per hour). The /// AirPlay/HomePod device on the LAN floods thousands of entries per hour), and `wasapi` DEBUG-logs
/// ring keeps their INFO-and-up; stderr under `RUST_LOG` is unaffected. Prefix-matched on module /// the default audio device once a second (the device-watchdog poll). The ring keeps their
/// path boundaries. /// INFO-and-up; the file/stderr filter caps them separately (see `main`'s EnvFilter directives).
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd"]; /// Prefix-matched on module path boundaries.
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd", "wasapi"];
fn is_noisy_debug(target: &str) -> bool { fn is_noisy_debug(target: &str) -> bool {
NOISY_DEBUG_TARGETS.iter().any(|t| { NOISY_DEBUG_TARGETS.iter().any(|t| {
@@ -140,6 +141,25 @@ fn is_noisy_debug(target: &str) -> bool {
}) })
} }
/// Init the `log`→`tracing` bridge and install `subscriber` as the global default. Replaces
/// `SubscriberInitExt::init()` (which auto-inits the bridge with no crate filtering) so we can
/// **drop the `wasapi` crate's records at the bridge**: it polls the default audio device ~1×/s
/// and `log::debug!`s it, and those bridged events carry the bridge shim target at *filter* time,
/// so a downstream level/target filter on the file layer can't catch them (the ring can, in
/// `on_event`, via `normalized_metadata` — but the fmt layer filters pre-event). `ignore_crate`
/// stops them at the source, before they ever become tracing events, so neither sink sees them.
/// The bridge max-level stays DEBUG so every *other* `log`-crate dependency still reaches the ring.
pub fn install_global<S>(subscriber: S)
where
S: tracing::Subscriber + Send + Sync + 'static,
{
let _ = tracing_log::LogTracer::builder()
.with_max_level(log::LevelFilter::Debug)
.ignore_crate("wasapi")
.init();
let _ = tracing::subscriber::set_global_default(subscriber);
}
/// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a /// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a
/// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at /// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at
/// `info` (remote debugging must not require a restart with a different env). /// `info` (remote debugging must not require a restart with a different env).
+12 -11
View File
@@ -117,18 +117,19 @@ fn main() {
// A second layer tees DEBUG-and-up into the in-memory ring served by GET /api/v1/logs — // A second layer tees DEBUG-and-up into the in-memory ring served by GET /api/v1/logs —
// deliberately not gated by RUST_LOG, so console-side debugging never needs a restart. // deliberately not gated by RUST_LOG, so console-side debugging never needs a restart.
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer; use tracing_subscriber::Layer;
tracing_subscriber::registry() log_capture::install_global(
.with( tracing_subscriber::registry()
log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG), .with(
) log_capture::RingLayer
.with( .with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
tracing_subscriber::fmt::layer() )
.with_writer(std::io::stderr) .with(
.with_filter(filter), tracing_subscriber::fmt::layer()
) .with_writer(std::io::stderr)
.init(); .with_filter(filter),
),
);
} }
// Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise // Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise
+6 -1
View File
@@ -38,6 +38,7 @@ mod hooks;
mod host; mod host;
mod library; mod library;
mod native; mod native;
mod plugins;
mod session; mod session;
mod shared; mod shared;
mod stats; mod stats;
@@ -223,7 +224,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
)) ))
.routes(routes!(stats::logs_get)) .routes(routes!(stats::logs_get))
.routes(routes!(events::stream_events)) .routes(routes!(events::stream_events))
.routes(routes!(hooks::get_hooks, hooks::set_hooks)), .routes(routes!(hooks::get_hooks, hooks::set_hooks))
.routes(routes!(plugins::list_plugins))
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
.routes(routes!(plugins::get_ui_credential)),
) )
.split_for_parts() .split_for_parts()
} }
@@ -261,6 +265,7 @@ pub fn openapi_json() -> String {
(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 = "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)"), (name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
) )
)] )]
struct ApiDoc; struct ApiDoc;
+554
View File
@@ -0,0 +1,554 @@
//! Plugin registry (plugin-ui-surface design): an in-memory, lease-based directory of running
//! `punktfunk-plugin-*` processes and the loopback UI each one serves.
//!
//! A plugin (an out-of-process script under the scripting runner, RFC §8) that wants a UI serves
//! it on a **loopback** port behind a per-boot secret, then **registers** here — `{title, ui:{port,
//! secret, icon}}` — over the admin/loopback lane it already holds via the SDK. The web console
//! reads [`list_plugins`] to grow a nav entry and reverse-proxies to the port (fetching the secret
//! from [`get_ui_credential`] server-side, never exposing it to the browser). The host itself never
//! dials the plugin, never health-checks it, and never persists any of this: it is a phone book with
//! expiry.
//!
//! Lease model (design §3, D8): a registration lives for [`LEASE_TTL`]; the plugin renews with the
//! same idempotent `PUT` every 30 s. Expiry is **lazy** — a crashed plugin's entry simply stops
//! listing once stale; there is no reaper task and nothing to persist across a host restart (the
//! supervised plugin re-registers on its next tick). Every consumer dials `127.0.0.1:<port>` only —
//! a registration stores a *port*, never an address, so it can never point the proxy elsewhere (D5).
//!
//! Auth: these routes carry no special handling — they are outside the [`super::auth::cert_may_access`]
//! read-only allowlist, so the middleware confines them to a **bearer + loopback** peer like every
//! other mutation. LAN clients have no business here.
use super::shared::*;
use crate::events::{emit, EventKind};
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
use std::time::{Duration, Instant};
/// How long a registration stays live after its last renewal. The SDK helper renews every 30 s, so
/// this tolerates two missed ticks before a plugin drops out of the listing.
const LEASE_TTL: Duration = Duration::from_secs(90);
// ---------------------------------------------------------------- wire shapes
/// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request
/// body, never a response ([`PluginUiPublic`] is the secret-free view).
#[derive(Deserialize, ToSchema)]
pub(crate) struct PluginUi {
/// The **loopback** port the plugin serves its UI on. The host and console only ever dial
/// `127.0.0.1:<port>`; a registration can never carry a hostname.
pub port: u16,
/// Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every
/// request to the plugin's UI server. Rotated whenever the plugin restarts.
pub secret: String,
/// Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
/// Register/renew body for `PUT /plugins/{id}`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct PluginRegistration {
/// Human-readable title for the console nav entry (164 chars; control chars stripped).
pub title: String,
/// Optional plugin version, purely informational (≤32 chars).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUi>,
}
/// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.
#[derive(Serialize, ToSchema)]
pub(crate) struct PluginUiPublic {
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
/// One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists
/// and has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).
#[derive(Serialize, ToSchema)]
pub(crate) struct PluginSummary {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUiPublic>,
}
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
/// This is the only endpoint that returns a secret; the console BFF denylists it from the browser.
#[derive(Serialize, ToSchema)]
pub(crate) struct UiCredential {
pub port: u16,
pub secret: String,
}
// ---------------------------------------------------------------- registry core
/// The stored UI surface (internal — parsed + validated, no wire derives).
#[derive(Clone, PartialEq)]
struct StoredUi {
port: u16,
secret: String,
icon: Option<String>,
}
/// One live registration. `expires_at` is a **monotonic** [`Instant`] (immune to wall-clock jumps).
struct Stored {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
expires_at: Instant,
}
impl Stored {
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
self.title == title && self.version == *version && self.ui == *ui
}
}
/// The process-wide plugin registry.
pub(crate) struct PluginRegistry {
inner: RwLock<HashMap<String, Stored>>,
}
/// A validated registration ready to store (title trimmed, ui fields checked).
struct Valid {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
}
impl PluginRegistry {
fn new() -> Self {
Self {
inner: RwLock::new(HashMap::new()),
}
}
/// Insert or renew `id`. Returns `true` when an operator-visible field changed (new plugin,
/// restart, or re-scan) — the signal the caller emits `plugins.changed` on. A pure renewal
/// returns `false`.
fn upsert(&self, id: &str, v: Valid) -> bool {
let expires_at = Instant::now() + LEASE_TTL;
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let changed = match map.get(id) {
// An *expired* prior entry counts as a change (it had stopped listing).
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
None => true,
};
map.insert(
id.to_string(),
Stored {
title: v.title,
version: v.version,
ui: v.ui,
expires_at,
},
);
changed
}
/// The current listing (sorted by title, then id), plus the ids of entries that had expired and
/// were pruned by this call — the caller emits `plugins.changed` for those. Prunes under the
/// write lock so a stale entry is reaped exactly once.
fn snapshot(&self) -> (Vec<PluginSummary>, Vec<String>) {
let now = Instant::now();
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let mut expired: Vec<String> = map
.iter()
.filter(|(_, s)| now >= s.expires_at)
.map(|(id, _)| id.clone())
.collect();
for id in &expired {
map.remove(id);
}
expired.sort();
let mut live: Vec<PluginSummary> = map
.iter()
.map(|(id, s)| PluginSummary {
id: id.clone(),
title: s.title.clone(),
version: s.version.clone(),
ui: s.ui.as_ref().map(|u| PluginUiPublic {
port: u.port,
icon: u.icon.clone(),
}),
})
.collect();
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
(live, expired)
}
/// The `{port, secret}` for a live plugin's UI, or `None` if unknown/expired/UI-less. Does not
/// prune (a read path) — a stale entry is reaped by the next [`snapshot`](Self::snapshot).
fn credential(&self, id: &str) -> Option<UiCredential> {
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
let s = map.get(id)?;
if !s.is_live() {
return None;
}
let ui = s.ui.as_ref()?;
Some(UiCredential {
port: ui.port,
secret: ui.secret.clone(),
})
}
/// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an
/// already-expired or unknown id returns `false` (nothing to announce).
fn remove(&self, id: &str) -> bool {
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
map.remove(id).is_some_and(|s| s.is_live())
}
}
impl Stored {
fn is_live(&self) -> bool {
Instant::now() < self.expires_at
}
}
/// The process-wide registry singleton (the [`crate::events::bus`] shape).
pub(crate) fn registry() -> &'static PluginRegistry {
static REG: OnceLock<PluginRegistry> = OnceLock::new();
REG.get_or_init(PluginRegistry::new)
}
// ---------------------------------------------------------------- validation
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
/// enforces, so a plugin's registration id always matches its package name.
fn valid_plugin_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.as_bytes()[0].is_ascii_lowercase()
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// Strip control characters (defense against a title/version smuggling terminal escapes or newlines
/// into a log line or the console nav), then trim.
fn sanitize(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.trim()
.to_string()
}
/// Validate a registration body into the internal [`Valid`] form, or a human-readable reason.
fn validate(reg: PluginRegistration) -> Result<Valid, String> {
let title = sanitize(&reg.title);
if title.is_empty() {
return Err("title must not be empty".into());
}
if title.chars().count() > 64 {
return Err("title must be at most 64 characters".into());
}
let version = match reg.version {
Some(v) => {
let v = sanitize(&v);
if v.chars().count() > 32 {
return Err("version must be at most 32 characters".into());
}
(!v.is_empty()).then_some(v)
}
None => None,
};
let ui = match reg.ui {
Some(u) => Some(validate_ui(u)?),
None => None,
};
Ok(Valid { title, version, ui })
}
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
if u.port < 1024 {
return Err("ui.port must be a non-privileged port (>= 1024)".into());
}
let n = u.secret.len();
if !(16..=128).contains(&n) {
return Err("ui.secret must be 16128 characters".into());
}
if !u
.secret
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err("ui.secret must be [A-Za-z0-9_-]".into());
}
let icon = match u.icon {
Some(icon) => {
let ok = (1..=48).contains(&icon.len())
&& icon
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !ok {
return Err("ui.icon must be a lucide name ([a-z0-9-], 148 chars)".into());
}
Some(icon)
}
None => None,
};
Ok(StoredUi {
port: u.port,
secret: u.secret,
icon,
})
}
// ---------------------------------------------------------------- handlers
/// Register or renew a plugin
///
/// Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs
/// this every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console
/// will proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed
/// (first registration, restart, or re-scan) — a pure renewal is silent.
#[utoipa::path(
put,
path = "/plugins/{id}",
tag = "plugins",
operation_id = "registerPlugin",
params(("id" = String, Path, description = "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)")),
request_body = PluginRegistration,
responses(
(status = NO_CONTENT, description = "Registered / renewed"),
(status = BAD_REQUEST, description = "Invalid id or registration", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn register_plugin(
Path(id): Path<String>,
ApiJson(reg): ApiJson<PluginRegistration>,
) -> Response {
if !valid_plugin_id(&id) {
return api_error(
StatusCode::BAD_REQUEST,
"invalid plugin id (expected kebab-case `[a-z][a-z0-9-]*`, ≤64)",
);
}
let valid = match validate(reg) {
Ok(v) => v,
Err(e) => return api_error(StatusCode::BAD_REQUEST, &e),
};
if registry().upsert(&id, valid) {
tracing::info!(plugin = %id, "plugin registered");
emit(EventKind::PluginsChanged { id });
}
StatusCode::NO_CONTENT.into_response()
}
/// List registered plugins
///
/// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry
/// reports its id, title, optional version, and — for plugins that serve one — a UI descriptor
/// (loopback port + icon). The console renders these as nav entries and proxies to the port; it
/// fetches the secret separately, server-side.
#[utoipa::path(
get,
path = "/plugins",
tag = "plugins",
operation_id = "listPlugins",
responses(
(status = OK, description = "Live plugin registrations", body = [PluginSummary]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_plugins() -> Json<Vec<PluginSummary>> {
let (plugins, expired) = registry().snapshot();
// Lazy expiry: an entry that aged out is reaped here (exactly once) and announced, so a consumer
// watching the event stream sees the departure even though nothing actively deregistered it.
for id in expired {
tracing::info!(plugin = %id, "plugin lease expired");
emit(EventKind::PluginsChanged { id });
}
Json(plugins)
}
/// Fetch a plugin UI's proxy credential
///
/// Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.
/// Bearer + loopback only (like every mutation), and additionally excluded from the console's browser
/// passthrough: the secret never reaches a browser.
#[utoipa::path(
get,
path = "/plugins/{id}/ui-credential",
tag = "plugins",
operation_id = "getPluginUiCredential",
params(("id" = String, Path, description = "The plugin id")),
responses(
(status = OK, description = "The proxy credential", body = UiCredential),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No live plugin with that id, or it serves no UI", body = ApiError),
)
)]
pub(crate) async fn get_ui_credential(Path(id): Path<String>) -> Response {
match registry().credential(&id) {
Some(cred) => Json(cred).into_response(),
None => api_error(StatusCode::NOT_FOUND, "no live plugin UI with that id"),
}
}
/// Deregister a plugin
///
/// The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls
/// this from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was
/// removed. Idempotent — deleting an unknown/expired id is a no-op `204`.
#[utoipa::path(
delete,
path = "/plugins/{id}",
tag = "plugins",
operation_id = "deregisterPlugin",
params(("id" = String, Path, description = "The plugin id")),
responses(
(status = NO_CONTENT, description = "Deregistered (or already absent)"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn delete_plugin(Path(id): Path<String>) -> Response {
if registry().remove(&id) {
tracing::info!(plugin = %id, "plugin deregistered");
emit(EventKind::PluginsChanged { id });
}
StatusCode::NO_CONTENT.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn reg(title: &str, port: u16, secret: &str) -> PluginRegistration {
PluginRegistration {
title: title.into(),
version: None,
ui: Some(PluginUi {
port,
secret: secret.into(),
icon: Some("gamepad-2".into()),
}),
}
}
const SECRET: &str = "abcdefghijklmnop0123"; // 20 chars, valid alphabet
#[test]
fn id_validation() {
assert!(valid_plugin_id("rom-manager"));
assert!(valid_plugin_id("a"));
assert!(valid_plugin_id("x9"));
assert!(!valid_plugin_id("")); // empty
assert!(!valid_plugin_id("9lives")); // must start with a letter
assert!(!valid_plugin_id("-lead")); // must start with a letter
assert!(!valid_plugin_id("Rom")); // no uppercase
assert!(!valid_plugin_id("rom_manager")); // no underscore
assert!(!valid_plugin_id(&"a".repeat(65))); // too long
}
#[test]
fn registration_validation() {
assert!(validate(reg("ROM Manager", 49321, SECRET)).is_ok());
// control chars stripped from the title
let v = validate(PluginRegistration {
title: "Ro\u{7}m\n".into(),
version: None,
ui: None,
})
.unwrap();
assert_eq!(v.title, "Rom");
// privileged port rejected
assert!(validate(reg("x", 80, SECRET)).is_err());
// short secret rejected
assert!(validate(reg("x", 49321, "tooshort")).is_err());
// bad secret alphabet rejected
assert!(validate(reg("x", 49321, "bad secret with spaces!!")).is_err());
// empty title rejected
assert!(validate(reg(" ", 49321, SECRET)).is_err());
}
#[test]
fn upsert_reports_change_but_not_renewal() {
let r = PluginRegistry::new();
// first registration is a change
assert!(r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
// identical renewal is not
assert!(!r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
// a new secret (restart) is a change
assert!(r.upsert(
"p",
validate(reg("Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
));
// a new title (re-scan) is a change
assert!(r.upsert(
"p",
validate(reg("New Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
));
}
#[test]
fn snapshot_lists_secret_free_sorted() {
let r = PluginRegistry::new();
r.upsert("zeta", validate(reg("Zeta", 50000, SECRET)).unwrap());
r.upsert("alpha", validate(reg("Alpha", 50001, SECRET)).unwrap());
let (plugins, expired) = r.snapshot();
assert!(expired.is_empty());
assert_eq!(
plugins.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
vec!["alpha", "zeta"] // sorted by title
);
// the summary type has no secret field at all — check the credential path carries it
assert_eq!(r.credential("alpha").unwrap().secret, SECRET);
assert_eq!(r.credential("alpha").unwrap().port, 50001);
}
#[test]
fn credential_absent_for_ui_less_and_unknown() {
let r = PluginRegistry::new();
r.upsert(
"headless",
validate(PluginRegistration {
title: "Headless".into(),
version: None,
ui: None,
})
.unwrap(),
);
assert!(r.credential("headless").is_none()); // registered but no UI
assert!(r.credential("nope").is_none()); // unknown
}
#[test]
fn expired_entries_drop_from_listing_and_credential() {
let r = PluginRegistry::new();
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
// Force the lease into the past.
{
let mut map = r.inner.write().unwrap();
map.get_mut("p").unwrap().expires_at =
Instant::now().checked_sub(Duration::from_secs(1)).unwrap();
}
assert!(r.credential("p").is_none()); // expired → no credential
let (plugins, expired) = r.snapshot();
assert!(plugins.is_empty());
assert_eq!(expired, vec!["p".to_string()]); // reaped + announced once
// second snapshot no longer reports it as freshly-expired
assert!(r.snapshot().1.is_empty());
}
#[test]
fn remove_reports_live_only() {
let r = PluginRegistry::new();
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
assert!(r.remove("p")); // live → announced
assert!(!r.remove("p")); // already gone → silent
}
}
+95
View File
@@ -138,6 +138,18 @@ async fn cert_auth_is_a_read_only_allowlist() {
"the client roster {p} must require the bearer token, not just a paired cert" "the client roster {p} must require the bearer token, not just a paired cert"
); );
} }
// The plugin directory is admin-only — a paired streaming cert has no business enumerating the
// host's running plugins or reaching a plugin UI's proxy credential (plugin-ui-surface §3).
for p in [
"/api/v1/plugins",
"/api/v1/plugins/rom-manager/ui-credential",
] {
assert_eq!(
send_cert(&app, get_req(p), fp).await,
StatusCode::UNAUTHORIZED,
"the plugin directory {p} must require the bearer token, not just a paired cert"
);
}
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer). // PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
assert_eq!( assert_eq!(
send_cert(&app, get_req("/api/v1/native/pair"), fp).await, send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
@@ -574,6 +586,89 @@ async fn idr_requires_an_active_stream() {
assert!(state.force_idr.load(Ordering::SeqCst)); assert!(state.force_idr.load(Ordering::SeqCst));
} }
/// The plugin registry round-trips through the router: register → list (secret-free) → credential
/// (secret present) → deregister. Guards the wiring, auth, and — the security-critical bit — that
/// the UI secret never appears in the browser-visible listing (plugin-ui-surface §7, D6).
#[tokio::test]
async fn plugin_registry_roundtrip() {
let app = test_app(test_state(), None);
let id = "test-plugin-roundtrip";
let secret = "s3cr3t-abcdefghijkl"; // 19 chars, valid [A-Za-z0-9_-]
// Register with a UI surface → 204.
let (status, _) = send(
&app,
put_json(
&format!("/api/v1/plugins/{id}"),
serde_json::json!({
"title": "Test Plugin",
"ui": { "port": 49321, "secret": secret, "icon": "gamepad-2" }
}),
),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
// It lists — and the secret appears NOWHERE in the listing body.
let (status, body) = send(&app, get_req("/api/v1/plugins")).await;
assert_eq!(status, StatusCode::OK);
let mine = body
.as_array()
.unwrap()
.iter()
.find(|p| p["id"] == id)
.expect("registered plugin is listed");
assert_eq!(mine["title"], "Test Plugin");
assert_eq!(mine["ui"]["port"], 49321);
assert_eq!(mine["ui"]["icon"], "gamepad-2");
assert!(
!body.to_string().contains(secret),
"the listing must never carry the UI secret"
);
// The credential endpoint (server-side proxy lookup) DOES carry it.
let (status, body) = send(
&app,
get_req(&format!("/api/v1/plugins/{id}/ui-credential")),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["secret"], secret);
assert_eq!(body["port"], 49321);
// Deregister → gone from the listing, credential 404s.
let (status, _) = send(
&app,
axum::http::Request::delete(format!("/api/v1/plugins/{id}"))
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (_, body) = send(&app, get_req("/api/v1/plugins")).await;
assert!(
body.as_array().unwrap().iter().all(|p| p["id"] != id),
"deregistered plugin must not list"
);
let (status, _) = send(
&app,
get_req(&format!("/api/v1/plugins/{id}/ui-credential")),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// A structurally invalid registration is a 400 (privileged port).
let (status, _) = send(
&app,
put_json(
&format!("/api/v1/plugins/{id}"),
serde_json::json!({ "title": "x", "ui": { "port": 80, "secret": secret } }),
),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
/// The OpenAPI document lists every route with a unique operationId (codegen relies /// The OpenAPI document lists every route with a unique operationId (codegen relies
/// on both), and the checked-in copy is current. /// on both), and the checked-in copy is current.
#[test] #[test]
+61 -1
View File
@@ -201,6 +201,20 @@ fn frame_driven_enabled() -> bool {
*ON.get_or_init(|| std::env::var("PUNKTFUNK_FRAME_DRIVEN").as_deref() != Ok("0")) *ON.get_or_init(|| std::env::var("PUNKTFUNK_FRAME_DRIVEN").as_deref() != Ok("0"))
} }
/// Adaptive pipeline depth (latency plan, from the 2026-07-17 on-glass finding on a `.173` RTX
/// 4090): the capturer's pipeline depth of 2 measured **~13 ms of glass-to-glass latency** over
/// depth 1 at 60 fps (17 ms → 4 ms) — the AU is ready in µs but depth-2 holds it a whole frame
/// interval unpolled while N+1 is submitted. Depth-2 exists to overlap the convert of N+1 with
/// the encode of N under GPU contention (the depth-1 ~50 fps collapse), so run **depth-1 by
/// default** and escalate to the capturer's max ONLY when the loop can't hold its cadence at
/// depth-1 (the contention tell), then stick there for the session (escalate-and-hold — no
/// oscillation; de-escalation is a v2 item). `PUNKTFUNK_IDD_ADAPTIVE=0` pins the capturer's full
/// depth (the pre-adaptive behaviour). Off when the capturer's max depth is already 1.
fn idd_adaptive_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("PUNKTFUNK_IDD_ADAPTIVE").as_deref() != Ok("0"))
}
/// Seal one access unit and send it with MICROBURST pacing (the shared /// Seal one access unit and send it with MICROBURST pacing (the shared
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap` /// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and /// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
@@ -1131,6 +1145,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
Vec<u32>, Vec<u32>,
Vec<u32>, Vec<u32>,
) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
// Adaptive pipeline depth (see [`idd_adaptive_enabled`]): run depth-1 for latency and
// escalate to the capturer's max on sustained cadence overrun. `cur_depth` is the live
// target (clamped to the capturer's current max each iteration — a rebuild can change it);
// `behind_score` is a leaky bucket over the "fell behind the cadence deadline" signal;
// `depth_frames` skips the startup warmup so first-frame bring-up cost can't false-escalate.
let mut cur_depth: usize = 1;
let mut behind_score: u32 = 0;
let mut depth_frames: u64 = 0;
// ~20 net behind-frames (≈0.3 s sustained) escalates; a lone hitch decays away. Warmup skips
// the first ~1 s so bring-up (display acquire, encoder open) never triggers it.
const DEPTH_ESCALATE: u32 = 20;
const DEPTH_BEHIND_CAP: u32 = 60;
const DEPTH_WARMUP_FRAMES: u64 = 60;
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in // Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
// place — a different compositor at the SAME client mode — keeping the Session + send thread // place — a different compositor at the SAME client mode — keeping the Session + send thread
@@ -1718,7 +1745,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
} }
// How deep to pipeline (1 = synchronous submit→poll, the original behaviour). The IDD-push // How deep to pipeline (1 = synchronous submit→poll, the original behaviour). The IDD-push
// capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1. // capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1.
let depth = capturer.pipeline_depth().max(1); // Adaptive (default): start at 1 for latency, `cur_depth` escalates on sustained overrun (the
// tail below). Pinned to the capturer's max when adaptive is off or the max is already 1.
let max_depth = capturer.pipeline_depth().max(1);
let depth = if idd_adaptive_enabled() {
cur_depth.clamp(1, max_depth)
} else {
max_depth
};
let submit_ns = now_ns(); let submit_ns = now_ns();
// Wire pts: a fresh frame anchors at its capture-delivery stamp (`CapturedFrame.pts_ns`, // Wire pts: a fresh frame anchors at its capture-delivery stamp (`CapturedFrame.pts_ns`,
// stamped when the capture thread handed it over) so client-measured latency covers // stamped when the capture thread handed it over) so client-measured latency covers
@@ -1934,6 +1968,32 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
"encode stall detected — encoder rebuilt in place, forcing an IDR"); "encode stall detected — encoder rebuilt in place, forcing an IDR");
last_au_at = std::time::Instant::now(); last_au_at = std::time::Instant::now();
} }
// Adaptive-depth escalate signal (measured BEFORE the trailing sleep): "behind" = the
// frame's work overran its cadence deadline `next`, so the trailing sleep would be
// zero/negative. At depth-1 that means the synchronous poll (encode + WDDM wait) can't
// fit a frame interval — the contention case pipelining is for — so escalate to the
// capturer's max and hold there. Leaky bucket + warmup skip reject one-off hitches and
// bring-up. Once escalated, `cur_depth` stays (no de-escalation in v1).
if idd_adaptive_enabled() && cur_depth < max_depth {
depth_frames += 1;
if depth_frames > DEPTH_WARMUP_FRAMES {
let behind = std::time::Instant::now() >= next;
behind_score = if behind {
(behind_score + 1).min(DEPTH_BEHIND_CAP)
} else {
behind_score.saturating_sub(1)
};
if behind_score >= DEPTH_ESCALATE {
cur_depth = max_depth;
tracing::info!(
depth = cur_depth,
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
(GPU contention); pipelining for the rest of the session (latency \
trade for throughput)"
);
}
}
}
if frame_driven_enabled() && capturer.supports_arrival_wait() { if frame_driven_enabled() && capturer.supports_arrival_wait() {
// T1.1 frame-driven trigger: instead of sleeping out the whole tick and then // T1.1 frame-driven trigger: instead of sleeping out the whole tick and then
// SAMPLING (which holds a frame that arrived just after the previous sample for up // SAMPLING (which holds a frame that arrived just after the previous sample for up
+10 -11
View File
@@ -153,37 +153,36 @@ fn rotate_if_large(path: &std::path::Path) {
/// supervisor serves no mgmt API itself, but the layer is harmless and keeps both inits uniform. /// supervisor serves no mgmt API itself, but the layer is harmless and keeps both inits uniform.
pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) { pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer; use tracing_subscriber::Layer;
let ring = let ring =
crate::log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG); crate::log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG);
let log_path = service_log_path(); let log_path = service_log_path();
rotate_if_large(&log_path); rotate_if_large(&log_path);
// `install_global` inits the log bridge with `ignore_crate("wasapi")` (see its doc) and sets
// the subscriber global — replacing `SubscriberInitExt::init`, which would bridge every crate.
match std::fs::OpenOptions::new() match std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open(log_path) .open(log_path)
{ {
Ok(file) => { Ok(file) => {
tracing_subscriber::registry() crate::log_capture::install_global(
.with(ring) tracing_subscriber::registry().with(ring).with(
.with(
tracing_subscriber::fmt::layer() tracing_subscriber::fmt::layer()
.with_ansi(false) .with_ansi(false)
.with_writer(move || file.try_clone().expect("clone service log handle")) .with_writer(move || file.try_clone().expect("clone service log handle"))
.with_filter(filter), .with_filter(filter),
) ),
.init(); );
} }
Err(_) => { Err(_) => {
tracing_subscriber::registry() crate::log_capture::install_global(
.with(ring) tracing_subscriber::registry().with(ring).with(
.with(
tracing_subscriber::fmt::layer() tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr) .with_writer(std::io::stderr)
.with_filter(filter), .with_filter(filter),
) ),
.init(); );
} }
} }
} }
+1
View File
@@ -29,6 +29,7 @@ and nothing you configure here runs anywhere near the streaming path.
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | 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 | | `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}`) | | `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
| `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id |
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled | | `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` Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
+11 -11
View File
@@ -87,9 +87,9 @@ pick a host from the list, just like the other native apps.
A headless CLI path exists for scripting/measurement: A headless CLI path exists for scripting/measurement:
```sh ```sh
punktfunk-client # open the WinUI 3 window (host list / settings) punktfunk-client # open the WinUI 3 window (host list / settings)
punktfunk-client --discover # list hosts on the network punktfunk-client --discover # list hosts on the network
punktfunk-client --headless --connect <host>:9777 # no window: connect, count frames, print stats punktfunk-client --headless --speed-test --connect <host>:9777 # no window: probe the link, print measured/recommended bitrate
``` ```
Prefer the broadest compatibility, or no install? **Moonlight** also streams to Windows (see below). Prefer the broadest compatibility, or no install? **Moonlight** also streams to Windows (see below).
@@ -121,13 +121,13 @@ punktfunk-probe --connect <host>:9777 --pin <fp> # connect to one
| You're streaming to… | Use | | You're streaming to… | Use |
|---|---| |---|---|
| A Mac, iPhone, iPad, or Apple TV | The **Apple app** | | A Mac, iPhone, iPad, or Apple TV | The **[Apple app](#apple-app-mac-iphone-ipad-apple-tv)** |
| A Linux desktop or laptop | **`punktfunk-client`** (GTK4) | | A Linux desktop or laptop | **[`punktfunk-client`](#linux-desktop-client-gtk4)** (GTK4) |
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the GTK4 client in Desktop Mode | | A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the [GTK4 client](#linux-desktop-client-gtk4) in Desktop Mode |
| An Android phone or TV | The **Android app** | | An Android phone or TV | The **[Android app](#android-app-phone--android-tv)** |
| Windows | The native **`punktfunk-client`** (signed MSIX) or **Moonlight** | | Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed MSIX) or **[Moonlight](/docs/moonlight)** |
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **Moonlight** | | An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **[Moonlight](/docs/moonlight)** |
| A browser, another smart TV, or any other device | **Moonlight** | | A browser, another smart TV, or any other device | **[Moonlight](/docs/moonlight)** |
| Automated tests / latency measurement | **`punktfunk-probe`** (headless) | | Automated tests / latency measurement | **[`punktfunk-probe`](#linux-reference-client-headless)** (headless) |
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing). Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing).
+3 -3
View File
@@ -88,8 +88,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. | | `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
| `PUNKTFUNK_10BIT` | `1` | HEVC Main10 / HDR. Honored only when the client also advertises 10-bit. **Windows host only** (the Linux host stays 8-bit). | | `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. **Windows host only** (the Linux host stays 8-bit). |
| `PUNKTFUNK_444` | `1` | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. | | `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. | | `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. | | `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
@@ -142,7 +142,7 @@ notes for context.
|---|---|---| |---|---|---|
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). | | `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. | | `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. |
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `high`; `realtime` is the strongest lever but can freeze NVENC on some setups. | | `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. | | `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
## Diagnostics ## Diagnostics
+4 -4
View File
@@ -43,15 +43,15 @@ GPU path** that keeps latency low even at high resolutions and frame rates.
punktfunk speaks two protocols over the same host: punktfunk speaks two protocols over the same host:
- **GameStream** — the protocol Moonlight uses. Any [Moonlight](/docs/moonlight) client connects with - **GameStream** — the protocol Moonlight uses. Start the host with `--gamestream` and any
no special software. This is the most compatible way in. [Moonlight](/docs/moonlight) client connects with no special software. This is the most compatible way in.
- **punktfunk/1 (native)** — a purpose-built protocol with a QUIC control channel and a UDP data - **punktfunk/1 (native)** — a purpose-built protocol with a QUIC control channel and a UDP data
channel hardened with forward error correction and encryption. It's lower-latency and more resilient channel hardened with forward error correction and encryption. It's lower-latency and more resilient
on imperfect networks, and it's what the [native clients](/docs/clients) (Apple, Linux, Windows, on imperfect networks, and it's what the [native clients](/docs/clients) (Apple, Linux, Windows,
Android) use. Android) use.
Both run from a single host process, so you don't choose up front — Moonlight clients use GameStream, The native `punktfunk/1` plane runs by default (the secure default); add `--gamestream` and both planes
the native clients use punktfunk/1. serve from a single host process — Moonlight clients use GameStream, the native clients use punktfunk/1.
## Pairing and trust ## Pairing and trust
+3 -3
View File
@@ -18,9 +18,9 @@ It's built for the things that make streaming feel native:
plug to deal with, even on the secure desktop. plug to deal with, even on the secure desktop.
- **Low latency, GPU end to end.** Frames go straight from the compositor to the GPU encoder - **Low latency, GPU end to end.** Frames go straight from the compositor to the GPU encoder
(NVENC) with zero CPU copies, and over a transport tuned for responsiveness rather than throughput. (NVENC) with zero CPU copies, and over a transport tuned for responsiveness rather than throughput.
- **Works with the apps you already have.** punktfunk speaks the GameStream protocol, so any - **Works with the apps you already have.** punktfunk also speaks the GameStream protocol, so with
**Moonlight** client connects out of the box — and a faster **native protocol** with dedicated apps `--gamestream` any **Moonlight** client connects — alongside a faster **native protocol** with
for **macOS, iOS, tvOS, Linux, Windows, and Android**. dedicated apps for **macOS, iOS, tvOS, Linux, Windows, and Android**.
- **Secure by default.** Hosts require a one-time PIN pairing; after that, devices reconnect on a - **Secure by default.** Hosts require a one-time PIN pairing; after that, devices reconnect on a
pinned identity. No accounts, no cloud. pinned identity. No accounts, no cloud.
+5 -3
View File
@@ -83,15 +83,17 @@ The Windows client ships as a **signed MSIX** in the package registry. Builds us
certificate, so you import that certificate once before Windows will install the package. certificate, so you import that certificate once before Windows will install the package.
1. Open the [packages page](https://git.unom.io/unom/-/packages) (generic group), find 1. Open the [packages page](https://git.unom.io/unom/-/packages) (generic group), find
**`punktfunk-client-windows`**, and download the newest **`.msix`** and its matching **`.cer`**. **`punktfunk-client-windows`**, and download the newest **`.msix`** and its matching **`.cer`** for
your CPU — the artifacts are arch-suffixed (`…_x64.msix` / `…_arm64.msix`).
2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is 2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is
trusted — but it's the **same certificate for every release**, so this is genuinely one-time and trusted — but it's the **same certificate for every release**, so this is genuinely one-time and
later updates need nothing. In an **admin** PowerShell: later updates need nothing. In an **admin** PowerShell:
```powershell ```powershell
Import-Certificate -FilePath .\punktfunk-client-windows.cer ` # use the _arm64 files instead on an Arm device
Import-Certificate -FilePath .\punktfunk-client-windows_x64.cer `
-CertStoreLocation Cert:\LocalMachine\TrustedPeople -CertStoreLocation Cert:\LocalMachine\TrustedPeople
Add-AppxPackage .\punktfunk-client-windows.msix Add-AppxPackage .\punktfunk-client-windows_x64.msix
``` ```
If Windows reports a missing dependency, install the If Windows reports a missing dependency, install the
+2 -2
View File
@@ -37,13 +37,13 @@ punktfunk also runs as a native host on **Windows 11 22H2+ (x64)**, shipped as a
installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations. installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations.
1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest 1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest
**`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**. **`punktfunk-host-setup-<ver>.exe`** and the matching **`punktfunk-host-windows_<ver>.cer`**.
2. **Trust the publisher certificate once.** The installer is signed with a self-signed certificate 2. **Trust the publisher certificate once.** The installer is signed with a self-signed certificate
whose public `.cer` is published next to it — the **same certificate for every release**, so this is whose public `.cer` is published next to it — the **same certificate for every release**, so this is
genuinely one-time and later updates need nothing. In an **admin** PowerShell: genuinely one-time and later updates need nothing. In an **admin** PowerShell:
```powershell ```powershell
Import-Certificate -FilePath .\punktfunk-host-setup.cer ` Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
-CertStoreLocation Cert:\LocalMachine\TrustedPublisher -CertStoreLocation Cert:\LocalMachine\TrustedPublisher
``` ```
+2 -2
View File
@@ -33,8 +33,8 @@ Pick your distro to install, then your desktop to configure — the two are inde
needs one of these compositor backends to create a virtual display. needs one of these compositor backends to create a virtual display.
> **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a > **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
> signed installer that registers a service and bundles a virtual-display driver (whose driver- > signed installer that registers a service and bundles a virtual-display driver whose driver
> framework needs make 22H2 the hard floor — Windows 10 is not supported). It encodes on NVIDIA > framework (IddCx 1.10) makes 22H2 the hard floor — Windows 10 is not supported. It encodes on NVIDIA
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see > (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
> [Windows Host](/docs/windows-host). > [Windows Host](/docs/windows-host).
+7 -5
View File
@@ -51,6 +51,8 @@ see [Status & Progress](/docs/status).
(the encoder re-targets mid-stream when bitrate is set to Automatic). (the encoder re-targets mid-stream when bitrate is set to Automatic).
- **Surround sound** — 5.1 and 7.1 end to end: the host encodes multichannel via multistream Opus and - **Surround sound** — 5.1 and 7.1 end to end: the host encodes multichannel via multistream Opus and
the native clients render more than two channels, with clean, synchronous stereo where the path is stereo. the native clients render more than two channels, with clean, synchronous stereo where the path is stereo.
- **Clipboard sync** — bidirectional **text and images** between host and client, carried on a side
plane over the native protocol's QUIC channel.
## 🟡 In progress ## 🟡 In progress
@@ -90,11 +92,11 @@ see [Status & Progress](/docs/status).
rate and the client presents with tearing-control/VRR instead of a fixed cadence, for tear- and rate and the client presents with tearing-control/VRR instead of a fixed cadence, for tear- and
judder-free gaming. Builds on the client's presentation-feedback path and the per-session virtual judder-free gaming. Builds on the client's presentation-feedback path and the per-session virtual
outputs. outputs.
- **Desktop quality-of-life.** The essentials that make remote *work* pleasant, each a new side plane - **Desktop quality-of-life.** More of the essentials that make remote *work* pleasant, each a new side
over the existing QUIC datagram channel: bidirectional rich **clipboard sync** (text and images), plane over the existing QUIC datagram channel: **multi-monitor streaming** (present the host's several
**multi-monitor streaming** (present the host's several outputs as separate client windows), and outputs as separate client windows) and **virtual-webcam redirection** (the client's camera shows up
**virtual-webcam redirection** (the client's camera shows up as a webcam on the host, so video calls as a webcam on the host, so video calls run on the remote machine). *(Clipboard sync already shipped —
run on the remote machine). see above.)*
## ⛔ Parked / blocked ## ⛔ Parked / blocked
@@ -87,7 +87,7 @@ Intel (QSV); the host falls back to software H.264 without one.
After a reboot, from another machine on the network: After a reboot, from another machine on the network:
```sh ```sh
punktfunk-probe --discover # or just look for the host in a native client / Moonlight punktfunk-probe --discover # source-build dev tool (not packaged); or just open a native client / Moonlight and look for the host
``` ```
If the host is listed, it's up. If not, check `journalctl --user -u punktfunk-host` on the host. If the host is listed, it's up. If not, check `journalctl --user -u punktfunk-host` on the host.
+3 -3
View File
@@ -32,9 +32,9 @@ host is newer than the Linux host.)
- **Native resolution, no scaling.** Every session gets a virtual display at the client's - **Native resolution, no scaling.** Every session gets a virtual display at the client's
exact resolution and refresh rate, via per-compositor backends for **KWin**, exact resolution and refresh rate, via per-compositor backends for **KWin**,
**gamescope**, **Mutter**, and **Sway/wlroots**. **gamescope**, **Mutter**, and **Sway/wlroots**.
- **Zero-copy GPU pipeline.** Captured frames stay on the GPU (dmabuf → CUDA → NVENC) with - **Zero-copy GPU pipeline.** Captured frames stay on the GPU dmabuf → CUDA → NVENC on NVIDIA, and
automatic split-encode at very high resolutions. Stable 240 fps at 5120×1440 has been VAAPI or Vulkan Video on AMD/Intel — with automatic split-encode at very high resolutions. Stable
measured. A GPU-less software H.264 encoder exists as an explicit fallback. 240 fps at 5120×1440 has been measured. A GPU-less software H.264 encoder exists as an explicit fallback.
- **HDR (10-bit), on the Windows host.** An HDR Windows desktop is captured and encoded as HEVC - **HDR (10-bit), on the Windows host.** An HDR Windows desktop is captured and encoded as HEVC
Main10 (BT.2020 PQ) to HDR-capable clients (Windows, Android). Linux hosts stream 8-bit for now — Main10 (BT.2020 PQ) to HDR-capable clients (Windows, Android). Linux hosts stream 8-bit for now —
HDR there is blocked upstream at the compositor. HDR there is blocked upstream at the compositor.
+2 -2
View File
@@ -28,7 +28,7 @@ You need three things on the Deck:
``` ```
(Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck). Without it, the (Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck). Without it, the
panel's **Stream** button reports `client-not-found`.) panel's **Stream** button reports `flatpak-not-found`.)
3. **A punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds 3. **A punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds
it automatically over mDNS, so nothing to configure here. it automatically over mDNS, so nothing to configure here.
@@ -103,7 +103,7 @@ The plugin check follows the [channel](/docs/channels) you installed from: a plu
| Symptom | Fix | | Symptom | Fix |
|---|---| |---|---|
| **Stream** shows `client-not-found` | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). | | **Stream** shows `flatpak-not-found` | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. | | No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. |
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. | | Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. |
| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. | | Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. |
+1 -1
View File
@@ -27,7 +27,7 @@ The host auto-detects a wlroots session, so you usually need nothing here. To fo
these in `~/.config/punktfunk/host.env`: these in `~/.config/punktfunk/host.env`:
```ini ```ini
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr (Hyprland has its own: PUNKTFUNK_COMPOSITOR=hyprland) PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr, hyprland (all the wlroots family; the exact backend is auto-detected)
PUNKTFUNK_INPUT_BACKEND=wlr PUNKTFUNK_INPUT_BACKEND=wlr
PUNKTFUNK_VIDEO_SOURCE=virtual PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU. # GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
+3 -1
View File
@@ -49,6 +49,8 @@ Download the signed `punktfunk-host-setup-<ver>.exe` from the
displays, displays,
- installs the bundled **virtual gamepad drivers** (DualSense, DualShock 4, Xbox 360), - installs the bundled **virtual gamepad drivers** (DualSense, DualShock 4, Xbox 360),
- registers the bundled **HDR Vulkan layer** so Vulkan games can enable HDR over the virtual display, - registers the bundled **HDR Vulkan layer** so Vulkan games can enable HDR over the virtual display,
- optionally installs **VB-CABLE** (VB-Audio, donationware) as the virtual microphone for client
mic passthrough — a checkbox in the installer,
- sets up the **web management console** (see below). - sets up the **web management console** (see below).
For an unattended install, append `/VERYSILENT`. Upgrades and uninstall go through **Add/Remove For an unattended install, append `/VERYSILENT`. Upgrades and uninstall go through **Add/Remove
@@ -122,7 +124,7 @@ pipeline orchestration are all shared with the Linux host. The Windows host is a
| **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) | | **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) |
| **Input — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble | | **Input — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble |
| **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** | | **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** |
| **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic | | **Virtual mic** | PipeWire `Audio/Source` | **VB-CABLE** virtual device (optional), captured via WASAPI |
The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The
host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real
+15 -3
View File
@@ -52,7 +52,7 @@
packages = forAllSystems (system: packages = forAllSystems (system:
let pf = packagesFor system; let pf = packagesFor system;
in { in {
inherit (pf) punktfunk-host punktfunk-client; inherit (pf) punktfunk-host punktfunk-client punktfunk-web punktfunk-scripting punktfunk-tray;
default = pf.punktfunk-host; default = pf.punktfunk-host;
}); });
@@ -68,14 +68,26 @@
type = "app"; type = "app";
program = "${pf.punktfunk-client}/bin/punktfunk-client"; program = "${pf.punktfunk-client}/bin/punktfunk-client";
}; };
# `nix run .#punktfunk-web` — the console (auto-wire the mgmt token / cert via env or the
# NixOS module; see packaging/nix/README.md).
punktfunk-web = {
type = "app";
program = "${pf.punktfunk-web}/bin/punktfunk-web-server";
};
# `nix run .#punktfunk-scripting -- --list` — the plugin/script runner.
punktfunk-scripting = {
type = "app";
program = "${pf.punktfunk-scripting}/bin/punktfunk-scripting";
};
default = self.apps.${system}.punktfunk-host; default = self.apps.${system}.punktfunk-host;
}); });
# `nix flake check` builds both packages. # `nix flake check` builds every package (web included — needs its deps hash filled in, see
# packaging/nix/README.md).
checks = forAllSystems (system: checks = forAllSystems (system:
let pf = packagesFor system; let pf = packagesFor system;
in { in {
inherit (pf) punktfunk-host punktfunk-client; inherit (pf) punktfunk-host punktfunk-client punktfunk-web punktfunk-scripting;
}); });
# `nix develop` — the pinned toolchain plus every system lib the workspace links, wired so # `nix develop` — the pinned toolchain plus every system lib the workspace links, wired so
+50 -7
View File
@@ -14,10 +14,11 @@
# picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software # picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software
# fallback, so it works everywhere. See README.md. # fallback, so it works everywhere. See README.md.
pkgbase=punktfunk pkgbase=punktfunk
# punktfunk-web (the browser console) is OPT-IN: building it needs `bun` (AUR-only as bun-bin on # punktfunk-web (the browser console) and punktfunk-scripting (the plugin/script runner) are OPT-IN:
# stock Arch/SteamOS), so a default makepkg builds only host+client with no JS tooling — mirroring # both need `bun` (AUR-only as bun-bin on stock Arch/SteamOS), so a default makepkg builds only
# the RPM spec's `%bcond_with web` (off by default). Set PF_WITH_WEB=1 to also build punktfunk-web # host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web` / `%bcond_with
# (appended to pkgname + bun to makedepends below). # scripting` (off by default). Set PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 to also build them (each
# appends its pkgname + bun to makedepends below).
pkgname=('punktfunk-host' 'punktfunk-client') pkgname=('punktfunk-host' 'punktfunk-client')
# CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes -> # CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
# X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel # X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
@@ -39,11 +40,16 @@ options=('!lto' '!debug')
makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git' 'vulkan-headers' makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git' 'vulkan-headers'
'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei') 'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei')
# Opt-in punktfunk-web: only then is bun (the build tool AND the vendored runtime) required. # Opt-in punktfunk-web / punktfunk-scripting: only then is bun (the build tool AND the vendored
# runtime) required. Adding bun twice is harmless (makepkg dedups makedepends).
if [ "${PF_WITH_WEB:-0}" = 1 ]; then if [ "${PF_WITH_WEB:-0}" = 1 ]; then
pkgname+=('punktfunk-web') pkgname+=('punktfunk-web')
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
fi fi
if [ "${PF_WITH_SCRIPTING:-0}" = 1 ]; then
pkgname+=('punktfunk-scripting')
makedepends+=('bun')
fi
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root — # AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root —
# build() uses it instead AND the fetch is skipped entirely (a canary pkgver has no tag to # build() uses it instead AND the fetch is skipped entirely (a canary pkgver has no tag to
@@ -78,12 +84,26 @@ build() {
# Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 # Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0
# opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe. # opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \ cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
# across everything in one build, so co-building the tray with the host pulls the host's
# ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio
# runtime by design) then panics at startup ("there is no reactor running, must be called from the
# context of a Tokio 1.x runtime"). Built alone, its zbus stays on async-io. (Same split the .deb does.)
cargo build --release --locked -p punktfunk-tray
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS), # Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
# built AND run with bun. # built AND run with bun.
if [ "${PF_WITH_WEB:-0}" = 1 ]; then if [ "${PF_WITH_WEB:-0}" = 1 ]; then
( cd web && bun install --frozen-lockfile && bun run build ) ( cd web && bun install --frozen-lockfile && bun run build )
fi fi
# Plugin/script runner (opt-in): one self-contained JS via `bun build --target=bun` (effect + the
# SDK inlined; the dynamic plugin import stays a runtime import). bun is also its vendored runtime.
if [ "${PF_WITH_SCRIPTING:-0}" = 1 ]; then
( cd sdk && bun install --frozen-lockfile --ignore-scripts && \
bun build src/runner-cli.ts --target=bun --outfile="$srcdir/runner-cli.js" )
grep -q 'attempt=' "$srcdir/runner-cli.js" \
|| { echo "ERROR: runner bundle missing the dynamic plugin import — wrong build" >&2; exit 1; }
fi
} }
package_punktfunk-host() { package_punktfunk-host() {
@@ -99,7 +119,8 @@ package_punktfunk-host() {
'sway: stream a wlroots desktop (Sway VirtualDisplay backend)' 'sway: stream a wlroots desktop (Sway VirtualDisplay backend)'
'xdg-desktop-portal-kde: portal for the headless KDE session helper' 'xdg-desktop-portal-kde: portal for the headless KDE session helper'
'xdg-desktop-portal-wlr: portal for the headless Sway session helper' 'xdg-desktop-portal-wlr: portal for the headless Sway session helper'
'punktfunk-web: browser management console (device pairing + status)') 'punktfunk-web: browser management console (device pairing + status)'
'punktfunk-scripting: plugin/script runner for host automation (bun)')
install=punktfunk-host.install install=punktfunk-host.install
# User-editable config: the headless game-mode drop-in (see below) — don't clobber local edits. # User-editable config: the headless game-mode drop-in (see below) — don't clobber local edits.
backup=('etc/gamescope-session-plus/sessions.d/steam') backup=('etc/gamescope-session-plus/sessions.d/steam')
@@ -227,3 +248,25 @@ package_punktfunk-web() {
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-web/LICENSE-MIT" install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-web/LICENSE-MIT"
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-web/LICENSE-APACHE" install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-web/LICENSE-APACHE"
} }
package_punktfunk-scripting() {
pkgdesc="punktfunk plugin/script runner (Effect SDK on bun) — supervises host automation scripts + punktfunk-plugin-* packages"
# bun is the runtime (it import()s the operator's .ts plugins), a vendored native binary, so this
# package is arch-specific (not 'any'). OPT-IN: the systemd --user unit ships disabled (the runner
# is inert until you add scripts/plugins). No nodejs/bun dependency.
local R; R="$(_repo)"
# Pre-built self-contained bundle (from build()) + a PATH-stable launcher (matches the .deb/.rpm).
install -Dm0644 "$srcdir/runner-cli.js" "$pkgdir/usr/share/punktfunk-scripting/runner-cli.js"
# Vendor the build env's bun into a private dir so it never collides with a system-wide bun.
install -Dm0755 "$(command -v bun)" "$pkgdir/usr/lib/punktfunk-scripting/bun"
install -d "$pkgdir/usr/bin"
printf '%s\n' '#!/bin/sh' 'exec /usr/lib/punktfunk-scripting/bun /usr/share/punktfunk-scripting/runner-cli.js "$@"' \
> "$pkgdir/usr/bin/punktfunk-scripting"
chmod 0755 "$pkgdir/usr/bin/punktfunk-scripting"
# systemd USER unit — installed but NOT auto-enabled (opt-in). Enable once you have automation:
# systemctl --user enable --now punktfunk-scripting
install -Dm0644 "$R/scripts/punktfunk-scripting.service" "$pkgdir/usr/lib/systemd/user/punktfunk-scripting.service"
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-scripting/LICENSE-MIT"
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-scripting/LICENSE-APACHE"
}
+29 -4
View File
@@ -18,11 +18,24 @@ here.)
Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput` Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput`
udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself (built in the Ubuntu doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself. The NVIDIA driver
26.04 rust-ci image, so the lib soname package names match the target). The NVIDIA driver
(`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of (`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of
band, like on the RPM side. band, like on the RPM side.
## Ubuntu 24.04 LTS (and why it needs a special build)
`punktfunk-host` needs **FFmpeg 8** (libavcodec62), but Ubuntu 24.04 LTS ships FFmpeg 6.1
(libavcodec60). So a host `.deb` built the obvious way — on the same Ubuntu 26.04 image as the
client (`ci/rust-ci.Dockerfile`) — declares `Depends: libavcodec62, …` and a glibc-2.41 floor that
24.04's apt can't satisfy ("the required packages are too recent"). To fix that, the host `.deb` is
instead built on an **Ubuntu 24.04 image** (`ci/rust-ci-noble.Dockerfile`) that carries a from-source
FFmpeg 8, and that FFmpeg is **bundled into the package** (`build-deb.sh BUNDLE_FFMPEG=1` → the
libav* land in `/usr/lib/punktfunk-host`, the binary's rpath points there, and the libav* sonames are
dropped from `Depends`). The result is **one** host `.deb` that installs on **Ubuntu 24.04 LTS through
26.04** (glibc floor 2.39; no distro-FFmpeg dependency). The client/web/scripting `.deb`s still build
on 26.04 (the native client needs SDL3 / GTK4 ≥ 4.20, absent on 24.04) — install the client on the box
you stream *to*, which is independent of the host's distro.
## Install on a host (one-time) ## Install on a host (one-time)
The registry is public, so no apt auth is needed — just trust the repo's signing key: The registry is public, so no apt auth is needed — just trust the repo's signing key:
@@ -144,5 +157,17 @@ VERSION=0.0.1 bash packaging/debian/build-deb.sh # -> dist/punktfunk-host_0.0.
``` ```
Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing. Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing.
Build it in the rust-ci image (or on an Ubuntu 26.04 box) so the resolved `Depends` match the Building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
hosts; building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
That plain invocation hard-depends on the build box's system FFmpeg, so it only installs on a box
with the same libav* soname. For the **universal** package CI ships (installs on 24.04 LTS → 26.04),
build it in the noble image with FFmpeg bundled:
```sh
docker build -f ci/rust-ci-noble.Dockerfile -t pf-noble ci
docker run --rm -v "$PWD:/src" -w /src pf-noble \
bash -lc 'VERSION=0.0.1 BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh'
```
`BUNDLE_FFMPEG=1` needs `patchelf` and an FFmpeg install at `FFMPEG_PREFIX` (default `/opt/ffmpeg`,
which the noble image provides).
+63 -6
View File
@@ -13,13 +13,23 @@
# FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a # FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a
# specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band. # specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band.
# #
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-deb.sh # BUNDLE_FFMPEG=1 (Ubuntu 24.04 LTS builds, ci/rust-ci-noble.Dockerfile): instead of hard-depending
# on the distro's libav* — which don't exist on 24.04 (it ships FFmpeg 6.1 / libavcodec60, the host
# needs 8 / libavcodec62) — copy a from-source FFmpeg into /usr/lib/punktfunk-host, repoint the
# binary's rpath there, and drop the libav*/libsw*/libpostproc sonames from the auto Depends. Set
# FFMPEG_PREFIX to that FFmpeg's install prefix (default /opt/ffmpeg, as the noble image sets it).
# See packaging/debian/README.md → "Ubuntu 24.04 LTS".
#
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] [BUNDLE_FFMPEG=1] bash packaging/debian/build-deb.sh
# Output: dist/punktfunk-host_<version>_<arch>.deb # Output: dist/punktfunk-host_<version>_<arch>.deb
set -euo pipefail set -euo pipefail
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}" VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
ARCH="${ARCH:-amd64}" ARCH="${ARCH:-amd64}"
PKG="punktfunk-host" PKG="punktfunk-host"
BUNDLE_FFMPEG="${BUNDLE_FFMPEG:-0}"
FFMPEG_PREFIX="${FFMPEG_PREFIX:-/opt/ffmpeg}"
LIBDIR_REL="usr/lib/$PKG" # bundled FFmpeg lands here: /usr/lib/punktfunk-host
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)" ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR" cd "$ROOTDIR"
@@ -119,8 +129,41 @@ printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <n
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \ "$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
| gzip -9n > "$DOCDIR/changelog.Debian.gz" | gzip -9n > "$DOCDIR/changelog.Debian.gz"
# --- bundled FFmpeg (Ubuntu 24.04 LTS builds) --------------------------------
# Copy the from-source libav*/libsw*/libpostproc .so's into /usr/lib/punktfunk-host and repoint the
# binary at them, so the package carries FFmpeg 8 instead of depending on a distro libavcodec62 that
# 24.04 doesn't have. BUNDLED_LIBS is fed to dpkg-shlibdeps below so the libs' OWN external deps
# (libva2, libdrm2, …, all present on 24.04) still become Depends.
BUNDLED_LIBS=""
if [ "$BUNDLE_FFMPEG" = "1" ]; then
command -v patchelf >/dev/null || { echo "BUNDLE_FFMPEG=1 needs patchelf" >&2; exit 1; }
[ -d "$FFMPEG_PREFIX/lib" ] || { echo "FFMPEG_PREFIX=$FFMPEG_PREFIX has no lib/ — build FFmpeg first" >&2; exit 1; }
DEST="$STAGE/$LIBDIR_REL"
install -d "$DEST"
# cp -a preserves the SONAME symlink chain (libavcodec.so -> .so.62 -> .so.62.x.x); the loader
# resolves the binary's DT_NEEDED (libavcodec.so.62) to the middle link.
shopt -s nullglob
for so in "$FFMPEG_PREFIX"/lib/lib{avcodec,avformat,avutil,avfilter,avdevice,swscale,swresample,postproc}.so*; do
cp -a "$so" "$DEST/"
done
shopt -u nullglob
ls "$DEST"/libavcodec.so.* >/dev/null 2>&1 || { echo "no libav* found under $FFMPEG_PREFIX/lib" >&2; exit 1; }
# Each bundled lib finds its siblings (libavcodec needs libavutil) via its own $ORIGIN RUNPATH;
# patch only the real versioned files, not the symlinks. The executable then finds the top-level
# libs via ../lib/$PKG, written as DT_RPATH (--force-rpath) so it's also searched transitively —
# belt-and-suspenders against DT_RUNPATH's non-transitivity.
for so in "$DEST"/*.so.*; do
[ -L "$so" ] && continue
patchelf --set-rpath '$ORIGIN' "$so"
done
patchelf --force-rpath --set-rpath "\$ORIGIN/../lib/$PKG" "$STAGE/usr/bin/$PKG"
BUNDLED_LIBS="$(printf '%s ' "$DEST"/*.so.*)"
echo "==> bundled FFmpeg from $FFMPEG_PREFIX into /$LIBDIR_REL"
fi
# --- dependencies ------------------------------------------------------------ # --- dependencies ------------------------------------------------------------
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header). # Auto: the binary's directly-linked shared libs (libcuda ignored, see header). In bundle mode the
# bundled .so's are appended so their external deps (libva2/libdrm2/…) are captured too.
SHLIB_TMP="$(mktemp -d)" SHLIB_TMP="$(mktemp -d)"
mkdir -p "$SHLIB_TMP/debian" mkdir -p "$SHLIB_TMP/debian"
cat > "$SHLIB_TMP/debian/control" <<EOF cat > "$SHLIB_TMP/debian/control" <<EOF
@@ -130,8 +173,16 @@ Package: $PKG
Architecture: any Architecture: any
Depends: \${shlibs:Depends} Depends: \${shlibs:Depends}
EOF EOF
SHDEPS_RAW="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \ # In bundle mode the libav* live in FFMPEG_PREFIX/lib — not a standard loader path, and the
| sed -n 's/^shlibs:Depends=//p')" # target/release binary carries no rpath (only the staged copy does) — so dpkg-shlibdeps can't
# locate libavcodec.so.62 and exits 2. Point it there via LD_LIBRARY_PATH. Stderr is captured so a
# future resolution failure is visible instead of swallowed.
SHDEPS_RAW="$(
cd "$SHLIB_TMP"
if [ "$BUNDLE_FFMPEG" = "1" ]; then export LD_LIBRARY_PATH="$FFMPEG_PREFIX/lib"; fi
dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" $BUNDLED_LIBS 2>"$SHLIB_TMP/err" \
| sed -n 's/^shlibs:Depends=//p'
)" || { echo "dpkg-shlibdeps failed (exit $?):" >&2; sed 's/^/ /' "$SHLIB_TMP/err" >&2; rm -rf "$SHLIB_TMP"; exit 1; }
rm -rf "$SHLIB_TMP" rm -rf "$SHLIB_TMP"
[ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; } [ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
@@ -139,8 +190,12 @@ rm -rf "$SHLIB_TMP"
# GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves # GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves
# libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are # libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are
# provided by whatever driver the host runs, so this must never be a package dependency. # provided by whatever driver the host runs, so this must never be a package dependency.
# In bundle mode also drop the FFmpeg sonames: they're shipped inside the package (/usr/lib/$PKG),
# not pulled from apt, so a `Depends: libavcodec62` would wrongly re-block install on 24.04.
FILTER='^(libnvidia-compute|libcuda)'
[ "$BUNDLE_FFMPEG" = "1" ] && FILTER='^(libnvidia-compute|libcuda|libav|libsw|libpostproc)'
SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \ SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \
| grep -ivE '^(libnvidia-compute|libcuda)' | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')" | grep -ivE "$FILTER" | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
[ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; } [ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; }
# Manual additions shlibdeps can't see: # Manual additions shlibdeps can't see:
@@ -156,7 +211,9 @@ DEPENDS="$SHDEPS, libei1, pipewire, wireplumber"
# punktfunk-web = the management web console (pairing + status) every user needs — a separate # punktfunk-web = the management web console (pairing + status) every user needs — a separate
# Architecture:all .deb; Recommends so `apt install punktfunk-host` pulls it by default, while a # Architecture:all .deb; Recommends so `apt install punktfunk-host` pulls it by default, while a
# headless/encoding-only box can opt out with --no-install-recommends. # headless/encoding-only box can opt out with --no-install-recommends.
RECOMMENDS="ffmpeg, gamescope, pipewire-pulse, mesa-va-drivers, intel-media-va-driver, punktfunk-web" # punktfunk-scripting = the plugin/script runner (host automation on bun). Recommends so it's pulled
# by default; its systemd --user unit ships disabled (inert until you add scripts/plugins).
RECOMMENDS="ffmpeg, gamescope, pipewire-pulse, mesa-va-drivers, intel-media-va-driver, punktfunk-web, punktfunk-scripting"
SUGGESTS="kwin-wayland, mutter" SUGGESTS="kwin-wayland, mutter"
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)" INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Build the punktfunk-scripting .deb — the plugin/script runner (the SDK's `punktfunk-scripting`,
# built on Effect, run on bun).
#
# Runtime is BUN: the runner `import()`s the operator's `.ts` plugin/script files directly, which
# only bun can do. Like the web console, we VENDOR a bun binary into the package (bun isn't in apt),
# which makes the package per-arch (amd64/arm64), NOT `all`. Unlike the console it is NOT a Nitro
# bundle: we `bun build` the runner CLI into ONE self-contained JS (effect + the SDK inlined; the
# dynamic plugin import stays a runtime import), so there is no node_modules to ship. The host's
# punktfunk-host .deb Recommends this so a default `apt install punktfunk-host` pulls the runner too;
# its systemd --user unit is installed but NOT auto-enabled (the runner is inert until you add
# scripts/plugins — enable it with `systemctl --user enable --now punktfunk-scripting`).
#
# Usage: VERSION=0.0.1~ci42.gdeadbee [DEB_ARCH=amd64] [BUN_BIN=/path/to/bun] bash packaging/debian/build-scripting-deb.sh
# Output: dist/punktfunk-scripting_<version>_<arch>.deb
set -euo pipefail
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
PKG="punktfunk-scripting"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
# Per-arch: vendor bun for the target Debian arch. Map deb arch → bun's release arch tag.
DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}"
BUN_VERSION="${BUN_VERSION:-1.3.14}" # pinned bun build vendored into the package (matches build-web-deb.sh)
case "$DEB_ARCH" in
amd64) BUN_ARCH=x64 ;;
arm64) BUN_ARCH=aarch64 ;;
*) echo "ERROR: unsupported DEB_ARCH=$DEB_ARCH (want amd64 or arm64)" >&2; exit 1 ;;
esac
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
SHAREDIR="$STAGE/usr/share/$PKG"
DOCDIR="$STAGE/usr/share/doc/$PKG"
LIBDIR="$STAGE/usr/lib/$PKG"
# --- build the runner bundle -------------------------------------------------
# One self-contained JS: `bun build --target=bun` inlines effect + the @punktfunk/host SDK; the
# runner's dynamic `import()` of the operator's plugin files is left as a runtime import (bun keeps
# unresolvable dynamic specifiers external). `--ignore-scripts` on install: nothing needs a
# postinstall, and we skip the `prepare` codegen (it wants ../api/openapi.json — not needed here).
mkdir -p "$SHAREDIR"
(
cd sdk
bun install --frozen-lockfile --ignore-scripts
bun build src/runner-cli.ts --target=bun --outfile="$SHAREDIR/runner-cli.js"
)
grep -q 'attempt=' "$SHAREDIR/runner-cli.js" \
|| { echo "ERROR: runner bundle missing the dynamic plugin import — wrong build" >&2; exit 1; }
# --- vendor the bun runtime --------------------------------------------------
# Honor a pre-fetched bun (CI may cache it) via BUN_BIN; else download the pinned release.
mkdir -p "$LIBDIR"
if [ -n "${BUN_BIN:-}" ]; then
echo "==> vendoring bun from BUN_BIN=$BUN_BIN"
install -m0755 "$BUN_BIN" "$LIBDIR/bun"
else
url="https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip"
echo "==> downloading bun $BUN_VERSION ($BUN_ARCH) from $url"
tmp="$(mktemp -d)"
curl -fsSL "$url" -o "$tmp/bun.zip"
unzip -q "$tmp/bun.zip" -d "$tmp"
install -m0755 "$tmp/bun-linux-${BUN_ARCH}/bun" "$LIBDIR/bun"
rm -rf "$tmp"
fi
"$LIBDIR/bun" --version
# --- file layout -------------------------------------------------------------
# Stable PATH-independent launcher (the systemd unit's ExecStart) — runs the bundle on vendored bun.
install -d "$STAGE/usr/bin"
cat > "$STAGE/usr/bin/punktfunk-scripting" <<'WRAP'
#!/bin/sh
# The runner runs on the vendored bun (it import()s the operator's .ts plugins); bun lives privately
# under /usr/lib/punktfunk-scripting so it never collides with a system-wide bun on PATH.
exec /usr/lib/punktfunk-scripting/bun /usr/share/punktfunk-scripting/runner-cli.js "$@"
WRAP
chmod 0755 "$STAGE/usr/bin/punktfunk-scripting"
install -Dm0644 scripts/punktfunk-scripting.service "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service"
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
install -Dm0644 sdk/README.md "$DOCDIR/README.md"
cat > "$DOCDIR/copyright" <<EOF
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: punktfunk
Source: https://git.unom.io/unom/punktfunk
Files: *
Copyright: punktfunk contributors
License: MIT or Apache-2.0
Dual-licensed. Full texts in /usr/share/doc/$PKG/LICENSE-MIT and
/usr/share/doc/$PKG/LICENSE-APACHE.
EOF
printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <noreply@anthropic.com> %s\n' \
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
| gzip -9n > "$DOCDIR/changelog.Debian.gz"
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
install -d "$STAGE/DEBIAN"
cat > "$STAGE/DEBIAN/control" <<EOF
Package: $PKG
Version: $VERSION
Architecture: $DEB_ARCH
Maintainer: unom <noreply@anthropic.com>
Installed-Size: $INSTALLED_KB
Section: net
Priority: optional
Homepage: https://git.unom.io/unom/punktfunk
Description: punktfunk plugin/script runner (Effect SDK on bun)
Runs a punktfunk host's automation: loose scripts in ~/.config/punktfunk/scripts and installed
punktfunk-plugin-* packages under ~/.config/punktfunk/plugins, each supervised (Effect fibers with
capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run).
Bundles its own bun runtime (no system nodejs/bun dependency).
.
OPT-IN: the systemd --user unit is installed but not auto-enabled (the runner is inert until you add
scripts or plugins). A plugin auto-wires to the host's mgmt token + identity cert on the same box —
no env editing. Enable it with: systemctl --user enable --now punktfunk-scripting
EOF
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
echo "then enable the runner for your user:"
echo " systemctl --user enable --now punktfunk-scripting"
fi
exit 0
EOF
chmod 0755 "$STAGE/DEBIAN/postinst"
mkdir -p dist
OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb"
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
echo "built $OUT"
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size|Depends' || true
+54 -1
View File
@@ -16,8 +16,10 @@ and the native Linux **client**, a **NixOS module** that wires up everything the
| --- | --- | | --- | --- |
| `packages.x86_64-linux.punktfunk-host` | `punktfunk-host` + `punktfunk-tray` (built with `nvenc` + `vulkan-encode`, like CI) | | `packages.x86_64-linux.punktfunk-host` | `punktfunk-host` + `punktfunk-tray` (built with `nvenc` + `vulkan-encode`, like CI) |
| `packages.x86_64-linux.punktfunk-client` | `punktfunk-client` (GTK4 shell) + `punktfunk-session` (Vulkan streamer, without the Skia OSD — see caveats) | | `packages.x86_64-linux.punktfunk-client` | `punktfunk-client` (GTK4 shell) + `punktfunk-session` (Vulkan streamer, without the Skia OSD — see caveats) |
| `packages.x86_64-linux.punktfunk-web` | the management web console (bun-built Nitro SSR bundle; SPAKE2 pairing + host status) |
| `packages.x86_64-linux.punktfunk-scripting` | the plugin/script runner (bun-bundled Effect SDK; supervises host automation) |
| `packages.x86_64-linux.default` | = `punktfunk-host` | | `packages.x86_64-linux.default` | = `punktfunk-host` |
| `nixosModules.default` | `services.punktfunk.host` / `services.punktfunk.client` | | `nixosModules.default` | `services.punktfunk.host` / `.client` / `.web` / `.scripting` |
| `devShells.x86_64-linux.default` | pinned Rust (from `rust-toolchain.toml`) + all build deps | | `devShells.x86_64-linux.default` | pinned Rust (from `rust-toolchain.toml`) + all build deps |
| `apps` / `checks` / `formatter` | `nix run`, `nix flake check`, `nix fmt` | | `apps` / `checks` / `formatter` | `nix run`, `nix flake check`, `nix fmt` |
@@ -105,6 +107,39 @@ systemctl --user enable --now punktfunk-host
`services.punktfunk.client`: `enable`, `openFirewall` (UDP 5353), `package`. `services.punktfunk.client`: `enable`, `openFirewall` (UDP 5353), `package`.
`services.punktfunk.web` (the management console — **on by default whenever the host is enabled**,
mirroring the RPM's `Recommends: punktfunk-web`):
| Option | Default | Meaning |
| --- | --- | --- |
| `enable` | `host.enable` | Run the console as a `systemd --user` service on **TCP 47992 (HTTPS)**. Set `false` for a console-less host. |
| `openFirewall` | `host.openFirewall` | Open TCP 47992 so other devices on the LAN can reach it. |
| `autoStart` | `host.autoStart` | Add the console user service to `default.target` (appliance mode). |
| `package` | flake's | Override the package. |
The console is **auto-wired** to the host on the same box: it reads the host's per-user
`~/.config/punktfunk/{mgmt-token,cert.pem,key.pem}` (written by `serve`), serves HTTPS with the
host's own identity cert, and proxies the loopback mgmt API with the bearer token injected
server-side (never sent to the browser). A login password is generated on first start — read it
with `journalctl --user -u punktfunk-web-init` (or `~/.config/punktfunk/web-password`). Then open
`https://<host-ip>:47992` and trust the self-signed host cert once. Enable it (with the host) via
`systemctl --user enable --now punktfunk-web`.
`services.punktfunk.scripting` (the plugin/script runner — installed with the host, but **opt-in to
run**):
| Option | Default | Meaning |
| --- | --- | --- |
| `enable` | `host.enable` | Install the runner + define its `systemd --user` unit `punktfunk-scripting`. |
| `autoStart` | `false` | Add the unit to `default.target`. Off even on an auto-start host — running operator scripts/plugins is a deliberate opt-in. |
| `package` | flake's | Override the package. |
The runner discovers loose scripts under `~/.config/punktfunk/scripts` and installed
`punktfunk-plugin-*` packages under `~/.config/punktfunk/plugins`, and supervises each as an Effect
fiber (SIGTERM shuts the tree down structurally so plugin finalizers run). A plugin auto-wires to
the host's mgmt token + identity cert. It's inert until you add automation, so the unit ships
un-started; turn it on with `systemctl --user enable --now punktfunk-scripting`.
### What the host module configures for you ### What the host module configures for you
Everything the RPM's `%install` + `%post` do, declaratively: Everything the RPM's `%install` + `%post` do, declaratively:
@@ -175,6 +210,24 @@ The shell exports `PF_FFVK_VULKAN_INCLUDE` (Vulkan headers for pf-ffvk bindgen)
- **First build compiles from scratch** (no split dep cache — pyrowave-sys builds a CMake tree in - **First build compiles from scratch** (no split dep cache — pyrowave-sys builds a CMake tree in
its build.rs that a crane "dummy" source would drop) and has no public binary cache, so expect a its build.rs that a crane "dummy" source would drop) and has no public binary cache, so expect a
long initial build. `nix develop` gives incremental rebuilds. long initial build. `nix develop` gives incremental rebuilds.
- **The status tray is built in its own derivation, on purpose.** `punktfunk-tray` uses `ksni`'s
`async-io` zbus executor with no tokio runtime (by design — see `crates/punktfunk-tray/Cargo.toml`).
Cargo unifies features across everything in one `cargo build`, so co-building the tray with the
host would pull the host's `ashpd → zbus/tokio` onto the tray's shared `zbus`, and the tray then
panics at startup (`there is no reactor running, must be called from the context of a Tokio 1.x
runtime`). Building it as a separate `-p punktfunk-tray` invocation keeps its `zbus` on async-io;
the host package copies the resulting binary into its `$out`. (The deb/rpm/arch builds co-build the
two in one `cargo build`, so they share this latent crash on Linux — a separate fix.)
- **The bun packages (`punktfunk-web`, `punktfunk-scripting`) — their `bun install` deps hashes.**
Both build their `node_modules` in a *fixed-output derivation* (`bun install` needs the network +
the read-public `@unom` npm registry). Each `outputHash` (in `packaging/nix/packages.nix`) is
pinned to a resolved dependency set and **must be refreshed when its lockfile changes**
`web/bun.lock` for the console, `sdk/bun.lock` for the runner: set that `outputHash = lib.fakeHash`,
run `nix build .#punktfunk-web` (or `.#punktfunk-scripting`), and copy the `got: sha256-…` value
Nix prints back into the field. Everything downstream is offline (the console's codegen + vite
build; the runner's `bun build --target=bun` bundle), so only the deps FODs ever need network.
Both launchers exec `pkgs.bun` from the store — unlike the deb/rpm, which vendor a bun binary
because apt/dnf have none.
- **Commit `flake.lock`:** it pins the input revisions (nixpkgs / crane / rust-overlay). It is - **Commit `flake.lock`:** it pins the input revisions (nixpkgs / crane / rust-overlay). It is
generated on first eval and checked in. generated on first eval and checked in.
- **Session Skia OSD is off under Nix.** `punktfunk-session`'s default `ui` feature draws its - **Session Skia OSD is off under Nix.** `punktfunk-session`'s default `ui` feature draws its
+167 -2
View File
@@ -1,8 +1,9 @@
# NixOS integration for punktfunk — the declarative equivalent of everything the RPM/deb do in # NixOS integration for punktfunk — the declarative equivalent of everything the RPM/deb do in
# their %install + %post (packaging/rpm/punktfunk.spec, packaging/debian/build-deb.sh): # their %install + %post (packaging/rpm/punktfunk.spec, packaging/debian/build-deb.sh):
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB # the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
# UDP socket-buffer sysctls, the firewall openers, and the `input`-group membership for virtual # UDP socket-buffer sysctls, the firewall openers, the `input`-group membership for virtual
# gamepads. # gamepads, the management web console (`services.punktfunk.web`, on by default with the host — the
# RPM/deb Recommends), and the opt-in plugin/script runner (`services.punktfunk.scripting`).
# #
# Usage (flake): # Usage (flake):
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk"; # { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
@@ -149,6 +150,84 @@ in
description = "Open UDP 5353 (mDNS) so the client can auto-discover hosts on the LAN."; description = "Open UDP 5353 (mDNS) so the client can auto-discover hosts on the LAN.";
}; };
}; };
# The management web console (SPAKE2 PIN pairing + host status) — the browser UI every client
# needs. Ships by DEFAULT alongside the host (mirrors the RPM's `Recommends: punktfunk-web` and
# the .deb the host package pulls in), auto-wired to the host's mgmt token + identity cert.
web = {
enable = mkOption {
type = types.bool;
default = cfg.host.enable;
defaultText = literalExpression "config.services.punktfunk.host.enable";
description = ''
Run the management web console as a `systemd --user` service on TCP 47992 (HTTPS). Enabled
by default whenever the host is enabled set to `false` for a console-less host. It
auto-wires to `~/.config/punktfunk/{mgmt-token,cert.pem,key.pem}` (written by the host's
`serve`) and generates a login password on first start.
'';
};
package = mkOption {
type = types.package;
default = self.packages.${system}.punktfunk-web;
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-web";
description = "The punktfunk-web package (the bun-built Nitro SSR console bundle).";
};
openFirewall = mkOption {
type = types.bool;
default = cfg.host.openFirewall;
defaultText = literalExpression "config.services.punktfunk.host.openFirewall";
description = "Open TCP 47992 so the console is reachable from other devices on the LAN.";
};
autoStart = mkOption {
type = types.bool;
default = cfg.host.autoStart;
defaultText = literalExpression "config.services.punktfunk.host.autoStart";
description = ''
Start the console automatically in every user's graphical session (adds it to the user
`default.target`). Follows the host's `autoStart` by default for a login-less appliance,
enable lingering for the user as well.
'';
};
};
# The plugin/script runner — host automation on bun. Ships with the host (the RPM/deb Recommends
# it), but running it is OPT-IN: the `systemd --user` unit is defined yet NOT added to
# `default.target`, because the runner is inert until you add scripts/plugins. Turn it on with
# `systemctl --user enable --now punktfunk-scripting`.
scripting = {
enable = mkOption {
type = types.bool;
default = cfg.host.enable;
defaultText = literalExpression "config.services.punktfunk.host.enable";
description = ''
Install the plugin/script runner and define its `systemd --user` unit
(`punktfunk-scripting`). Enabled by default whenever the host is but the unit is not
auto-started (see `autoStart`), since the runner does nothing until you add scripts to
`~/.config/punktfunk/scripts` or install `punktfunk-plugin-*` packages under
`~/.config/punktfunk/plugins`. A plugin auto-wires to the host's mgmt token + identity cert.
'';
};
package = mkOption {
type = types.package;
default = self.packages.${system}.punktfunk-scripting;
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-scripting";
description = "The punktfunk-scripting package (the bun-bundled Effect SDK runner).";
};
autoStart = mkOption {
type = types.bool;
default = false;
description = ''
Start the runner automatically in every user's graphical session (adds it to the user
`default.target`). Off by default even when the host auto-starts running arbitrary
operator scripts/plugins is a deliberate opt-in; enable it once you have automation to run.
'';
};
};
}; };
config = mkMerge [ config = mkMerge [
@@ -226,5 +305,91 @@ in
allowedUDPPorts = [ 5353 ]; allowedUDPPorts = [ 5353 ];
}; };
}) })
# --- web console ---------------------------------------------------------------------------
# The declarative equivalent of the punktfunk-web .deb / RPM subpackage: the two systemd --user
# units (the console + its first-run password generator) plus the firewall opener, all auto-wired
# to the host's per-user mgmt token + identity cert (no env editing on a packaged install).
(mkIf cfg.web.enable {
environment.systemPackages = [ cfg.web.package ];
networking.firewall = mkIf cfg.web.openFirewall {
allowedTCPPorts = [ 47992 ]; # console HTTPS (packaging/linux/punktfunk-web.xml)
};
# First-run setup: generate the console login password once, in the user's config dir, and
# surface it to the --user journal. Self-gates via ConditionPathExists (mirrors
# scripts/punktfunk-web-init.service).
systemd.user.services.punktfunk-web-init = {
description = "punktfunk web console first-run setup (login password)";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
unitConfig.ConditionPathExists = "!%h/.config/punktfunk/web-password";
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${cfg.web.package}/share/punktfunk-web/web-init.sh";
};
};
# The console itself: Nitro SSR on bun, HTTPS on 47992 with the host's identity cert, proxying
# the host's loopback mgmt API with the bearer token injected server-side. mgmt-token is
# REQUIRED (the host's `serve` writes it) — if absent the unit fails and Restart retries until
# the host has created it; web-password is optional ('-'). Mirrors scripts/punktfunk-web.service.
systemd.user.services.punktfunk-web = {
description = "punktfunk management web console";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
after = [ "punktfunk-web-init.service" "punktfunk-host.service" ];
wants = [ "punktfunk-web-init.service" ];
wantedBy = optional cfg.web.autoStart "default.target";
environment = {
PUNKTFUNK_MGMT_URL = "https://127.0.0.1:47990";
PORT = "47992";
HOST = "0.0.0.0";
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
# mark the session cookie Secure. The host's `serve` writes these PEMs.
PUNKTFUNK_UI_TLS_CERT = "%h/.config/punktfunk/cert.pem";
PUNKTFUNK_UI_TLS_KEY = "%h/.config/punktfunk/key.pem";
PUNKTFUNK_UI_SECURE = "1";
};
serviceConfig = {
Type = "simple";
EnvironmentFile = [
"%h/.config/punktfunk/mgmt-token"
"-%h/.config/punktfunk/web-password"
];
ExecStart = "${cfg.web.package}/bin/punktfunk-web-server";
Restart = "on-failure";
RestartSec = 2;
};
};
})
# --- plugin/script runner ------------------------------------------------------------------
# Installs the runner + defines its opt-in `systemd --user` unit (mirrors the deb/rpm
# punktfunk-scripting subpackage). NOT auto-started unless `scripting.autoStart` is set.
(mkIf cfg.scripting.enable {
environment.systemPackages = [ cfg.scripting.package ];
systemd.user.services.punktfunk-scripting = {
description = "punktfunk plugin/script runner";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
# Plugins talk to the host's loopback mgmt API; order after it (soft — the runner backs off
# and retries per unit, so this is ordering only, not a hard requirement).
after = [ "punktfunk-host.service" ];
wantedBy = optional cfg.scripting.autoStart "default.target";
serviceConfig = {
Type = "simple";
ExecStart = "${cfg.scripting.package}/bin/punktfunk-scripting";
Restart = "on-failure";
RestartSec = 2;
# Deliver SIGTERM to the runner (it orchestrates the structural shutdown of its unit
# fibers) and give it room to run their finalizers before the cgroup is reaped.
KillMode = "mixed";
KillSignal = "SIGTERM";
TimeoutStopSec = 30;
};
};
})
]; ];
} }
+221 -2
View File
@@ -56,6 +56,12 @@
gsettings-desktop-schemas, gsettings-desktop-schemas,
adwaita-icon-theme, adwaita-icon-theme,
vulkan-headers, vulkan-headers,
# web console (punktfunk-web): a bun-built Nitro SSR bundle, run on bun.
bun,
nodejs,
makeWrapper,
cacert,
stdenvNoCC,
}: }:
let let
gbm = if libgbm != null then libgbm else mesa; gbm = if libgbm != null then libgbm else mesa;
@@ -101,12 +107,34 @@ let
platforms = [ "x86_64-linux" ]; # NVENC desktop host; matches the RPM's ExclusiveArch: x86_64 platforms = [ "x86_64-linux" ]; # NVENC desktop host; matches the RPM's ExclusiveArch: x86_64
maintainers = [ ]; maintainers = [ ];
}; };
# The Linux status tray, built in ISOLATION from the host — this is load-bearing, not tidiness.
# Cargo unifies features across every package in a single `cargo build`, so co-building the tray
# with the host would pull the host's `ashpd → zbus/tokio` onto the tray's SHARED `zbus`. The tray
# deliberately runs zbus on `ksni`'s `async-io` executor with the `blocking` API and no tokio
# runtime (see crates/punktfunk-tray/Cargo.toml), so a tokio-flavoured zbus panics at startup:
# "there is no reactor running, must be called from the context of a Tokio 1.x runtime". A separate
# invocation keeps the tray's zbus on async-io. (The host package copies this binary into its $out.)
punktfunk-tray = craneLib.buildPackage (commonArgs // {
pname = "punktfunk-tray";
cargoExtraArgs = "--locked -p punktfunk-tray";
PUNKTFUNK_BUILD_VERSION = buildVersion;
# Pure-Rust leaf: ksni/zbus talk to the dbus socket, ureq+rustls(ring) + punktfunk-core `tls` —
# nothing to link (no buildInputs) and no GPU driver runpath (it never dlopens libcuda/EGL/vulkan).
meta = meta // {
description = "punktfunk host status tray (Linux StatusNotifierItem)";
mainProgram = "punktfunk-tray";
};
});
in in
{ {
inherit punktfunk-tray;
punktfunk-host = craneLib.buildPackage (commonArgs // { punktfunk-host = craneLib.buildPackage (commonArgs // {
pname = "punktfunk-host"; pname = "punktfunk-host";
# HOST ONLY — the tray is a separate derivation (see the note above; co-building crashes it).
cargoExtraArgs = cargoExtraArgs =
"--locked -p punktfunk-host -p punktfunk-tray " "--locked -p punktfunk-host "
+ "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode"; + "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode";
PUNKTFUNK_BUILD_VERSION = buildVersion; PUNKTFUNK_BUILD_VERSION = buildVersion;
@@ -126,6 +154,10 @@ in
# the host's authorization .desktop must name the ACTUAL binary path (the store path here). The # the host's authorization .desktop must name the ACTUAL binary path (the store path here). The
# tray/client .desktop Exec lines are rewritten for the same reason (menu launch / autostart). # tray/client .desktop Exec lines are rewritten for the same reason (menu launch / autostart).
postInstall = '' postInstall = ''
# The status tray, built in its own derivation (see the punktfunk-tray note above) so its zbus
# stays on async-io; ship it in the host's $out so the tray .desktop below resolves it.
install -Dm0755 ${punktfunk-tray}/bin/punktfunk-tray "$out/bin/punktfunk-tray"
# udev: /dev/uinput + /dev/uhid (virtual gamepads) + the vhci sysfs perms for the virtual Deck. # udev: /dev/uinput + /dev/uhid (virtual gamepads) + the vhci sysfs perms for the virtual Deck.
install -Dm0644 scripts/60-punktfunk.rules "$out/lib/udev/rules.d/60-punktfunk.rules" install -Dm0644 scripts/60-punktfunk.rules "$out/lib/udev/rules.d/60-punktfunk.rules"
@@ -161,7 +193,8 @@ in
# empty at build time): append the driver runpath so the runtime dlopen of libcuda.so.1 / # empty at build time): append the driver runpath so the runtime dlopen of libcuda.so.1 /
# libnvidia-encode.so.1 / libEGL.so.1 / the GPU's libvulkan ICD resolves from the running system. # libnvidia-encode.so.1 / libEGL.so.1 / the GPU's libvulkan ICD resolves from the running system.
postFixup = '' postFixup = ''
addDriverRunpath "$out/bin/punktfunk-host" "$out/bin/punktfunk-tray" # Only the host dlopens the GPU stack; the tray (its own derivation, copied in above) does not.
addDriverRunpath "$out/bin/punktfunk-host"
''; '';
meta = meta // { meta = meta // {
@@ -232,4 +265,190 @@ in
mainProgram = "punktfunk-client"; mainProgram = "punktfunk-client";
}; };
}); });
# --- management web console (punktfunk-web) ------------------------------------------------------
# The browser console every client needs for SPAKE2 PIN pairing + host status: a TanStack Start /
# React app that vite builds into a Nitro SSR bundle, run on `bun` (the Nitro `bun` preset + a
# custom Bun.serve TLS entry — node can't run it; web/nitro-entry/bun-https.mjs). This mirrors the
# Debian punktfunk-web .deb (packaging/debian/build-web-deb.sh) and the RPM's `--with web`
# subpackage, which the host package Recommends so a default install pulls the console too.
#
# Unlike apt/dnf — which have no bun in their repos and so VENDOR a bun binary into the package —
# Nix has `pkgs.bun`, so the launcher just execs it from the store (no vendored runtime). The
# systemd `--user` units + firewall wiring live in the NixOS module, pointed at this store path.
punktfunk-web =
let
# Offline node_modules for the console. `bun install` needs the network AND the @unom npm
# registry (web/.npmrc → https://git.unom.io/api/packages/unom/npm/, read-public: the same
# anonymous pull CI's rpm/deb builds do), so it lives in a fixed-output derivation — FODs get
# network, and `outputHash` pins the result. `--ignore-scripts` skips the install-time
# `prepare` codegen (it wants ../api/openapi.json, outside this web-only src scope); the build
# derivation below runs codegen itself where the whole tree is present.
#
# ⚠ When web/bun.lock changes, this hash must be refreshed: set `outputHash = lib.fakeHash`,
# rebuild, and copy the sha256 Nix reports back here (see packaging/nix/README.md).
webDeps = stdenvNoCC.mkDerivation {
pname = "punktfunk-web-deps";
inherit version;
src = src + "/web";
nativeBuildInputs = [ bun cacert ];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache
export SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt
export NODE_EXTRA_CA_CERTS=$SSL_CERT_FILE
# copyfile backend ⇒ node_modules is fully materialised (no links into the ephemeral
# cache), so the tree survives the copy into the content-addressed $out.
bun install --frozen-lockfile --ignore-scripts --no-progress --backend=copyfile
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -R node_modules $out/node_modules
runHook postInstall
'';
dontFixup = true;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
outputHash = "sha256-OA4NjwapsCV/z+0rftDCMAQJGWw63Mi/GARetmuy0QU="; # web/bun.lock deps (refresh on lockfile change; see README).
};
in
stdenvNoCC.mkDerivation {
pname = "punktfunk-web";
inherit src version;
# nodejs: the JS build tools' `.bin` shims are `#!/usr/bin/env node`; patchShebangs (below)
# repoints them at this node so they run in the sandbox. bun is still the RUNTIME (the launcher
# execs it); node is build-time only, for orval/paraglide/vite.
nativeBuildInputs = [ bun nodejs makeWrapper ];
# No cross-derivation dep cache: codegen + the vite build are fully offline (every input is in
# the vendored node_modules, the checked-in api/openapi.json, and web/project.inlang).
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
cp -R ${webDeps}/node_modules web/node_modules
chmod -R u+w web/node_modules
# The JS CLIs (orval, paraglide-js, vite, …) ship a `#!/usr/bin/env node` shebang, and the
# build sandbox has no /usr/bin/env — rewrite them to the store `node` before running any
# script (else `bun run codegen` dies with "bad interpreter: /usr/bin/env"). Patch the WHOLE
# node_modules, not just .bin: bun's .bin entries are symlinks (skipped by patchShebangs'
# `-type f`); the real shebang lives in each package's `dist/bin/*.js` that they point to.
patchShebangs web/node_modules
cd web
# `codegen` = orval (a typed React-Query client from ../api/openapi.json) + paraglide-js i18n
# compile; both write into src/ and are prerequisites of the build (normally the install-time
# `prepare` hook, which was skipped in the deps FOD).
bun run codegen
# `build` = vite build ⇒ the Nitro `bun`-preset SSR bundle in .output (our Bun.serve TLS entry).
bun run build
# Guard: assert we produced the bun bundle, not a node one (same check the deb/rpm builders do).
grep -q 'Bun\.serve' .output/server/index.mjs \
|| { echo "ERROR: web/.output is not a bun bundle (wrong nitro preset)" >&2; exit 1; }
cd ..
runHook postBuild
'';
installPhase = ''
runHook preInstall
# The SSR bundle + its static assets, plus the first-run helper and env sample.
mkdir -p $out/share/punktfunk-web/.output
cp -R web/.output/server $out/share/punktfunk-web/.output/server
cp -R web/.output/public $out/share/punktfunk-web/.output/public
install -Dm0755 scripts/web-init.sh $out/share/punktfunk-web/web-init.sh
install -Dm0644 web/web.env.example $out/share/punktfunk-web/web.env.example
# PATH-stable launcher: run the SSR bundle on bun from the store (mirrors the deb/rpm
# /usr/bin/punktfunk-web-server, minus the vendored-bun indirection).
makeWrapper ${bun}/bin/bun $out/bin/punktfunk-web-server \
--add-flags "$out/share/punktfunk-web/.output/server/index.mjs"
runHook postInstall
'';
dontFixup = true;
meta = meta // {
description = "punktfunk management web console (Nitro SSR on bun + React)";
mainProgram = "punktfunk-web-server";
};
};
# --- plugin/script runner (punktfunk-scripting) --------------------------------------------------
# The host's automation runner: the `@punktfunk/host` SDK's `punktfunk-scripting` CLI (built on
# Effect), which discovers ~/.config/punktfunk/{scripts,plugins} and supervises each unit as an
# Effect fiber. It runs on `bun` (it import()s the operator's `.ts` plugin files, which only bun
# can do). Mirrors the Debian punktfunk-scripting .deb / the RPM's `--with scripting` subpackage,
# which the host package Recommends — the NixOS module wires the opt-in systemd --user unit.
#
# Unlike the deb/rpm we don't `bun build` into a bundle + vendor bun; we still bundle (one
# self-contained JS, effect inlined) but the launcher execs `pkgs.bun` from the store.
punktfunk-scripting =
let
# Offline node_modules for the SDK build — same fixed-output pattern as punktfunk-web's webDeps
# (`bun install` needs the network). ⚠ Refresh `outputHash` when sdk/bun.lock changes (set
# lib.fakeHash, rebuild, copy the printed sha256 — see packaging/nix/README.md).
sdkDeps = stdenvNoCC.mkDerivation {
pname = "punktfunk-scripting-deps";
inherit version;
src = src + "/sdk";
nativeBuildInputs = [ bun cacert ];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache
export SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt
export NODE_EXTRA_CA_CERTS=$SSL_CERT_FILE
bun install --frozen-lockfile --ignore-scripts --no-progress --backend=copyfile
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -R node_modules $out/node_modules
runHook postInstall
'';
dontFixup = true;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
outputHash = "sha256-+KCKCA0q0bwTxr7bsA3X4DbT/8nUjJA/JIoJU6BfiZw="; # sdk/bun.lock deps (refresh on lockfile change; see README).
};
in
stdenvNoCC.mkDerivation {
pname = "punktfunk-scripting";
inherit src version;
nativeBuildInputs = [ bun makeWrapper ];
# `bun build --target=bun` bundles the runner CLI to ONE self-contained JS: effect + the SDK
# are inlined, and the runner's dynamic `import()` of the operator's plugin files is left as a
# runtime import (bun keeps unresolvable dynamic specifiers external). Fully offline.
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
cp -R ${sdkDeps}/node_modules sdk/node_modules
chmod -R u+w sdk/node_modules
( cd sdk && bun build src/runner-cli.ts --target=bun --outfile=$TMPDIR/runner-cli.js )
grep -q 'attempt=' $TMPDIR/runner-cli.js \
|| { echo "ERROR: runner bundle missing the dynamic plugin import wrong build" >&2; exit 1; }
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm0644 $TMPDIR/runner-cli.js $out/share/punktfunk-scripting/runner-cli.js
# Launcher: run the bundle on bun from the store (mirrors the deb/rpm /usr/bin/punktfunk-scripting).
makeWrapper ${bun}/bin/bun $out/bin/punktfunk-scripting \
--add-flags "$out/share/punktfunk-scripting/runner-cli.js"
runHook postInstall
'';
dontFixup = true;
meta = meta // {
description = "punktfunk plugin/script runner (Effect SDK on bun)";
mainProgram = "punktfunk-scripting";
};
};
} }
+5 -1
View File
@@ -15,6 +15,10 @@ PF_RELEASE="${PF_RELEASE:-1}"
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works. # builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
WEB_OPT=() WEB_OPT=()
[ "${PF_WITH_WEB:-0}" = "1" ] && WEB_OPT=(--with web) [ "${PF_WITH_WEB:-0}" = "1" ] && WEB_OPT=(--with web)
# PF_WITH_SCRIPTING=1 builds the punktfunk-scripting subpackage (the plugin/script runner). Same bun
# requirement as web; default off so a bare `rpmbuild`/COPR still works.
SCRIPTING_OPT=()
[ "${PF_WITH_SCRIPTING:-0}" = "1" ] && SCRIPTING_OPT=(--with scripting)
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)" ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR" cd "$ROOTDIR"
@@ -51,7 +55,7 @@ fi
# resolves them from RPMs. Our builder image provides the toolchain via rustup (so # resolves them from RPMs. Our builder image provides the toolchain via rustup (so
# rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which # rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which
# rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link. # rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link.
rpmbuild -bb --nodeps "${WEB_OPT[@]}" \ rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" \
--define "_topdir $TOP" \ --define "_topdir $TOP" \
--define "pf_version ${PF_VERSION}" \ --define "pf_version ${PF_VERSION}" \
--define "pf_release ${PF_RELEASE}" \ --define "pf_release ${PF_RELEASE}" \
+83 -1
View File
@@ -49,6 +49,12 @@ ExclusiveArch: x86_64
# the console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb. # the console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb.
%bcond_with web %bcond_with web
# Plugin/script runner subpackage (punktfunk-scripting). OFF by default for the same reason as web:
# building the bun bundle needs `bun`, absent from a plain rpmbuild / COPR mock chroot. CI's builder
# image has bun and builds with `--with scripting`, so the Gitea RPM registry carries it. Mirrors the
# Debian punktfunk-scripting .deb.
%bcond_with scripting
# --- Build toolchain --------------------------------------------------------- # --- Build toolchain ---------------------------------------------------------
BuildRequires: cargo BuildRequires: cargo
BuildRequires: rust BuildRequires: rust
@@ -112,6 +118,10 @@ Recommends: intel-media-driver
# Weak-dep so `dnf install punktfunk` pulls it where it exists (the Gitea registry); harmless where # Weak-dep so `dnf install punktfunk` pulls it where it exists (the Gitea registry); harmless where
# it doesn't (a COPR build without `--with web` simply has no punktfunk-web to satisfy). # it doesn't (a COPR build without `--with web` simply has no punktfunk-web to satisfy).
Recommends: punktfunk-web Recommends: punktfunk-web
# The plugin/script runner (host automation on bun). Same weak-dep story: pulled where it exists,
# harmless where a `--with scripting`-less build didn't produce it. Its systemd --user unit ships
# disabled — the runner is inert until you add scripts/plugins.
Recommends: punktfunk-scripting
%description %description
punktfunk is a Linux-first, low-latency desktop and game streaming host. It speaks punktfunk is a Linux-first, low-latency desktop and game streaming host. It speaks
@@ -156,6 +166,23 @@ mgmt token, identity cert, and a generated login password, no env editing. Bundl
runtime. Enable with `systemctl --user enable --now punktfunk-web`. runtime. Enable with `systemctl --user enable --now punktfunk-web`.
%endif %endif
%if %{with scripting}
%package scripting
Summary: punktfunk plugin/script runner (Effect SDK on bun)
# Runtime is BUN — the runner import()s the operator's .ts plugin files, which only bun can do. bun
# isn't in Fedora repos, so we VENDOR it into the package (arch-specific, not noarch). The runner
# itself is bundled to ONE self-contained JS (effect + SDK inlined), so no node_modules ship.
%description scripting
The plugin/script runner for a punktfunk streaming host: it discovers loose scripts under
~/.config/punktfunk/scripts and installed punktfunk-plugin-* packages under ~/.config/punktfunk/
plugins, and supervises each as an Effect fiber (capped-jittered restart; SIGTERM shuts the whole
tree down structurally so plugin finalizers run). A plugin auto-wires to the host's mgmt token +
identity cert on the same box no env editing. Bundles its own bun runtime. OPT-IN: the systemd
--user unit ships disabled (the runner is inert until you add scripts/plugins). Enable with
`systemctl --user enable --now punktfunk-scripting`.
%endif
%prep %prep
%autosetup -n %{name}-%{version} %autosetup -n %{name}-%{version}
@@ -185,7 +212,13 @@ export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
# Pure Rust `ash` (no new lib / no link-time dep); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 opts # Pure Rust `ash` (no new lib / no link-time dep); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 opts
# back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully. # back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \ cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
# across everything in one build, so co-building the tray with the host pulls the host's
# ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio
# runtime by design) then panics at startup ("there is no reactor running, must be called from the
# context of a Tokio 1.x runtime"). Built alone, its zbus stays on async-io. (Same split the .deb does.)
cargo build --release --locked -p punktfunk-tray
%if %{with web} %if %{with web}
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve # Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
@@ -197,6 +230,18 @@ if ! grep -q 'Bun\.serve' web/.output/server/index.mjs; then
fi fi
%endif %endif
%if %{with scripting}
# Plugin/script runner: bundle the SDK's runner CLI to ONE self-contained JS with bun
# (`--target=bun` inlines effect + the SDK; the dynamic plugin import stays a runtime import). bun is
# both the build tool AND the vendored runtime (in %%install below).
(cd sdk && bun install --frozen-lockfile --ignore-scripts && \
bun build src/runner-cli.ts --target=bun --outfile=../runner-cli.js)
if ! grep -q 'attempt=' runner-cli.js; then
echo "ERROR: runner bundle missing the dynamic plugin import wrong build" >&2
exit 1
fi
%endif
%install %install
# Binary # Binary
install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-host install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-host
@@ -316,6 +361,22 @@ install -Dm0755 scripts/web-init.sh %{buildroot}%{_datadir}/punkt
install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punktfunk-web/web.env.example install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punktfunk-web/web.env.example
%endif %endif
%if %{with scripting}
# --- plugin/script runner subpackage (punktfunk-scripting) ---
install -Dm0644 runner-cli.js %{buildroot}%{_datadir}/punktfunk-scripting/runner-cli.js
# Vendor the build env's bun (arch-specific, like the web subpackage) into a private libexec dir.
install -Dm0755 "$(command -v bun)" %{buildroot}%{_libexecdir}/punktfunk-scripting/bun
# PATH-stable launcher (matches the .deb's /usr/bin/punktfunk-scripting) — runs the bundle on bun.
cat > %{buildroot}%{_bindir}/punktfunk-scripting <<'WRAP'
#!/bin/sh
exec /usr/libexec/punktfunk-scripting/bun /usr/share/punktfunk-scripting/runner-cli.js "$@"
WRAP
chmod 0755 %{buildroot}%{_bindir}/punktfunk-scripting
# systemd --user unit — installed but NOT auto-enabled (opt-in; the runner is inert until you add
# scripts/plugins). Enable with `systemctl --user enable --now punktfunk-scripting`.
install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/punktfunk-scripting.service
%endif
%files %files
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt %license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%doc README.md packaging/README.md %doc README.md packaging/README.md
@@ -360,6 +421,17 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
%{_userunitdir}/punktfunk-web-init.service %{_userunitdir}/punktfunk-web-init.service
%endif %endif
%if %{with scripting}
%files scripting
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%{_bindir}/punktfunk-scripting
%dir %{_libexecdir}/punktfunk-scripting
%{_libexecdir}/punktfunk-scripting/bun
%dir %{_datadir}/punktfunk-scripting
%{_datadir}/punktfunk-scripting/runner-cli.js
%{_userunitdir}/punktfunk-scripting.service
%endif
%post client %post client
# Pick up the DualSense hidraw rule without a reboot (best-effort; on rpm-ostree it # Pick up the DualSense hidraw rule without a reboot (best-effort; on rpm-ostree it
# applies on the next boot into the layered deployment). # applies on the next boot into the layered deployment).
@@ -403,7 +475,17 @@ echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generat
echo "Then open https://<host-ip>:47992" echo "Then open https://<host-ip>:47992"
%endif %endif
%if %{with scripting}
%post scripting
echo "punktfunk-scripting installed. It runs your automation add scripts to"
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
echo "then enable the runner: systemctl --user enable --now punktfunk-scripting"
%endif
%changelog %changelog
* Thu Jul 17 2026 punktfunk <noreply@anthropic.com> - 0.0.1-3
- Add punktfunk-scripting subpackage (plugin/script runner, --with scripting; bun-bundled Effect SDK).
* Mon Jun 15 2026 punktfunk <noreply@anthropic.com> - 0.0.1-2 * Mon Jun 15 2026 punktfunk <noreply@anthropic.com> - 0.0.1-2
- Add punktfunk-web subpackage (management console, --with web; auto-wired to the host token). - Add punktfunk-web subpackage (management console, --with web; auto-wired to the host token).
* Wed Jun 10 2026 punktfunk <noreply@anthropic.com> - 0.0.1-1 * Wed Jun 10 2026 punktfunk <noreply@anthropic.com> - 0.0.1-1
+34 -11
View File
@@ -27,7 +27,8 @@ param(
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD, [string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build) [string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
[string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console [string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console
[string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console [string]$ScriptingBundle = $env:SCRIPTING_BUNDLE, # built runner-cli.js -> bundle the plugin/script runner
[string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console + runner
[string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic [string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic
[switch]$NoDriver, # build without the bundled pf-vdisplay driver [switch]$NoDriver, # build without the bundled pf-vdisplay driver
[switch]$NoSign # skip signing (local debug) [switch]$NoSign # skip signing (local debug)
@@ -257,28 +258,50 @@ if ($ffmpegBinSrc -and (Test-Path $ffmpegBinSrc)) {
} }
else { Write-Host "no FFMPEG_DIR\bin -> installer built WITHOUT FFmpeg DLLs (nvenc/software-only host)" } else { Write-Host "no FFMPEG_DIR\bin -> installer built WITHOUT FFmpeg DLLs (nvenc/software-only host)" }
# --- stage the web management console (the self-contained .output tree + a portable bun + launcher) - # --- stage the bun runtime + the two bun payloads (web console, plugin/script runner) --------------
# The console runs as the PunktfunkWeb scheduled task (`bun {app}\web\.output\server\index.mjs`), # Both the web console and the runner run on bun. Stage everything ISCC reads into $OutDir (the
# auto-wired to the host's loopback mgmt API. Stage everything ISCC reads into $OutDir (the # non-WOW64-redirected C:\t area, same reason as the .iss/host.env staging above). bun is staged ONCE
# non-WOW64-redirected C:\t area, same reason as the .iss/host.env staging above). The .output is # and shared: the two payloads pass their own defines and the .iss keys WithWeb / WithScripting on
# self-contained (Nitro noExternals - deps bundled + tree-shaken, no node_modules), so bun runs it # (their dir + BunExe). Each payload is omitted when its inputs are unset (e.g. a local debug pack).
# directly; omitted when -WebDir/-BunExe are unset (host-only installer, e.g. a local debug pack). $haveBun = $BunExe -and (Test-Path $BunExe)
if ($WebDir -and (Test-Path $WebDir) -and $BunExe -and (Test-Path $BunExe)) { $wantWeb = $WebDir -and (Test-Path $WebDir) -and $haveBun
$wantScripting = $ScriptingBundle -and (Test-Path $ScriptingBundle) -and $haveBun
if ($wantWeb -or $wantScripting) {
$bunStage = Join-Path $OutDir 'bun.exe'
Copy-Item -LiteralPath $BunExe -Destination $bunStage -Force
$defines += "/DBunExe=$bunStage"
}
# The web console: the self-contained .output tree (Nitro noExternals - deps bundled + tree-shaken,
# no node_modules), run by the PunktfunkWeb scheduled task, auto-wired to the host's loopback mgmt API.
if ($wantWeb) {
$webStage = Join-Path $OutDir 'web' $webStage = Join-Path $OutDir 'web'
if (Test-Path $webStage) { Remove-Item $webStage -Recurse -Force } if (Test-Path $webStage) { Remove-Item $webStage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $webStage | Out-Null New-Item -ItemType Directory -Force -Path $webStage | Out-Null
Copy-Item (Join-Path $WebDir '*') -Destination $webStage -Recurse -Force Copy-Item (Join-Path $WebDir '*') -Destination $webStage -Recurse -Force
$bunStage = Join-Path $OutDir 'bun.exe'
Copy-Item -LiteralPath $BunExe -Destination $bunStage -Force
$webRun = Join-Path $OutDir 'web-run.cmd' $webRun = Join-Path $OutDir 'web-run.cmd'
Copy-Item (Join-Path $repoRoot 'scripts\windows\web-run.cmd') -Destination $webRun -Force Copy-Item (Join-Path $repoRoot 'scripts\windows\web-run.cmd') -Destination $webRun -Force
# The console is provisioned by `punktfunk-host.exe web setup` (not a staged web-setup.ps1). # The console is provisioned by `punktfunk-host.exe web setup` (not a staged web-setup.ps1).
$defines += "/DWebDir=$webStage" $defines += "/DWebDir=$webStage"
$defines += "/DBunExe=$bunStage"
$defines += "/DWebRunCmd=$webRun" $defines += "/DWebRunCmd=$webRun"
Write-Host "bundling the web console from $WebDir (+ bun $BunExe)" Write-Host "bundling the web console from $WebDir (+ bun $BunExe)"
} }
else { Write-Host "no -WebDir/-BunExe -> installer built WITHOUT the web console" } else { Write-Host "no -WebDir/-BunExe -> installer built WITHOUT the web console" }
# The plugin/script runner: one self-contained bundle (effect + the SDK inlined). Its scheduled task
# is registered DISABLED (opt-in) by the installer. Built by CI (SCRIPTING_BUNDLE) alongside the web
# console; omitted when -ScriptingBundle/-BunExe are unset.
if ($wantScripting) {
$scrStage = Join-Path $OutDir 'scripting'
if (Test-Path $scrStage) { Remove-Item $scrStage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $scrStage | Out-Null
$scrBundle = Join-Path $scrStage 'runner-cli.js'
Copy-Item -LiteralPath $ScriptingBundle -Destination $scrBundle -Force
$scrRun = Join-Path $scrStage 'scripting-run.cmd'
Copy-Item (Join-Path $repoRoot 'scripts\windows\scripting-run.cmd') -Destination $scrRun -Force
$defines += "/DScriptingBundle=$scrBundle"
$defines += "/DScriptingRunCmd=$scrRun"
Write-Host "bundling the plugin/script runner from $ScriptingBundle (+ bun $BunExe)"
}
else { Write-Host "no -ScriptingBundle/-BunExe -> installer built WITHOUT the plugin/script runner" }
# --- build + stage the HDR Vulkan layer (pf-vkhdr-layer) -------------------------------------- # --- build + stage the HDR Vulkan layer (pf-vkhdr-layer) --------------------------------------
# A tiny always-on Vulkan implicit layer (cdylib) that advertises HDR10/scRGB surface formats on the # A tiny always-on Vulkan implicit layer (cdylib) that advertises HDR10/scRGB surface formats on the
+44 -4
View File
@@ -40,6 +40,11 @@
#ifndef WebRunCmd #ifndef WebRunCmd
#define WebRunCmd "..\..\scripts\windows\web-run.cmd" #define WebRunCmd "..\..\scripts\windows\web-run.cmd"
#endif #endif
; The plugin/script runner launcher (the action the opt-in PunktfunkScripting task runs) - staged
; next to the .iss by pack-host-installer.ps1 (absolute path passed in).
#ifndef ScriptingRunCmd
#define ScriptingRunCmd "..\..\scripts\windows\scripting-run.cmd"
#endif
; StageDir (the staged pf-vdisplay payload + nefconc.exe + install-pf-vdisplay.ps1) is optional. ; StageDir (the staged pf-vdisplay payload + nefconc.exe + install-pf-vdisplay.ps1) is optional.
#ifdef StageDir #ifdef StageDir
#define WithDriver #define WithDriver
@@ -66,6 +71,13 @@
#define WithWeb #define WithWeb
#endif #endif
#endif #endif
; ScriptingBundle (the built runner-cli.js) + BunExe are passed together by pack-host-installer.ps1
; to bundle the plugin/script runner. Both required -> WithScripting.
#ifdef ScriptingBundle
#ifdef BunExe
#define WithScripting
#endif
#endif
; VkLayerDir (the staged pf-vkhdr-layer: pf_vkhdr_layer.dll + .json) is optional - present when the ; VkLayerDir (the staged pf-vkhdr-layer: pf_vkhdr_layer.dll + .json) is optional - present when the
; HDR Vulkan layer was built. It lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the ; HDR Vulkan layer was built. It lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the
; virtual display (the ICD won't advertise HDR there; the layer injects the surface formats, self- ; virtual display (the ICD won't advertise HDR there; the layer injects the surface formats, self-
@@ -179,15 +191,26 @@ Source: "{#LicensesDir}\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
; dynamically (replaceable DLLs) - FFmpeg is used under the LGPL v2.1+; see {app}\licenses. ; dynamically (replaceable DLLs) - FFmpeg is used under the LGPL v2.1+; see {app}\licenses.
Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
#endif #endif
; The portable bun runtime -> {app}\bun\bun.exe. Shared by the web console AND the plugin/script
; runner (both run on bun), so stage it once when EITHER is bundled.
#if defined(WithWeb) || defined(WithScripting)
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion
#endif
#ifdef WithWeb #ifdef WithWeb
; The web management console: the self-contained Nitro SSR bundle (.output = server + public; deps ; The web management console: the self-contained Nitro SSR bundle (.output = server + public; deps
; bundled in, no node_modules) -> {app}\web\.output, a portable bun runtime -> {app}\bun\bun.exe, and ; bundled in, no node_modules) -> {app}\web\.output, and the launcher the PunktfunkWeb task runs ->
; the launcher the PunktfunkWeb task runs -> {app}\web\web-run.cmd. (`punktfunk-host.exe web setup` ; {app}\web\web-run.cmd. (`punktfunk-host.exe web setup` provisions the console at install time - no
; provisions the console at install time - no staged provisioner script.) ; staged provisioner script.)
Source: "{#WebDir}\*"; DestDir: "{app}\web\.output"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#WebDir}\*"; DestDir: "{app}\web\.output"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion
Source: "{#WebRunCmd}"; DestDir: "{app}\web"; DestName: "web-run.cmd"; Flags: ignoreversion Source: "{#WebRunCmd}"; DestDir: "{app}\web"; DestName: "web-run.cmd"; Flags: ignoreversion
#endif #endif
#ifdef WithScripting
; The plugin/script runner: one self-contained bundle (effect + the SDK inlined) -> {app}\scripting\
; runner-cli.js, and the launcher the (opt-in) PunktfunkScripting task runs -> {app}\scripting\
; scripting-run.cmd. Runs on the shared bun above.
Source: "{#ScriptingBundle}"; DestDir: "{app}\scripting"; DestName: "runner-cli.js"; Flags: ignoreversion
Source: "{#ScriptingRunCmd}"; DestDir: "{app}\scripting"; DestName: "scripting-run.cmd"; Flags: ignoreversion
#endif
#ifdef WithDriver #ifdef WithDriver
; The driver payload + nefconc.exe + install-pf-vdisplay.ps1, extracted to {tmp} and removed after install. ; The driver payload + nefconc.exe + install-pf-vdisplay.ps1, extracted to {tmp} and removed after install.
Source: "{#StageDir}\*"; DestDir: "{tmp}\pfvdisplay"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver Source: "{#StageDir}\*"; DestDir: "{tmp}\pfvdisplay"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver
@@ -255,6 +278,16 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
#endif #endif
#ifdef WithScripting
; Register the plugin/script runner's scheduled task (boot, SYSTEM, restart-on-failure) but leave it
; DISABLED - the runner is OPT-IN (inert until you add scripts/plugins). Enable it when ready:
; Enable-ScheduledTask -TaskName PunktfunkScripting
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
; in the command, so no Inno {{ }} escaping needed.
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
StatusMsg: "Registering the punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
#endif
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the ; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
; icon appears without waiting for the next sign-in. ; icon appears without waiting for the next sign-in.
Filename: "{app}\punktfunk-tray.exe"; Flags: runasoriginaluser nowait skipifsilent; Tasks: trayicon Filename: "{app}\punktfunk-tray.exe"; Flags: runasoriginaluser nowait skipifsilent; Tasks: trayicon
@@ -283,6 +316,13 @@ Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 47992,3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 47992,3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup" Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
#endif #endif
#ifdef WithScripting
; Stop + remove the PunktfunkScripting task (leaves %ProgramData%\punktfunk config + the operator's
; scripts/plugins, like the rest of the uninstall does). Unconditional cleanup of the task name.
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName PunktfunkScripting -Confirm:$false -ErrorAction SilentlyContinue"""; \
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkScriptingCleanup"
#endif
[Code] [Code]
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its { True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
+34
View File
@@ -0,0 +1,34 @@
# punktfunk plugin/script runner — systemd USER unit (bun runtime, OPT-IN).
#
# Runs the operator's automation under supervision: loose files in ~/.config/punktfunk/scripts/ and
# installed `punktfunk-plugin-*` packages under ~/.config/punktfunk/plugins/. Each unit is an Effect
# fiber (a plugin restarts on failure with capped-jittered backoff; a bare script is one-shot).
# SIGTERM interrupts the whole tree STRUCTURALLY, so every plugin's scoped finalizers run before
# exit (clean deregister / preset release) — hence the generous stop timeout below.
#
# OPT-IN — unlike punktfunk-web, the package does NOT auto-enable this: the runner does nothing until
# you add scripts or install plugins. Turn it on once you have automation to run:
# systemctl --user enable --now punktfunk-scripting
#
# Auto-wired like the console: a plugin's connect() reads the host's mgmt token + identity cert from
# ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing.
[Unit]
Description=punktfunk plugin/script runner
Documentation=https://git.unom.io/unom/punktfunk
# Plugins talk to the host's loopback mgmt API; order after it. Soft (ordering only, no Requires):
# the runner supervises each unit with backoff, so a plugin started before the host simply retries.
After=punktfunk-host.service
[Service]
Type=simple
ExecStart=/usr/bin/punktfunk-scripting
Restart=on-failure
RestartSec=2
# Deliver the stop signal to the runner process itself (it orchestrates the structural shutdown of
# its unit fibers), and give it room to run their finalizers before the cgroup is reaped.
KillMode=mixed
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=default.target
+27
View File
@@ -0,0 +1,27 @@
@echo off
rem punktfunk plugin/script runner launcher - the action the PunktfunkScripting scheduled task runs.
rem
rem OPT-IN: the installer registers that task DISABLED (the runner is inert until you add automation).
rem Enable it once you have scripts/plugins: Enable-ScheduledTask -TaskName PunktfunkScripting
rem
rem Lays out next to the installed payload: {app}\scripting\scripting-run.cmd + runner-cli.js and
rem {app}\bun\bun.exe (so %~dp0 = {app}\scripting\). The runner discovers the operator's units under
rem %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's mgmt
rem token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). No env editing.
setlocal EnableExtensions
set "BUN=%~dp0..\bun\bun.exe"
set "RUNNER=%~dp0runner-cli.js"
if not exist "%RUNNER%" (
echo [punktfunk-scripting] runner bundle missing at "%RUNNER%".
exit /b 1
)
if not exist "%BUN%" (
echo [punktfunk-scripting] bundled bun missing at "%BUN%".
exit /b 1
)
rem The runner import()s the operator's .ts plugin files, so it runs on the bundled bun. SIGTERM (task
rem End) interrupts the whole unit tree structurally so plugin finalizers run before exit.
"%BUN%" "%RUNNER%"
+35
View File
@@ -150,6 +150,41 @@ export default definePlugin({
In v1 a plugin is a script you run (see below); the managed runner package is a later step. In v1 a plugin is a script you run (see below); the managed runner package is a later step.
### A plugin UI in the console — `servePluginUi`
A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the
operator. It serves the UI on a loopback ephemeral port behind a per-boot secret; `servePluginUi`
registers it with the host, and the console reverse-proxies to it and adds a nav entry gated by the
console's own session. Your code implements **zero human auth**.
```ts
import { definePlugin, servePluginUi } from "@punktfunk/host";
export default definePlugin({
name: "rom-manager",
main: async (pf) => {
const ui = await servePluginUi(pf, {
id: "rom-manager",
title: "ROM Manager",
icon: "gamepad-2", // a lucide icon name
staticDir: new URL("../dist/ui", import.meta.url), // your built SPA
fetch: (req) => appRouter(req), // plugin-local REST/SSE (after a static miss)
});
try {
await runForever();
} finally {
await ui.close(); // deregister + stop
}
},
});
```
Requests reach `fetch` **prefix-stripped** (the console proxy removed `/plugin-ui/<id>`), so your app
sees `/`, `/api/scan`, … — the original prefix is on `X-Forwarded-Prefix`. `servePluginUi` serves
`staticDir` first (with an `index.html` SPA fallback for navigations); return `undefined` from `fetch`
to fall through to it. Build your SPA with a relative base (`base: "./"` + hash routing) or an absolute
`base: "/plugin-ui/<id>/"`, and expect a dark canvas. Requires the Bun runtime (the runner is bun).
## The runner: `punktfunk-scripting` ## The runner: `punktfunk-scripting`
Instead of one unit file per script, run everything under the managed runner — it discovers Instead of one unit file per script, run everything under the managed runner — it discovers
+85
View File
@@ -0,0 +1,85 @@
// ── Example · console-hosted plugin UI ────────────────────────────────────────────────────────
// A plugin that surfaces a UI inside the punktfunk web console (plugin-ui-surface design). It
// serves a page + a live SSE feed on a loopback port behind a per-boot secret; `servePluginUi`
// registers it, and the console proxies to it and adds a "Demo UI" nav entry — no second password,
// no second port for the operator to learn. This also exercises the streaming path end-to-end (the
// U0 spike): the SSE feed must arrive through the console's reverse proxy unbuffered.
//
// Run under the scripting runner, or directly for a quick look: bun examples/plugin-ui.ts
import {
connect,
definePlugin,
type Punktfunk,
servePluginUi,
} from "../src/index.js";
// A tiny self-contained page: a heading and a list that prepends one line per SSE tick. The
// EventSource URL is RELATIVE (`./events`) so it resolves under the console proxy prefix.
const PAGE = `<!doctype html><meta charset=utf-8><title>Demo UI</title>
<style>body{font:15px system-ui;margin:2rem;color:#e5e7eb;background:#0b0b0f}li{opacity:.9}</style>
<h1>punktfunk plugin UI demo</h1><p>Live ticks (proxied SSE):</p><ul id=log></ul>
<script>
const log = document.getElementById("log");
new EventSource("./events").onmessage = (e) => {
const li = document.createElement("li"); li.textContent = e.data; log.prepend(li);
};
</script>`;
// The plugin's dynamic handler. Paths arrive prefix-stripped (the console proxy removed
// `/plugin-ui/demo-ui`), so we match `/` and `/events` directly.
const handle = (req: Request): Response | undefined => {
const { pathname } = new URL(req.url);
if (pathname === "/events") {
// A ticking SSE stream — the thing the proxy must forward unbuffered.
let n = 0;
const body = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
const timer = setInterval(() => {
controller.enqueue(
enc.encode(`data: tick ${++n} @ ${new Date().toISOString()}\n\n`),
);
}, 1000);
req.signal.addEventListener("abort", () => {
clearInterval(timer);
controller.close();
});
},
});
return new Response(body, {
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
});
}
if (pathname === "/" || pathname === "/index.html") {
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
}
return undefined; // fall through → 404
};
const plugin = definePlugin({
name: "demo-ui",
main: async (pf) => {
const ui = await servePluginUi(pf, {
id: "demo-ui",
title: "Demo UI",
icon: "puzzle",
fetch: handle,
});
console.log(`[demo-ui] serving on ${ui.url} — open the console's "Demo UI" nav entry`);
// Run until asked to stop, then deregister cleanly (abrupt kills fall back to lease expiry).
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
await ui.close();
},
});
export default plugin;
// Allow a direct `bun examples/plugin-ui.ts` run outside the managed runner.
if (import.meta.main) {
const pf = await connect();
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
pf.close();
}
File diff suppressed because one or more lines are too long
+5
View File
@@ -30,6 +30,11 @@ import {
export type { HostApi } from "./api.js"; export type { HostApi } from "./api.js";
export { HttpStatusError } from "./core.js"; export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js"; export type { ConnectOptions } from "./config.js";
export {
type PluginUiHandle,
type PluginUiOptions,
servePluginUi,
} from "./ui.js";
export type { export type {
ClientRef, ClientRef,
DeviceRef, DeviceRef,
+206
View File
@@ -0,0 +1,206 @@
// `servePluginUi` (plugin-ui-surface design §4) — the whole plugin side of a console-hosted UI in
// one call. A plugin serves its UI on a **loopback ephemeral port** behind a **per-boot secret**,
// registers `{title, ui:{port, secret, icon}}` with the host, and renews the lease on a timer; the
// web console reverse-proxies to it and grows a nav entry. The plugin author writes zero human auth,
// discovery, or TLS — all of that lives here.
//
// import { definePlugin, servePluginUi } from "@punktfunk/host";
//
// export default definePlugin({
// name: "rom-manager",
// main: async (pf) => {
// const ui = await servePluginUi(pf, {
// id: "rom-manager", title: "ROM Manager", icon: "gamepad-2",
// staticDir: new URL("../dist/ui", import.meta.url), // built SPA
// fetch: (req) => appRouter(req), // plugin-local REST/SSE
// });
// try { await runEngineForever(); } finally { await ui.close(); }
// },
// });
//
// Design notes:
// - **Runtime**: Bun (the scripting runner IS bun; a `node:http` lane is deferred — design Q1).
// - **Registration uses `pf.request`, not `pf.api.*`** (design D7): under the packaged runner the
// facade is built by the runner's *bundled* SDK copy, whose generated client may predate the
// `/plugins` endpoints; the untyped request seam has existed since 0.1.0 and is skew-proof.
// - **The host only ever dials 127.0.0.1:<port>** — we register a port, never an address (D5).
import { createHash, timingSafeEqual } from "node:crypto";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { Punktfunk } from "./index.js";
/** How often the lease is renewed (host TTL is 90 s — two missed ticks of slack). */
const DEFAULT_RENEW_MS = 30_000;
export interface PluginUiOptions {
/**
* The plugin's registered id its `definePlugin` name (`[a-z][a-z0-9-]*`). The console nav
* entry and the proxy path `/plugin-ui/<id>/**` key on this.
*/
id: string;
/** Human-readable title for the console nav entry. */
title: string;
/** Optional plugin version (informational, shown in the console page header). */
version?: string;
/** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */
icon?: string;
/**
* Directory of the built SPA. Requests are served from here first (with an `index.html` SPA
* fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem
* path or a `file:` URL (`new URL("../dist/ui", import.meta.url)`).
*/
staticDir?: string | URL;
/**
* The plugin's own dynamic handler (REST, SSE) tried after a static miss. Paths arrive
* **prefix-stripped** (the console proxy has already removed `/plugin-ui/<id>`), so this sees
* `/`, `/api/scan`, The original public prefix is on the `X-Forwarded-Prefix` header if you
* need absolute self-URLs. Return `undefined` to fall through to the SPA fallback.
*/
fetch?: (req: Request) => Response | Promise<Response | undefined> | undefined;
/** Advanced: lease-renewal cadence in ms (default 30 000). Mainly for tests. */
renewIntervalMs?: number;
}
export interface PluginUiHandle {
/** The loopback port the UI is bound to. */
readonly port: number;
/** `http://127.0.0.1:<port>` — the base the console proxy dials. */
readonly url: string;
/** Deregister and stop the server (best-effort DELETE, then force-close). */
close(): Promise<void>;
}
const warn = (m: string) => console.warn(`[punktfunk] servePluginUi: ${m}`);
/** A fresh per-boot secret: 32 random bytes as base64url (43 chars, `[A-Za-z0-9_-]`). */
const mintSecret = (): string => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Buffer.from(bytes).toString("base64url");
};
/** Resolve a request path to an absolute file inside `root`, or `null` if it escapes (traversal). */
const staticFile = (root: string, pathname: string): string | null => {
let rel: string;
try {
rel = decodeURIComponent(pathname);
} catch {
return null; // malformed %-encoding
}
if (rel.endsWith("/")) rel += "index.html";
if (!rel.startsWith("/")) rel = `/${rel}`;
const abs = path.resolve(root, `.${rel}`);
const rootAbs = path.resolve(root);
if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) return null;
return abs;
};
/**
* Serve a plugin UI and register it with the host. Returns once the server is listening and the
* first registration attempt has been made (a failed initial register is warned, not thrown the
* renewal loop keeps trying, so a momentarily-unreachable host doesn't take the plugin down).
*/
export const servePluginUi = async (
pf: Punktfunk,
opts: PluginUiOptions,
): Promise<PluginUiHandle> => {
if (!/^[a-z][a-z0-9-]*$/.test(opts.id)) {
throw new Error(
`servePluginUi: id "${opts.id}" must be kebab-case ([a-z][a-z0-9-]*)`,
);
}
if (typeof (globalThis as Record<string, unknown>).Bun === "undefined") {
throw new Error(
"servePluginUi requires the Bun runtime (the scripting runner is bun); a Node lane is not yet available",
);
}
const root = opts.staticDir
? typeof opts.staticDir === "string"
? opts.staticDir
: fileURLToPath(opts.staticDir)
: undefined;
// One per-boot secret; the console proxy must present it (as a bearer) on every request. Compared
// constant-time against its SHA-256 (mirrors the host's `token_eq`), so no length/content timing.
const secret = mintSecret();
const secretHash = createHash("sha256").update(secret).digest();
const authorized = (req: Request): boolean => {
const header = req.headers.get("authorization");
const presented = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
if (presented === undefined) return false;
const presentedHash = createHash("sha256").update(presented).digest();
return timingSafeEqual(presentedHash, secretHash);
};
const server = Bun.serve({
hostname: "127.0.0.1", // loopback only — nothing off-box can reach it
port: 0, // ephemeral: no port to configure or collide
async fetch(req) {
if (!authorized(req)) {
return new Response("unauthorized", { status: 401 });
}
const pathname = new URL(req.url).pathname;
// Built-in liveness — the console page probes this before mounting the iframe.
if (pathname === "/__health") {
return Response.json({ ok: true, id: opts.id, title: opts.title });
}
// 1) static asset
if (root) {
const file = staticFile(root, pathname);
if (file) {
const bf = Bun.file(file);
if (await bf.exists()) return new Response(bf);
}
}
// 2) the plugin's dynamic handler
if (opts.fetch) {
const res = await opts.fetch(req);
if (res) return res;
}
// 3) SPA fallback: a navigation that matched no asset gets index.html
if (
root &&
req.method === "GET" &&
(req.headers.get("accept") ?? "").includes("text/html")
) {
const index = Bun.file(path.join(root, "index.html"));
if (await index.exists()) return new Response(index);
}
return new Response("not found", { status: 404 });
},
});
const port = server.port;
if (port == null) throw new Error("Bun.serve did not report a bound port");
const url = `http://127.0.0.1:${port}`;
const body = {
title: opts.title,
...(opts.version !== undefined ? { version: opts.version } : {}),
ui: {
port,
secret,
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
},
};
const register = () => pf.request("PUT", `/plugins/${opts.id}`, body);
// Best-effort initial register: warn but keep the server up if the host is momentarily away.
await register().catch((e) => warn(`initial registration failed: ${e}`));
const timer = setInterval(() => {
register().catch((e) => warn(`lease renewal failed: ${e}`));
}, opts.renewIntervalMs ?? DEFAULT_RENEW_MS);
// Don't let the renewal timer alone keep the process alive — the plugin's main loop owns lifetime.
(timer as { unref?: () => void }).unref?.();
return {
port,
url,
async close() {
clearInterval(timer);
// Deregister promptly so the nav entry drops without waiting for the lease to expire.
await pf.request("DELETE", `/plugins/${opts.id}`).catch(() => {});
server.stop(true); // force-close (SSE/long-poll connections included)
},
};
};
+123
View File
@@ -0,0 +1,123 @@
// `servePluginUi` end to end: it registers with the host (secret and all), serves static + dynamic
// + SPA-fallback behind the per-boot secret, renews the lease, and deregisters on close. The mock
// host records what the helper PUTs/DELETEs so we can assert the registration shape.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { connect } from "../src/index.js";
import { servePluginUi } from "../src/ui.js";
const TOKEN = "test-token";
interface Registration {
id: string;
body: { title: string; version?: string; ui?: { port: number; secret: string; icon?: string } };
}
// A mock management host that captures plugin registry writes.
const registrations: Registration[] = [];
const deletes: string[] = [];
const host = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
if (req.headers.get("authorization") !== `Bearer ${TOKEN}`) {
return Response.json({ error: "invalid credentials" }, { status: 401 });
}
if (url.pathname === "/api/v1/host") return Response.json({ hostname: "mock" });
const m = url.pathname.match(/^\/api\/v1\/plugins\/([a-z][a-z0-9-]*)$/);
if (m) {
if (req.method === "PUT") {
registrations.push({ id: m[1], body: await req.json() });
return new Response(null, { status: 204 });
}
if (req.method === "DELETE") {
deletes.push(m[1]);
return new Response(null, { status: 204 });
}
}
return Response.json({ error: "not found" }, { status: 404 });
},
});
const hostUrl = `http://127.0.0.1:${host.port}`;
// A built SPA on disk: an index and one asset.
let staticDir: string;
beforeAll(() => {
staticDir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-ui-"));
fs.writeFileSync(path.join(staticDir, "index.html"), "INDEX");
fs.writeFileSync(path.join(staticDir, "app.js"), "ASSET");
});
afterAll(() => {
host.stop(true);
fs.rmSync(staticDir, { recursive: true, force: true });
});
/** GET the plugin's own server with the captured secret. */
const authed = (base: string, p: string, secret: string, init?: RequestInit) =>
fetch(`${base}${p}`, { ...init, headers: { authorization: `Bearer ${secret}`, ...init?.headers } });
describe("servePluginUi", () => {
test("registers, serves, renews, and deregisters", async () => {
const pf = await connect({ url: hostUrl, token: TOKEN });
const ui = await servePluginUi(pf, {
id: "demo",
title: "Demo",
version: "9.9.9",
icon: "puzzle",
staticDir,
fetch: (req) =>
new URL(req.url).pathname === "/api/ping"
? Response.json({ pong: true })
: undefined,
renewIntervalMs: 15,
});
// --- registration shape (the secret rides along; the port matches the bound server) ---
const reg = registrations.find((r) => r.id === "demo");
expect(reg).toBeDefined();
expect(reg?.body.title).toBe("Demo");
expect(reg?.body.version).toBe("9.9.9");
expect(reg?.body.ui?.port).toBe(ui.port);
expect(reg?.body.ui?.icon).toBe("puzzle");
const secret = reg?.body.ui?.secret ?? "";
expect(secret).toMatch(/^[A-Za-z0-9_-]{43}$/); // 32 random bytes, base64url
// --- the secret is mandatory on every request ---
expect((await fetch(`${ui.url}/__health`)).status).toBe(401);
const health = await authed(ui.url, "/__health", secret);
expect(health.status).toBe(200);
expect(await health.json()).toEqual({ ok: true, id: "demo", title: "Demo" });
// --- static assets, then SPA fallback for a navigation that matched no file ---
expect(await (await authed(ui.url, "/", secret, { headers: { accept: "text/html" } })).text()).toBe("INDEX");
expect(await (await authed(ui.url, "/app.js", secret)).text()).toBe("ASSET");
const spa = await authed(ui.url, "/some/client/route", secret, { headers: { accept: "text/html" } });
expect(await spa.text()).toBe("INDEX"); // SPA fallback
// A missing NON-navigation asset is a real 404, not the index.
expect((await authed(ui.url, "/missing.js", secret)).status).toBe(404);
// --- the plugin's dynamic handler wins for its own routes ---
expect(await (await authed(ui.url, "/api/ping", secret)).json()).toEqual({ pong: true });
// --- lease renewal keeps PUTting on the interval ---
const before = registrations.filter((r) => r.id === "demo").length;
await new Promise((r) => setTimeout(r, 60));
expect(registrations.filter((r) => r.id === "demo").length).toBeGreaterThan(before);
// --- close deregisters and stops the server ---
await ui.close();
expect(deletes).toContain("demo");
await expect(fetch(`${ui.url}/__health`)).rejects.toThrow(); // connection refused
pf.close();
});
test("rejects a non-kebab id before binding anything", async () => {
const pf = await connect({ url: hostUrl, token: TOKEN });
await expect(servePluginUi(pf, { id: "Bad_Id", title: "x" })).rejects.toThrow(/kebab/);
pf.close();
});
});
+5
View File
@@ -9,6 +9,11 @@
"nav_clients": "Gekoppelte Geräte", "nav_clients": "Gekoppelte Geräte",
"nav_pairing": "Kopplung", "nav_pairing": "Kopplung",
"nav_library": "Bibliothek", "nav_library": "Bibliothek",
"nav_plugins": "Plugins",
"plugin_offline_title": "Dieses Plugin läuft nicht",
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
"plugin_retry": "Erneut versuchen",
"plugin_open_new_tab": "In neuem Tab öffnen",
"nav_settings": "Einstellungen", "nav_settings": "Einstellungen",
"nav_more": "Mehr", "nav_more": "Mehr",
"status_title": "Live-Status", "status_title": "Live-Status",
+5
View File
@@ -11,6 +11,11 @@
"nav_library": "Library", "nav_library": "Library",
"nav_settings": "Settings", "nav_settings": "Settings",
"nav_more": "More", "nav_more": "More",
"nav_plugins": "Plugins",
"plugin_offline_title": "This plugin isn't running",
"plugin_offline_hint": "Start the scripting runner, then retry.",
"plugin_retry": "Retry",
"plugin_open_new_tab": "Open in new tab",
"status_title": "Live status", "status_title": "Live status",
"status_video": "Video", "status_video": "Video",
"status_audio": "Audio", "status_audio": "Audio",
+10
View File
@@ -14,6 +14,16 @@ import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event); const { pathname, search } = getRequestURL(event);
// A plugin UI's proxy credential (its per-boot secret) is fetched server-side by the
// /plugin-ui proxy and must NEVER reach a browser — deny it on the generic passthrough so a
// session-authed page can't read it (plugin-ui-surface §5, D6). The secret-free list at
// /api/v1/plugins is fine; only the {id}/ui-credential leaf is blocked.
if (/^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/.test(pathname)) {
setResponseStatus(event, 403);
return {
error: "plugin UI credentials are not accessible from the browser",
};
}
const base = mgmtUrl(); const base = mgmtUrl();
const target = `${base}${pathname}${search}`; const target = `${base}${pathname}${search}`;
const token = mgmtToken(); const token = mgmtToken();
+78
View File
@@ -0,0 +1,78 @@
// /plugin-ui/<id>/** → a plugin's loopback UI server (plugin-ui-surface §5). By the time we get
// here the gate (middleware/auth.ts) has confirmed a session — a plugin UI is reachable only by the
// logged-in operator, on the console's own origin, with no separate password. We look up the
// plugin's `{port, secret}` server-side, inject the secret as a bearer, strip the browser's cookie,
// and stream the response through (SSE included). The plugin only ever gets dialed on 127.0.0.1.
//
// This route runs in the built Bun/Nitro server. In `vite dev` a small middleware in vite.config.ts
// handles `/plugin-ui` instead (it intercepts before this route, like the /api dev proxy).
import {
defineEventHandler,
getProxyRequestHeaders,
getRequestURL,
readRawBody,
sendWebResponse,
setResponseStatus,
} from "h3";
import {
bustCredential,
fetchUiCredential,
PLUGIN_ID_RE,
} from "../../util/pluginProxy";
export default defineEventHandler(async (event) => {
const { pathname, search } = getRequestURL(event);
// /plugin-ui/<id>/<rest…>
const m = pathname.match(/^\/plugin-ui\/([^/]+)(\/.*)?$/);
const id = m?.[1];
if (!id || !PLUGIN_ID_RE.test(id)) {
setResponseStatus(event, 404);
return { error: "not a valid plugin-ui path" };
}
const rest = m?.[2] ?? "/";
const prefix = `/plugin-ui/${id}`;
// Forwardable request headers (h3 strips hop-by-hop + host); we set our own auth and drop the
// session cookie so plugin code never sees it.
const headers = getProxyRequestHeaders(event) as Record<string, string>;
delete headers.cookie;
delete headers.authorization;
headers["x-forwarded-prefix"] = prefix;
const method = event.method;
const body =
method === "GET" || method === "HEAD"
? undefined
: ((await readRawBody(event, false)) as Uint8Array | undefined);
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
const attempt = async (bustCache: boolean): Promise<Response | null> => {
const cred = await fetchUiCredential(id, { bustCache });
if (!cred) return null;
const target = `http://127.0.0.1:${cred.port}${rest}${search}`;
try {
return await fetch(target, {
method,
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
body: body as BodyInit | undefined,
redirect: "manual",
});
} catch {
// The port is dead (plugin crashed/restarted on a new port): drop the stale credential so
// the next request re-resolves it.
bustCredential(id);
return null;
}
};
let resp = await attempt(false);
// Stale secret after a plugin restart (S7): the plugin rejects our cached secret — re-fetch once.
if (resp?.status === 401) {
const retry = await attempt(true);
if (retry) resp = retry;
}
if (!resp) {
setResponseStatus(event, 502);
return { error: `plugin "${id}" is not running` };
}
return sendWebResponse(event, resp);
});
+74
View File
@@ -0,0 +1,74 @@
// Server-side helper for the plugin-UI reverse proxy (plugin-ui-surface §5). The console proxies
// `/plugin-ui/<id>/**` to a plugin's loopback UI server, injecting the plugin's per-boot secret —
// which it fetches here, from the management API, **server-side only** (the secret never reaches the
// browser; the BFF additionally denylists the credential endpoint from the generic passthrough).
//
// The credential is cached briefly so a burst of iframe asset requests doesn't hammer the host. On a
// 401 from the plugin (its secret rotated on restart within the cache window) the proxy busts this
// cache and re-fetches once — see the route.
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth";
/** A plugin id — its `definePlugin` name; the same shape the host validates. */
export const PLUGIN_ID_RE = /^[a-z][a-z0-9-]*$/;
/** The proxy credential for a plugin's loopback UI. */
export interface UiCredential {
port: number;
secret: string;
}
const TTL_MS = 15_000;
const cache = new Map<string, { cred: UiCredential | null; at: number }>();
/** Drop a cached credential (called when a plugin's secret proved stale). */
export function bustCredential(id: string): void {
cache.delete(id);
}
/**
* Fetch `{port, secret}` for a plugin's UI from the management API (bearer, loopback). Returns
* `null` when the plugin isn't registered / has no UI (a 404). Results are cached for {@link TTL_MS};
* pass `bustCache` to force a fresh read (the stale-secret retry). Throws only on a missing mgmt
* token (a deploy misconfig) a transient upstream error resolves to `null` (treated as offline)
* and is not cached.
*/
export async function fetchUiCredential(
id: string,
opts?: { bustCache?: boolean },
): Promise<UiCredential | null> {
const now = Date.now();
if (!opts?.bustCache) {
const hit = cache.get(id);
if (hit && now - hit.at < TTL_MS) return hit.cred;
}
const base = mgmtUrl();
const token = mgmtToken();
if (!token) {
throw new Error(
"management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)",
);
}
// The host serves the credential over HTTPS with its self-signed loopback cert; relax
// verification for that one loopback hop only (the same scoping the /api BFF uses).
const fetchOptions = isLoopbackUrl(base)
? ({ tls: { rejectUnauthorized: false } } as unknown as RequestInit)
: undefined;
const resp = await fetch(`${base}/api/v1/plugins/${id}/ui-credential`, {
...fetchOptions,
headers: { authorization: `Bearer ${token}` },
});
if (resp.ok) {
const cred = (await resp.json()) as UiCredential;
cache.set(id, { cred, at: now });
return cred;
}
if (resp.status === 404) {
// Definitively not running / no UI — cache the negative so a dead iframe doesn't spin.
cache.set(id, { cred: null, at: now });
return null;
}
// Transient (401/5xx): don't cache, let the next request retry.
return null;
}
+65
View File
@@ -0,0 +1,65 @@
// The plugin directory the console reads to grow its nav (plugin-ui-surface §5). This is a
// hand-written client (not orval-generated) so the nav works without regenerating the API client
// for the new endpoints; it rides the same `/api` BFF path as every other call, so the bearer token
// is injected server-side and the browser only ever sends its session cookie.
import { useQuery } from "@tanstack/react-query";
import {
Blocks,
Boxes,
Clapperboard,
Database,
FolderCog,
Gamepad2,
Home,
type LucideIcon,
Plug,
Puzzle,
Wrench,
} from "lucide-react";
import { apiFetch } from "@/api/fetcher";
export interface PluginUiSummary {
port: number;
icon?: string;
}
export interface PluginSummary {
id: string;
title: string;
version?: string;
/** Present iff the plugin serves a UI (and thus gets a nav entry). */
ui?: PluginUiSummary;
}
// A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat
// tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle.
const ICONS: Record<string, LucideIcon> = {
"gamepad-2": Gamepad2,
puzzle: Puzzle,
wrench: Wrench,
database: Database,
home: Home,
blocks: Blocks,
boxes: Boxes,
plug: Plug,
"folder-cog": FolderCog,
clapperboard: Clapperboard,
};
/** Resolve a registered icon name to a component (Puzzle fallback). */
export const pluginIcon = (name?: string): LucideIcon =>
(name ? ICONS[name] : undefined) ?? Puzzle;
/** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */
export function usePlugins() {
return useQuery({
queryKey: ["plugins"],
queryFn: () => apiFetch<PluginSummary[]>("/api/v1/plugins"),
refetchInterval: 30_000,
refetchOnWindowFocus: true,
});
}
/** Only the plugins that surface a UI — the ones that get a nav entry. */
export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] =>
(list ?? []).filter((p) => p.ui);
+62 -4
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { motion, stagger } from "motion/react"; import { motion, stagger } from "motion/react";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { Wordmark } from "@/components/wordmark"; import { Wordmark } from "@/components/wordmark";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n"; import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
@@ -91,6 +92,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</MLink> </MLink>
))} ))}
</motion.nav> </motion.nav>
<PluginNavSection />
<div className="mt-auto pt-4"> <div className="mt-auto pt-4">
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
@@ -119,14 +121,54 @@ export function AppShell({ children }: { children: ReactNode }) {
); );
} }
/** Desktop sidebar: the dynamic "Plugins" group, fed by the plugin directory. Renders nothing until
* at least one plugin surfaces a UI a host with no plugins sees zero extra chrome. */
function PluginNavSection() {
const { data } = usePlugins();
const plugins = uiPlugins(data);
if (plugins.length === 0) return null;
return (
<div className="mt-6 flex flex-col gap-1">
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
{m.nav_plugins()}
</p>
{plugins.map((p) => {
const Icon = pluginIcon(p.ui?.icon);
return (
<Link
key={p.id}
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
}}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
);
})}
</div>
);
}
/** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */ /** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */
function MobileNav() { function MobileNav() {
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
const pathname = useRouterState({ select: (s) => s.location.pathname }); const pathname = useRouterState({ select: (s) => s.location.pathname });
// Highlight "More" when the current route lives in the overflow. const { data } = usePlugins();
const overflowActive = MOBILE_OVERFLOW.some( const plugins = uiPlugins(data);
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`), // Highlight "More" when the current route lives in the overflow — plugins included.
); const overflowActive =
pathname.startsWith("/plugins/") ||
MOBILE_OVERFLOW.some(
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`),
);
// Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon // Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon
// at the same height. // at the same height.
const tab = const tab =
@@ -164,6 +206,22 @@ function MobileNav() {
<span className={lbl}>{label()}</span> <span className={lbl}>{label()}</span>
</Link> </Link>
))} ))}
{plugins.map((p) => {
const Icon = pluginIcon(p.ui?.icon);
return (
<Link
key={p.id}
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
onClick={() => setMoreOpen(false)}
className={cn(tab, "rounded-md")}
activeProps={{ className: "text-[var(--brand-light)]" }}
>
<Icon className="size-5 shrink-0" />
<span className={lbl}>{p.title}</span>
</Link>
);
})}
</div> </div>
</div> </div>
)} )}
+8
View File
@@ -0,0 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionPlugin } from "@/sections/Plugins";
// A plugin's console-hosted UI (plugin-ui-surface). The `$` splat carries the plugin's own path so
// deep links survive a reload; the section maps it to the iframe src and keeps it in sync.
export const Route = createFileRoute("/plugins/$pluginId/$")({
component: SectionPlugin,
});
+136
View File
@@ -0,0 +1,136 @@
// A plugin's UI, embedded in the console (plugin-ui-surface §5). We probe the plugin's liveness
// first and only mount the iframe when it answers — otherwise the iframe would show the proxy's raw
// 502. The iframe is same-origin (proxied through /plugin-ui), so the plugin can talk to its own
// loopback REST with the operator's session and, optionally, keep the address bar in sync by posting
// `{ type: "pf-ui:navigate", path }` to the parent.
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { ExternalLink, RefreshCw } from "lucide-react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { pluginIcon, usePlugins } from "@/api/plugins";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
const route = getRouteApi("/plugins/$pluginId/$");
export const SectionPlugin: FC = () => {
useLocale();
const { pluginId, _splat } = route.useParams();
const navigate = useNavigate();
const iframeRef = useRef<HTMLIFrameElement>(null);
// Header metadata (title/version/icon) from the directory; falls back to the id.
const { data: plugins } = usePlugins();
const meta = plugins?.find((p) => p.id === pluginId);
const Icon = pluginIcon(meta?.ui?.icon);
const title = meta?.title ?? pluginId;
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
// offline card (the manual Retry re-probes).
const health = useQuery({
queryKey: ["plugin-health", pluginId],
queryFn: async () => {
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
credentials: "same-origin",
});
if (!r.ok) throw new Error(`health ${r.status}`);
return true;
},
retry: false,
refetchInterval: (q) => (q.state.status === "error" ? false : 20_000),
});
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
// the console URL via postMessage (below), never the src — so there's no reload loop.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally pinned to the initial path
const initialSrc = useMemo(
() => `/plugin-ui/${pluginId}/${_splat ?? ""}`,
[pluginId],
);
// Keep the console address bar in sync with the plugin's internal routing.
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
const data = e.data as { type?: string; path?: string };
if (data?.type === "pf-ui:navigate" && typeof data.path === "string") {
navigate({
to: "/plugins/$pluginId/$",
params: { pluginId, _splat: data.path.replace(/^\//, "") },
replace: true,
});
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [pluginId, navigate]);
return (
<div className="flex h-[calc(100dvh-7rem)] min-h-[480px] flex-col gap-3 sm:h-[calc(100dvh-5rem)]">
{/* Header strip: identity + open-in-new-tab (the plugin stands alone full-window too). */}
<div className="flex items-center gap-3">
<Icon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{title}</h1>
{meta?.version && (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
v{meta.version}
</span>
)}
<a
href={`/plugin-ui/${pluginId}/`}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="size-4" />
{m.plugin_open_new_tab()}
</a>
</div>
{health.isError ? (
<OfflineCard title={title} onRetry={() => health.refetch()} />
) : health.isSuccess ? (
<iframe
ref={iframeRef}
src={initialSrc}
title={title}
className="w-full flex-1 rounded-lg border bg-card"
// The plugin is operator-installed code on our own origin (no new trust boundary —
// plugin-ui-surface §7.4); allow it to run scripts, forms, popups, and full-window.
sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-modals"
allow="fullscreen"
/>
) : (
// Probing: a calm shimmer rather than a flash of empty frame.
<div className="flex-1 animate-pulse rounded-lg border bg-card/50" />
)}
</div>
);
};
const OfflineCard: FC<{ title: string; onRetry: () => void }> = ({
title,
onRetry,
}) => (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed">
<div className="flex max-w-md flex-col items-center gap-3 p-8 text-center">
<h2 className="text-base font-semibold">{m.plugin_offline_title()}</h2>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{title}</span> {" "}
{m.plugin_offline_hint()}
</p>
{/* The exact runner commands, so the operator can act without leaving the page. */}
<pre className="w-full overflow-x-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
<code>
systemctl --user status punktfunk-scripting{"\n"}
Get-ScheduledTask PunktfunkScripting{" # Windows"}
</code>
</pre>
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" />
{m.plugin_retry()}
</Button>
</div>
</div>
);
+102 -1
View File
@@ -1,10 +1,12 @@
import * as nodeHttp from "node:http";
import * as nodeHttps from "node:https";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { paraglideVitePlugin } from "@inlang/paraglide-js"; import { paraglideVitePlugin } from "@inlang/paraglide-js";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin"; import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react"; import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig, type Plugin } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths"; import viteTsConfigPaths from "vite-tsconfig-paths";
// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir // Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir
@@ -16,6 +18,103 @@ const serverDir = fileURLToPath(new URL("./server", import.meta.url));
// route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL. // route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL.
const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990"; const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
// Dev-only `/plugin-ui/<id>/**` reverse proxy — the vite-dev counterpart of the Bun/Nitro route
// (server/routes/plugin-ui/[...].ts), which can't run in dev because it uses Bun's `tls` fetch
// option. Same contract: look up the plugin's {port, secret} from the management API server-side,
// inject the secret, strip the cookie, dial 127.0.0.1 only, stream the response (SSE included).
// Needs PUNKTFUNK_MGMT_TOKEN in the dev environment (like talking to any token-required host).
function pluginUiDevProxy(): Plugin {
const fetchCred = (
id: string,
token: string,
): Promise<{ port: number; secret: string } | null> =>
new Promise((resolve) => {
const u = new URL(`${MGMT_URL}/api/v1/plugins/${id}/ui-credential`);
const mod = u.protocol === "https:" ? nodeHttps : nodeHttp;
const r = mod.request(
u,
{
method: "GET",
headers: { authorization: `Bearer ${token}` },
rejectUnauthorized: false, // host's self-signed loopback cert
} as nodeHttps.RequestOptions,
(resp) => {
let data = "";
resp.on("data", (c) => {
data += c;
});
resp.on("end", () => {
if (resp.statusCode === 200) {
try {
resolve(JSON.parse(data));
} catch {
resolve(null);
}
} else resolve(null);
});
},
);
r.on("error", () => resolve(null));
r.end();
});
return {
name: "punktfunk-plugin-ui-dev-proxy",
configureServer(server) {
server.middlewares.use("/plugin-ui", async (req, res) => {
const raw = req.url ?? "/"; // connect strips the /plugin-ui mount prefix
const m = raw.match(/^\/([a-z][a-z0-9-]*)(\/[^?]*)?(\?.*)?$/);
const id = m?.[1];
if (!id) {
res.statusCode = 404;
res.end("bad plugin-ui path");
return;
}
const rest = m?.[2] ?? "/";
const search = m?.[3] ?? "";
const token = process.env.PUNKTFUNK_MGMT_TOKEN;
if (!token) {
res.statusCode = 503;
res.end("dev plugin-ui proxy: set PUNKTFUNK_MGMT_TOKEN");
return;
}
const cred = await fetchCred(id, token);
if (!cred) {
res.statusCode = 502;
res.end(`plugin "${id}" is not running`);
return;
}
const headers = { ...req.headers } as Record<string, string | string[]>;
delete headers.host;
delete headers.cookie;
delete headers.authorization;
headers["x-forwarded-prefix"] = `/plugin-ui/${id}`;
const proxyReq = nodeHttp.request(
{
host: "127.0.0.1",
port: cred.port,
method: req.method,
path: rest + search,
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
},
(pr) => {
res.statusCode = pr.statusCode ?? 502;
for (const [k, v] of Object.entries(pr.headers)) {
if (v !== undefined) res.setHeader(k, v);
}
pr.pipe(res); // stream (SSE included)
},
);
proxyReq.on("error", () => {
res.statusCode = 502;
res.end("plugin unreachable");
});
req.pipe(proxyReq);
});
},
};
}
export default defineConfig({ export default defineConfig({
server: { server: {
proxy: { proxy: {
@@ -24,6 +123,8 @@ export default defineConfig({
}, },
}, },
plugins: [ plugins: [
// First, so it intercepts /plugin-ui before the SSR catch-all in dev.
pluginUiDevProxy(),
viteTsConfigPaths({ projects: ["./tsconfig.json"] }), viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
tailwindcss(), tailwindcss(),
paraglideVitePlugin({ paraglideVitePlugin({