Compare commits

..

16 Commits

Author SHA1 Message Date
enricobuehler 4d2632a845 fix(client/deps): ring-only rustls in pf-client-core — finish dropping aws-lc-sys
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
audit / cargo-audit (push) Successful in 2m30s
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 18s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
flatpak / build-publish (push) Has started running
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has started running
docker / deploy-docs (push) Waiting to run
ci / bench (push) Successful in 6m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has started running
windows / build (aarch64-pc-windows-msvc) (push) Waiting to run
windows / build (x86_64-pc-windows-msvc) (push) Waiting to run
apple / swift (push) Successful in 1m25s
apple / screenshots (push) Waiting to run
ci / rust (push) Has started running
audit / bun-audit (push) Successful in 16s
android-screenshots / screenshots (push) Waiting to run
android / android (push) Has started running
arch / build-publish (push) Has started running
linux-client-screenshots / screenshots (push) Waiting to run
release / apple (push) Has started running
web-screenshots / screenshots (push) Waiting to run
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Waiting to run
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Waiting to run
windows-host / package (push) Has started running
ci / web (push) Successful in 48s
deb / build-publish (push) Has started running
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
54 changed files with 2348 additions and 252 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
# 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
@@ -45,8 +45,9 @@ jobs:
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
# it's AUR-only on Arch, so bootstrap the official binary.
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# 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 || {
curl -fsSL https://bun.sh/install | bash
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"
mkdir -p dist && chown builder: dist
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" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
makepkg -f -d --holdver
+2
View File
@@ -132,6 +132,8 @@ jobs:
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
# 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
# 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
env:
+6 -4
View File
@@ -92,9 +92,10 @@ jobs:
echo "rpm $V-$R -> group '$GROUP'"
- name: Build RPM
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
# globs it in; the host RPM Recommends it). Needs bun (ensured in Prep).
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
# 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)
env:
@@ -131,7 +132,8 @@ jobs:
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
- name: Publish the sysext feed
env:
+22 -2
View File
@@ -1,7 +1,8 @@
# 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
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script 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
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
#
@@ -52,6 +53,7 @@ on:
- 'packaging/windows/**'
- 'scripts/windows/**'
- 'web/**'
- 'sdk/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-host.yml'
@@ -212,6 +214,24 @@ jobs:
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
- 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
shell: pwsh
env:
+4
View File
@@ -43,3 +43,7 @@ CLAUDE.md
/result
/result-*
.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"
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]]
name = "axum"
version = "0.8.9"
@@ -762,6 +740,12 @@ dependencies = [
"cc",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -1028,12 +1012,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.16.0"
@@ -1288,12 +1266,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.32"
@@ -1510,6 +1482,16 @@ dependencies = [
"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]]
name = "gio"
version = "0.22.6"
@@ -2011,9 +1993,13 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
@@ -3650,7 +3636,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"aws-lc-rs",
"pem",
"ring",
"rustls-pki-types",
@@ -3879,7 +3864,6 @@ version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@@ -3944,7 +3928,6 @@ version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -5269,6 +5252,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wide"
version = "0.7.33"
@@ -6081,6 +6070,21 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "zvariant"
version = "5.12.0"
@@ -72,6 +72,14 @@ struct StreamCommands: Commands {
}
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
.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
Divider()
Button("Disconnect") { session?.disconnect() }
@@ -38,6 +38,7 @@ extension SettingsView {
}
#endif
#if !os(tvOS)
renderScaleRow
bitrateRows
#endif
} 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)
// MARK: - Stream mode (iOS wheel)
@@ -1,5 +1,6 @@
// App settings. The host creates a native virtual output at exactly the chosen size/refresh
// there is no scaling anywhere in the pipeline.
// App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
// 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,
// 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
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
@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.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@@ -51,6 +56,12 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
@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)
@State var showControllerTest = false
#endif
@@ -112,6 +123,7 @@ struct SettingsView: View {
TabView {
Form {
streamModeSection
inputSection
compositorSection
wakeSection
}
@@ -242,6 +254,7 @@ struct SettingsView: View {
Form {
streamModeSection
pointerSection
inputSection
compositorSection
wakeSection
keepAliveSection // iOS-only content; empty on tvOS
@@ -334,6 +347,10 @@ struct SettingsView: View {
return ScrollView {
VStack(spacing: 16) {
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(
title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
@@ -29,6 +29,11 @@ public final class ClipboardSync: NSObject {
("text/rtf", .rtf),
("text/html", .html),
("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
/// with these see nspasteboard.org).
@@ -205,11 +210,14 @@ public final class ClipboardSync: NSObject {
var kinds = Self.wireToPasteboard
.filter { types.contains($0.type) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// Images: macOS image copies usually carry TIFF (browsers add WebP/AVIF/GIF, screenshots
// TIFF) and only sometimes PNG announce the portable `image/png` whenever ANY
// convertible image type is present; `serveFetch` converts at fetch time (lazy, §3.5).
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
// 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" }),
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"))
}
@@ -385,6 +393,8 @@ private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider
case .rtf: return "text/rtf"
case .html: return "text/html"
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
}
}
@@ -41,6 +41,7 @@ import UIKit
import Foundation
import GameController
import PunktfunkCore
import PunktfunkShared
import os
/// 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 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)
/// 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.
@@ -273,6 +280,14 @@ public final class InputCapture {
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
}
#endif
@@ -318,7 +333,7 @@ public final class InputCapture {
chordModifiersDown.removeAll()
suppressedVK = nil
for vk in pressedVKs {
connection.send(.key(vk, down: false))
emitKey(vk, down: false)
}
for button in pressedButtons {
connection.send(.mouseButton(button, down: false))
@@ -331,6 +346,15 @@ public final class InputCapture {
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
/// 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
@@ -399,7 +423,7 @@ public final class InputCapture {
inputLog.debug(
"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
@@ -566,8 +590,15 @@ public final class InputCapture {
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel the only delivery
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
/// 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 }
// 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 fx = dx + residualScrollX
let iy = fy.rounded(.towardZero)
@@ -643,7 +674,7 @@ public final class InputCapture {
} else {
self.pressedVKs.remove(vk)
}
self.connection.send(.key(vk, down: pressed))
self.emitKey(vk, down: pressed)
}
#endif
}
@@ -1,7 +1,28 @@
// 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).
import PunktfunkShared
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
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev extend the two together).
static let hidToVK: [Int: UInt32] = {
@@ -15,6 +15,7 @@
// from looping request rollback request.
import Foundation
import PunktfunkShared
/// 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`).
@@ -51,6 +52,11 @@ public final class MatchWindowFollower {
private weak var connection: PunktfunkConnection?
private let debounce: 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 work: DispatchWorkItem?
@@ -74,15 +80,28 @@ public final class MatchWindowFollower {
public init(
connection: PunktfunkConnection,
enabled: Bool,
renderScale: Double = 1.0,
maxDimension: Int = 8192,
debounce: TimeInterval = 0.4,
minSpacing: TimeInterval = 1.0
) {
self.connection = connection
self.enabled = enabled
self.renderScale = RenderScale.sanitize(renderScale)
self.maxDimension = maxDimension
self.debounce = debounce
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).
public func setEnabled(_ on: Bool) {
enabled = on
@@ -109,7 +128,7 @@ public final class MatchWindowFollower {
/// mode yet nothing to compare against, skip.
private func reportSteering(widthPx: Int, heightPx: Int) {
guard let connection else { return }
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
let target = targetMode(widthPx: widthPx, heightPx: heightPx)
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0 else { return }
if target.width == mode.width, target.height == mode.height {
@@ -137,7 +156,7 @@ public final class MatchWindowFollower {
schedule()
return
}
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
let target = targetMode(widthPx: size.width, heightPx: size.height)
let mode = connection.currentMode()
pendingSize = nil
guard let req = MatchWindow.request(
@@ -621,6 +621,12 @@ public final class StreamLayerView: NSView {
guard let self, self.window?.isKeyWindow == true else { return }
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
guard self?.window?.isKeyWindow == true else { return }
// 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.
let follower = MatchWindowFollower(
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)
matchFollower = follower
layoutPresenter()
@@ -385,7 +385,10 @@ public final class StreamViewController: StreamViewControllerBase {
// default keeps the explicit mode.
let follower = MatchWindowFollower(
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
matchFollower = follower
#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
/// `nv12` (a 4:4:4 session must never subsample).
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`).
dbg_log_n: u64,
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
@@ -1352,15 +1355,17 @@ mod pipewire {
// sample LINEAR).
let modifier = (ud.modifier != 0).then_some(ud.modifier);
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
// session gets the planar-YUV444 convert (full chroma, takes precedence over
// NV12 — 4:4:4 must never subsample), otherwise `PUNKTFUNK_NV12` gets NV12 —
// both feed NVENC native YUV so it skips its internal RGB→YUV CSC. The
// LINEAR/Vulkan (gamescope) path stays RGB — its converts aren't wired here;
// a 4:4:4 session on LINEAR frames falls to the encoder's clear-error path
// (`want_444` with an RGB CUDA payload) rather than silently subsampling.
// GPU converts: a 4:4:4 session gets the planar-YUV444 convert on the tiled
// EGL/GL path (full chroma, takes precedence over NV12 — 4:4:4 must never
// subsample), otherwise `PUNKTFUNK_NV12` gets NV12 — tiled via the EGL/GL
// blit, LINEAR/gamescope via the Vulkan bridge's compute CSC (latency plan
// T2.5b) — so NVENC encodes native YUV and skips its internal RGB→YUV CSC on
// the contended SM. A 4:4:4 session on LINEAR frames has no convert and
// 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 nv12 = ud.nv12 && !yuv444 && modifier.is_some();
let mut nv12 = ud.nv12 && !ud.yuv444;
let imported = if let Some(m) = modifier {
if yuv444 {
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
@@ -1369,7 +1374,20 @@ mod pipewire {
} else {
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 {
nv12 = false;
importer.import_linear(&plane, w as u32, h as u32)
};
match imported {
@@ -1742,6 +1760,7 @@ mod pipewire {
vaapi_passthrough,
nv12: pf_zerocopy::nv12_enabled(),
yuv444: want_444,
linear_nv12_failed: false,
dbg_log_n: 0,
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
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
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_json = "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"] }
# 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.
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]
# 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";
/// Wire MIME for a PNG image.
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
/// 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/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
"image/png" => Some(WIRE_PNG),
"image/jpeg" => Some(WIRE_JPEG),
"image/gif" => Some(WIRE_GIF),
_ => match base {
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
_ => None,
@@ -270,6 +278,8 @@ pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
WIRE_HTML => &["text/html"],
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
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");
}
WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
other => push(other),
}
}
@@ -355,10 +367,13 @@ mod tests {
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("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.
assert_eq!(wayland_to_wire("TARGETS"), 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]
+66 -11
View File
@@ -48,7 +48,9 @@ use ::windows::Win32::UI::WindowsAndMessaging::{
};
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.
const WM_APP_CMD: u32 = WM_APP + 1;
@@ -98,6 +100,13 @@ struct WinClip {
fmt_html: u32,
fmt_rtf: 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.
own_hwnd: HWND,
}
@@ -137,6 +146,15 @@ impl WinClip {
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
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
}
@@ -192,6 +210,7 @@ impl WinClip {
}
}
*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).
@@ -265,8 +284,23 @@ impl WinClip {
/// `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.
fn on_render_format(&self, fmt: u32) {
let Some(wire) = self.wire_for_format(fmt) else {
return;
// The synthesized CF_DIB promise has no wire kind of its own: fetch the richest image
// 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 ev = ClipEvent::Paste {
@@ -281,9 +315,9 @@ impl WinClip {
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
};
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
// decode leaves the format unrendered (empty paste), matching the timeout path.
match winfmt::png_to_dib(&bytes) {
// The app asked for a bitmap: convert whatever image kind the client served. Bytes
// that don't decode leave the format unrendered (empty paste), like the timeout path.
match winfmt::image_to_dib(&bytes) {
Some(d) => d,
None => return,
}
@@ -310,6 +344,8 @@ impl WinClip {
WIRE_HTML => Some(self.fmt_html),
WIRE_RTF => Some(self.fmt_rtf),
WIRE_PNG => Some(self.fmt_png),
WIRE_JPEG => Some(self.fmt_jfif),
WIRE_GIF => Some(self.fmt_gif),
_ => None,
}
}
@@ -322,8 +358,12 @@ impl WinClip {
Some(WIRE_HTML)
} else if fmt == self.fmt_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)
} else if fmt == self.fmt_jfif {
Some(WIRE_JPEG)
} else if fmt == self.fmt_gif {
Some(WIRE_GIF)
} else {
None
}
@@ -340,9 +380,12 @@ impl WinClip {
}
}
// Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients)
// ask for the bitmap family, not the registered "PNG"; Windows synthesizes
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` converts on demand.
if w == WIRE_PNG && !out.contains(&(CF_DIB.0 as u32)) {
// ask for the bitmap family, not the registered image formats; Windows synthesizes
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` fetches the richest
// 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);
}
}
@@ -376,12 +419,18 @@ impl WindowsClipboard {
let fmt_html = register_format(w!("HTML Format"))?;
let fmt_rtf = register_format(w!("Rich Text Format"))?;
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 cw = Arc::clone(&current_wire);
let join = std::thread::Builder::new()
.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")?;
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`.
#[allow(clippy::too_many_arguments)]
fn pump_thread(
clip_tx: ClipTx,
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
@@ -580,6 +630,8 @@ fn pump_thread(
fmt_html: u32,
fmt_rtf: u32,
fmt_png: u32,
fmt_jfif: u32,
fmt_gif: u32,
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
) {
let hwnd = match create_window() {
@@ -598,9 +650,12 @@ fn pump_thread(
current_wire,
cmd_rx: RefCell::new(cmd_rx),
offered: RefCell::new(Vec::new()),
offered_wires: RefCell::new(Vec::new()),
fmt_html,
fmt_rtf,
fmt_png,
fmt_jfif,
fmt_gif,
own_hwnd: hwnd,
});
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)]
mod tests {
use super::*;
@@ -265,7 +348,7 @@ mod tests {
image::DynamicImage::ImageRgba8(img.clone())
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.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.
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);
@@ -282,85 +365,3 @@ mod tests {
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
//! 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,
//! so the whole two-thread async-retrieve subsystem of the Windows backend is absent here: `poll`
//! does the blocking `lock_bitstream`, exactly like the libav path.
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows
//! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode*
//! (`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
//! 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::ffi::c_void;
use std::ptr;
use std::sync::mpsc;
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
/// loop deep-pipelines (submits several frames before locking the oldest) so this must be ≥ the
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
/// Output bitstream buffers = max in-flight encodes; equals the input-surface ring depth. Must
/// stay ≥ the two-thread retrieve's in-flight cap ([`async_inflight_cap`], ≤ `POOL - 1`) so a
/// bitstream/ring slot is never reused mid-encode.
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-
/// 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.
@@ -300,6 +437,9 @@ pub struct NvencCudaEncoder {
/// 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.
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`
@@ -373,6 +513,7 @@ impl NvencCudaEncoder {
custom_vbv: false,
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
last_rfi_range: None,
async_rt: None,
})
}
@@ -381,6 +522,15 @@ impl NvencCudaEncoder {
if self.encoder.is_null() {
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.
for (_, map, _, _) in &self.pending {
if !map.is_null() {
@@ -804,6 +954,15 @@ impl NvencCudaEncoder {
}
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!(
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
bit_depth = self.bit_depth,
@@ -845,6 +1004,40 @@ impl NvencCudaEncoder {
_ => 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 {
@@ -891,6 +1084,19 @@ impl Encoder for NvencCudaEncoder {
// 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.
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;
self.next += 1;
@@ -1057,6 +1263,15 @@ impl Encoder for NvencCudaEncoder {
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(())
}
@@ -1130,6 +1345,26 @@ impl Encoder for NvencCudaEncoder {
}
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 {
return Ok(None);
};
+11
View File
@@ -320,6 +320,17 @@ impl RemoteImporter {
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(
&mut self,
plane: &DmabufPlane,
+47
View File
@@ -1386,3 +1386,50 @@ pub fn copy_pitched_to_buffer(
// within both. Wrapper → live table.
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.
vk: Option<super::vulkan::VkBridge>,
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,
render_fd: c_int,
}
@@ -647,6 +650,7 @@ impl EglImporter {
yuv444_blit: None,
vk: None,
linear_pool: None,
linear_nv12_pool: None,
gbm,
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`]).
/// No-op when the bridge hasn't been built (tiled-only captures).
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
/// `false` in-process — an in-process driver fault doesn't return.
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
/// the import-fail machinery handles it like any other worker error.
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.
@@ -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,
}
/// 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 {
_entry: ash::Entry,
instance: ash::Instance,
@@ -52,6 +66,9 @@ pub struct VkBridge {
mem_props: vk::PhysicalDeviceMemoryProperties,
src_cache: HashMap<i32, SrcBuf>,
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
@@ -94,18 +111,15 @@ impl VkBridge {
.ok_or_else(|| anyhow!("no NVIDIA Vulkan device"))?;
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
.get_physical_device_queue_family_properties(phys)
.iter()
.position(|q| {
q.queue_flags.intersects(
vk::QueueFlags::TRANSFER
| vk::QueueFlags::GRAPHICS
| vk::QueueFlags::COMPUTE,
)
})
.ok_or_else(|| anyhow!("no transfer-capable queue family"))?
.position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
.ok_or_else(|| anyhow!("no compute-capable queue family"))?
as u32;
let exts = [
@@ -161,6 +175,7 @@ impl VkBridge {
mem_props,
src_cache: HashMap::new(),
dst: None,
csc: None,
})
}
}
@@ -189,7 +204,11 @@ impl VkBridge {
.create_buffer(
&vk::BufferCreateInfo::default()
.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),
None,
)
@@ -256,7 +275,10 @@ impl VkBridge {
.create_buffer(
&vk::BufferCreateInfo::default()
.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),
None,
)
@@ -302,6 +324,246 @@ impl VkBridge {
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 /
/// 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
@@ -414,6 +676,14 @@ impl Drop for VkBridge {
self.device.destroy_buffer(d.buffer, 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_command_pool(self.cmd_pool, None);
self.device.destroy_device(None);
+3
View File
@@ -299,6 +299,9 @@ impl EglBackend {
req.modifier,
)?,
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.
cuda::make_current()?;
+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
# `mdns_sd::…` instead of "log".
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"
mdns-sd = "0.20"
# 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 +
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
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"
# 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
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
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
# 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.
tokio-rustls = "0.26"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
tower = { version = "0.5", features = ["util"] }
@@ -114,8 +120,6 @@ tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
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::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
+1 -1
View File
@@ -239,7 +239,7 @@ pub fn serve(
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
rt.block_on(async move {
// 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);
// 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).
+1 -1
View File
@@ -180,7 +180,7 @@ fn build_server_config(
key_pem: &str,
mandatory: bool,
) -> 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),
// so we don't pull the unmaintained `rustls-pemfile`.
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
+26 -6
View File
@@ -125,12 +125,13 @@ pub fn ring() -> &'static LogRing {
RING.get_or_init(LogRing::new)
}
/// Targets whose DEBUG/TRACE output is steady-state per-packet chatter, not diagnostics — left
/// in, they evict the entire ring tail (mdns-sd DEBUG-logs every multicast packet it can't parse,
/// so one chatty AirPlay/HomePod device on the LAN floods thousands of entries per hour). The
/// ring keeps their INFO-and-up; stderr under `RUST_LOG` is unaffected. Prefix-matched on module
/// path boundaries.
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd"];
/// Targets whose DEBUG/TRACE output is steady-state chatter, not diagnostics — left in, they evict
/// the entire ring tail: `mdns_sd` DEBUG-logs every multicast packet it can't parse (one chatty
/// AirPlay/HomePod device on the LAN floods thousands of entries per hour), and `wasapi` DEBUG-logs
/// the default audio device once a second (the device-watchdog poll). The ring keeps their
/// INFO-and-up; the file/stderr filter caps them separately (see `main`'s EnvFilter directives).
/// Prefix-matched on module path boundaries.
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd", "wasapi"];
fn is_noisy_debug(target: &str) -> bool {
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
/// 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).
+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 —
// deliberately not gated by RUST_LOG, so console-side debugging never needs a restart.
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;
tracing_subscriber::registry()
.with(
log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
)
.with(
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_filter(filter),
)
.init();
log_capture::install_global(
tracing_subscriber::registry()
.with(
log_capture::RingLayer
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
)
.with(
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_filter(filter),
),
);
}
// Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise
+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"))
}
/// 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
/// [`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
@@ -1131,6 +1145,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
Vec<u32>,
Vec<u32>,
) = (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 {
// 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
@@ -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
// 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();
// 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
@@ -1934,6 +1968,32 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
"encode stall detected — encoder rebuilt in place, forcing an IDR");
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() {
// 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
+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.
pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;
let ring =
crate::log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG);
let log_path = service_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()
.create(true)
.append(true)
.open(log_path)
{
Ok(file) => {
tracing_subscriber::registry()
.with(ring)
.with(
crate::log_capture::install_global(
tracing_subscriber::registry().with(ring).with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(move || file.try_clone().expect("clone service log handle"))
.with_filter(filter),
)
.init();
),
);
}
Err(_) => {
tracing_subscriber::registry()
.with(ring)
.with(
crate::log_capture::install_global(
tracing_subscriber::registry().with(ring).with(
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_filter(filter),
)
.init();
),
);
}
}
}
+15 -3
View File
@@ -52,7 +52,7 @@
packages = forAllSystems (system:
let pf = packagesFor system;
in {
inherit (pf) punktfunk-host punktfunk-client;
inherit (pf) punktfunk-host punktfunk-client punktfunk-web punktfunk-scripting punktfunk-tray;
default = pf.punktfunk-host;
});
@@ -68,14 +68,26 @@
type = "app";
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;
});
# `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:
let pf = packagesFor system;
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
+50 -7
View File
@@ -14,10 +14,11 @@
# picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software
# fallback, so it works everywhere. See README.md.
pkgbase=punktfunk
# punktfunk-web (the browser console) is OPT-IN: building it needs `bun` (AUR-only as bun-bin on
# stock Arch/SteamOS), so a default makepkg builds only host+client with no JS tooling — mirroring
# the RPM spec's `%bcond_with web` (off by default). Set PF_WITH_WEB=1 to also build punktfunk-web
# (appended to pkgname + bun to makedepends below).
# punktfunk-web (the browser console) and punktfunk-scripting (the plugin/script runner) are OPT-IN:
# both need `bun` (AUR-only as bun-bin on stock Arch/SteamOS), so a default makepkg builds only
# host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web` / `%bcond_with
# 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')
# 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
@@ -39,11 +40,16 @@ options=('!lto' '!debug')
makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git' 'vulkan-headers'
'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
pkgname+=('punktfunk-web')
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
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 —
# 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
# 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 \
-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),
# built AND run with bun.
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
( cd web && bun install --frozen-lockfile && bun run build )
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() {
@@ -99,7 +119,8 @@ package_punktfunk-host() {
'sway: stream a wlroots desktop (Sway VirtualDisplay backend)'
'xdg-desktop-portal-kde: portal for the headless KDE 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
# User-editable config: the headless game-mode drop-in (see below) — don't clobber local edits.
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-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"
}
+3 -1
View File
@@ -156,7 +156,9 @@ DEPENDS="$SHDEPS, libei1, pipewire, wireplumber"
# 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
# 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"
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-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` |
| `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 |
| `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.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
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
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.
- **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
generated on first eval and checked in.
- **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
# 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
# UDP socket-buffer sysctls, the firewall openers, and the `input`-group membership for virtual
# gamepads.
# UDP socket-buffer sysctls, the firewall openers, the `input`-group membership for virtual
# 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):
# { 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.";
};
};
# 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 [
@@ -226,5 +305,91 @@ in
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,
adwaita-icon-theme,
vulkan-headers,
# web console (punktfunk-web): a bun-built Nitro SSR bundle, run on bun.
bun,
nodejs,
makeWrapper,
cacert,
stdenvNoCC,
}:
let
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
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
{
inherit punktfunk-tray;
punktfunk-host = craneLib.buildPackage (commonArgs // {
pname = "punktfunk-host";
# HOST ONLY — the tray is a separate derivation (see the note above; co-building crashes it).
cargoExtraArgs =
"--locked -p punktfunk-host -p punktfunk-tray "
"--locked -p punktfunk-host "
+ "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode";
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
# tray/client .desktop Exec lines are rewritten for the same reason (menu launch / autostart).
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.
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 /
# libnvidia-encode.so.1 / libEGL.so.1 / the GPU's libvulkan ICD resolves from the running system.
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 // {
@@ -232,4 +265,190 @@ in
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.
WEB_OPT=()
[ "${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)"
cd "$ROOTDIR"
@@ -51,7 +55,7 @@ fi
# 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
# 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 "pf_version ${PF_VERSION}" \
--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.
%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 ---------------------------------------------------------
BuildRequires: cargo
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
# it doesn't (a COPR build without `--with web` simply has no punktfunk-web to satisfy).
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
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`.
%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
%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
# 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 \
-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}
# 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
%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
# Binary
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
%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
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%doc README.md packaging/README.md
@@ -360,6 +421,17 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
%{_userunitdir}/punktfunk-web-init.service
%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
# Pick up the DualSense hidraw rule without a reboot (best-effort; on rpm-ostree it
# 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"
%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
* 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
- 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
+34 -11
View File
@@ -27,7 +27,8 @@ param(
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
[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]$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
[switch]$NoDriver, # build without the bundled pf-vdisplay driver
[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)" }
# --- stage the web management console (the self-contained .output tree + a portable bun + launcher) -
# The console runs as the PunktfunkWeb scheduled task (`bun {app}\web\.output\server\index.mjs`),
# 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). The .output is
# self-contained (Nitro noExternals - deps bundled + tree-shaken, no node_modules), so bun runs it
# directly; omitted when -WebDir/-BunExe are unset (host-only installer, e.g. a local debug pack).
if ($WebDir -and (Test-Path $WebDir) -and $BunExe -and (Test-Path $BunExe)) {
# --- stage the bun runtime + the two bun payloads (web console, plugin/script runner) --------------
# Both the web console and the runner run on bun. 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
# and shared: the two payloads pass their own defines and the .iss keys WithWeb / WithScripting on
# (their dir + BunExe). Each payload is omitted when its inputs are unset (e.g. a local debug pack).
$haveBun = $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'
if (Test-Path $webStage) { Remove-Item $webStage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $webStage | Out-Null
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'
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).
$defines += "/DWebDir=$webStage"
$defines += "/DBunExe=$bunStage"
$defines += "/DWebRunCmd=$webRun"
Write-Host "bundling the web console from $WebDir (+ bun $BunExe)"
}
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) --------------------------------------
# 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
#define WebRunCmd "..\..\scripts\windows\web-run.cmd"
#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.
#ifdef StageDir
#define WithDriver
@@ -66,6 +71,13 @@
#define WithWeb
#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
; 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-
@@ -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.
Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
#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
; 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
; the launcher the PunktfunkWeb task runs -> {app}\web\web-run.cmd. (`punktfunk-host.exe web setup`
; provisions the console at install time - no staged provisioner script.)
; bundled in, no node_modules) -> {app}\web\.output, and the launcher the PunktfunkWeb task runs ->
; {app}\web\web-run.cmd. (`punktfunk-host.exe web setup` provisions the console at install time - no
; staged provisioner script.)
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
#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
; 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
@@ -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}"; \
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
#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
; icon appears without waiting for the next sign-in.
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"""; \
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
#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]
{ 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%"