45 Commits

Author SHA1 Message Date
enricobuehler afed2206ab feat(ci/release): wire iOS App Store signing via an Apple Distribution secret
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m16s
ci / rust (push) Successful in 1m25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
release / apple (push) Successful in 3m7s
deb / build-publish (push) Successful in 3m18s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (push) Successful in 4m43s
Prepares the iOS/TestFlight path. The runner has the iOS 26.5 SDK but no
signing identities, so import an Apple Distribution cert+key from
IOS_DIST_CERT_P12_B64 / IOS_DIST_CERT_PASSWORD into the same throwaway keychain
(the WWDR intermediates already fetched chain it). The iOS archive uses
automatic signing (-allowProvisioningUpdates + the ASC key creates/downloads the
App Store profile against the present cert, so no keychain-write that would hit
the macOS -61). Re-assert the keychain on the search list like the macOS sign
step. Until the secret is set the step self-skips with a warning, so it stays
green. Still needs an App Store Connect app record for io.unom.punktfunk to
upload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:09:56 +00:00
enricobuehler 39a49da567 fix(ci/release): skip iOS archive cleanly when the iOS SDK is absent
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 1m25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
deb / build-publish (push) Successful in 3m3s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 4m21s
The macOS Developer ID DMG path is green (signed + notarized + stapled). The
iOS/TestFlight step (already best-effort + continue-on-error) was failing on
this runner with 'iOS 26.5 is not installed' — the iOS platform SDK is a
separate Xcode component that isn't installed. Guard the step on
`xcodebuild -showsdks | grep iphoneos` and exit 0 with a warning when it's
missing, so runs are unambiguously green. Install on the runner with
`xcodebuild -downloadPlatform iOS` when iOS goes live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:51:09 +00:00
enricobuehler e64aefa25c fix(ci/release): scope codesign to the throwaway keychain (--keychain)
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m18s
ci / rust (push) Successful in 1m25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m2s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 4m21s
codesign --sign 'Developer ID Application' reported 'no identity found' even
though the import step's find-identity saw it: the bare lookup relies on the
default keychain search list, which doesn't reliably carry the throwaway
keychain across steps on this runner. Re-assert the search list + default
keychain in the signing step and pass --keychain "$KEYCHAIN" so the identity
search is scoped to it (it stays unlocked with a codesign-allowed partition
list from the import step, so no password is needed). Adds a find-identity
diagnostic right before signing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:43:33 +00:00
enricobuehler 4d93eb24ff fix(ci/release): archive unsigned + codesign Developer ID directly
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 1m18s
ci / rust (push) Successful in 1m24s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m2s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 4m19s
xcodebuild's archive gate demands a provisioning profile for the app's
keychain-access-groups entitlement (the 'Keychain Sharing' capability) under
both automatic AND manual signing — even though a Developer ID app honours that
team-prefixed entitlement at runtime with no profile. So manual signing just
traded the -61 keychain error for 'requires a provisioning profile'.

Sidestep the gate: archive with CODE_SIGNING_ALLOWED=NO, then codesign the app
bundle directly with the Developer ID identity, hardened runtime and a secure
timestamp, applying the entitlements via --entitlements (with $(AppIdentifierPrefix)
resolved to the team prefix, which codesign won't expand). Safe because the
bundle is a single statically-linked binary — static PunktfunkCore.xcframework,
SPM static products, macOS 14 target, no Embed-Frameworks phase — so there is no
nested code to sign inside-out. No Apple Developer portal profile or new secret
needed. iOS App Store path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:35:16 +00:00
enricobuehler 3c617f655e fix(ci/release): sign the macOS archive with Developer ID, not auto dev signing
ci / web (push) Successful in 26s
apple / swift (push) Successful in 1m15s
ci / rust (push) Successful in 1m25s
ci / docs-site (push) Successful in 29s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
deb / build-publish (push) Successful in 2m42s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (push) Successful in 5m6s
The cert import now yields a valid 'Developer ID Application' identity, but
the macOS `xcodebuild archive` step still inherited the project's automatic
'Apple Development' signing via -allowProvisioningUpdates. That made Xcode try
to mint an Apple Development cert (install fails in the CI keychain,
DVTSecErrorDomain -61 'Write permissions error') and locate a 'Mac App
Development' provisioning profile for io.unom.punktfunk (none exists) —
** ARCHIVE FAILED ** before signing even happened.

A Developer ID DMG needs neither: pin CODE_SIGN_STYLE=Manual + the Developer ID
identity + no profile, mirroring what the export step already does. The app is
non-sandboxed and its only entitlement (keychain-access-groups, team-prefixed)
is authorized by the Developer ID team, so no provisioning profile is required.
ENABLE_HARDENED_RUNTIME=YES is already set, so notarization stays happy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:46:00 +00:00
enricobuehler 7f18b3dcd0 fix(ci): install ca-certificates in the bun web/docs-site jobs
apple / swift (push) Successful in 1m16s
ci / rust (push) Successful in 1m23s
ci / web (push) Successful in 25s
ci / docs-site (push) Successful in 28s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
deb / build-publish (push) Successful in 2m44s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (push) Successful in 5m6s
The oven/bun:1 image is Debian-slim and ships no CA bundle, so
actions/checkout's git-over-HTTPS fetch died with 'Problem with the SSL
CA cert (path? access rights?)' — curl error 77 (no CA bundle file),
not an untrusted cert; git.unom.io serves a public Let's Encrypt cert.
The rust/deb/rpm builder images already install ca-certificates; do the
same in the two slim bun jobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:36:22 +00:00
enricobuehler 8970cfe188 style(vdisplay/mutter): drop trailing blank line (rustfmt --check)
The stray blank line after build_primary_config tripped cargo fmt --all
--check in CI. Formatting only, no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:36:22 +00:00
enricobuehler 263eab31e3 fix(m3): release held mouse buttons/keys when a session ends (stuck-click after reconnect)
ci / rust (push) Failing after 34s
ci / web (push) Failing after 46s
ci / docs-site (push) Failing after 38s
apple / swift (push) Successful in 1m18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m42s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (push) Successful in 5m17s
The pointer/keyboard injector is host-lifetime (one EIS connection for every punktfunk/1
session), so its existing release_all only fires on EIS disconnect — never when a *client*
session ends. A button still down at an abrupt client disconnect therefore stayed latched in
the compositor: Mutter keeps the destroyed press's implicit pointer grab, so after reconnect a
stuck left-button-down turns every motion into a drag (windows move, text selects) while a
fresh click's press is swallowed — clicking buttons and text inputs does nothing. Only the one
held button is affected; keyboard and the other buttons are fine, exactly as reported.

Fix: input_thread now tracks the buttons/keys the client holds and, when the session ends,
synthesizes the matching up-events through the host-lifetime injector (whose EIS connection —
and the dangling grab — outlive the session). Backend-agnostic (normal inject path), so it
covers libei/EIS, wlr and uinput alike.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:31:15 +00:00
enricobuehler 7ecf2d8dfd fix(inject/libei): emit the continuous scroll axis so small scrolls register
ci / rust (push) Failing after 40s
ci / web (push) Failing after 37s
apple / swift (push) Successful in 1m23s
ci / docs-site (push) Failing after 41s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 3m0s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (push) Successful in 4m18s
The libei backend forwarded mouse wheel only via scroll_discrete (120-per-detent).
Mutter floors a sub-detent delta — a trackpad, a precise/high-res wheel, or a
fractional smooth-scroll event — to zero whole clicks, so small scrolls never land and
you have to spin the wheel a lot before anything moves. Emit the continuous `scroll`
axis (logical px, ~15 px/detent) alongside the discrete steps, matching the wlroots
backend's 15-px/notch behaviour, so every delta moves proportionally while full
detents still drive line/page scrolling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:37:07 +00:00
enricobuehler 55dfb4800f fix(vdisplay/mutter): stop the teardown layout-restore from SIGSEGVing gnome-shell
After a session ends, the Mutter backend (with PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY=1)
re-asserted the physical monitor layout with an explicit ApplyMonitorsConfig. On
Mutter 50 + NVIDIA that monitor reconfig — issued while the just-removed high-refresh
virtual output is still tearing down — SIGSEGVs gnome-shell. Observed live on
home-worker-3: the teardown ApplyMonitorsConfig returns "recipient disconnected from
message bus" (the shell died mid-call), GDM's crash-loop guard then drops to the
greeter and STAYS there, so org.gnome.Mutter.RemoteDesktop/DisplayConfig vanish and
every subsequent reconnect fails with RemoteDesktop.CreateSession ServiceUnknown —
i.e. "after a disconnect I can't reconnect anymore."

make_virtual_primary applies an APPLY_TEMPORARY config, which Mutter reverts on its
own once the virtual output disappears and our DisplayConfig connection closes. So the
explicit restore was both redundant and the crash trigger: drop it, drop the dc_pre
connection at teardown, and let Mutter revert the temporary config itself. Setup is
unchanged (the virtual output is still made primary so the desktop lands on the
streamed surface). Removes the now-unused to_apply_logicals/apply_config helpers.

Verified live on home-worker-3 (5120x1440@240, VIRTUAL_PRIMARY=1): 6/6 back-to-back
connect/disconnect cycles streamed cleanly with gnome-shell holding the same PID
throughout (previously it crashed within the first few disconnects).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:37:07 +00:00
enricobuehler 47112f44b7 feat(apple): surface host online status on the home grid
ci / web (push) Failing after 36s
ci / docs-site (push) Failing after 39s
ci / rust (push) Successful in 1m19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
apple / swift (push) Successful in 1m24s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m52s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 5m25s
Saved host cards now show a presence dot — green when the host is advertising on
the LAN right now, grey when not seen. Cross-references each StoredHost against the
live mDNS discovery set (HostDiscovery). No host changes: the host already
advertises _punktfunk._udp with a stable id + cert fingerprint, which the client
already browses.

- StoredHost.matches(DiscoveredHost): fingerprint-first (survives a DHCP address
  change), address:port fallback. The discovered-section dedup now uses the same
  match, so a saved host whose IP changed no longer also shows up as a stranger.
- HostCardView gains an isOnline presence dot (accessibility-labelled).
- HomeView.isOnline recomputes on every @Published discovery change, so the dot
  tracks hosts joining/leaving the network live.

Online detection is LAN-scoped by design: a remote/cross-subnet host that doesn't
advertise here shows grey ("not seen"), not a false "offline". Swift-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:32:57 +02:00
enricobuehler dad5a08c1f chore(capture): tidy the GNOME flash diagnostic — it's the CORRUPTED skip
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 1m20s
ci / web (push) Failing after 34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m52s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 5m12s
Live confirmation on worker-3: the flash was Mutter's CORRUPTED, size-0
cursor-update buffers (chunk_flags=CORRUPTED) carrying recycled old frames —
drained=1 always, so latest-frame-only draining wasn't the lever, the CORRUPTED
skip was (OBS issue 8630). Demote the verbose drain diagnostic to a rate-limited
debug line and document the root cause inline. Validated: zero-copy back on GNOME
(dmabuf->CUDA, 5120x1440) AND flash-free with FORCE_SHM off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:28:11 +00:00
enricobuehler d8da12bbbd fix(capture/mutter): latest-frame-only dequeue (the real GNOME flash fix)
ci / web (push) Failing after 39s
apple / swift (push) Successful in 1m18s
ci / rust (push) Successful in 1m22s
ci / docs-site (push) Failing after 44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 3m17s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (push) Successful in 4m42s
Deep research (OBS Studio's linux-pipewire, Mutter bug tracker) found the GNOME
stale-frame flash is a buffer-RECYCLING race, not damage (Mutter sends whole
frames, no SPA_META_VideoDamage) and not buffer count. OBS's proven fix is
latest-frame-only dequeue: each process callback, drain ALL queued PipeWire
buffers, requeue the older ones, and consume only the NEWEST — plus skip
CORRUPTED buffers. Our code dequeued one buffer per callback (oldest-first) and
the bounded channel dropped the NEWEST when full, so during Mutter's bursty
delivery the encoder got stale frames → the flash.

Switch the process callback to raw dequeue_raw_buffer + drain-to-newest (requeue
older), extract the consume logic into consume_frame(spa_buf) sourcing datas via
the transparent Data cast, skip SPA_META_HEADER_FLAG_CORRUPTED / CORRUPTED-chunk
buffers (size-0 skip kept SHM-only so dmabuf isn't regressed), and remove the
earlier content-hash drop heuristic (it couldn't tell stale re-deliveries from
legit repeating content). Diagnostic logs drain depth + chunk/header flags.
Reverts none of the FORCE_SHM / dmabuf_fence work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:15:01 +00:00
enricobuehler 79508b2666 fix(capture/mutter): drop stale re-delivered frames (the GNOME flash)
ci / web (push) Failing after 40s
apple / swift (push) Successful in 1m17s
ci / docs-site (push) Failing after 37s
ci / rust (push) Successful in 1m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m53s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 5m11s
Instrumented worker-3: even on the ordered FORCE_SHM download path, Mutter
re-delivers COMPLETE OLD pool buffers — 655 frames in a 15 s session whose content
exactly matched an earlier frame (not damage-incremental; full old frames, in
runs, ~45% during motion). NVIDIA gives no fence to prevent it, so the producer
delivery can't be made clean from our side.

Detect it and drop it: hash a spatial sample of each captured frame; a frame whose
content equals an EARLIER distinct frame (vs the current one, whose duplicates pass
through) is a stale re-delivery — skip it so the encoder never emits the flash
(try_latest re-sends the last good frame; brief hold instead of a backward jump).
Runs on the CPU/SHM path (where Mutter+NVIDIA capture lives); never triggers on
static content or non-Mutter compositors (no reverts). PUNKTFUNK_KEEP_STALE=1
disables it for A/B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:46:27 +00:00
enricobuehler 340cbcfe22 fix(packaging): point the packaged systemd unit at /usr/bin/punktfunk-host
ci / web (push) Failing after 46s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 1m19s
ci / docs-site (push) Failing after 42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m53s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 5m17s
scripts/punktfunk-host.service is dev-oriented — its ExecStart references the
source tree (%h/punktfunk/target/release/punktfunk-host). When the deb/rpm ship
it to /usr/lib/systemd/user, a fresh install with no hand-rolled unit would try
to run a binary that isn't there. Rewrite the ExecStart to the installed
/usr/bin/punktfunk-host during packaging (sed in build-deb.sh + the spec); the
source unit stays as-is for from-source dev. Hosts with a custom ~/.config unit
(which shadows the packaged one) are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:25:30 +00:00
enricobuehler 4098b252bc fix(abi): exclude internal Apple recvmsg_x FFI from the C header
ci / web (push) Failing after 46s
apple / swift (push) Successful in 1m17s
ci / docs-site (push) Failing after 32s
ci / rust (push) Successful in 1m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 3m16s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 4m43s
cbindgen swept transport/udp.rs's `recvmsg_x` foreign import and its `MsghdrX`
#[repr(C)] struct into the generated C header — they're internal Apple-only FFI,
not part of the public C ABI, and reference socklen_t/ssize_t/iovec which the C
ABI harness doesn't include, so c_abi_harness_round_trips failed to compile.
Add them to cbindgen.toml export.exclude and regenerate the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:44:03 +00:00
enricobuehler f9b857aac2 feat(capture): true SHM path (PUNKTFUNK_FORCE_SHM) for race-free Mutter+NVIDIA
ci / web (push) Failing after 37s
apple / swift (push) Failing after 1m3s
ci / rust (push) Failing after 1m11s
ci / docs-site (push) Failing after 43s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m55s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 5m17s
Empirically, Mutter+NVIDIA dmabuf capture has NO working GPU sync — confirmed on
worker-3: explicit sync fails buffer alloc (EINVAL, no cogl sync_fd), and the
dmabuf carries no implicit fence (EXPORT_SYNC_FILE waited=false). So any dmabuf
read — zero-copy import OR mmap — races Mutter's render and flashes the buffer's
previous frame. The prior "CPU fallback" still listed DmaBuf in its buffer types,
so Mutter kept handing dmabufs and it never fixed anything (got worse).

PUNKTFUNK_FORCE_SHM=1 offers MemPtr+MemFd ONLY (no DmaBuf), forcing Mutter to
glReadPixels-download into mappable memory — which orders against its render, so
the frame is complete + current by construction (race-free). Costs the download
(~3 ms) + zero-copy; correct at 1080p/4K60. KWin/gamescope are unaffected (they
blit into the buffer, no read-before-render race) and keep zero-copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:35:28 +00:00
enricobuehler 92c6da9546 fix(capture/mutter): restore zero-copy + sync via dmabuf implicit fence
ci / web (push) Failing after 42s
apple / swift (push) Failing after 1m5s
ci / rust (push) Failing after 1m10s
ci / docs-site (push) Failing after 44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m54s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 5m13s
The previous attempt (8531135) dropped zero-copy on Mutter+NVIDIA for a sticky
CPU/SHM fallback that (a) still listed SPA_DATA_DmaBuf in its buffer types, so
Mutter kept handing dmabufs that got mmap-read UNsynced — making the flashing
worse, not better — and (b) hinged on producer explicit sync, which Mutter+NVIDIA
cannot do (`error alloc buffers` / no cogl sync_fd, confirmed in worker-3 logs).

Revert the capture restructure to the original zero-copy dmabuf path, and fix the
NVIDIA stale-frame race the RIGHT way for a producer that can't do explicit sync:
the consumer snapshots the dmabuf's implicit fence (DMA_BUF_IOCTL_EXPORT_SYNC_FILE)
and waits the producer's render before sampling (new dmabuf_fence module, ioctl
number unit-tested). Covers the GPU import and the CPU mmap read. Logs once whether
a render was actually in flight (waited=true → the driver fences and the race is
closed; false → no implicit fence, so we learn zero-copy still needs SHM here).

drm_sync (the explicit-sync primitive) is kept and verified but marked unused —
no targeted compositor produces a usable sync_fd today; ready to wire in when one
does. The Bug-2 input fix (held-key release on disconnect) from 8531135 is kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:28:17 +00:00
enricobuehler 8531135bb7 fix(capture/mutter): stale-frame flashes + stuck input after disconnect on GNOME
ci / web (push) Failing after 49s
apple / swift (push) Failing after 1m4s
ci / rust (push) Failing after 1m9s
ci / docs-site (push) Failing after 42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m58s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 4m17s
Deep dive into the two GNOME-only host bugs (KWin/gamescope clean):

1. Stale-frame flashes (windows at old positions, typed text reverting):
   Mutter renders its virtual monitors DIRECTLY into the PipeWire buffer
   pool, and NVIDIA has no implicit dmabuf fencing — our zero-copy
   import raced the render and encoded each pool buffer's PREVIOUS
   contents. Fix, in order of preference:
   - Consumer-side PipeWire explicit sync (SPA_META_SyncTimeline): new
     drm_sync module (DRM timeline-syncobj wait/signal via raw ioctls,
     unit-tested incl. a live signal->wait round trip); announced
     post-format via update_params (the OBS pattern — at connect time
     the meta makes producers fail allocation, observed on KWin), with
     a blocks=3 Buffers filter so the producer's sync pod wins; acquire
     point awaited before any read (GPU import or CPU mmap), release
     point signaled on every path.
   - Where the producer can't do explicit sync (Mutter on NVIDIA today:
     no cogl sync_fd, "error alloc buffers"), a sticky fallback flips
     the capture to the synchronous CPU/shm path — Mutter's glReadPixels
     download orders against its render, so frames are correct by
     construction. First session pays one ~10 s probe+retry; later
     sessions go straight there. Validated live on home-worker-3
     (GNOME 50 + RTX 4090): clean fallback, 30 MB HEVC streamed.
   - Sync is only announced on Mutter sessions (new VirtualOutput.mutter
     tag): KWin+NVIDIA fails allocation when merely asked, and doesn't
     need it (verified unchanged: zero-copy CUDA import + 1.1 MB/10 s).
   PUNKTFUNK_EXPLICIT_SYNC=0 disables the probe outright.

2. Clicks wedged in the focused app after disconnect+reconnect: a client
   vanishing mid-press left keys/buttons latched in the compositor —
   Mutter keeps the destroyed EIS device's implicit grab and the focused
   app stops taking clicks until restarted. EiState now tracks held
   keys/buttons/touches (wire codes) and synthesizes releases through
   the normal inject path before the EIS connection goes away.

GNOME hosts on NVIDIA temporarily lose zero-copy (correctness over
throughput); the moment Mutter+driver gain working explicit sync, the
sync path engages automatically and zero-copy returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:42 +00:00
enricobuehler 2ebffe3457 perf(core): recvmsg_x batched receive on Apple (macOS client)
apple / swift (push) Failing after 1m2s
ci / rust (push) Failing after 1m11s
ci / web (push) Failing after 39s
ci / docs-site (push) Failing after 41s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 3m5s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 4m30s
macOS/iOS have no recvmmsg(2), so the Mac client did one recv() syscall per
packet (non-allocating after the earlier fix, but still a syscall each — a
single-core wall at line rate that Moonlight avoids). Add the Darwin recvmsg_x(2)
batched-receive path (the recv counterpart of Linux recvmmsg): one syscall drains
up to RECV_BATCH datagrams into the reused ring. struct msghdr_x + the extern
aren't in the libc crate, so declared here (cfg target_vendor=apple).

Opt-in via PUNKTFUNK_RECVMSG_X (it's FFI we can't exercise off-Apple) with
auto-fallback to the tested scalar recv-loop on any unexpected error. Linux
recvmmsg + the non-Apple scalar loop are unchanged; apple.yml compiles the path.

Re GRO: Linux recv already batches via recvmmsg (32/syscall), so UDP GRO is only a
marginal add there and needs a recv-path redesign to split coalesced buffers —
deferred as low-ROI vs the Mac, which had no batching at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:52:39 +00:00
enricobuehler 9c86f667ca perf(core): in-place AES-GCM seal + reused wire-buffer pool (host send)
ci / web (push) Failing after 39s
ci / docs-site (push) Failing after 33s
apple / swift (push) Successful in 1m16s
ci / rust (push) Successful in 1m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m3s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 4m35s
The host sealed every packet with ~3 heap allocations: aes-gcm's convenience
encrypt() allocates the ciphertext Vec, seal_for_wire allocates the seq||ct||tag
wire Vec, and seal_frame allocated a fresh Vec<Vec<u8>> per frame. At line rate
(~250k–500k pkt/s for 2.5–5 Gbps) that's the single-core allocator wall.

- SessionCrypto::seal_in_place uses AeadInPlace::encrypt_in_place_detached to
  encrypt into the caller's buffer and write the detached tag at the end —
  byte-identical to seal's ciphertext||tag, no allocation (unit-tested for byte
  equality + decrypt).
- Session keeps a wire_pool the caller returns via reclaim_wires; seal_frame
  seals each packet in place into the reused buffers (clear() keeps capacity), so
  after warmup there's no per-packet ciphertext/wire allocation. paced_submit and
  submit_frame reclaim the pool after sending.

End-to-end encrypted/lossless multi-frame tests stay green (validates the pool
reuse doesn't corrupt across frames). Next: write packetize directly into a
contiguous send buffer (kills the remaining shard allocs + GSO's coalescing copy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:47:38 +00:00
enricobuehler 448986f41c perf(core): UDP GSO send path (the multi-Gbps lever)
apple / swift (push) Successful in 1m16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / rust (push) Successful in 1m31s
deb / build-publish (push) Successful in 2m36s
ci / web (push) Failing after 36s
ci / docs-site (push) Failing after 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m42s
rpm / build-publish (push) Successful in 4m38s
docker / deploy-docs (push) Successful in 17s
sendmmsg already batches syscalls but still builds one sk_buff per datagram —
the kernel-side wall above ~1 Gbps. UDP Generic Segmentation Offload hands the
kernel one big buffer it splits into gso_size datagrams, building ~1 GSO skb per
≤64 segments. Research (LWN/Cloudflare/Tailscale) measures ~2.4x throughput at
equal CPU and 17-44x fewer syscalls, and that sendmmsg batching alone is
insufficient — you need true segmentation offload.

Adds Transport::send_gso (default = send_batch) + a UdpTransport Linux override:
coalesces a frame's equal-size wire packets (shards are zero-padded to a constant
size, so a whole frame is one gso_size) into ≤64-segment sendmsg(UDP_SEGMENT)
calls. seal/send routes through it. Opt-in via PUNKTFUNK_GSO (new unsafe hot-path
code) with automatic fallback to sendmmsg on any GSO error (unsupported kernel/
path), latched per process. Loopback unit test validates the cmsg segmentation;
full session over loopback streams clean (0% loss). Linux-only; loopback/non-Linux
keep sendmmsg/scalar.

Next levers: in-place AES-GCM seal (kill per-packet allocs), UDP GRO on recv,
drop the sleep-pacing in favor of the kernel qdisc, jumbo MTU.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:29:51 +00:00
enricobuehler 4b1bbfdf0e feat(client-linux): VAAPI hardware decode — zero-copy dmabuf into GraphicsOffload
ci / docs-site (push) Failing after 45s
ci / web (push) Failing after 32s
apple / swift (push) Successful in 1m16s
ci / rust (push) Failing after 1m18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Failing after 1m38s
rpm / build-publish (push) Successful in 4m10s
Stage 1.5: on Intel/AMD clients libavcodec's VAAPI hwaccel decodes on
the GPU; frames map to DRM-PRIME dmabufs (av_hwframe_map, zero copy)
and reach GTK as GdkDmabufTexture (BT.709 limited CICP color state —
GDK's dmabuf default is BT.601). Inside GtkGraphicsOffload that is the
decoder-to-subsurface path, direct-scanout eligible when fullscreen.

Fallback ladder, live-verified on the NVIDIA dev box: no VAAPI device
-> software decode at session start (logged reason); a mid-session
VAAPI error (e.g. broken nvidia-vaapi-driver) demotes to software and
the host's IDR/RFI recovery resynchronizes; a rejected dmabuf import
logs and the stream continues. PUNKTFUNK_DECODER=software|vaapi
overrides; the first-frame log now names the active path.

The hwaccel path is raw ffmpeg-sys FFI (ffmpeg-next wraps none of it):
hw device ctx + get_format pinned to AV_PIX_FMT_VAAPI (NONE on
mismatch so cpu-fallback never silently engages inside libavcodec),
thread_count=1, LOW_DELAY. Surface lifetime rides DrmFrameGuard into
the texture's release func — GDK runs it on both success and failure.

Needs an Intel/AMD client box (Steam Deck/Bazzite) to live-verify the
hardware path; the software path is unchanged and revalidated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:26:59 +00:00
enricobuehler b5c30dff4f perf(host): lift bitrate cap to 8G, raise MTU to 1452, FEC env knob
Groundwork for multi-Gbps (2.5G link here, 5G to the Mac Studio). The encoder is
pixel-rate bound, not bitrate bound, so these unblock the transport:
- MAX_BITRATE_KBPS 2G -> 8G, MAX_PROBE_KBPS 3G -> 10G (the cap was policy, not a
  hardware limit — NVENC emits multi-Gbps trivially with the 2-way split).
- Welcome shard_payload 1200 -> 1452: fills a 1500 MTU, ~17% fewer packets for
  free (even size, FEC-safe; negotiated so the client follows).
- PUNKTFUNK_FEC_PCT env overrides the 20% FEC default — a clean wired LAN can drop
  it (every recovery shard is wire bytes+packets); 0 disables FEC.

Next: UDP GSO (the dominant lever — research shows ~2.4x throughput / ~40x fewer
syscalls; sendmmsg batching alone is insufficient) + in-place AES-GCM seal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:20:46 +00:00
enricobuehler aac48408fd Merge remote-tracking branch 'origin/main'
ci / web (push) Failing after 44s
apple / swift (push) Successful in 1m16s
ci / rust (push) Failing after 1m17s
ci / docs-site (push) Failing after 44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
deb / build-publish (push) Failing after 47s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 1m5s
2026-06-12 23:18:12 +00:00
enricobuehler 4ff6f447a8 ci(packaging): punktfunk-client .deb + RPM subpackage
Hook the Linux client into the existing packaging CI:

- deb.yml builds both binaries and publishes punktfunk-host AND
  punktfunk-client to the Gitea apt registry; new
  packaging/debian/build-client-deb.sh mirrors the host script
  (shlibdeps auto-Depends — GTK4/libadwaita/SDL3/FFmpeg/PipeWire
  sonames; no NVIDIA filter, the client links no CUDA). Built and
  inspected locally on Ubuntu 26.04.
- punktfunk.spec gains a "client" subpackage (binary + desktop entry +
  udev rule); rpm.yml's publish loop picks it up unchanged.
- New shared assets: packaging/linux/io.unom.Punktfunk.desktop and
  scripts/70-punktfunk-client.rules — DualSense hidraw uaccess (USB +
  Bluetooth, steam-devices style) so SDL's HIDAPI driver gets
  touchpad/motion/lightbar/triggers instead of degrading to evdev.
- Builder images learn the client link deps (rust-ci already had
  them; fedora-rpm adds gtk4/libadwaita/SDL3-devel) with idempotent
  install steps in deb.yml/rpm.yml since jobs run against the
  previous push's image.

Workspace check CI (build/clippy/test) already covers the crate since
f09def4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:18:12 +00:00
enricobuehler 11fc3be726 fix(core): libc is a unix-wide dep — unbreak iOS/tvOS xcframework slices
ci / web (push) Failing after 37s
ci / docs-site (push) Failing after 36s
apple / swift (push) Successful in 1m17s
deb / build-publish (push) Failing after 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 1s
ci / rust (push) Failing after 1m22s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 56s
6b5ee9f added a libc-based batched recv_batch for the Apple/BSD targets
(cfg(all(unix, not(target_os = "linux")))) but left libc declared only under
cfg(target_os = "linux"). The macOS host build pulls libc in transitively so it
compiled, but the iOS/tvOS cross-compiles (no transitive libc, dev-deps off) failed
with E0433 "cannot find crate libc", breaking the full xcframework build. Widen the
gate to cfg(unix): libc is now used by sendmmsg/recvmmsg on Linux AND recv() on the
other unix (Apple/BSD) targets.

Verified: cargo build --release -p punktfunk-core --features quic for
aarch64-apple-ios, x86_64-apple-ios, and aarch64-apple-tvos (-Z build-std) all link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:12:56 +02:00
enricobuehler 67a32711b3 chore(apple): Xcode 27 project upgrade + hardened runtime
apple / swift (push) Failing after 27s
ci / web (push) Failing after 9s
ci / docs-site (push) Failing after 44s
ci / rust (push) Failing after 1m15s
deb / build-publish (push) Failing after 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 36s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 57s
Applied via Xcode's recommended-settings upgrade and distribution prep:
- LastUpgradeCheck / scheme LastUpgradeVersion 2650 -> 2700.
- DEVELOPMENT_TEAM (F4H37KF6WC) hoisted to the project-level build configs; the
  now-redundant per-target copies are dropped (all targets inherit it).
- ENABLE_HARDENED_RUNTIME = YES on the macOS app target (required for Developer ID
  notarization). Signing stays Apple Development + Config/Punktfunk.entitlements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:09:16 +02:00
enricobuehler 4be993df87 fix(apple/stage2): disable layer vsync wait to kill fullscreen stutter
apple / swift (push) Failing after 28s
ci / web (push) Failing after 47s
ci / rust (push) Failing after 1m19s
ci / docs-site (push) Failing after 33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 13s
deb / build-publish (push) Failing after 44s
The experimental stage-2 presenter (CAMetalLayer + display link) stuttered badly
in fullscreen but ran fine windowed. render() runs on the display-link / MAIN
thread and calls layer.nextDrawable(), which blocks that thread until a drawable
frees. With the layer's own displaySyncEnabled left on (default), present also
waits for the hardware vsync, so the block serializes the main thread to the
display — windowed, the WindowServer's looser compositing hides it; fullscreen's
tighter, more-direct path exposes it as judder. (Apple dev-forum guidance:
displaySync off measurably reduces nextDrawable() blocking.)

- displaySyncEnabled = false (macOS-only): the display link is already the per-
  vsync pacing source, so the layer's redundant vsync wait only adds the stall.
- maximumDrawableCount = 3 (explicit): more in-flight headroom before
  nextDrawable() has to block on the main thread.

Swift-only (no core/ABI change → no xcframework rebuild). Validated: swift build;
swift test (39 passed, 0 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:07:57 +02:00
enricobuehler 6b5ee9f47b perf(core): batched non-allocating recv on Apple targets (macOS client wall)
apple / swift (push) Failing after 28s
ci / rust (push) Failing after 1m18s
ci / web (push) Failing after 47s
ci / docs-site (push) Failing after 35s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 16s
deb / build-publish (push) Failing after 43s
The batched `recvmmsg` recv path was Linux-only; macOS fell back to the trait
default, which calls the scalar `recv` — a fresh `vec![0u8; 2049]` allocation
(plus zeroing and a copy) PER PACKET on the single receive thread. At line rate
that alloc/free churn, not the syscall, was the single-core wall: measured the
real Mac client topping out ~315 Mbps and dropping the session at 800, while a
Linux client (recvmmsg) held a clean 1 Gbps against the same host, and Moonlight
(batched recv) does 900 on the same Mac.

Add a `cfg(all(unix, not(linux)))` `recv_batch` that drains up to RECV_BATCH
datagrams per call with `libc::recv(MSG_DONTWAIT)` straight into the caller's
reused ring buffers — no per-packet allocation or copy. Still one syscall per
datagram (a future `recvmsg_x` batch would cut that too), but it removes the
dominant cost. Linux recvmmsg path and the Windows/loopback default unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:05:54 +00:00
enricobuehler c56b1b455a feat(punktfunk/1): request-IDR recovery for a wedged client decode
apple / swift (push) Successful in 1m17s
ci / rust (push) Failing after 31s
ci / web (push) Failing after 42s
ci / docs-site (push) Failing after 40s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 15s
deb / build-publish (push) Failing after 43s
Fixes the intermittent first-connect freeze. The host streams infinite GOP — one
opening IDR, then P-frames only (recovery keyframes just on loss) — so when the
client's decoder wedges on the cold first session (a lost/corrupt opening IDR, a
bad early P-frame) the picture stays frozen until the far-off next keyframe. The
client had no way to ask for one; now it does.

Add a RequestKeyframe control message (client -> host, reliable control stream),
mirroring Reconfigure:
- core: quic.rs RequestKeyframe (type 0x03) + roundtrip test; client.rs
  CtrlRequest::Keyframe + NativeClient::request_keyframe; abi.rs
  punktfunk_connection_request_keyframe (header regenerated).
- host: m3.rs decodes it in the control loop and signals the encode loop, which
  coalesces a burst and calls enc.request_keyframe() — wiring the existing
  NvencEncoder hook (force_kf -> next frame pict_type=I), the same recovery the
  GameStream path already had via force_idr.
- apple: PunktfunkConnection.requestKeyframe(); StreamPump (stage-1) requests on
  layer.status==.failed; Stage2Pipeline (stage-2) on a sync submit failure and on
  the async decode-error callback via a thread-safe KeyframeRecovery. All
  throttled to <=1/250ms (the decode stays wedged for several frames until the IDR
  lands, so per-frame requests would flood the control stream).

Self-healing: a lost recovery IDR is re-requested after the throttle; the host
coalesces bursts into a single IDR.

Validated: cargo fmt + clippy clean; core + host test suites green (incl. new
request_keyframe_roundtrip); swift build + test (39 passed); xcframework rebuilt
(all 5 slices), header regenerated with no unrelated drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:48:24 +02:00
enricobuehler 71d6b64f81 fix(ci): POSIX shell in deb/rpm Version step (dash "Bad substitution")
ci / docs-site (push) Failing after 43s
ci / rust (push) Failing after 2m13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
ci / web (push) Failing after 47s
apple / swift (push) Successful in 1m17s
deb / build-publish (push) Successful in 2m48s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (push) Failing after 30s
docker / deploy-docs (push) Successful in 17s
deb.yml runs in the Ubuntu rust-ci image whose /bin/sh is dash, where the bash
substring `${GITHUB_SHA::8}` is a "Bad substitution" — the deb build failed at the
Version step every run. Compute the short SHA with `cut` instead. (rpm.yml ran fine
because the Fedora image's /bin/sh is bash, but fix it the same way for robustness.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:48:12 +00:00
enricobuehler 0b1322d1c6 fix(packaging): ship the UDP socket-buffer sysctl in the .deb and .rpm
ci / web (push) Failing after 46s
apple / swift (push) Successful in 1m16s
ci / docs-site (push) Failing after 38s
ci / rust (push) Failing after 1m52s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
deb / build-publish (push) Failing after 2m6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m47s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Failing after 3m4s
The host requests a 32 MB SO_SNDBUF, but the kernel clamps it to net.core.wmem_max
(~416 KB on a stock box) — so high-bitrate frames overflow the socket buffer and
the host drops a large fraction of packets on send (measured 28.5% loss / 54k
dropped at 1 Gbps to a clean LAN client on a fresh Bazzite box). scripts/99-punktfunk-net.conf
fixes it (32 MB caps) but the packages never installed it. Ship it to
/usr/lib/sysctl.d/ (auto-applied at boot by systemd-sysctl) and apply it in the
deb/rpm postinst. This is the dominant cause of the sub-Gbps ceiling on an
untuned host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:41:45 +00:00
enricobuehler 06346e5037 docs(rpm): use repo_gpgcheck for the unsigned Gitea RPMs
ci / web (push) Failing after 40s
ci / rust (push) Successful in 1m8s
apple / swift (push) Successful in 1m17s
ci / docs-site (push) Failing after 48s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
deb / build-publish (push) Failing after 2m21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m25s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m24s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 3m45s
Gitea GPG-signs the repo metadata but not the individual packages, while its
auto-served bazzite.repo sets gpgcheck=1 — so `rpm-ostree install` fails with
"could not be verified" on our unsigned RPMs. Document writing the repo
explicitly with gpgcheck=0 + repo_gpgcheck=1 (verify the signed metadata, which
carries each package checksum) instead of curling the served .repo. Note the
TLS-only fallback and that per-package signing is future hardening.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:07:42 +00:00
enricobuehler 58cb416abb ci(rpm): publish punktfunk-host RPM to the Gitea registry (Bazzite)
ci / web (push) Failing after 44s
ci / rust (push) Successful in 1m7s
apple / swift (push) Successful in 1m16s
ci / docs-site (push) Failing after 38s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
deb / build-publish (push) Failing after 2m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m21s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 3m57s
Mirrors the apt pipeline for Fedora Atomic / Bazzite. New `rpm` workflow builds
the host RPM in a Fedora 43 builder image (ci/fedora-rpm.Dockerfile — matches
Bazzite's libavcodec.so.61, with a self-contained 16-symbol libcuda link stub so
no NVIDIA packages are needed in CI) and uploads to Gitea's public RPM registry
(group "bazzite") on every main push (rolling 0.0.1-0.ciN.<sha>) and v* tag
(clean X.Y.Z-1). Bazzite hosts then track it with `rpm-ostree upgrade`.

- packaging/rpm/build-rpm.sh: git-archive tarball + rpmbuild (--nodeps, since the
  toolchain is rustup + dnf, not RPMs); copies to dist/, asserts no cuda/nvidia leak.
- punktfunk.spec: overridable pf_version/pf_release for CI snapshots; exclude
  libcuda.so from auto-Requires (NVENC/EGL come from the driver, out of band) —
  same NVIDIA filter as the .deb; fix a bogus changelog weekday.
- docker.yml builds+pushes the new fedora-rpm image; packaging README + rpm/README
  document the rpm-ostree install/update path (recommended option).

Builder image seeded to the registry so rpm.yml's first run finds it. RPM build +
clean-Requires verified locally in the image (libavcodec.so.61 / libavutil.so.59,
no cuda).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:32:46 +00:00
enricobuehler e2257a6158 fix(apple): persist Keychain trust — sign macOS + data-protection keychain
ci / web (push) Failing after 34s
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Successful in 19s
deb / build-publish (push) Failing after 2m19s
The client identity prompted for Keychain access on every launch/rebuild. Root
cause: the macOS app target was ad-hoc signed (CODE_SIGN_IDENTITY = "-"), and
the identity lived in the file keychain whose "Always Allow" ACL is bound to the
app's exact code signature (cdhash for ad-hoc). Every rebuild changed the binary
-> changed the cdhash -> the ACL no longer matched -> re-prompt.

- Sign the macOS target with Apple Development (team already set) instead of
  ad-hoc, so the designated requirement is identity-based and stable across
  rebuilds.
- Move the identity to the data-protection keychain (kSecUseDataProtectionKeychain)
  gated by a team-scoped keychain-access-group entitlement — access is granted by
  the app's entitlement, not a per-binary ACL, so it's prompt-free and survives
  rebuilds. Add Config/Punktfunk.entitlements and wire CODE_SIGN_ENTITLEMENTS into
  all six app configs (macOS/iOS/tvOS).
- Unsigned / ad-hoc builds (e.g. `swift run`) lack the entitlement
  (errSecMissingEntitlement) — fall back to the legacy file keychain so they still
  work (with the old prompt), no hard failure.

macOS re-mints the identity on first run (the old file-keychain copy isn't in the
data-protection keychain) -> one re-pair, which is acceptable. iOS keeps its
identity (the explicit access group equals the prior default).

Validated: swift build; swift test (39 passed, 0 failures); xcodebuild
-showBuildSettings confirms Apple Development + Config/Punktfunk.entitlements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:25:51 +02:00
enricobuehler dfed90bff2 ci(deb): publish punktfunk-host .deb to the Gitea apt registry
ci / web (push) Failing after 49s
ci / rust (push) Successful in 1m6s
apple / swift (push) Successful in 1m18s
ci / docs-site (push) Failing after 40s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / deploy-docs (push) Successful in 20s
deb / build-publish (push) Failing after 2m17s
Wires up the half-built Debian packaging: build-deb.sh existed but nothing
invoked or published it. Adds a `deb` workflow that builds the release host in
the Ubuntu 26.04 rust-ci image, packages it (dpkg-shlibdeps-resolved Depends,
NVIDIA driver filtered out), and uploads to Gitea's public Debian registry on
every main push (rolling 0.0.1~ciN.<sha>) and v* tag (clean X.Y.Z). Ubuntu hosts
then track it with `apt update && apt upgrade`.

Also: box-setup docs (packaging/debian/README.md), a pointer from the packaging
README, ignore dist/, and drop backticks from the package Description (the
unquoted control heredoc ran them as a command substitution).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:14:40 +00:00
enricobuehler 184f94e867 Merge remote-tracking branch 'origin/main'
ci / web (push) Failing after 36s
ci / rust (push) Successful in 1m8s
ci / docs-site (push) Failing after 34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / swift (push) Successful in 1m17s
docker / deploy-docs (push) Successful in 16s
2026-06-12 21:12:02 +00:00
enricobuehler a95984bb4f feat(client-linux): feature parity with the Swift client
Everything the macOS app does that stage 1 lacked, before any new
feature work (user directive):

- Input capture is now a deliberate, reversible STATE (Moonlight-
  style): engaged on stream start and click-into-video (the engaging
  click is suppressed), released by Ctrl+Alt+Shift+Q (toggles) or
  focus loss; held keys/buttons are flushed host-side on release;
  cursor hiding + shortcut inhibition follow the state; HUD hint when
  released. Per-session window handlers disconnect with the page.
- Gamepads: app-lifetime SDL service (GamepadManager parity) — pad
  list + "Forwarded controller" pin in Settings (auto = most recent),
  "Automatic" pad TYPE resolves from the physical pad at connect;
  DualSense touchpad contacts + ~250 Hz motion samples on the 0xCC
  plane (Swift GamepadWire scale constants); feedback grows adaptive-
  trigger replay and player LEDs via raw DS5 effects packets (the
  wire's 11-byte blocks drop into SDL_SendGamepadEffect verbatim);
  held pad state zeroed on pad switch/detach. sdl3 "hidapi" feature.
- Microphone uplink: PipeWire capture -> Opus 20 ms -> 0xCB datagrams
  (validated live: host received 711 mic packets), Settings toggle.
- Speed test per saved host (Swift's "Test Network Speed…"): 2 s
  probe burst, goodput/loss + recommended ~70 % bitrate, one-tap apply.
- Settings: host compositor preference (sent in the Hello), native-
  display resolution/refresh resolved from the window's monitor at
  connect (new default), bitrate ceiling to 3 Gbit/s.
- Hosts page: saved/trusted hosts section for direct pinned reconnect
  (mDNS not required), rebuilt on every page return.

Deliberately not ported: audio device pickers (PipeWire routing owns
this on Linux), resize-to-request_mode (not wired in Swift either),
pointer-lock relative mouse (stage-2 presenter, needs raw Wayland).
DualSense fidelity needs a physical pad to live-verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:11:52 +00:00
enricobuehler dea749186d fix(quic/apple): QUIC keep-alive + reconnect input re-engage
ci / rust (push) Failing after 36s
ci / web (push) Failing after 51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m16s
docker / deploy-docs (push) Successful in 17s
Three native-client bugs isolated against a stock Moonlight client (which
stays connected / keeps input working under the same actions):

- Connection drops mid-stream: the quinn endpoints (host + client) ran with
  default transport config, so keep_alive_interval was OFF. Any quiet stretch
  (no input, audio muted/stalled, a capture hiccup, a mode change) let the
  idle timer expire and quinn closed the session -> next_au=Closed -> "Session
  ended". Moonlight's ENet sends keepalive pings; we sent nothing. Add a shared
  TransportConfig (keep-alive 4s under an explicit 20s idle timeout) to both
  endpoint::server_from_der and endpoint::client_pinned_with_identity.

- Reconnect input dead (macOS): the session-start auto-capture one-shot was
  consumed even when engageCapture(fromClick:false) was refused (window not key
  yet at the instant of reconnect), with no retry -> capture stayed off and
  input never forwarded. Clear the one-shot only on a successful engage, and
  retry on NSWindow.didBecomeKey. Stays scoped to session start, so it does not
  resurrect the rejected auto-grab-on-activation behavior.

- Reconnect input dead (iOS): wasCapturedOnResign leaked stale state across
  sessions and the foreground-restore could fire before this session's
  InputCapture was wired (setForwarding no-ops on nil). Reset it per session in
  start() and guard the didBecomeActive restore on inputCapture != nil.

Validated: cargo build -p punktfunk-core --features quic; swift build;
swift test (39 passed, 0 failures); xcframework rebuilt (all 5 slices), no
ABI/header drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:07:54 +02:00
enricobuehler a8a6224fd8 fix(encode): bound per-frame size with a tight VBV buffer
ci / rust (push) Failing after 36s
ci / web (push) Failing after 36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / deploy-docs (push) Successful in 17s
ci / docs-site (push) Failing after 39s
apple / swift (push) Successful in 1m16s
NVENC ran CBR (bit_rate == max_bit_rate, rc=cbr) but never set rc_buffer_size,
so it used a loose default VBV. A high-motion P-frame was then allowed to spike
to many times the average frame size; the extra packets overflow the depth-2
send queue (newest frame dropped) and the kernel UDP buffer (WouldBlock drops),
which the client sees as framedrops/jitter — and on the infinite-GOP GameStream
path as old/stale frames flashing until the next RFI.

Set a tight ~1-frame VBV (rc_buffer_size = bitrate/fps) so the encoder holds
frame size roughly constant and absorbs motion as a momentary QP/quality dip
instead — the Sunshine/Moonlight low-latency model. Tunable via
PUNKTFUNK_VBV_FRAMES (default 1.0); larger trades burst tolerance for motion
quality. Fixes both the punktfunk/1 and GameStream paths (shared encoder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:58:46 +00:00
enricobuehler 5f088c6f56 fix(client-linux): absolute mouse was dropped — pack the surface size in flags
ci / web (push) Failing after 45s
ci / rust (push) Successful in 1m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
apple / swift (push) Successful in 1m18s
ci / docs-site (push) Failing after 42s
docker / deploy-docs (push) Successful in 17s
The MouseMoveAbs wire contract packs the client coordinate-space size
as (width << 16) | height in `flags` (same as touch); injectors
normalize against it and drop the event when it is zero. The GTK
client sent flags=0, so KWin's libei path refused every motion
(`emitted=false`) — found via the first real user test from
home-worker-3.

- ui_stream: send_abs() packs the negotiated mode into flags for
  motion + click-position events.
- core input.rs: document the contract on MouseMoveAbs itself (it was
  only implied by TouchDown's doc).
- client-rs --input-test: add a MouseMoveAbs sweep so the absolute
  path stays covered — Moonlight and the Mac client only send relative
  motion, which is why this gap survived every prior live test.

Validated live against serve --native: kind=MouseMoveAbs emitted=true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:50:53 +00:00
enricobuehler f09def4138 ci: GTK4/libadwaita/SDL3 dev packages for punktfunk-client-linux
ci / web (push) Failing after 38s
apple / swift (push) Successful in 1m14s
ci / docs-site (push) Failing after 42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / deploy-docs (push) Successful in 17s
ci / rust (push) Successful in 5m38s
Baked into the rust-ci image, plus an idempotent apt step in the rust
job itself — ci.yml runs against the previous push's image (docker.yml
bootstrap note), so the image change alone would leave this push and
the next one red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:17:54 +00:00
enricobuehler 96a35ca84c feat(client-linux): native GTK4 client — stage 1, first light at 1080p60
ci / rust (push) Failing after 29s
ci / web (push) Failing after 35s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
ci / docs-site (push) Failing after 38s
apple / swift (push) Successful in 1m15s
docker / deploy-docs (push) Successful in 17s
New crate crates/punktfunk-client-linux (binary punktfunk-client), the
native Linux client on the Option A architecture (2026-06-12 research):

- GTK4/libadwaita shell linking punktfunk-core directly (no C ABI):
  mDNS host list, TOFU fingerprint prompt, SPAKE2 PIN pairing dialog,
  preferences (mode/bitrate/gamepad/shortcut capture), stats overlay,
  --connect host[:port] for scripting.
- Video: FFmpeg software HEVC decode (LOW_DELAY, slice threads) ->
  RGBA -> GdkMemoryTexture inside GtkGraphicsOffload (the dmabuf
  subsurface path lights up when VAAPI lands; black-background keeps
  fullscreen scanout-eligible).
- Audio: Opus -> PipeWire playback stream, the host virtual-mic's
  adaptive jitter ring inverted.
- Input: keyboard as the exact inverse of the host VK table (evdev
  keycodes, layout-independent; unit-tested), absolute mouse through
  the Contain-fit transform, WHEEL_DELTA(120) scroll, compositor
  shortcut inhibition while streaming, Ctrl+Alt+Shift+Q release chord,
  F11 fullscreen. SDL3 gamepad capture (single pad-0 model) + rumble
  and DualSense lightbar feedback on the same thread.
- Session pump owns video+audio pulls; the gamepad thread owns
  rumble+hidout — possible because NativeClient's plane receivers are
  now mutexed, making it Sync (Arc-shared, compiler-verified per-plane
  contract instead of the ABI's manual assertion).
- Linux-gated deps + a stub main keep cargo build --workspace green on
  macOS.

Validated live against serve --native on this box: 1920x1080@60,
locked 60 fps, capture->decoded p50 ~6.4 ms (software decode, debug
build). Teardown keys off AdwNavigationPage::hidden — NavigationView
push fires a transient unmap/map cycle that must not end the session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:16:30 +00:00
enricobuehler 99b4de32ee feat(pairing): delegated approval (§8b-1) — approve an unpaired device from the console
ci / web (push) Failing after 40s
ci / rust (push) Successful in 1m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 1m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Successful in 16s
An identified-but-unpaired device that knocks on a pairing-required host is now
held as a pending request the operator approves from the web console — pairing it
with no PIN fetched out of band — instead of a flat reject.

- core: Hello gains an optional trailing device name (len u8 || UTF-8, ≤64,
  same trailing-back-compat pattern as compositor/gamepad/bitrate). client-rs
  --name sends it; the connector sends None (fingerprint-derived label).
- native_pairing: in-memory pending queue (note_pending dedups by fingerprint,
  evicts the least-recently-active past a 32 cap, 10-min TTL); approve_pending
  pins the fingerprint, deny drops it. Names are sanitized (strip control/ANSI/
  bidi — untrusted wire input); add()/remove() roll back in-memory on a persist
  failure; pairing clears any stale pending knock.
- m3: the require_pairing gate records the knock (sanitized label) before
  rejecting; anonymous (certless) clients record nothing.
- mgmt: GET /native/pending, POST /native/pending/{id}/approve (optional {name})
  and /deny; OpenAPI + tests; docs/api/openapi.json regenerated.
- web: a "Waiting for approval" section on the Pairing page (live-poll, Approve/
  Deny, error-surfaced via QueryState); en+de strings.
- Also completes an in-progress NativeClient Sync refactor (receivers behind
  per-plane mutexes) that was left half-applied in the tree.

Adversarially reviewed (4 lenses + 3-vote verify); the confirmed findings are
fixed here. Validated live on the GNOME box: knock (with a wire name, and a
malicious ANSI/bidi name that got neutralized) → pending → approve → the same
identity streams real video. Full workspace tests + clippy + fmt green; web tsc
clean. Roadmap §8b-1 marked done; §8b-2 (peer-push approval) is the client
follow-up. See docs-site pairing page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:14:05 +00:00
77 changed files with 7523 additions and 437 deletions
+17 -5
View File
@@ -18,6 +18,14 @@ jobs:
steps:
- uses: actions/checkout@v4
# punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml
# runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this
# keeps the job green across image-content changes; a no-op once the image has them.
- name: GTK4/libadwaita/SDL3 dev packages
run: |
apt-get update
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
# registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
@@ -69,10 +77,12 @@ jobs:
working-directory: web
steps:
# oven/bun ships neither git nor a real node (only a bun shim) — actions/checkout
# needs both.
- name: Install git + node
# needs both. The slim Debian base also lacks ca-certificates, so without it git's
# HTTPS fetch of the repo dies with "Problem with the SSL CA cert (path? access
# rights?)" — no CA bundle to validate git.unom.io's (public) Let's Encrypt cert.
- name: Install git + node + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends git nodejs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
@@ -92,9 +102,11 @@ jobs:
run:
working-directory: docs-site
steps:
- name: Install git
# ca-certificates: the slim Debian base lacks a CA bundle, so actions/checkout's
# HTTPS fetch otherwise fails with "Problem with the SSL CA cert" (see web job).
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends git
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
+93
View File
@@ -0,0 +1,93 @@
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
# target boxes run.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages
# Box setup (once): see packaging/debian/README.md
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
name: deb
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: git.unom.io
OWNER: unom
DISTRIBUTION: stable
COMPONENT: main
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link
# deps are also baked into the rust-ci image, but this job runs against the image
# from the PREVIOUS push (docker.yml bootstrap note) — keep it green across image
# changes; a no-op once the image has them.
- name: dpkg-dev + client link deps
run: |
apt-get update
apt-get install -y --no-install-recommends dpkg-dev \
libgtk-4-dev libadwaita-1-dev libsdl3-dev
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
with:
path: target
key: cargo-target-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-${{ env.rustc }}-
- name: Build release host + client
run: |
git config --global --add safe.directory "$PWD"
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
- name: Version
# Tag v1.2.3 -> 1.2.3 (a real release); a main push -> 0.0.1~ciN.g<sha>, which sorts
# BEFORE 0.0.1 (the '~') yet monotonically increases by run number, so `apt upgrade`
# always moves the boxes to the newest main build.
run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
*) V="0.0.1~ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "package version $V"
- name: Build .debs
run: |
VERSION="$VERSION" bash packaging/debian/build-deb.sh
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for DEB in dist/*.deb; do
echo "uploading $DEB"
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done
echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT"
+4
View File
@@ -2,6 +2,7 @@
# punktfunk-web — management console (web/Dockerfile, repo-root context)
# punktfunk-docs — documentation site (docs-site/Dockerfile)
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope.
@@ -38,6 +39,9 @@ jobs:
- image: punktfunk-rust-ci
dockerfile: ci/rust-ci.Dockerfile
context: ci
- image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile
context: ci
steps:
- uses: actions/checkout@v4
+79 -28
View File
@@ -80,10 +80,12 @@ jobs:
- name: Build PunktfunkCore.xcframework (mac + iOS)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
- name: Import Developer ID certificate (throwaway keychain)
- name: Import signing certificates (throwaway keychain)
env:
P12_B64: ${{ secrets.DEVID_CERT_P12_B64 }}
P12_PASSWORD: ${{ secrets.DEVID_CERT_PASSWORD }}
IOS_P12_B64: ${{ secrets.IOS_DIST_CERT_P12_B64 }}
IOS_P12_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/punktfunk-ci.keychain-db"
KEYCHAIN_PASS="$(uuidgen)"
@@ -108,6 +110,15 @@ jobs:
security import "$RUNNER_TEMP/devid.p12" -k "$KEYCHAIN" -P "$P12_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
rm -f "$RUNNER_TEMP/devid.p12"
# iOS App Store distribution identity (optional — imported only when the secret is
# set; the iOS/TestFlight job stays best-effort until it is). The WWDR intermediates
# fetched above also chain this Apple Distribution cert.
if [ -n "$IOS_P12_B64" ]; then
printf '%s' "$IOS_P12_B64" | base64 -d > "$RUNNER_TEMP/ios-dist.p12"
security import "$RUNNER_TEMP/ios-dist.p12" -k "$KEYCHAIN" -P "$IOS_P12_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
rm -f "$RUNNER_TEMP/ios-dist.p12"
fi
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
@@ -120,41 +131,63 @@ jobs:
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
chmod 600 "$RUNNER_TEMP/asc.p8"
- name: Archive macOS
- name: Archive macOS (unsigned — signed by codesign below)
run: |
# Archive WITHOUT signing, then codesign with Developer ID in the next step. We do
# NOT let xcodebuild sign during archive because the app's keychain-access-groups
# entitlement is the "Keychain Sharing" capability, and Xcode's archive gate demands
# a provisioning profile for it under BOTH automatic and manual signing — even
# though a Developer ID app honours that team-prefixed entitlement at RUNTIME with
# no profile (the gate is an Xcode build-phase check, not a real requirement). Raw
# codesign has no such gate. Safe because the bundle is a single statically-linked
# binary: static PunktfunkCore.xcframework, SPM static products, macOS 14 target (no
# embedded Swift dylibs), and no Embed-Frameworks phase — so nothing nested to sign.
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
CODE_SIGNING_ALLOWED=NO
- name: Export macOS (Developer ID)
- name: Sign macOS app (Developer ID, hardened runtime)
run: |
cat > "$RUNNER_TEMP/export-devid.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>developer-id</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>destination</key><string>export</string>
<!-- Manual + explicit cert: with -allowProvisioningUpdates Xcode prefers
CLOUD-managed Developer ID signing, which the App-Manager-role API key
can't do ("Cloud signing permission error") and it never falls back to
the perfectly valid local identity. -->
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Developer ID Application</string>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-devid.plist" \
-exportPath "$RUNNER_TEMP/export-devid"
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
# codesign does NOT expand $(AppIdentifierPrefix) (an Xcode build-setting var), so
# resolve it to the real team prefix — otherwise keychain-access-groups would be the
# literal string instead of the team-scoped group.
RESOLVED="$RUNNER_TEMP/Punktfunk.entitlements"
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
clients/apple/Config/Punktfunk.entitlements > "$RESOLVED"
# codesign must be pointed at the throwaway keychain explicitly: on this runner the
# default keychain search list does not reliably carry across steps, so a bare
# --sign "Developer ID Application" reports "no identity found" even though the
# import step found it there. Re-assert the search list + default keychain in THIS
# step's context (no password needed — it stays unlocked with a codesign-allowed
# partition list from the import step) AND scope codesign to it with --keychain.
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
security default-keychain -d user -s "$KEYCHAIN"
echo "signing identity keychain: $KEYCHAIN"
security find-identity -v -p codesigning "$KEYCHAIN"
# Inside-out: sign any nested Mach-O first (defensive — the static build normally
# has none), then the app bundle with the resolved entitlements + hardened runtime +
# secure timestamp, which is what notarization requires.
if [ -d "$APP/Contents/Frameworks" ]; then
find "$APP/Contents/Frameworks" -depth \( -name '*.framework' -o -name '*.dylib' \) \
-print0 | while IFS= read -r -d '' f; do
codesign --force --options runtime --timestamp \
--keychain "$KEYCHAIN" \
--sign "Developer ID Application" "$f"
done
fi
codesign --force --options runtime --timestamp \
--keychain "$KEYCHAIN" \
--entitlements "$RESOLVED" \
--sign "Developer ID Application" "$APP"
codesign --verify --strict --verbose=2 "$APP"
# Stage where the DMG step expects it ($RUNNER_TEMP/export-devid/Punktfunk.app).
mkdir -p "$RUNNER_TEMP/export-devid"
rm -rf "$RUNNER_TEMP/export-devid/Punktfunk.app"
cp -R "$APP" "$RUNNER_TEMP/export-devid/Punktfunk.app"
- name: Notarized DMG
run: |
@@ -196,6 +229,24 @@ jobs:
# is done so real upload failures fail the run.
continue-on-error: true
run: |
# The iOS platform SDK is a separate Xcode component and isn't installed on every
# runner; without it `archive` dies with "iOS 26.5 is not installed". Skip cleanly
# (this is best-effort anyway) instead of a red step — install it on the runner with
# `xcodebuild -downloadPlatform iOS` when iOS/TestFlight is ready to go live.
if ! DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -showsdks 2>/dev/null | grep -q iphoneos; then
echo "::warning::iOS platform SDK not installed on this runner — skipping iOS/TestFlight."
exit 0
fi
# App Store signing uses the Apple Distribution identity imported above from
# IOS_DIST_CERT_P12_B64. Skip cleanly until that secret exists; re-assert the
# throwaway keychain on the search list + as default so automatic signing finds it
# (the search list doesn't reliably carry across steps on this runner).
if ! security find-identity -v -p codesigning "$KEYCHAIN" | grep -q "Apple Distribution"; then
echo "::warning::no Apple Distribution identity present — set IOS_DIST_CERT_P12_B64. Skipping iOS/TestFlight."
exit 0
fi
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
security default-keychain -d user -s "$KEYCHAIN"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-iOS \
-destination 'generic/platform=iOS' \
+77
View File
@@ -0,0 +1,77 @@
# Build the punktfunk-host RPM and publish it to Gitea's RPM package registry, so Bazzite /
# Fedora Atomic hosts layer + update it with rpm-ostree. Counterpart to deb.yml (apt). Runs in
# the Fedora 43 builder image (ci/fedora-rpm.Dockerfile) so the RPM's auto library Requires
# (libavcodec.so.NN, …) match the target's sonames.
#
# Registry (public, unom org), group "bazzite":
# repo file https://git.unom.io/api/packages/unom/rpm/bazzite.repo
# Box setup (once): see packaging/rpm/README.md
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
name: rpm
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: git.unom.io
OWNER: unom
RPM_GROUP: bazzite
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
image: git.unom.io/unom/punktfunk-fedora-rpm:latest
timeout-minutes: 90
env:
CARGO_HOME: /usr/local/cargo
steps:
- uses: actions/checkout@v4
# rpmbuild + git archive need the checkout trusted; cache the crates download.
# The client link deps are also baked into the fedora-rpm image, but this job runs
# against the image from the PREVIOUS push (docker.yml bootstrap note) — keep it
# green across image changes; a no-op once the image has them.
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
- uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- name: Version
# Tag v1.2.3 -> 1.2.3-1 (release); main push -> 0.0.1-0.ciN.g<sha>, whose release "0."
# sorts BEFORE the eventual "1" yet increases by run number, so `rpm-ostree upgrade`
# always moves to the newest main build.
run: |
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1" ;;
*) V="0.0.1"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
esac
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
echo "rpm $V-$R"
- name: Build RPM
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" bash packaging/rpm/build-rpm.sh
- name: Publish to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
# Publish only the main package (skip -debuginfo/-debugsource subpackages).
for rpm in dist/*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
echo "uploading $rpm"
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/$RPM_GROUP/upload"
done
echo "published to $OWNER/rpm/$RPM_GROUP"
+3
View File
@@ -13,3 +13,6 @@ clients/apple/PunktfunkCore.xcframework/
clients/apple/.swiftpm/
# Xcode per-user state
xcuserdata/
# Debian package build output
/dist/
+24 -3
View File
@@ -86,8 +86,28 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
`RemoteFirstLightTests` (full pipeline over the LAN). See
[`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter
(`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via
`tools/latency-probe` (scaffold), iOS variant. The Linux reference client
(`punktfunk-client-rs`) gets VAAPI + wgpu on the same connector later.
`tools/latency-probe` (scaffold), iOS variant.
**Linux stage 1 done, first light 2026-06-12** (`crates/punktfunk-client-linux`, binary
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
against `serve --native` on this box: 1080p60, steady 60 fps, capture→decoded p50
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
uplink (validated live), per-host speed test, compositor pref, native-display mode
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode; needs an
Intel/AMD client box to live-verify the hw path. Next: the stage-2 raw-Wayland
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~24 ms
at high res).
@@ -141,7 +161,8 @@ crates/punktfunk-host/
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs · native_pairing.rs
crates/punktfunk-client-rs/ punktfunk/1 reference client (M3 headless; M4 adds decode+present)
crates/punktfunk-client-rs/ punktfunk/1 reference client (M3 headless test/measurement tool)
crates/punktfunk-client-linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
web/ TanStack web console over the mgmt API (status · devices · pairing)
packaging/ Fedora/Bazzite RPM · bootc · COPR (packaging/bazzite/README.md)
tools/{loss-harness,latency-probe}/ measurement (plan §10)
Generated
+388
View File
@@ -196,6 +196,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -419,6 +431,29 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cairo-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [
"bitflags",
"cairo-sys-rs",
"glib",
"libc",
]
[[package]]
name = "cairo-sys-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -896,6 +931,16 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "field-offset"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset",
"rustc_version",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1057,6 +1102,63 @@ dependencies = [
"slab",
]
[[package]]
name = "gdk-pixbuf"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"libc",
]
[[package]]
name = "gdk-pixbuf-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gdk4"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
"gdk4-sys",
"gio",
"glib",
"libc",
"pango",
]
[[package]]
name = "gdk4-sys"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1117,12 +1219,202 @@ dependencies = [
"polyval",
]
[[package]]
name = "gio"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"pin-project-lite",
"smallvec",
]
[[package]]
name = "gio-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
]
[[package]]
name = "glib"
version = "0.22.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a"
dependencies = [
"bitflags",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "glib-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "gobject-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "graphene-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1"
dependencies = [
"glib",
"graphene-sys",
"libc",
]
[[package]]
name = "graphene-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c"
dependencies = [
"glib-sys",
"libc",
"pkg-config",
"system-deps",
]
[[package]]
name = "gsk4"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62"
dependencies = [
"cairo-rs",
"gdk4",
"glib",
"graphene-rs",
"gsk4-sys",
"libc",
"pango",
]
[[package]]
name = "gsk4-sys"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a"
dependencies = [
"cairo-sys-rs",
"gdk4-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "gtk4"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2"
dependencies = [
"cairo-rs",
"field-offset",
"futures-channel",
"gdk-pixbuf",
"gdk4",
"gio",
"glib",
"graphene-rs",
"gsk4",
"gtk4-macros",
"gtk4-sys",
"libc",
"pango",
]
[[package]]
name = "gtk4-macros"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gtk4-sys"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"gsk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "h2"
version = "0.4.14"
@@ -1427,6 +1719,37 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libadwaita"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4"
dependencies = [
"gdk4",
"gio",
"glib",
"gtk4",
"libadwaita-sys",
"libc",
"pango",
]
[[package]]
name = "libadwaita-sys"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d"
dependencies = [
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gtk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "libc"
version = "0.2.186"
@@ -1753,6 +2076,30 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "pango"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438"
dependencies = [
"gio",
"glib",
"libc",
"pango-sys",
]
[[package]]
name = "pango-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -1948,6 +2295,26 @@ dependencies = [
"unarray",
]
[[package]]
name = "punktfunk-client-linux"
version = "0.0.1"
dependencies = [
"anyhow",
"async-channel",
"ffmpeg-next",
"gtk4",
"libadwaita",
"mdns-sd",
"opus",
"pipewire",
"punktfunk-core",
"sdl3",
"serde",
"serde_json",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "punktfunk-client-rs"
version = "0.0.1"
@@ -2516,6 +2883,27 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdl3"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2"
dependencies = [
"bitflags",
"libc",
"sdl3-sys",
]
[[package]]
name = "sdl3-sys"
version = "0.6.6+SDL-3.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
dependencies = [
"pkg-config",
"vcpkg",
]
[[package]]
name = "security-framework"
version = "3.7.0"
+1
View File
@@ -4,6 +4,7 @@ members = [
"crates/punktfunk-core",
"crates/punktfunk-host",
"crates/punktfunk-client-rs",
"crates/punktfunk-client-linux",
"tools/latency-probe",
"tools/loss-harness",
]
+52
View File
@@ -0,0 +1,52 @@
# CI builder for the punktfunk RPM — Fedora 43 to match Bazzite's base (so the RPM's
# auto-generated library Requires, e.g. libavcodec.so.NN, pin to exactly what the target
# runs). Used by .gitea/workflows/rpm.yml; built+pushed by .gitea/workflows/docker.yml.
#
# docker build -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm ci
#
# Mirrors ci/rust-ci.Dockerfile (the Ubuntu workspace builder) for the rpmbuild side.
FROM fedora:43
# RPM Fusion (free + nonfree) provides the NVENC-capable ffmpeg-devel the host links against.
RUN dnf -y install \
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
&& dnf -y install \
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache)
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs \
# build toolchain + bindgen
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
# ffmpeg (NVENC), capture/audio/display link deps
ffmpeg-devel pipewire-devel wayland-devel libxkbcommon-devel opus-devel \
mesa-libGL-devel mesa-libgbm-devel \
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
gtk4-devel libadwaita-devel SDL3-devel \
&& dnf clean all
# libcuda link stub — the zerocopy path links a fixed set of cuXxx driver symbols, but CI has
# no GPU and never RUNS CUDA. Rather than drag in the NVIDIA userspace stack, synthesize a stub
# libcuda.so.1 that just defines those symbols (the SAME approach the Ubuntu image takes with the
# real driver lib, minus the driver). On Bazzite the real driver provides libcuda.so.1 at runtime.
# The symbol list is `nm -D --undefined-only` of the built host binary; a new cu* call would fail
# the link with a clear "undefined reference", flagging this list to update.
RUN set -eux; : > /tmp/cuda_stub.c; \
for s in cuCtxCreate_v2 cuCtxSetCurrent cuCtxSynchronize cuDestroyExternalMemory \
cuDeviceGet cuExternalMemoryGetMappedBuffer cuGraphicsGLRegisterImage \
cuGraphicsMapResources cuGraphicsSubResourceGetMappedArray cuGraphicsUnmapResources \
cuGraphicsUnregisterResource cuImportExternalMemory cuInit cuMemAllocPitch_v2 \
cuMemcpy2D_v2 cuMemFree_v2; do \
echo "int $s(void){return 0;}" >> /tmp/cuda_stub.c; \
done; \
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib64/libcuda.so.1 /tmp/cuda_stub.c; \
ln -sf libcuda.so.1 /usr/lib64/libcuda.so; \
rm -f /tmp/cuda_stub.c; ldconfig; test -e /usr/lib64/libcuda.so
# Rustup (not Fedora's packaged rust) so rust-toolchain.toml's pinned channel resolves, matching
# the Ubuntu builder. Shared location so jobs running as any uid can use it.
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo --version
+2
View File
@@ -20,6 +20,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
# zerocopy link deps (GL via libglvnd, EGL, GBM)
libgl-dev libegl-dev libgbm-dev \
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
&& rm -rf /var/lib/apt/lists/*
# libcuda link stub: the NVIDIA userspace library (no kernel module needed) provides
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Keychain Sharing: a team-scoped access group so the punktfunk/1 client identity in
the data-protection keychain is gated by the app's entitlement (team + bundle id),
not a per-binary ACL. That is what makes Keychain access persist across rebuilds
with NO prompt — see ClientIdentityStore. $(AppIdentifierPrefix) expands to the
team prefix at signing time. -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
</array>
</dict>
</plist>
@@ -163,7 +163,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastUpgradeCheck = 2650;
LastUpgradeCheck = 2700;
TargetAttributes = {
AA0000000000000000000009 = {
CreatedOnToolsVersion = 26.0;
@@ -272,6 +272,7 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = F4H37KF6WC;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -328,6 +329,7 @@
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = F4H37KF6WC;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -353,13 +355,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F4H37KF6WC;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
@@ -387,13 +389,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = F4H37KF6WC;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
@@ -421,9 +423,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
@@ -459,9 +461,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
@@ -496,9 +498,9 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
@@ -525,9 +527,9 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F4H37KF6WC;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@@ -1,5 +1,6 @@
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
// generated once and stored in the login Keychain. The certificate's fingerprint is how
// generated once and stored in the data-protection Keychain (with a legacy file-keychain
// fallback for unsigned builds see `query(dataProtection:)`). The certificate's fingerprint is how
// hosts recognize this client after PIN pairing losing the key un-pairs this Mac from
// every host, so the pair is presented on every connect but never regenerated once
// stored. That invariant drives the error handling below: a Keychain that *refuses
@@ -42,8 +43,9 @@ final class ClientIdentityStore: @unchecked Sendable {
break // genuine first run mint below
case .corrupt:
// Our own item, undecodable: the pairings it backed are unusable either
// way, so deliberately self-heal by replacing it.
SecItemDelete(Self.query as CFDictionary)
// way, so deliberately self-heal by replacing it (both keychains, best-effort).
SecItemDelete(Self.query(dataProtection: true) as CFDictionary)
SecItemDelete(Self.query(dataProtection: false) as CFDictionary)
case .denied(let status):
throw IdentityError.keychain(status)
}
@@ -89,14 +91,35 @@ final class ClientIdentityStore: @unchecked Sendable {
case denied(OSStatus)
}
private static let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "io.unom.punktfunk",
kSecAttrAccount as String: "client-identity",
]
/// Item coordinates. We prefer the DATA-PROTECTION keychain: with the app's
/// `keychain-access-groups` entitlement, items there are gated by the app's identity
/// (team + bundle id) instead of a per-binary ACL so a SIGNED build reads them across
/// rebuilds with NO Keychain prompt (a per-binary ACL re-prompts on every resign, which
/// is why an ad-hoc-signed app asked every launch). An ad-hoc / unsigned build (e.g.
/// `swift run`) has no such entitlement `SecItem*` returns `errSecMissingEntitlement`
/// there, and we fall back to the legacy file keychain (still works, with the old prompt).
private static func query(dataProtection: Bool) -> [String: Any] {
var q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "io.unom.punktfunk",
kSecAttrAccount as String: "client-identity",
]
if dataProtection { q[kSecUseDataProtectionKeychain as String] = true }
return q
}
private func copyStored() -> ReadResult {
var query = Self.query
let result = read(dataProtection: true)
// No entitlement (ad-hoc / unsigned build): the data-protection keychain is
// unavailable read the legacy file keychain instead.
if case .denied(errSecMissingEntitlement) = result {
return read(dataProtection: false)
}
return result
}
private func read(dataProtection: Bool) -> ReadResult {
var query = Self.query(dataProtection: dataProtection)
query[kSecReturnData as String] = true
var out: CFTypeRef?
switch SecItemCopyMatching(query as CFDictionary, &out) {
@@ -116,8 +139,16 @@ final class ClientIdentityStore: @unchecked Sendable {
guard let data = try? JSONEncoder().encode(
Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM))
else { return errSecParam }
var add = Self.query
var add = Self.query(dataProtection: true)
add[kSecValueData as String] = data
return SecItemAdd(add as CFDictionary, nil)
// After-first-unlock so a background reconnect can still read it; the access-group
// entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free.
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecMissingEntitlement else { return status }
// Ad-hoc / unsigned build: persist to the legacy file keychain instead.
var legacy = Self.query(dataProtection: false)
legacy[kSecValueData as String] = data
return SecItemAdd(legacy as CFDictionary, nil)
}
}
@@ -148,6 +148,7 @@ struct HomeView: View {
private func hostCard(_ host: StoredHost) -> some View {
HostCardView(
host: host,
isOnline: isOnline(host),
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
isMostRecent: host.id == mostRecentHostID,
isBusy: model.isBusy,
@@ -176,12 +177,18 @@ struct HomeView: View {
.padding(.top, store.hosts.isEmpty ? 0 : 8)
}
/// Discovered hosts not already saved (matched by address+port) the saved grid shows the
/// rest, so this section only surfaces genuinely-new hosts on the network.
/// A saved host is "online" iff a live mDNS advert currently matches it (see
/// `StoredHost.matches`). Recomputed on every discovery change (the @Published set), so the
/// dot tracks hosts appearing/leaving the network live.
private func isOnline(_ host: StoredHost) -> Bool {
discovery.hosts.contains { host.matches($0) }
}
/// Discovered hosts not already saved the saved grid shows the rest, so this section only
/// surfaces genuinely-new hosts on the network. Same match as the online dot, so a saved host
/// whose IP changed (still fingerprint-matched) doesn't also appear here as a stranger.
private var discoveredUnsaved: [DiscoveredHost] {
discovery.hosts.filter { d in
!store.hosts.contains { $0.address == d.host && $0.port == d.port }
}
discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } }
}
/// The host of the most recent session its card carries the accent ring.
@@ -24,6 +24,9 @@ private struct CardMetrics {
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
struct HostCardView: View {
let host: StoredHost
/// Currently advertising on the LAN (matched against live mDNS discovery). False means
/// "not seen on this network" off, or a remote/cross-subnet host we can't observe.
let isOnline: Bool
let isConnecting: Bool
let isMostRecent: Bool
let isBusy: Bool
@@ -48,9 +51,16 @@ struct HostCardView: View {
}
.frame(height: m.iconBox)
VStack(spacing: 2) {
Text(host.displayName)
.font(m.nameFont)
.lineLimit(1)
HStack(spacing: 6) {
// Presence dot: green = advertising on the LAN now; grey = not seen.
Circle()
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
.frame(width: 7, height: 7)
.accessibilityLabel(isOnline ? "Online" : "Offline")
Text(host.displayName)
.font(m.nameFont)
.lineLimit(1)
}
HStack(spacing: 4) {
if host.pinnedSHA256 != nil {
Image(systemName: "lock.fill")
@@ -25,6 +25,26 @@ struct StoredHost: Identifiable, Codable, Hashable {
var displayName: String { name.isEmpty ? address : name }
}
extension StoredHost {
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host drives the
/// "online" indicator and de-dupes the discovered section. Matched by certificate
/// fingerprint when both sides carry it (so it survives a DHCP address change), otherwise
/// by address:port. Online detection is LAN-scoped: a host not advertising on this network
/// (off, or a remote/cross-subnet address) simply won't match "not seen", not proven off.
func matches(_ discovered: DiscoveredHost) -> Bool {
if let pin = pinnedSHA256, let fp = discovered.fingerprintHex,
pin.hexLower == fp.lowercased() {
return true
}
return address == discovered.host && port == discovered.port
}
}
private extension Data {
/// Lowercase hex, no separators to compare a pinned fingerprint against the mDNS `fp`.
var hexLower: String { map { String(format: "%02x", $0) }.joined() }
}
@MainActor
final class HostStore: ObservableObject {
private static let key = DefaultsKey.hosts
@@ -80,6 +80,19 @@ public final class MetalVideoPresenter {
layer.pixelFormat = .bgra8Unorm
layer.framebufferOnly = true
layer.isOpaque = true
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
// display-link / MAIN thread) has to block waiting for one to free.
layer.maximumDrawableCount = 3
#if os(macOS)
// The display link already paces exactly one present per vsync. Leaving the layer's
// own vsync wait on means `commandBuffer.present` ALSO blocks for the hardware vsync,
// so `nextDrawable()` stalls the MAIN thread until a drawable frees windowed, the
// WindowServer's looser compositing hides it; FULLSCREEN's tighter, more-direct path
// serializes the main thread to the display and the stall surfaces as bad judder.
// Disabling the layer-level sync lets present return promptly (the display link is the
// pacing source), which is what fixes the fullscreen stutter. macOS-only property.
layer.displaySyncEnabled = false
#endif
self.layer = layer
}
@@ -332,6 +332,21 @@ public final class PunktfunkConnection {
_ = punktfunk_connection_request_mode(h, width, height, refreshHz)
}
/// Ask the host's encoder to emit a fresh IDR keyframe now recovery when the local
/// decoder has wedged. The host opens the infinite-GOP stream with one IDR and then sends
/// P-frames only, so a stalled decode (a lost/corrupt opening IDR, a bad early P-frame
/// most likely on the cold first connect) would otherwise stay frozen until the next
/// loss-triggered recovery keyframe, which may be far off. Fire-and-forget; the recovered
/// keyframe is the only ack. THROTTLE at the call site the decode stays wedged for
/// several frames until the IDR lands, so requesting every frame would flood the control
/// stream. Silently dropped after close.
public func requestKeyframe() {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
_ = punktfunk_connection_request_keyframe(h)
}
/// The currently active session mode (updated by accepted `requestMode` switches).
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
abiLock.lock()
@@ -44,11 +44,36 @@ private final class PumpToken: @unchecked Sendable {
func cancel() { lock.lock(); live = false; lock.unlock() }
}
/// Throttled host keyframe requests for decode recovery. The decoder's async error callback
/// (a VT thread) and the pump thread (a submit failure) both signal a wedge; this coalesces
/// them so the control stream isn't flooded while the decode stays stalled for several frames
/// until the requested IDR lands. Bound to the live connection in `start`, unbound in `stop`.
private final class KeyframeRecovery: @unchecked Sendable {
private let lock = NSLock()
private var connection: PunktfunkConnection?
private var lastNs: UInt64 = 0
func bind(_ c: PunktfunkConnection?) {
lock.lock(); connection = c; lastNs = 0; lock.unlock()
}
func request() {
lock.lock()
let now = DispatchTime.now().uptimeNanoseconds
let due = lastNs == 0 || now &- lastNs > 250_000_000 // 250 ms since the last request
if due { lastNs = now }
let conn = due ? connection : nil
lock.unlock()
conn?.requestKeyframe()
}
}
public final class Stage2Pipeline {
private let ring = ReadyRing()
private let presenter: MetalVideoPresenter
private let decoder: VideoDecoder
private let presentMeter: LatencyMeter
private let recovery = KeyframeRecovery()
private var token = PumpToken()
private var offsetNs: Int64 = 0
@@ -63,9 +88,13 @@ public final class Stage2Pipeline {
self.presenter = presenter
self.presentMeter = presentMeter
let ring = ring
let recovery = recovery
self.decoder = VideoDecoder(
onDecoded: { ring.submit($0) },
onDecodeError: { _ in /* the pump resets the session via reset() on the next IDR */ })
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump
// resets to re-gate on the next IDR, and we ask the host to send one now (infinite
// GOP it wouldn't otherwise come soon). Throttled in KeyframeRecovery.
onDecodeError: { _ in recovery.request() })
}
/// Start pulling AUs into the decoder. `onFrame` fires per AU at receipt (captureclient
@@ -77,9 +106,11 @@ public final class Stage2Pipeline {
onSessionEnd: (@Sendable () -> Void)?
) {
offsetNs = connection.clockOffsetNs
recovery.bind(connection) // arm host-keyframe recovery for this session
token = PumpToken() // fresh token per start cancel is permanent (like StreamPump)
let token = token
let decoder = decoder
let recovery = recovery
let thread = Thread {
var format: CMVideoFormatDescription?
while token.isLive {
@@ -92,8 +123,10 @@ public final class Stage2Pipeline {
guard let f = format, token.isLive else { continue }
if !decoder.decode(au: au, format: f) {
// Submit/decoder error: drop the session and re-gate on the next IDR's
// in-band parameter sets (a delta frame can't recover) stage-1's policy.
// in-band parameter sets (a delta frame can't recover) stage-1's policy
// and ask the host for that IDR now (infinite GOP; throttled).
decoder.reset()
recovery.request()
}
} catch {
if token.isLive { onSessionEnd?() }
@@ -125,6 +158,7 @@ public final class Stage2Pipeline {
public func stop() {
token.cancel()
decoder.reset()
recovery.bind(nil) // stop requesting keyframes once the session is torn down
}
deinit { token.cancel() }
@@ -41,6 +41,7 @@ final class StreamPump {
let thread = Thread {
var format: CMVideoFormatDescription?
var lastKeyframeRequest = Date.distantPast
while token.isLive {
do {
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
@@ -49,13 +50,19 @@ final class StreamPump {
format = f // refreshed on every IDR (mode changes included)
}
if layer.status == .failed {
// Decode wedged: flush and re-gate on the next in-band parameter
// sets resuming with a delta frame can't recover. (A
// request-IDR channel on punktfunk/1 is a host-side TODO; with the
// host's infinite GOP this may otherwise stay black until the
// next recovery keyframe.)
// Decode wedged: flush and re-gate on the next in-band parameter sets
// (resuming with a delta frame can't recover), AND ask the host for a
// fresh IDR. With the host's infinite GOP the next keyframe could be
// far off, so without the request the picture stays frozen the
// intermittent first-connect freeze. Throttled: the layer stays .failed
// across several polls until the IDR lands, and one request suffices.
layer.flush()
format = AnnexB.formatDescription(fromIDR: au.data)
let now = Date()
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
connection.requestKeyframe()
lastKeyframeRequest = now
}
}
guard let f = format,
let sample = AnnexB.sampleBuffer(au: au, format: f),
@@ -197,6 +197,17 @@ public final class StreamLayerView: NSView {
self?.releaseCapture()
})
}
// Becoming key RETRIES a still-pending session-start auto-capture the case where a
// session began (reconnect) while this window wasn't key yet, so engageCapture(fromClick:
// false) was refused by its key-window guard and, with no retry, capture stayed off and
// input dead. This is a no-op once capture engaged (pendingAutoCapture is cleared) and
// after a manual /focus-loss release (the flag is already false), so it does NOT
// resurrect the deliberately-rejected "auto-grab on every activation" behavior.
windowObservers.append(NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main
) { [weak self] _ in
self?.attemptPendingCapture()
})
attemptPendingCapture()
}
@@ -301,8 +312,13 @@ public final class StreamLayerView: NSView {
private func attemptPendingCapture() {
guard pendingAutoCapture, window != nil, bounds.width > 0 else { return }
pendingAutoCapture = false // one shot, even if the engage below is refused
engageCapture(fromClick: false)
// Clear the one-shot only once it ACTUALLY engaged. If the engage was refused the
// app/window isn't key yet (common right after a reconnect), or the cursor grab raced
// app activation leave it armed so didBecomeKey (or the next layout pass) retries.
// This stays scoped to session start: a later manual release (, focus loss) doesn't
// re-arm it, so it never resurrects auto-grab-on-activation.
if captured { pendingAutoCapture = false }
}
private func engageCapture(fromClick: Bool) {
@@ -172,6 +172,10 @@ public final class StreamViewController: UIViewController {
self.connection = connection
loadViewIfNeeded()
#if os(iOS)
// Fresh session: drop any resign/foreground capture-restore state left over from a
// prior session (stop() doesn't clear it). Otherwise a stale `true` could later
// re-engage capture on a foreground that the new session never asked for.
wasCapturedOnResign = false
// Read the LIVE mode per touch batch an accepted requestMode() mid-stream
// changes the letterbox, and touches must follow it.
streamView.currentHostMode = { [weak connection] in
@@ -247,7 +251,10 @@ public final class StreamViewController: UIViewController {
observers.append(NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self, self.wasCapturedOnResign, self.captureEnabled, self.connection != nil
// inputCapture != nil: don't try to restore before this session's capture is wired
// up setForwarding would silently no-op on the nil handlers and leave input dead.
guard let self, self.wasCapturedOnResign, self.captureEnabled,
self.connection != nil, self.inputCapture != nil
else { return }
self.setCaptured(true)
})
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "punktfunk-client-linux"
description = "Native Linux punktfunk/1 client — GTK4/libadwaita shell, FFmpeg decode, PipeWire audio, SDL3 gamepads"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[[bin]]
name = "punktfunk-client"
path = "src/main.rs"
# Everything is Linux-gated so `cargo build --workspace` stays green on macOS (the Mac
# client lives in clients/apple); on other platforms this builds as a stub binary.
[target.'cfg(target_os = "linux")'.dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
# PreferencesDialog need libadwaita ≥ 1.5.
gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
async-channel = "2"
# Video decode (same FFmpeg pin as the host) and audio.
ffmpeg-next = "8"
opus = "0.3"
pipewire = "0.9"
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
# need the hidapi driver).
sdl3 = { version = "0.18", features = ["hidapi"] }
mdns-sd = "0.20"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+477
View File
@@ -0,0 +1,477 @@
//! The application shell: window, navigation, trust dialogs, session lifecycle.
use crate::session::{SessionEvent, SessionParams};
use crate::trust::{KnownHost, KnownHosts, Settings};
use crate::ui_hosts::ConnectRequest;
use adw::prelude::*;
use gtk::{gdk, glib};
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref};
use std::cell::RefCell;
use std::rc::Rc;
const APP_ID: &str = "io.unom.Punktfunk";
struct App {
window: adw::ApplicationWindow,
nav: adw::NavigationView,
toasts: adw::ToastOverlay,
settings: Rc<RefCell<Settings>>,
identity: (String, String),
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
gamepad: crate::gamepad::GamepadService,
/// One session at a time — ignore connects while one is starting/running.
busy: std::cell::Cell<bool>,
}
impl App {
fn toast(&self, msg: &str) {
self.toasts.add_toast(adw::Toast::new(msg));
}
}
pub fn run() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_activate(build_ui);
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
// keeps GApplication from rejecting unknown options.
app.run_with_args(&[] as &[&str])
}
/// `--connect host[:port]` — skip the hosts page and start a session immediately
/// (scripting + headless testing; trust follows the same known-hosts/TOFU rules).
fn cli_connect_request() -> Option<ConnectRequest> {
let args: Vec<String> = std::env::args().collect();
let target = args
.iter()
.skip_while(|a| *a != "--connect")
.nth(1)?
.clone();
let (addr, port) = match target.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().ok()?),
None => (target.clone(), 9777),
};
Some(ConnectRequest {
name: addr.clone(),
addr,
port,
fp_hex: None,
pair_required: false,
})
}
fn build_ui(gtk_app: &adw::Application) {
let identity = match crate::trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
tracing::error!("client identity: {e:#}");
std::process::exit(1);
}
};
let nav = adw::NavigationView::new();
let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&nav));
let window = adw::ApplicationWindow::builder()
.application(gtk_app)
.title("Punktfunk")
.default_width(1100)
.default_height(720)
.content(&toasts)
.build();
let app = Rc::new(App {
window: window.clone(),
nav: nav.clone(),
toasts,
settings: Rc::new(RefCell::new(Settings::load())),
identity,
gamepad: crate::gamepad::GamepadService::start(),
busy: std::cell::Cell::new(false),
});
let hosts_page = crate::ui_hosts::new(
{
let app = app.clone();
Rc::new(move |req| initiate_connect(app.clone(), req))
},
{
let app = app.clone();
Rc::new(move || {
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad)
})
},
{
let app = app.clone();
Rc::new(move |req| speed_test(app.clone(), req))
},
);
nav.add(&hosts_page);
window.present();
if let Some(req) = cli_connect_request() {
initiate_connect(app, req);
}
}
/// The trust gate in front of every connect. Discovered hosts carry their fingerprint in
/// the mDNS advert, so trust is decided *before* any traffic: known → pinned connect;
/// unknown → TOFU prompt (or straight to pairing when the host requires it). Manual
/// entries have no advance fingerprint: trust on first use, pin from then on.
fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
if app.busy.get() {
return;
}
let known = KnownHosts::load();
match &req.fp_hex {
Some(fp_hex) => {
if known.find_by_fp(fp_hex).is_some() {
start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex));
} else if req.pair_required {
// TOFU alone won't pass the host's gate — go straight to the ceremony.
pin_dialog(app, req);
} else {
tofu_dialog(app, req);
}
}
None => {
let pin = known
.find_by_addr(&req.addr, req.port)
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex));
start_session(app, req, pin);
}
}
}
/// First contact with a discovered host: show the advertised fingerprint and let the user
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
let fp = req.fp_hex.clone().unwrap_or_default();
let dialog = adw::AlertDialog::new(
Some("New Host"),
Some(&format!(
"{} at {}:{}\n\nCertificate fingerprint:\n{}\n\nPairing with a PIN verifies it; \
trusting accepts it as-is.",
req.name, req.addr, req.port, fp
)),
);
dialog.add_responses(&[
("cancel", "Cancel"),
("pair", "Pair with PIN…"),
("trust", "Trust & Connect"),
]);
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("trust"));
dialog.set_close_response("cancel");
let parent = app.window.clone();
dialog.connect_response(None, move |_, response| match response {
"trust" => {
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: req.name.clone(),
addr: req.addr.clone(),
port: req.port,
fp_hex: fp.clone(),
paired: false,
});
let _ = known.save();
start_session(app.clone(), req.clone(), crate::trust::parse_hex32(&fp));
}
"pair" => pin_dialog(app.clone(), req.clone()),
_ => {}
});
dialog.present(Some(&parent));
}
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
/// of it pins the host's certificate (and registers ours) with no offline-guessable
/// transcript.
fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
let entry = gtk::Entry::builder()
.input_purpose(gtk::InputPurpose::Digits)
.placeholder_text("4-digit PIN shown by the host")
.activates_default(true)
.build();
let dialog = adw::AlertDialog::new(
Some("Pair with PIN"),
Some(&format!(
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
req.name
)),
);
dialog.set_extra_child(Some(&entry));
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("pair"));
dialog.set_close_response("cancel");
let parent = app.window.clone();
dialog.connect_response(Some("pair"), move |_, _| {
let pin = entry.text().to_string();
let app = app.clone();
let req = req.clone();
let identity = app.identity.clone();
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
let (host, port, name) = (req.addr.clone(), req.port, glib::host_name().to_string());
std::thread::spawn(move || {
let result = NativeClient::pair(
&host,
port,
(&identity.0, &identity.1),
pin.trim(),
&name,
std::time::Duration::from_secs(90),
)
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
let _ = tx.send_blocking(result);
});
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(fp)) => {
let fp_hex = crate::trust::hex(&fp);
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: req.name.clone(),
addr: req.addr.clone(),
port: req.port,
fp_hex,
paired: true,
});
let _ = known.save();
app.toast("Paired — connecting…");
start_session(app.clone(), req, Some(fp));
}
Ok(Err(msg)) => app.toast(&msg),
Err(_) => {}
}
});
});
dialog.present(Some(&parent));
}
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
fn speed_test(app: Rc<App>, req: ConnectRequest) {
if app.busy.replace(true) {
return;
}
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
let status = gtk::Label::new(Some("Connecting…"));
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
dialog.set_extra_child(Some(&status));
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
dialog.set_response_enabled("apply", false);
dialog.set_close_response("close");
dialog.present(Some(&app.window));
let (tx, rx) =
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
let identity = app.identity.clone();
let (host, port) = (req.addr.clone(), req.port);
std::thread::spawn(move || {
let result = (|| {
let c = NativeClient::connect(
&host,
port,
punktfunk_core::config::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
CompositorPref::Auto,
GamepadPref::Auto,
0,
pin,
Some(identity),
std::time::Duration::from_secs(15),
)
.map_err(|e| format!("connect: {e:?}"))?;
c.request_probe(3_000_000, 2_000)
.map_err(|e| format!("probe: {e:?}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
std::thread::sleep(std::time::Duration::from_millis(250));
let r = c.probe_result();
if r.done {
// Let the last UDP shards land before tearing down.
std::thread::sleep(std::time::Duration::from_millis(400));
return Ok(c.probe_result());
}
if std::time::Instant::now() > deadline {
return Err("probe timed out".to_string());
}
}
})();
let _ = tx.send_blocking(result);
});
glib::spawn_future_local(async move {
let outcome = rx.recv().await;
app.busy.set(false);
match outcome {
Ok(Ok(r)) => {
let mbps = f64::from(r.throughput_kbps) / 1000.0;
let recommended_kbps = r.throughput_kbps / 10 * 7;
status.set_text(&format!(
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
r.loss_pct,
f64::from(recommended_kbps) / 1000.0,
));
dialog.set_response_enabled("apply", true);
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
let settings = app.settings.clone();
let toasts = app.toasts.clone();
dialog.connect_response(Some("apply"), move |_, _| {
let mut s = settings.borrow_mut();
s.bitrate_kbps = recommended_kbps;
s.save();
toasts.add_toast(adw::Toast::new(&format!(
"Bitrate set to {:.0} Mbit/s",
f64::from(recommended_kbps) / 1000.0
)));
});
}
Ok(Err(msg)) => status.set_text(&msg),
Err(_) => {}
}
});
}
/// The mode to request: explicit settings, with `0` fields resolved to the native
/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's
/// native-display default).
fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
let s = app.settings.borrow();
let mut mode = punktfunk_core::config::Mode {
width: s.width,
height: s.height,
refresh_hz: s.refresh_hz,
};
if mode.width == 0 || mode.refresh_hz == 0 {
let monitor = app
.window
.surface()
.zip(gdk::Display::default())
.and_then(|(surf, d)| d.monitor_at_surface(&surf));
if let Some(m) = monitor {
let geo = m.geometry();
let scale = m.scale_factor().max(1);
if mode.width == 0 {
mode.width = (geo.width() * scale) as u32;
mode.height = (geo.height() * scale) as u32;
}
if mode.refresh_hz == 0 {
mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32;
}
}
}
// No monitor info (early call, odd compositor) — a sane floor.
if mode.width == 0 {
(mode.width, mode.height) = (1920, 1080);
}
if mode.refresh_hz == 0 {
mode.refresh_hz = 60;
}
mode
}
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
if app.busy.replace(true) {
return;
}
let mode = resolve_mode(&app);
let s = app.settings.borrow();
let params = SessionParams {
host: req.addr.clone(),
port: req.port,
mode,
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
// "Automatic" matches the physical pad (Swift parity); an explicit choice wins.
gamepad: match GamepadPref::from_name(&s.gamepad) {
Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(),
Some(explicit) => explicit,
},
bitrate_kbps: s.bitrate_kbps,
mic_enabled: s.mic_enabled,
pin,
identity: app.identity.clone(),
};
let inhibit = s.inhibit_shortcuts;
drop(s);
let tofu = pin.is_none();
let mut handle = crate::session::start(params);
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
glib::spawn_future_local(async move {
let mut frames = Some(frames);
let mut page: Option<crate::ui_stream::StreamPage> = None;
while let Ok(event) = handle.events.recv().await {
match event {
SessionEvent::Connected {
connector,
mode,
fingerprint,
} => {
// A TOFU connect just observed the real fingerprint — pin it from now on.
if tofu {
let fp_hex = crate::trust::hex(&fingerprint);
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: req.name.clone(),
addr: req.addr.clone(),
port: req.port,
fp_hex: fp_hex.clone(),
paired: false,
});
let _ = known.save();
app.toast(&format!(
"Trusted on first use — fingerprint {}",
&fp_hex[..16]
));
}
tracing::debug!(?mode, "connected — pushing stream page");
let title = format!(
"{} · {}×{}@{}",
req.name, mode.width, mode.height, mode.refresh_hz
);
app.gamepad.attach(connector.clone());
let p = crate::ui_stream::new(
&app.window,
connector,
frames.take().expect("Connected delivered once"),
handle.stop.clone(),
inhibit,
&title,
);
app.nav.push(&p.page);
page = Some(p);
}
SessionEvent::Stats(s) => {
if let Some(p) = &page {
p.update_stats(s);
}
}
SessionEvent::Failed(msg) => {
tracing::warn!(%msg, "connect failed");
app.toast(&msg);
app.busy.set(false);
break;
}
SessionEvent::Ended(err) => {
app.gamepad.detach();
app.nav.pop_to_tag("hosts");
if let Some(e) = err {
app.toast(&e);
}
app.busy.set(false);
break;
}
}
}
});
}
+382
View File
@@ -0,0 +1,382 @@
//! Audio: playback (decoded PCM → a PipeWire playback stream) and the microphone uplink
//! (PipeWire capture → Opus → 0xCB datagrams, the inverse of the host's virtual mic).
//!
//! Playback mirrors the host's virtual-mic producer (`punktfunk-host::audio::linux`) with
//! the same adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded chunks on
//! the network clock; PipeWire pulls whole quanta on the device clock. Prime to ~3
//! quanta before producing, cap the ring so latency stays bounded, re-prime after a real
//! drain.
use anyhow::{Context, Result};
use punktfunk_core::client::NativeClient;
use std::collections::VecDeque;
use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
use std::sync::Arc;
const SAMPLE_RATE: u32 = 48_000;
const CHANNELS: usize = 2;
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
const MIC_FRAME: usize = 960;
struct Terminate;
pub struct AudioPlayer {
pcm_tx: SyncSender<Vec<f32>>,
quit_tx: pipewire::channel::Sender<Terminate>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl AudioPlayer {
/// Spawn the PipeWire playback thread. Failure (no PipeWire in the session) is
/// survivable — the caller streams video-only.
pub fn spawn() -> Result<AudioPlayer> {
// 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop.
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
let thread = std::thread::Builder::new()
.name("punktfunk-audio".into())
.spawn(move || {
if let Err(e) = pw_thread(pcm_rx, quit_rx) {
tracing::warn!(error = %e, "audio playback thread ended");
}
})
.context("spawn audio thread")?;
Ok(AudioPlayer {
pcm_tx,
quit_tx,
thread: Some(thread),
})
}
/// Queue one interleaved-stereo f32 chunk. Drops the chunk if the PipeWire side is
/// wedged (the renderer conceals the gap; never block the session pump).
pub fn push(&self, pcm: Vec<f32>) {
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
// Thread already dead — Drop will reap it; nothing to do per-chunk.
}
}
}
impl Drop for AudioPlayer {
fn drop(&mut self) {
let _ = self.quit_tx.send(Terminate);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Producer-side state: incoming decoded PCM and the ring the process callback drains.
struct PlayerData {
rx: Receiver<Vec<f32>>,
ring: VecDeque<f32>,
primed: bool,
}
fn pw_thread(
pcm_rx: Receiver<Vec<f32>>,
quit_rx: pipewire::channel::Receiver<Terminate>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
static PW_INIT: std::sync::Once = std::sync::Once::new();
PW_INIT.call_once(pw::init);
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
let core = context
.connect_rc(None)
.context("pw connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-client",
properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Game",
*pw::keys::NODE_NAME => "punktfunk-client",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
*pw::keys::NODE_LATENCY => "240/48000",
},
)
.context("pw Stream")?;
let ud = PlayerData {
rx: pcm_rx,
ring: VecDeque::new(),
primed: false,
};
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed(|_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire playback stream state");
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
while let Ok(chunk) = ud.rx.try_recv() {
ud.ring.extend(chunk);
}
let stride = 4 * CHANNELS; // F32LE interleaved
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
let want = want_frames * CHANNELS;
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
// genuine drain.
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
while ud.ring.len() > target.max(want) + want {
ud.ring.pop_front();
}
if !ud.primed && ud.ring.len() >= target {
ud.primed = true;
}
let n_frames = if let Some(slice) = data.data() {
for k in 0..want {
let s = if ud.primed {
ud.ring.pop_front().unwrap_or(0.0)
} else {
0.0
};
let off = k * 4;
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
}
want_frames
} else {
0
};
if ud.ring.is_empty() {
ud.primed = false;
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() = stride as _;
*chunk.size_mut() = (stride * n_frames) as _;
}));
if outcome.is_err() {
tracing::error!("panic in pipewire playback callback");
}
})
.register()
.context("register playback listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(SAMPLE_RATE);
info.set_channels(CHANNELS as u32);
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("pod from bytes")?];
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.context("pw stream connect")?;
mainloop.run();
tracing::debug!("pipewire playback loop exited");
Ok(())
}
/// The microphone uplink: capture the default input device, Opus-encode 20 ms chunks,
/// ship them as 0xCB datagrams into the host's virtual PipeWire source.
pub struct MicStreamer {
quit_tx: pipewire::channel::Sender<Terminate>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl MicStreamer {
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
let thread = std::thread::Builder::new()
.name("punktfunk-mic".into())
.spawn(move || {
if let Err(e) = mic_thread(&connector, quit_rx) {
tracing::warn!(error = %e, "mic uplink thread ended");
}
})
.context("spawn mic thread")?;
Ok(MicStreamer {
quit_tx,
thread: Some(thread),
})
}
}
impl Drop for MicStreamer {
fn drop(&mut self) {
let _ = self.quit_tx.send(Terminate);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Capture-side state: accumulated PCM and the Opus encoder (encoding a 20 ms frame is
/// ~100 µs — fine inside the process callback).
struct MicData {
connector: Arc<NativeClient>,
ring: VecDeque<f32>,
encoder: opus::Encoder,
seq: u32,
out: Vec<u8>,
}
fn mic_thread(
connector: &Arc<NativeClient>,
quit_rx: pipewire::channel::Receiver<Terminate>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
static PW_INIT: std::sync::Once = std::sync::Once::new();
PW_INIT.call_once(pw::init);
let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Stereo, opus::Application::Voip)
.map_err(|e| anyhow::anyhow!("opus encoder: {e}"))?;
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw mic MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw mic Context")?;
let core = context
.connect_rc(None)
.context("pw mic connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-mic-capture",
properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
},
)
.context("pw mic Stream")?;
let ud = MicData {
connector: connector.clone(),
ring: VecDeque::new(),
encoder,
seq: 0,
out: vec![0u8; 4000],
};
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed(|_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire mic capture stream state");
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let n = data.chunk().size() as usize;
if let Some(slice) = data.data() {
for s in slice[..n.min(slice.len())].chunks_exact(4) {
ud.ring
.push_back(f32::from_le_bytes([s[0], s[1], s[2], s[3]]));
}
}
// Ship every complete 20 ms stereo frame.
while ud.ring.len() >= MIC_FRAME * CHANNELS {
let pcm: Vec<f32> = ud.ring.drain(..MIC_FRAME * CHANNELS).collect();
match ud.encoder.encode_float(&pcm, &mut ud.out) {
Ok(len) => {
let pts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let _ = ud.connector.send_mic(ud.seq, pts, ud.out[..len].to_vec());
ud.seq = ud.seq.wrapping_add(1);
}
Err(e) => tracing::debug!(error = %e, "opus mic encode"),
}
}
}));
if outcome.is_err() {
tracing::error!("panic in pipewire mic callback");
}
})
.register()
.context("register mic listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(SAMPLE_RATE);
info.set_channels(CHANNELS as u32);
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize mic format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("mic pod from bytes")?];
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.context("pw mic stream connect")?;
mainloop.run();
tracing::debug!("pipewire mic capture loop exited");
Ok(())
}
@@ -0,0 +1,76 @@
//! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys
//! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream
//! results to the UI.
use mdns_sd::{ServiceDaemon, ServiceEvent};
#[derive(Clone, Debug)]
pub struct DiscoveredHost {
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
pub key: String,
pub name: String,
pub addr: String,
pub port: u16,
/// Host certificate fingerprint to pin (lowercase hex), empty if not advertised.
pub fp_hex: String,
/// Pairing requirement: `"required"` or `"optional"`.
pub pair: String,
}
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
/// dropped (the send fails) or the daemon dies.
pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
let (tx, rx) = async_channel::unbounded();
std::thread::Builder::new()
.name("punktfunk-mdns".into())
.spawn(move || {
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
tracing::warn!(error = %e, "mDNS daemon failed — discovery disabled");
return;
}
};
let receiver = match daemon.browse("_punktfunk._udp.local.") {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
return;
}
};
while let Ok(event) = receiver.recv() {
if let ServiceEvent::ServiceResolved(info) = event {
let props = info.get_properties();
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
else {
continue;
};
let id = val("id");
let host = DiscoveredHost {
key: if id.is_empty() {
info.get_fullname().to_string()
} else {
id
},
name: info
.get_fullname()
.split('.')
.next()
.unwrap_or("?")
.to_string(),
addr,
port: info.get_port(),
fp_hex: val("fp"),
pair: val("pair"),
};
if tx.send_blocking(host).is_err() {
break; // UI gone — stop browsing
}
}
}
let _ = daemon.shutdown();
})
.expect("spawn mdns thread");
rx
}
@@ -0,0 +1,529 @@
//! App-lifetime gamepad service over SDL3 (mirrors the Swift client's `GamepadManager` +
//! `GamepadCapture`/`GamepadFeedback`).
//!
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most
//! recently connected), and — while a session is attached — forwards buttons/axes,
//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on
//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet
//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the
//! wire when the active pad switches or the session detaches, so nothing sticks down.
//!
//! This thread is also the single consumer of the rumble and HID-output pull planes.
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::{HidOutput, RichInput};
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
/// us gyro in rad/s and accel in m/s²; the DualSense report wants raw LSBs.
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
const ACCEL_LSB_PER_G: f32 = 10_000.0;
const G: f32 = 9.80665;
#[derive(Clone, Debug)]
pub struct PadInfo {
pub id: u32,
pub name: String,
pub is_dualsense: bool,
}
enum Ctl {
Attach(Arc<NativeClient>),
Detach,
Pin(Option<u32>),
}
#[derive(Clone)]
pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>,
active: Arc<Mutex<Option<PadInfo>>>,
pinned: Arc<Mutex<Option<u32>>>,
ctl: Sender<Ctl>,
}
impl GamepadService {
pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new()));
let active = Arc::new(Mutex::new(None));
let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into())
.spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
}
})
{
tracing::warn!(error = %e, "gamepad service failed to start");
}
GamepadService {
pads,
active,
pinned,
ctl,
}
}
pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone()
}
pub fn active(&self) -> Option<PadInfo> {
self.active.lock().unwrap().clone()
}
pub fn pinned(&self) -> Option<u32> {
*self.pinned.lock().unwrap()
}
pub fn set_pinned(&self, id: Option<u32>) {
let _ = self.ctl.send(Ctl::Pin(id));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
pub fn detach(&self) {
let _ = self.ctl.send(Ctl::Detach);
}
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
/// (Swift parity); no pad connected leaves the host's own default.
pub fn auto_pref(&self) -> GamepadPref {
match self.active() {
Some(p) if p.is_dualsense => GamepadPref::DualSense,
Some(_) => GamepadPref::Xbox360,
None => GamepadPref::Auto,
}
}
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: 0, // pad index 0 — single-pad model
});
}
fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
use sdl3::gamepad::Button;
Some(match b {
Button::South => wire::BTN_A,
Button::East => wire::BTN_B,
Button::West => wire::BTN_X,
Button::North => wire::BTN_Y,
Button::Back => wire::BTN_BACK,
Button::Start => wire::BTN_START,
Button::Guide => wire::BTN_GUIDE,
Button::LeftStick => wire::BTN_LS_CLICK,
Button::RightStick => wire::BTN_RS_CLICK,
Button::LeftShoulder => wire::BTN_LB,
Button::RightShoulder => wire::BTN_RB,
Button::DPadUp => wire::BTN_DPAD_UP,
Button::DPadDown => wire::BTN_DPAD_DOWN,
Button::DPadLeft => wire::BTN_DPAD_LEFT,
Button::DPadRight => wire::BTN_DPAD_RIGHT,
Button::Touchpad => wire::BTN_TOUCHPAD,
_ => return None,
})
}
/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput
/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255.
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
use sdl3::gamepad::Axis;
match axis {
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
Axis::RightX => (wire::AXIS_RS_X, v as i32),
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
}
}
/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
/// Enable bits select only the fields each update touches, so rumble (driven separately
/// through SDL) and untouched fields keep their state.
#[derive(Default)]
struct Ds5Feedback;
impl Ds5Feedback {
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
const LED_RGB: usize = 44;
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
let mut p = [0u8; 47];
let (flag, off) = if which == 1 {
(0x04, Self::RIGHT_TRIGGER)
} else {
(0x08, Self::LEFT_TRIGGER)
};
p[0] = flag;
let n = effect.len().min(11);
p[off..off + n].copy_from_slice(&effect[..n]);
p
}
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x04; // lightbar enable
p[Self::LED_RGB] = r;
p[Self::LED_RGB + 1] = g;
p[Self::LED_RGB + 2] = b;
p
}
fn player_packet(bits: u8) -> [u8; 47] {
let mut p = [0u8; 47];
p[1] = 0x10; // player-LED enable
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
}
struct Worker {
subsystem: sdl3::GamepadSubsystem,
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
/// Connection order; the most recently connected is the auto selection.
order: Vec<u32>,
pinned: Option<u32>,
attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6],
held_buttons: Vec<u32>,
last_accel: [i16; 3],
}
impl Worker {
fn active_id(&self) -> Option<u32> {
self.pinned
.filter(|id| self.opened.contains_key(id))
.or_else(|| self.order.last().copied())
}
fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?;
Some(PadInfo {
id,
name: pad.name().unwrap_or_else(|| "Controller".into()),
is_dualsense: matches!(
self.subsystem
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
sdl3::gamepad::GamepadType::PS5
),
})
}
/// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) {
if let Some(c) = &self.attached {
for b in self.held_buttons.drain(..) {
send(c, InputKind::GamepadButton, b, 0);
}
for (id, v) in self.last_axis.iter_mut().enumerate() {
if *v != 0 && *v != i32::MIN {
send(c, InputKind::GamepadAxis, id as u32, 0);
}
*v = i32::MIN;
}
} else {
self.held_buttons.clear();
self.last_axis = [i32::MIN; 6];
}
}
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return };
if let Some(pad) = self.opened.get_mut(&id) {
use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } {
let _ = pad.sensor_set_enabled(s, enabled);
}
}
}
}
}
#[allow(clippy::too_many_lines)]
fn run(
pads_out: &Mutex<Vec<PadInfo>>,
active_out: &Mutex<Option<PadInfo>>,
pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>,
) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
// own thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
let mut w = Worker {
subsystem,
opened: HashMap::new(),
order: Vec::new(),
pinned: None,
attached: None,
last_axis: [i32::MIN; 6],
held_buttons: Vec::new(),
last_accel: [0; 3],
};
let publish = |w: &Worker| {
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
list.reverse(); // most recent first — the Settings list order
*pads_out.lock().unwrap() = list;
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
*pinned_out.lock().unwrap() = w.pinned;
};
loop {
// Control plane from the UI thread.
loop {
match ctl.try_recv() {
Ok(Ctl::Attach(c)) => {
w.attached = Some(c);
w.last_axis = [i32::MIN; 6];
w.set_sensors(true);
}
Ok(Ctl::Detach) => {
w.flush_held();
w.set_sensors(false);
w.attached = None;
}
Ok(Ctl::Pin(id)) => {
let before = w.active_id();
w.pinned = id;
if w.active_id() != before {
w.flush_held();
if w.attached.is_some() {
w.set_sensors(true);
}
}
publish(&w);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
}
}
while let Some(event) = pump.poll_event() {
use sdl3::event::Event;
let active = w.active_id();
match event {
Event::ControllerDeviceAdded { which, .. } => {
if !w.opened.contains_key(&which) {
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
Ok(pad) => {
tracing::info!(
name = pad.name().unwrap_or_default(),
"gamepad attached"
);
w.opened.insert(which, pad);
w.order.push(which);
if w.attached.is_some() && w.active_id() == Some(which) {
w.set_sensors(true);
}
publish(&w);
}
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
}
}
}
Event::ControllerDeviceRemoved { which, .. } => {
if w.opened.remove(&which).is_some() {
w.order.retain(|&id| id != which);
if active == Some(which) {
w.flush_held();
}
tracing::info!("gamepad detached");
publish(&w);
}
}
Event::ControllerButtonDown { which, button, .. }
if active == Some(which) && w.attached.is_some() =>
{
if let Some(bit) = button_bit(button) {
w.held_buttons.push(bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
1,
);
}
}
Event::ControllerButtonUp { which, button, .. }
if active == Some(which) && w.attached.is_some() =>
{
if let Some(bit) = button_bit(button) {
w.held_buttons.retain(|&b| b != bit);
send(
w.attached.as_ref().unwrap(),
InputKind::GamepadButton,
bit,
0,
);
}
}
Event::ControllerAxisMotion {
which, axis, value, ..
} if active == Some(which) && w.attached.is_some() => {
let (id, v) = axis_value(axis, value);
if w.last_axis[id as usize] != v {
w.last_axis[id as usize] = v;
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
}
}
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
Event::ControllerTouchpadDown {
which,
finger,
x,
y,
..
}
| Event::ControllerTouchpadMotion {
which,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
let _ = w
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: true,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
}
Event::ControllerTouchpadUp {
which,
finger,
x,
y,
..
} if active == Some(which) && w.attached.is_some() => {
let _ = w
.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Touchpad {
pad: 0,
finger: finger as u8,
active: false,
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
});
}
// Motion: accel events update the cache; each gyro event ships a sample
// (the DualSense reports both at ~250 Hz). Scale convention shared with
// the Swift client — sign/scale derived, not yet live-verified.
Event::ControllerSensorUpdated {
which,
sensor,
data,
..
} if active == Some(which) && w.attached.is_some() => {
use sdl3::sensor::SensorType;
match sensor {
SensorType::Accelerometer => {
for (i, v) in data.iter().enumerate() {
w.last_accel[i] =
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
}
}
SensorType::Gyroscope => {
let mut gyro = [0i16; 3];
for (i, v) in data.iter().enumerate() {
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
}
let _ =
w.attached
.as_ref()
.unwrap()
.send_rich_input(RichInput::Motion {
pad: 0,
gyro,
accel: w.last_accel,
});
}
_ => {}
}
}
_ => {}
}
}
// Feedback planes (this thread is their single consumer). The host re-sends
// rumble state periodically, so a generous duration with refresh-on-update is
// safe — a dropped stop heals within ~500 ms.
if let Some(connector) = w.attached.clone() {
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 {
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
let _ = p.set_rumble(low, high, 5_000);
}
}
}
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = w.active_id() else { continue };
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
let Some(pad) = w.opened.get_mut(&id) else {
continue;
};
match hid {
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
}
HidOutput::Led { pad: 0, r, g, b } => {
let _ = pad.set_led(r, g, b);
}
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
}
HidOutput::Trigger {
pad: 0,
which,
ref effect,
} if is_ds => {
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
_ => {}
}
}
}
std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
2
} else {
30
}));
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Local key/button codes → the punktfunk input wire contract.
//!
//! The wire carries Windows Virtual-Key codes (the GameStream convention; the host maps
//! them back with `inject::vk_to_evdev`). GTK hands us the hardware keycode, which on
//! Wayland (and X11) is the evdev code + 8 — so this table is the exact inverse of the
//! host's, keyed on evdev codes. Layout-independent by construction: positional keys map
//! positionally, exactly what a game expects.
/// Map a Linux evdev key code to the Windows VK code the host expects. `None` = a key the
/// wire contract doesn't cover (media keys etc.) — drop it rather than guess.
pub fn evdev_to_vk(evdev: u16) -> Option<u8> {
Some(match evdev {
// --- Navigation / editing / whitespace ---
14 => 0x08, // KEY_BACKSPACE -> VK_BACK
15 => 0x09, // KEY_TAB -> VK_TAB
28 => 0x0D, // KEY_ENTER -> VK_RETURN
119 => 0x13, // KEY_PAUSE -> VK_PAUSE
58 => 0x14, // KEY_CAPSLOCK -> VK_CAPITAL
1 => 0x1B, // KEY_ESC -> VK_ESCAPE
57 => 0x20, // KEY_SPACE -> VK_SPACE
104 => 0x21, // KEY_PAGEUP -> VK_PRIOR
109 => 0x22, // KEY_PAGEDOWN -> VK_NEXT
107 => 0x23, // KEY_END -> VK_END
102 => 0x24, // KEY_HOME -> VK_HOME
105 => 0x25, // KEY_LEFT -> VK_LEFT
103 => 0x26, // KEY_UP -> VK_UP
106 => 0x27, // KEY_RIGHT -> VK_RIGHT
108 => 0x28, // KEY_DOWN -> VK_DOWN
99 => 0x2C, // KEY_SYSRQ -> VK_SNAPSHOT
110 => 0x2D, // KEY_INSERT -> VK_INSERT
111 => 0x2E, // KEY_DELETE -> VK_DELETE
// --- Digit row (KEY_1..KEY_9 are 2..10, KEY_0 is 11) ---
11 => 0x30,
2 => 0x31,
3 => 0x32,
4 => 0x33,
5 => 0x34,
6 => 0x35,
7 => 0x36,
8 => 0x37,
9 => 0x38,
10 => 0x39,
// --- Letters (evdev order is QWERTY rows, not alphabetical) ---
30 => 0x41, // A
48 => 0x42, // B
46 => 0x43, // C
32 => 0x44, // D
18 => 0x45, // E
33 => 0x46, // F
34 => 0x47, // G
35 => 0x48, // H
23 => 0x49, // I
36 => 0x4A, // J
37 => 0x4B, // K
38 => 0x4C, // L
50 => 0x4D, // M
49 => 0x4E, // N
24 => 0x4F, // O
25 => 0x50, // P
16 => 0x51, // Q
19 => 0x52, // R
31 => 0x53, // S
20 => 0x54, // T
22 => 0x55, // U
47 => 0x56, // V
17 => 0x57, // W
45 => 0x58, // X
21 => 0x59, // Y
44 => 0x5A, // Z
// --- Meta / context-menu ---
125 => 0x5B, // KEY_LEFTMETA -> VK_LWIN
126 => 0x5C, // KEY_RIGHTMETA -> VK_RWIN
127 => 0x5D, // KEY_COMPOSE -> VK_APPS
// --- Numpad ---
82 => 0x60, // KP0
79 => 0x61,
80 => 0x62,
81 => 0x63,
75 => 0x64,
76 => 0x65,
77 => 0x66,
71 => 0x67,
72 => 0x68,
73 => 0x69, // KP9
55 => 0x6A, // KEY_KPASTERISK -> VK_MULTIPLY
78 => 0x6B, // KEY_KPPLUS -> VK_ADD
96 => 0x6C, // KEY_KPENTER -> VK_SEPARATOR
74 => 0x6D, // KEY_KPMINUS -> VK_SUBTRACT
83 => 0x6E, // KEY_KPDOT -> VK_DECIMAL
98 => 0x6F, // KEY_KPSLASH -> VK_DIVIDE
// --- Function keys ---
59 => 0x70, // F1
60 => 0x71,
61 => 0x72,
62 => 0x73,
63 => 0x74,
64 => 0x75,
65 => 0x76,
66 => 0x77,
67 => 0x78,
68 => 0x79, // F10
87 => 0x7A, // F11
88 => 0x7B, // F12
// --- Locks ---
69 => 0x90, // KEY_NUMLOCK -> VK_NUMLOCK
70 => 0x91, // KEY_SCROLLLOCK -> VK_SCROLL
// --- Left/right modifiers (specific VKs; the host maps both generics here too) ---
42 => 0xA0, // KEY_LEFTSHIFT -> VK_LSHIFT
54 => 0xA1, // KEY_RIGHTSHIFT -> VK_RSHIFT
29 => 0xA2, // KEY_LEFTCTRL -> VK_LCONTROL
97 => 0xA3, // KEY_RIGHTCTRL -> VK_RCONTROL
56 => 0xA4, // KEY_LEFTALT -> VK_LMENU
100 => 0xA5, // KEY_RIGHTALT -> VK_RMENU
// --- OEM punctuation (US-layout positions) ---
39 => 0xBA, // KEY_SEMICOLON -> VK_OEM_1
13 => 0xBB, // KEY_EQUAL -> VK_OEM_PLUS
51 => 0xBC, // KEY_COMMA -> VK_OEM_COMMA
12 => 0xBD, // KEY_MINUS -> VK_OEM_MINUS
52 => 0xBE, // KEY_DOT -> VK_OEM_PERIOD
53 => 0xBF, // KEY_SLASH -> VK_OEM_2
41 => 0xC0, // KEY_GRAVE -> VK_OEM_3
26 => 0xDB, // KEY_LEFTBRACE -> VK_OEM_4
43 => 0xDC, // KEY_BACKSLASH -> VK_OEM_5
27 => 0xDD, // KEY_RIGHTBRACE -> VK_OEM_6
40 => 0xDE, // KEY_APOSTROPHE -> VK_OEM_7
86 => 0xE2, // KEY_102ND -> VK_OEM_102
_ => return None,
})
}
/// Map a GTK/GDK mouse button number to the GameStream button id the wire expects
/// (1=left, 2=middle, 3=right, 4=X1, 5=X2). GDK reports back/forward as 8/9.
pub fn gdk_button_to_gs(button: u32) -> Option<u32> {
Some(match button {
1 => 1,
2 => 2,
3 => 3,
8 => 4,
9 => 5,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The table must be the exact inverse of the host's `vk_to_evdev` for every key the
/// host knows (modulo the generic-modifier VKs, which collapse onto the same evdev
/// codes as the specific left-hand ones).
#[test]
fn roundtrips_through_the_host_table() {
// Mirror of the host's table (inject::vk_to_evdev), generic modifiers excluded.
let host_pairs: &[(u8, u16)] = &[
(0x08, 14),
(0x09, 15),
(0x0D, 28),
(0x13, 119),
(0x14, 58),
(0x1B, 1),
(0x20, 57),
(0x21, 104),
(0x22, 109),
(0x23, 107),
(0x24, 102),
(0x25, 105),
(0x26, 103),
(0x27, 106),
(0x28, 108),
(0x2C, 99),
(0x2D, 110),
(0x2E, 111),
(0x30, 11),
(0x31, 2),
(0x39, 10),
(0x41, 30),
(0x5A, 44),
(0x5B, 125),
(0x60, 82),
(0x69, 73),
(0x70, 59),
(0x7B, 88),
(0x90, 69),
(0xA0, 42),
(0xA5, 100),
(0xBA, 39),
(0xE2, 86),
];
for &(vk, evdev) in host_pairs {
assert_eq!(evdev_to_vk(evdev), Some(vk), "evdev {evdev}");
}
assert_eq!(evdev_to_vk(113), None); // KEY_MUTE — not in the wire contract
}
}
+42
View File
@@ -0,0 +1,42 @@
//! `punktfunk-client` — the native Linux punktfunk/1 client (design: Option A, 2026-06-12).
//!
//! GTK4/libadwaita shell · `NativeClient` linked as a crate (no C ABI) · FFmpeg decode →
//! `GtkGraphicsOffload` present · PipeWire audio · SDL3 gamepads. The trust surface
//! mirrors the Apple client: persistent identity, TOFU prompt with the host fingerprint,
//! SPAKE2 PIN pairing.
#[cfg(target_os = "linux")]
mod app;
#[cfg(target_os = "linux")]
mod audio;
#[cfg(target_os = "linux")]
mod discovery;
#[cfg(target_os = "linux")]
mod gamepad;
#[cfg(target_os = "linux")]
mod keymap;
#[cfg(target_os = "linux")]
mod session;
#[cfg(target_os = "linux")]
mod trust;
#[cfg(target_os = "linux")]
mod ui_hosts;
#[cfg(target_os = "linux")]
mod ui_settings;
#[cfg(target_os = "linux")]
mod ui_stream;
#[cfg(target_os = "linux")]
mod video;
#[cfg(target_os = "linux")]
fn main() -> gtk::glib::ExitCode {
app::run()
}
/// GTK4/PipeWire/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on
/// macOS (the Mac client lives in clients/apple).
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
std::process::exit(2);
}
@@ -0,0 +1,234 @@
//! Session controller: one worker thread runs connect → pump (video pull + decode, audio
//! pull + Opus decode, stats), feeding the GTK main loop over channels. The UI keeps the
//! `Arc<NativeClient>` from the `Connected` event for direct input sends (no extra hop on
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
//! video+audio here, rumble+hidout on the gamepad thread.
use crate::audio;
use crate::video::{DecodedFrame, Decoder};
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use punktfunk_core::PunktfunkError;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct SessionParams {
pub host: String,
pub port: u16,
pub mode: Mode,
pub compositor: CompositorPref,
pub gamepad: GamepadPref,
pub bitrate_kbps: u32,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
pub pin: Option<[u8; 32]>,
pub identity: (String, String),
}
#[derive(Clone, Copy, Default)]
pub struct Stats {
pub fps: f32,
pub mbps: f32,
pub decode_ms: f32,
/// Median capture→decoded latency over the last window (host-clock corrected).
pub latency_ms: f32,
}
pub enum SessionEvent {
Connected {
connector: Arc<NativeClient>,
mode: Mode,
fingerprint: [u8; 32],
},
Failed(String),
Ended(Option<String>),
Stats(Stats),
}
pub struct SessionHandle {
pub events: async_channel::Receiver<SessionEvent>,
pub frames: async_channel::Receiver<DecodedFrame>,
pub stop: Arc<AtomicBool>,
}
pub fn start(params: SessionParams) -> SessionHandle {
let (ev_tx, ev_rx) = async_channel::unbounded();
// Tiny frame queue, newest wins: force_send displaces the oldest when the UI lags.
let (frame_tx, frame_rx) = async_channel::bounded(2);
let stop = Arc::new(AtomicBool::new(false));
let stop_w = stop.clone();
std::thread::Builder::new()
.name("punktfunk-session".into())
.spawn(move || pump(params, ev_tx, frame_tx, stop_w))
.expect("spawn session thread");
SessionHandle {
events: ev_rx,
frames: frame_rx,
stop,
}
}
fn now_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
fn pump(
params: SessionParams,
ev_tx: async_channel::Sender<SessionEvent>,
frame_tx: async_channel::Sender<DecodedFrame>,
stop: Arc<AtomicBool>,
) {
let connector = match NativeClient::connect(
&params.host,
params.port,
params.mode,
params.compositor,
params.gamepad,
params.bitrate_kbps,
params.pin,
Some(params.identity),
Duration::from_secs(15),
) {
Ok(c) => Arc::new(c),
Err(e) => {
let msg = match e {
PunktfunkError::Crypto => {
"Host identity rejected — wrong fingerprint, or the host requires pairing"
.to_string()
}
PunktfunkError::Timeout => "Connection timed out".to_string(),
other => format!("Connect failed: {other:?}"),
};
let _ = ev_tx.send_blocking(SessionEvent::Failed(msg));
return;
}
};
let _ = ev_tx.send_blocking(SessionEvent::Connected {
connector: connector.clone(),
mode: connector.mode(),
fingerprint: connector.host_fingerprint,
});
let mut decoder = match Decoder::new() {
Ok(d) => d,
Err(e) => {
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
return;
}
};
// Audio is best-effort: a session without it still streams. Gamepads are the
// app-lifetime service's job (the UI attaches it on Connected).
let player = audio::AudioPlayer::spawn()
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
.ok();
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
.ok();
let _mic = params
.mic_enabled
.then(|| {
audio::MicStreamer::spawn(connector.clone())
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
.ok()
})
.flatten();
let clock_offset = connector.clock_offset_ns;
let mut total_frames = 0u64;
let mut window_start = Instant::now();
let mut frames_n = 0u32;
let mut bytes_n = 0u64;
let mut decode_us_sum = 0u64;
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
let mut pcm = vec![0f32; 5760 * 2]; // decode scratch: max Opus frame (120 ms stereo)
let end: Option<String> = loop {
if stop.load(Ordering::SeqCst) {
break None;
}
match connector.next_frame(Duration::from_millis(4)) {
Ok(frame) => {
let t0 = Instant::now();
match decoder.decode(&frame.data) {
Ok(Some(decoded)) => {
total_frames += 1;
if total_frames == 1 {
let (w, h, path) = match &decoded {
DecodedFrame::Cpu(c) => (c.width, c.height, "software"),
DecodedFrame::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
};
tracing::info!(width = w, height = h, path, "first frame decoded");
}
// Latency: our wall clock expressed in the host's capture clock,
// minus the host-stamped capture pts (same math as client-rs).
let lat = (now_ns() as i128 + clock_offset as i128 - frame.pts_ns as i128)
.max(0) as u64;
if lat > 0 && lat < 10_000_000_000 {
lat_us.push(lat / 1000);
}
decode_us_sum += t0.elapsed().as_micros() as u64;
frames_n += 1;
bytes_n += frame.data.len() as u64;
let _ = frame_tx.force_send(decoded);
}
Ok(None) => {}
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
}
}
Err(PunktfunkError::NoFrame) => {}
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
Err(e) => break Some(format!("session: {e:?}")),
}
// Drain audio between frames (packets land every 5 ms; the queue holds 320 ms).
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
match dec.decode_float(&pkt.data, &mut pcm, false) {
Ok(samples) => player.push(pcm[..samples * 2].to_vec()),
Err(e) => tracing::debug!(error = %e, "opus decode"),
}
}
}
if window_start.elapsed() >= Duration::from_secs(1) {
let secs = window_start.elapsed().as_secs_f32();
lat_us.sort_unstable();
let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0);
tracing::debug!(
fps = frames_n,
lat_p50_us = p50,
total_frames,
"stream window"
);
let _ = ev_tx.try_send(SessionEvent::Stats(Stats {
fps: frames_n as f32 / secs,
mbps: bytes_n as f32 * 8.0 / 1e6 / secs,
decode_ms: if frames_n > 0 {
decode_us_sum as f32 / frames_n as f32 / 1000.0
} else {
0.0
},
latency_ms: p50 as f32 / 1000.0,
}));
window_start = Instant::now();
frames_n = 0;
bytes_n = 0;
decode_us_sum = 0;
lat_us.clear();
}
};
tracing::info!(
total_frames,
reason = end.as_deref().unwrap_or("user"),
"session ended"
);
stop.store(true, Ordering::SeqCst);
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
}
+164
View File
@@ -0,0 +1,164 @@
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
//!
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-client-rs`
//! so a box pairs once whichever client it uses.
use anyhow::{anyhow, Context, Result};
use punktfunk_core::quic::endpoint;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub fn config_dir() -> Result<PathBuf> {
let home = std::env::var("HOME").context("HOME unset")?;
Ok(PathBuf::from(home).join(".config/punktfunk"))
}
/// This client's persistent identity, generated on first use — presented on every connect
/// so hosts can recognize it once paired.
pub fn load_or_create_identity() -> Result<(String, String)> {
let dir = config_dir()?;
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
return Ok((c, k));
}
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?;
std::fs::write(&cp, &c)?;
std::fs::write(&kp, &k)?;
tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k))
}
pub fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn parse_hex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
/// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a
/// PIN ceremony) and where we last reached it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KnownHost {
pub name: String,
pub addr: String,
pub port: u16,
/// SHA-256 of the host certificate, lowercase hex — the pin for every later connect.
pub fp_hex: String,
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
pub paired: bool,
}
#[derive(Default, Serialize, Deserialize)]
pub struct KnownHosts {
pub hosts: Vec<KnownHost>,
}
impl KnownHosts {
fn path() -> Result<PathBuf> {
Ok(config_dir()?.join("client-known-hosts.json"))
}
pub fn load() -> KnownHosts {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
let p = Self::path()?;
std::fs::create_dir_all(p.parent().unwrap())?;
std::fs::write(&p, serde_json::to_string_pretty(self)?)?;
Ok(())
}
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
}
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
}
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
/// (a later TOFU connect must not demote a PIN-paired host).
pub fn upsert(&mut self, entry: KnownHost) {
if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) {
h.name = entry.name;
h.addr = entry.addr;
h.port = entry.port;
h.paired |= entry.paired;
} else {
self.hosts.push(entry);
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
/// resolved at connect time.
pub width: u32,
pub height: u32,
pub refresh_hz: u32,
/// Requested encoder bitrate (kbps); 0 = host default.
pub bitrate_kbps: u32,
pub gamepad: String,
/// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable).
pub compositor: String,
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
pub inhibit_shortcuts: bool,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
}
impl Default for Settings {
fn default() -> Self {
Settings {
width: 0,
height: 0,
refresh_hz: 0,
bitrate_kbps: 0,
gamepad: "auto".into(),
compositor: "auto".into(),
inhibit_shortcuts: true,
mic_enabled: false,
}
}
}
impl Settings {
fn path() -> Result<PathBuf> {
Ok(config_dir()?.join("client-gtk-settings.json"))
}
pub fn load() -> Settings {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) {
let Ok(p) = Self::path() else { return };
let _ = std::fs::create_dir_all(p.parent().unwrap());
if let Ok(s) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&p, s);
}
}
}
@@ -0,0 +1,240 @@
//! The hosts page: saved (trusted) hosts, live mDNS discovery, manual connect entry.
use crate::discovery::{self, DiscoveredHost};
use crate::trust::KnownHosts;
use adw::prelude::*;
use gtk::glib;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
/// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the
/// host was discovered (drives the TOFU prompt *before* connecting); manual entries have
/// none and trust on first use.
#[derive(Clone, Debug)]
pub struct ConnectRequest {
pub name: String,
pub addr: String,
pub port: u16,
pub fp_hex: Option<String>,
pub pair_required: bool,
}
pub fn new(
on_connect: Rc<dyn Fn(ConnectRequest)>,
on_settings: Rc<dyn Fn()>,
on_speed_test: Rc<dyn Fn(ConnectRequest)>,
) -> adw::NavigationPage {
let list = gtk::ListBox::new();
list.add_css_class("boxed-list");
list.set_selection_mode(gtk::SelectionMode::None);
let placeholder = gtk::Label::new(Some("Searching the LAN for hosts…"));
placeholder.add_css_class("dim-label");
placeholder.set_margin_top(24);
placeholder.set_margin_bottom(24);
list.set_placeholder(Some(&placeholder));
// key → (row, latest advert); the activation closure looks the advert up by key so
// re-adverts (new address, pairing flipped) take effect without rebuilding rows.
type Rows = Rc<RefCell<HashMap<String, (adw::ActionRow, DiscoveredHost)>>>;
let rows: Rows = Rc::new(RefCell::new(HashMap::new()));
{
let rx = discovery::browse();
let rows = rows.clone();
let list = list.downgrade();
let on_connect = on_connect.clone();
glib::spawn_future_local(async move {
while let Ok(host) = rx.recv().await {
let Some(list) = list.upgrade() else { break };
let mut map = rows.borrow_mut();
let subtitle = format!(
"{}:{} · pairing {}",
host.addr,
host.port,
if host.pair.is_empty() {
"optional"
} else {
&host.pair
}
);
if let Some((row, stored)) = map.get_mut(&host.key) {
row.set_title(&host.name);
row.set_subtitle(&subtitle);
*stored = host;
} else {
let row = adw::ActionRow::builder()
.title(&host.name)
.subtitle(&subtitle)
.activatable(true)
.build();
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
{
let rows = rows.clone();
let key = host.key.clone();
let on_connect = on_connect.clone();
row.connect_activated(move |_| {
if let Some((_, h)) = rows.borrow().get(&key) {
on_connect(ConnectRequest {
name: h.name.clone(),
addr: h.addr.clone(),
port: h.port,
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
pair_required: h.pair == "required",
});
}
});
}
list.append(&row);
map.insert(host.key.clone(), (row, host));
}
}
});
}
// Manual connect: host:port (punktfunk/1 default port 9777).
let manual = adw::EntryRow::builder().title("host:port").build();
let connect_btn = gtk::Button::with_label("Connect");
connect_btn.set_valign(gtk::Align::Center);
connect_btn.add_css_class("suggested-action");
manual.add_suffix(&connect_btn);
let submit = {
let manual = manual.clone();
let on_connect = on_connect.clone();
move || {
let text = manual.text().to_string();
let text = text.trim();
if text.is_empty() {
return;
}
let (addr, port) = match text.rsplit_once(':') {
Some((a, p)) => match p.parse::<u16>() {
Ok(port) => (a.to_string(), port),
Err(_) => return,
},
None => (text.to_string(), 9777),
};
on_connect(ConnectRequest {
name: addr.clone(),
addr,
port,
fp_hex: None,
pair_required: false,
});
}
};
{
let submit = submit.clone();
connect_btn.connect_clicked(move |_| submit());
}
manual.connect_entry_activated(move |_| submit());
let manual_list = gtk::ListBox::new();
manual_list.add_css_class("boxed-list");
manual_list.set_selection_mode(gtk::SelectionMode::None);
manual_list.append(&manual);
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. Rebuilt every time
// the page is shown, so fresh TOFU/pairing entries appear on return.
let saved_label = gtk::Label::new(Some("Saved hosts"));
saved_label.add_css_class("heading");
saved_label.set_halign(gtk::Align::Start);
let saved_list = gtk::ListBox::new();
saved_list.add_css_class("boxed-list");
saved_list.set_selection_mode(gtk::SelectionMode::None);
let rebuild_saved = {
let saved_list = saved_list.clone();
let saved_label = saved_label.clone();
let on_connect = on_connect.clone();
let on_speed_test = on_speed_test.clone();
move || {
saved_list.remove_all();
let known = KnownHosts::load();
saved_label.set_visible(!known.hosts.is_empty());
saved_list.set_visible(!known.hosts.is_empty());
for k in &known.hosts {
let row = adw::ActionRow::builder()
.title(&k.name)
.subtitle(format!(
"{}:{}{}",
k.addr,
k.port,
if k.paired {
" · paired"
} else {
" · trusted"
}
))
.activatable(true)
.build();
let req = ConnectRequest {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
pair_required: false,
};
let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
speed_btn.set_tooltip_text(Some("Test network speed"));
speed_btn.set_valign(gtk::Align::Center);
speed_btn.add_css_class("flat");
{
let on_speed_test = on_speed_test.clone();
let req = req.clone();
speed_btn.connect_clicked(move |_| on_speed_test(req.clone()));
}
row.add_suffix(&speed_btn);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let on_connect = on_connect.clone();
row.connect_activated(move |_| on_connect(req.clone()));
saved_list.append(&row);
}
}
};
rebuild_saved();
let content = gtk::Box::new(gtk::Orientation::Vertical, 18);
content.set_margin_top(24);
content.set_margin_bottom(24);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&saved_label);
content.append(&saved_list);
let discovered_label = gtk::Label::new(Some("Hosts on this network"));
discovered_label.add_css_class("heading");
discovered_label.set_halign(gtk::Align::Start);
content.append(&discovered_label);
content.append(&list);
let manual_label = gtk::Label::new(Some("Manual connection"));
manual_label.add_css_class("heading");
manual_label.set_halign(gtk::Align::Start);
content.append(&manual_label);
content.append(&manual_list);
let clamp = adw::Clamp::builder()
.maximum_size(560)
.child(&content)
.build();
let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&clamp)
.build();
let header = adw::HeaderBar::new();
let settings_btn = gtk::Button::from_icon_name("preferences-system-symbolic");
settings_btn.set_tooltip_text(Some("Preferences"));
settings_btn.connect_clicked(move |_| on_settings());
header.pack_end(&settings_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled));
let page = adw::NavigationPage::builder()
.title("Punktfunk")
.tag("hosts")
.child(&toolbar)
.build();
page.connect_shown(move |_| rebuild_saved());
page
}
@@ -0,0 +1,189 @@
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
//! capture behavior. Written back to disk when the dialog closes.
use crate::trust::Settings;
use adw::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
const RESOLUTIONS: &[(u32, u32)] = &[
(0, 0),
(1280, 720),
(1920, 1080),
(2560, 1440),
(3840, 2160),
];
/// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
pub fn show(
parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService,
) {
let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build();
let res_names: Vec<String> = RESOLUTIONS
.iter()
.map(|&(w, h)| {
if w == 0 {
"Native display".to_string()
} else {
format!("{w} × {h}")
}
})
.collect();
let res_row = adw::ComboRow::builder()
.title("Resolution")
.subtitle("The host creates a virtual output at exactly this size")
.model(&gtk::StringList::new(
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let hz_names: Vec<String> = REFRESH
.iter()
.map(|&r| {
if r == 0 {
"Native".to_string()
} else {
format!("{r} Hz")
}
})
.collect();
let hz_row = adw::ComboRow::builder()
.title("Refresh rate")
.model(&gtk::StringList::new(
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate");
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
let compositor_row = adw::ComboRow::builder()
.title("Host compositor")
.subtitle("Advisory — the host falls back to auto-detect when unavailable")
.model(&gtk::StringList::new(&[
"Automatic",
"KWin",
"wlroots (Sway/Hyprland)",
"Mutter (GNOME)",
"gamescope",
]))
.build();
stream.add(&res_row);
stream.add(&hz_row);
stream.add(&bitrate_row);
stream.add(&compositor_row);
let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently
// connected; pinning survives until the app exits (Swift parity).
let pads = gamepads.pads();
let mut pad_names = vec!["Automatic (most recent)".to_string()];
pad_names.extend(pads.iter().map(|p| {
if p.is_dualsense {
format!("{} · DualSense", p.name)
} else {
p.name.clone()
}
}));
let forward_row = adw::ComboRow::builder()
.title("Forwarded controller")
.subtitle(if pads.is_empty() {
"No controllers detected"
} else {
"Exactly one controller is forwarded to the host"
})
.model(&gtk::StringList::new(
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let pinned_i = gamepads
.pinned()
.and_then(|id| pads.iter().position(|p| p.id == id))
.map_or(0, |i| i + 1);
forward_row.set_selected(pinned_i as u32);
{
let svc = gamepads.clone();
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect();
forward_row.connect_selected_notify(move |row| {
let sel = row.selected() as usize;
svc.set_pinned(if sel == 0 {
None
} else {
ids.get(sel - 1).copied()
});
});
}
let pad_row = adw::ComboRow::builder()
.title("Gamepad type")
.subtitle("The virtual pad the host creates — Automatic matches the physical pad")
.model(&gtk::StringList::new(&[
"Automatic",
"Xbox 360",
"DualSense",
]))
.build();
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
input.add(&forward_row);
input.add(&pad_row);
input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build();
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone")
.build();
audio.add(&mic_row);
page.add(&stream);
page.add(&input);
page.add(&audio);
// Seed from the current settings.
{
let s = settings.borrow();
let res_i = RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.unwrap_or(0);
res_row.set_selected(res_i as u32);
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32);
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
pad_row.set_selected(pad_i as u32);
let comp_i = COMPOSITORS
.iter()
.position(|&c| c == s.compositor)
.unwrap_or(0);
compositor_row.set_selected(comp_i as u32);
inhibit_row.set_active(s.inhibit_shortcuts);
mic_row.set_active(s.mic_enabled);
}
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.add(&page);
dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut();
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
(s.width, s.height) = (w, h);
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string();
s.inhibit_shortcuts = inhibit_row.is_active();
s.mic_enabled = mic_row.is_active();
s.save();
});
dialog.present(Some(parent));
}
@@ -0,0 +1,427 @@
//! The stream page: decoded frames into a `GtkGraphicsOffload`-wrapped picture, local
//! input captured and forwarded on the wire contract.
//!
//! Input capture is a deliberate, reversible STATE (Moonlight-style, mirroring the Swift
//! client): engaged when the stream starts and when the user clicks into the video (that
//! click is suppressed toward the host); released by Ctrl+Alt+Shift+Q (toggles) or focus
//! loss — held keys/buttons are flushed host-side on release so nothing sticks down.
//! While captured the local cursor is hidden (the host renders its own) and compositor
//! shortcuts are inhibited (configurable); while released nothing is forwarded and the
//! HUD says how to recapture.
//!
//! Keys are hardware keycodes (evdev + 8 on Wayland) → VK via `keymap`, layout-
//! independent. Mouse is absolute (`MouseMoveAbs` scaled into the negotiated mode through
//! the letterbox transform, surface size packed in `flags`) — pointer-lock relative
//! capture is the stage-2 presenter's job. F11 toggles fullscreen locally.
use crate::keymap;
use crate::session::Stats;
use crate::video::DecodedFrame;
use adw::prelude::*;
use gtk::{gdk, glib};
use punktfunk_core::client::NativeClient;
use punktfunk_core::input::{InputEvent, InputKind};
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub struct StreamPage {
pub page: adw::NavigationPage,
stats_label: gtk::Label,
}
impl StreamPage {
pub fn update_stats(&self, s: Stats) {
self.stats_label.set_text(&format!(
"{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms",
s.fps, s.mbps, s.decode_ms, s.latency_ms
));
}
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags,
});
}
/// Forward an absolute pointer position: widget coordinates → video pixels through the
/// Contain-fit letterbox. `flags` packs the coordinate-space size (`(w << 16) | h`, the
/// same contract as touch) — the host normalizes against it before mapping into the EIS
/// region; without it the event is dropped.
fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y: f64) {
let w = widget.as_ref();
let mode = connector.mode();
let (ww, wh) = (w.width().max(1) as f64, w.height().max(1) as f64);
let (vw, vh) = (mode.width.max(1) as f64, mode.height.max(1) as f64);
let scale = (ww / vw).min(wh / vh);
let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0);
let px = (((x - ox) / scale).round()).clamp(0.0, vw - 1.0) as i32;
let py = (((y - oy) / scale).round()).clamp(0.0, vh - 1.0) as i32;
let flags = (mode.width << 16) | (mode.height & 0xffff);
send(connector, InputKind::MouseMoveAbs, 0, px, py, flags);
}
/// The capture state shared by every input controller on the page.
struct Capture {
connector: Arc<NativeClient>,
window: adw::ApplicationWindow,
overlay: gtk::Overlay,
hint: gtk::Label,
inhibit_shortcuts: bool,
captured: Cell<bool>,
/// VKs / GameStream button ids currently held — flushed up on release.
held_keys: RefCell<HashSet<u8>>,
held_buttons: RefCell<HashSet<u32>>,
}
impl Capture {
fn engage(&self) {
if self.captured.replace(true) {
return;
}
self.overlay
.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
self.hint.set_visible(false);
if self.inhibit_shortcuts {
if let Some(tl) = self
.window
.surface()
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
{
tl.inhibit_system_shortcuts(None::<&gdk::Event>);
}
}
}
fn release(&self) {
if !self.captured.replace(false) {
return;
}
self.overlay.set_cursor(None);
self.hint.set_visible(true);
if let Some(tl) = self
.window
.surface()
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
{
tl.restore_system_shortcuts();
}
// Flush everything held so nothing sticks down on the host.
for vk in self.held_keys.borrow_mut().drain() {
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
for b in self.held_buttons.borrow_mut().drain() {
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
}
}
}
#[allow(clippy::too_many_lines)]
pub fn new(
window: &adw::ApplicationWindow,
connector: Arc<NativeClient>,
frames: async_channel::Receiver<DecodedFrame>,
stop: Arc<AtomicBool>,
inhibit_shortcuts: bool,
title: &str,
) -> StreamPage {
let picture = gtk::Picture::new();
picture.set_content_fit(gtk::ContentFit::Contain);
// The offload path: with a dmabuf-backed texture (stage 1.5) this becomes a
// subsurface the compositor can scan out directly; with memory textures it is a
// no-op wrapper. Black letterboxing keeps fullscreen scanout-eligible.
let offload = gtk::GraphicsOffload::new(Some(&picture));
offload.set_black_background(true);
let stats_label = gtk::Label::new(None);
stats_label.add_css_class("osd");
stats_label.add_css_class("numeric");
stats_label.set_halign(gtk::Align::Start);
stats_label.set_valign(gtk::Align::Start);
stats_label.set_margin_start(12);
stats_label.set_margin_top(12);
let hint = gtk::Label::new(Some(
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases",
));
hint.add_css_class("osd");
hint.set_halign(gtk::Align::Center);
hint.set_valign(gtk::Align::End);
hint.set_margin_bottom(24);
hint.set_visible(false);
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&offload));
overlay.add_overlay(&stats_label);
overlay.add_overlay(&hint);
overlay.set_focusable(true);
let capture = Rc::new(Capture {
connector: connector.clone(),
window: window.clone(),
overlay: overlay.clone(),
hint: hint.clone(),
inhibit_shortcuts,
captured: Cell::new(false),
held_keys: RefCell::new(HashSet::new()),
held_buttons: RefCell::new(HashSet::new()),
});
let header = adw::HeaderBar::new();
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
{
let window = window.clone();
fullscreen_btn.connect_clicked(move |_| {
if window.is_fullscreen() {
window.unfullscreen();
} else {
window.fullscreen();
}
});
}
header.pack_end(&fullscreen_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&overlay));
// Fullscreen = the stream and nothing else. (Window handlers are disconnected when
// the page dies — the window outlives every session.)
let fs_handler = {
let toolbar = toolbar.clone();
window.connect_fullscreened_notify(move |w| {
toolbar.set_reveal_top_bars(!w.is_fullscreen());
})
};
let page = adw::NavigationPage::builder()
.title(title)
.tag("stream")
.child(&toolbar)
.build();
// --- Frame consumer: newest texture wins, set on the GTK frame clock's cadence. ---
{
let picture = picture.downgrade();
// The host encodes BT.709 limited-range; without an explicit color state GDK
// would convert NV12 dmabufs with the (BT.601) dmabuf default.
let rec709 = {
let cicp = gdk::CicpParams::new();
cicp.set_color_primaries(1);
cicp.set_transfer_function(1);
cicp.set_matrix_coefficients(1);
cicp.set_range(gdk::CicpRange::Narrow);
cicp.build_color_state().ok()
};
glib::spawn_future_local(async move {
while let Ok(f) = frames.recv().await {
let Some(picture) = picture.upgrade() else {
break;
};
match f {
DecodedFrame::Cpu(c) => {
let bytes = glib::Bytes::from_owned(c.rgba);
let tex = gdk::MemoryTexture::new(
c.width as i32,
c.height as i32,
gdk::MemoryFormat::R8g8b8a8,
&bytes,
c.stride,
);
picture.set_paintable(Some(&tex));
}
DecodedFrame::Dmabuf(d) => {
let mut b = gdk::DmabufTextureBuilder::new()
.set_display(&picture.display())
.set_width(d.width)
.set_height(d.height)
.set_fourcc(d.fourcc)
.set_modifier(d.modifier)
.set_n_planes(d.planes.len() as u32)
.set_color_state(rec709.as_ref());
for (i, p) in d.planes.iter().enumerate() {
b = unsafe { b.set_fd(i as u32, p.fd) }
.set_offset(i as u32, p.offset)
.set_stride(i as u32, p.stride);
}
let guard = d.guard;
// GDK runs the release func whether the import succeeds or not.
match unsafe { b.build_with_release_func(move || drop(guard)) } {
Ok(tex) => picture.set_paintable(Some(&tex)),
Err(e) => {
// Import rejected (format/modifier) — surfaces once per
// session in practice; the stream continues on the next
// frame, and PUNKTFUNK_DECODER=software is the escape.
tracing::warn!(error = %e, "dmabuf texture import failed");
}
}
}
}
}
});
}
// --- Keyboard ---
{
let key = gtk::EventControllerKey::new();
key.set_propagation_phase(gtk::PropagationPhase::Capture);
let cap = capture.clone();
let window_k = window.clone();
key.connect_key_pressed(move |_, keyval, keycode, state| {
let chord = gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SHIFT_MASK;
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
if cap.captured.get() {
cap.release();
} else {
cap.engage();
}
return glib::Propagation::Stop;
}
if keyval == gdk::Key::F11 {
if window_k.is_fullscreen() {
window_k.unfullscreen();
} else {
window_k.fullscreen();
}
return glib::Propagation::Stop;
}
if !cap.captured.get() {
return glib::Propagation::Proceed;
}
if let Some(vk) = keycode
.checked_sub(8)
.and_then(|c| keymap::evdev_to_vk(c as u16))
{
cap.held_keys.borrow_mut().insert(vk);
send(&cap.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
}
glib::Propagation::Stop
});
let cap = capture.clone();
key.connect_key_released(move |_, _keyval, keycode, _state| {
if let Some(vk) = keycode
.checked_sub(8)
.and_then(|c| keymap::evdev_to_vk(c as u16))
{
// Flush-on-release may have beaten us to it — only forward if still held.
if cap.held_keys.borrow_mut().remove(&vk) {
send(&cap.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
}
});
overlay.add_controller(key);
}
// --- Mouse: absolute motion, buttons, wheel — forwarded only while captured ---
{
let motion = gtk::EventControllerMotion::new();
let cap = capture.clone();
motion.connect_motion(move |_, x, y| {
if cap.captured.get() {
send_abs(&cap.overlay, &cap.connector, x, y);
}
});
overlay.add_controller(motion);
}
{
let click = gtk::GestureClick::builder().button(0).build();
let cap = capture.clone();
click.connect_pressed(move |g, _n, x, y| {
cap.overlay.grab_focus();
if !cap.captured.get() {
cap.engage(); // the engaging click is suppressed toward the host
return;
}
send_abs(&cap.overlay, &cap.connector, x, y);
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
cap.held_buttons.borrow_mut().insert(gs);
send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
}
});
let cap = capture.clone();
click.connect_released(move |g, _n, _x, _y| {
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
if cap.held_buttons.borrow_mut().remove(&gs) {
send(&cap.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
}
}
});
overlay.add_controller(click);
}
{
let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);
let cap = capture.clone();
scroll.connect_scroll(move |_, dx, dy| {
if !cap.captured.get() {
return glib::Propagation::Proceed;
}
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
// positive = down. Smooth fractions survive — libei's discrete scroll is
// 120-based too.
let vy = (-dy * 120.0) as i32;
if vy != 0 {
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
}
let vx = (dx * 120.0) as i32;
if vx != 0 {
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
}
glib::Propagation::Stop
});
overlay.add_controller(scroll);
}
// --- Capture lifecycle ---
{
// Engaged when the stream starts (trust is already confirmed by then).
let cap = capture.clone();
overlay.connect_map(move |w| {
w.grab_focus();
cap.engage();
});
}
// Focus loss releases (Alt-Tab away, another window) — Swift does the same.
let active_handler = {
let cap = capture.clone();
window.connect_is_active_notify(move |w| {
if !w.is_active() {
cap.release();
}
})
};
{
let cap = capture.clone();
overlay.connect_unmap(move |_| cap.release());
}
// The page's `hidden` fires once navigation away completes (back button, pop on
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
{
let window = window.clone();
let stop_h = stop.clone();
let handlers = RefCell::new(Some((fs_handler, active_handler)));
page.connect_hidden(move |_| {
tracing::debug!("stream page hidden — ending session");
if let Some((fs, active)) = handlers.borrow_mut().take() {
window.disconnect(fs);
window.disconnect(active);
}
if window.is_fullscreen() {
window.unfullscreen();
}
stop_h.store(true, Ordering::SeqCst);
});
}
StreamPage { page, stats_label }
}
+347
View File
@@ -0,0 +1,347 @@
//! Video decode: reassembled HEVC access units → frames for the GTK presenter.
//!
//! Two backends, picked at session start (override: `PUNKTFUNK_DECODER=software|vaapi`):
//!
//! * **VAAPI** (Intel/AMD): libavcodec hwaccel decodes on the GPU; each frame is mapped
//! to a DRM-PRIME dmabuf (`av_hwframe_map`, zero copy) and handed to the UI as fds +
//! plane layout for `GdkDmabufTextureBuilder` — inside `GtkGraphicsOffload` that is the
//! decoder-to-subsurface path, direct-scanout eligible when fullscreen. NVIDIA boxes
//! have no usable VAAPI (nvidia-vaapi-driver is broken for this — Moonlight blacklists
//! it); device creation fails there and the software path takes over. A mid-session
//! VAAPI error also falls back — the host's IDR/RFI recovery resynchronizes.
//! * **Software**: libavcodec on the CPU + swscale to RGBA (`GdkMemoryTexture` upload).
//! Slice threading only — frame threading would add a frame of latency per thread.
//!
//! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no
//! B-frames, in-band parameter sets on every IDR), so decode is strictly one-in/one-out.
use anyhow::{anyhow, bail, Context as _, Result};
use ffmpeg::format::Pixel;
use ffmpeg::software::scaling;
use ffmpeg::util::frame::Video as AvFrame;
use ffmpeg_next as ffmpeg;
use std::os::fd::RawFd;
use std::ptr;
pub enum DecodedFrame {
Cpu(CpuFrame),
Dmabuf(DmabufFrame),
}
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
pub struct CpuFrame {
pub width: u32,
pub height: u32,
/// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD).
pub stride: usize,
pub rgba: Vec<u8>,
}
/// A decoded frame still on the GPU: dmabuf fds + plane layout for
/// `GdkDmabufTextureBuilder`. The fds belong to `guard`'s mapped DRM frame — they stay
/// valid until the guard drops (the texture's release func).
pub struct DmabufFrame {
pub width: u32,
pub height: u32,
/// DRM fourcc of the layer (NV12 for 8-bit VAAPI output).
pub fourcc: u32,
pub modifier: u64,
pub planes: Vec<DmabufPlane>,
pub guard: DrmFrameGuard,
}
pub struct DmabufPlane {
pub fd: RawFd,
pub offset: u32,
pub stride: u32,
}
/// Owns the mapped DRM-PRIME `AVFrame` (which in turn references the VAAPI surface).
/// Dropping it releases the surface back to the decoder pool and closes the fds.
pub struct DrmFrameGuard(*mut ffmpeg::ffi::AVFrame);
// An AVFrame is plain refcounted data; freeing it from the GTK main thread is fine.
unsafe impl Send for DrmFrameGuard {}
impl Drop for DrmFrameGuard {
fn drop(&mut self) {
unsafe { ffmpeg::ffi::av_frame_free(&mut self.0) };
}
}
enum Backend {
Vaapi(VaapiDecoder),
Software(SoftwareDecoder),
}
pub struct Decoder {
backend: Backend,
}
impl Decoder {
pub fn new() -> Result<Decoder> {
ffmpeg::init().context("ffmpeg init")?;
let choice = std::env::var("PUNKTFUNK_DECODER").unwrap_or_default();
if choice != "software" {
match VaapiDecoder::new() {
Ok(v) => {
tracing::info!("VAAPI hardware decode active (zero-copy dmabuf)");
return Ok(Decoder {
backend: Backend::Vaapi(v),
});
}
Err(e) => {
if choice == "vaapi" {
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
}
tracing::info!(reason = %e, "VAAPI unavailable — software decode");
}
}
}
Ok(Decoder {
backend: Backend::Software(SoftwareDecoder::new()?),
})
}
/// Feed one access unit; returns the decoded frame (the host's streams are
/// one-in/one-out). A software decode error after packet loss is survivable — log
/// upstream and keep feeding. A VAAPI error demotes to software for the rest of the
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes.
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedFrame>> {
match &mut self.backend {
Backend::Vaapi(v) => match v.decode(au) {
Ok(f) => Ok(f.map(DecodedFrame::Dmabuf)),
Err(e) => {
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
self.backend = Backend::Software(SoftwareDecoder::new()?);
Ok(None)
}
},
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedFrame::Cpu)),
}
}
}
// --- software backend ---------------------------------------------------------------
struct SoftwareDecoder {
decoder: ffmpeg::decoder::Video,
/// Rebuilt whenever the decoded format/size changes (mid-stream `Reconfigure`).
sws: Option<(scaling::Context, Pixel, u32, u32)>,
}
impl SoftwareDecoder {
fn new() -> Result<SoftwareDecoder> {
let codec =
ffmpeg::decoder::find(ffmpeg::codec::Id::HEVC).ok_or(anyhow!("no HEVC decoder"))?;
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
unsafe {
let raw = ctx.as_mut_ptr();
(*raw).flags |= ffmpeg::ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
// Slice threading adds no frame delay (frame threading adds thread_count-1).
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
(*raw).thread_count = 0; // auto
}
let decoder = ctx.decoder().video().context("open HEVC decoder")?;
Ok(SoftwareDecoder { decoder, sws: None })
}
fn decode(&mut self, au: &[u8]) -> Result<Option<CpuFrame>> {
let packet = ffmpeg::Packet::copy(au);
self.decoder
.send_packet(&packet)
.map_err(|e| anyhow!("send_packet: {e}"))?;
let mut frame = AvFrame::empty();
let mut out = None;
while self.decoder.receive_frame(&mut frame).is_ok() {
out = Some(self.convert_rgba(&frame)?);
}
Ok(out)
}
fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
let rebuild =
!matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h);
if rebuild {
let ctx = scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
.context("swscale context")?;
self.sws = Some((ctx, fmt, w, h));
}
let (sws, ..) = self.sws.as_mut().unwrap();
let mut rgba = AvFrame::empty();
sws.run(frame, &mut rgba).map_err(|e| anyhow!("sws: {e}"))?;
Ok(CpuFrame {
width: w,
height: h,
stride: rgba.stride(0),
rgba: rgba.data(0).to_vec(),
})
}
}
// --- VAAPI backend --------------------------------------------------------------------
//
// Raw FFI: ffmpeg-next has no hwaccel wrappers. All pointers are owned here and freed in
// Drop; decoded surfaces transfer out through DrmFrameGuard.
const AVERROR_EAGAIN: i32 = -11; // -EAGAIN; Linux-only crate
fn averr(what: &str, code: i32) -> anyhow::Error {
anyhow!("{what}: {}", ffmpeg::Error::from(code))
}
/// libavcodec offers the formats it can decode into; pick the VAAPI hw surface. Falling
/// back to the first (software) entry would silently decode on the CPU *and* break our
/// dmabuf mapping — return NONE instead so the error surfaces and the session demotes
/// to the software backend explicitly.
unsafe extern "C" fn pick_vaapi(
_ctx: *mut ffmpeg::ffi::AVCodecContext,
mut list: *const ffmpeg::ffi::AVPixelFormat,
) -> ffmpeg::ffi::AVPixelFormat {
unsafe {
while *list != ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE {
if *list == ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI {
return ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
}
list = list.add(1);
}
}
ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE
}
struct VaapiDecoder {
ctx: *mut ffmpeg::ffi::AVCodecContext,
hw_device: *mut ffmpeg::ffi::AVBufferRef,
packet: *mut ffmpeg::ffi::AVPacket,
frame: *mut ffmpeg::ffi::AVFrame,
}
// Single-owner pointers, only touched from the session pump thread.
unsafe impl Send for VaapiDecoder {}
impl VaapiDecoder {
fn new() -> Result<VaapiDecoder> {
use ffmpeg::ffi;
unsafe {
let mut hw_device: *mut ffi::AVBufferRef = ptr::null_mut();
let r = ffi::av_hwdevice_ctx_create(
&mut hw_device,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
ptr::null(),
ptr::null_mut(),
0,
);
if r < 0 {
bail!("no VAAPI device ({})", ffmpeg::Error::from(r));
}
let codec = ffi::avcodec_find_decoder(ffi::AVCodecID::AV_CODEC_ID_HEVC);
if codec.is_null() {
ffi::av_buffer_unref(&mut hw_device);
bail!("no HEVC decoder");
}
let ctx = ffi::avcodec_alloc_context3(codec);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
(*ctx).get_format = Some(pick_vaapi);
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
(*ctx).thread_count = 1; // hwaccel: threads only add latency
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
if r < 0 {
let mut ctx = ctx;
ffi::avcodec_free_context(&mut ctx);
let mut hw_device = hw_device;
ffi::av_buffer_unref(&mut hw_device);
bail!("avcodec_open2: {}", ffmpeg::Error::from(r));
}
Ok(VaapiDecoder {
ctx,
hw_device,
packet: ffi::av_packet_alloc(),
frame: ffi::av_frame_alloc(),
})
}
}
fn decode(&mut self, au: &[u8]) -> Result<Option<DmabufFrame>> {
use ffmpeg::ffi;
unsafe {
let r = ffi::av_new_packet(self.packet, au.len() as i32);
if r < 0 {
return Err(averr("av_new_packet", r));
}
ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len());
let r = ffi::avcodec_send_packet(self.ctx, self.packet);
ffi::av_packet_unref(self.packet);
if r < 0 {
return Err(averr("send_packet", r));
}
let mut out = None;
loop {
let r = ffi::avcodec_receive_frame(self.ctx, self.frame);
if r == AVERROR_EAGAIN {
break;
}
if r < 0 {
return Err(averr("receive_frame", r));
}
out = Some(self.map_dmabuf()?); // newest wins; older guards drop here
ffi::av_frame_unref(self.frame);
}
Ok(out)
}
}
/// Map the VAAPI surface to DRM PRIME (zero copy) and lift the descriptor into a
/// `DmabufFrame`. The mapped frame keeps the surface alive via its buffer refs.
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
bail!("decoder returned a software frame (no VAAPI surface)");
}
let drm = ffi::av_frame_alloc();
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
let r = ffi::av_hwframe_map(drm, self.frame, ffi::AV_HWFRAME_MAP_READ as i32);
if r < 0 {
let mut drm = drm;
ffi::av_frame_free(&mut drm);
return Err(averr("av_hwframe_map", r));
}
let desc = (*drm).data[0] as *const ffi::AVDRMFrameDescriptor;
let guard = DrmFrameGuard(drm);
let d = &*desc;
if d.nb_layers < 1 {
bail!("DRM descriptor without layers");
}
let layer = &d.layers[0];
let mut planes = Vec::with_capacity(layer.nb_planes as usize);
for p in &layer.planes[..layer.nb_planes as usize] {
let obj = &d.objects[p.object_index as usize];
planes.push(DmabufPlane {
fd: obj.fd,
offset: p.offset as u32,
stride: p.pitch as u32,
});
}
Ok(DmabufFrame {
width: (*self.frame).width as u32,
height: (*self.frame).height as u32,
fourcc: layer.format,
modifier: d.objects[0].format_modifier,
planes,
guard,
})
}
}
}
impl Drop for VaapiDecoder {
fn drop(&mut self) {
use ffmpeg::ffi;
unsafe {
ffi::av_packet_free(&mut self.packet);
ffi::av_frame_free(&mut self.frame);
ffi::avcodec_free_context(&mut self.ctx);
ffi::av_buffer_unref(&mut self.hw_device);
}
}
}
+15
View File
@@ -371,6 +371,9 @@ async fn session(args: Args) -> Result<()> {
compositor: args.compositor,
gamepad: args.gamepad,
bitrate_kbps: args.bitrate_kbps,
// `--name` (also the pairing label) — shown in the host's pending-approval list when
// this client knocks on a pairing-required host.
name: Some(args.name.clone()),
}
.encode(),
)
@@ -525,6 +528,7 @@ async fn session(args: Args) -> Result<()> {
// low-latency input path without a real input device.
if args.input_test {
let conn2 = conn.clone();
let (mw, mh) = (args.mode.width, args.mode.height);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
tracing::info!("input-test: sending scripted datagrams for ~6s");
@@ -544,6 +548,17 @@ async fn session(args: Args) -> Result<()> {
flags: 0,
};
let _ = conn2.send_datagram(mv.encode().to_vec().into());
// Absolute motion too (the GTK client's path): a diagonal sweep, with the
// coordinate-space size packed in `flags` — the contract injectors require.
let abs = InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x: ((i * mw) / 160) as i32,
y: ((i * mh) / 160) as i32,
flags: (mw << 16) | (mh & 0xffff),
};
let _ = conn2.send_datagram(abs.encode().to_vec().into());
if i % 20 == 0 {
for kind in [InputKind::KeyDown, InputKind::KeyUp] {
let key = InputEvent {
+6 -3
View File
@@ -46,9 +46,12 @@ hmac = { version = "0.12", optional = true }
spake2 = { version = "0.4", optional = true }
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
# `sendmmsg` batched UDP send (the 1 Gbps+ syscall lever) — Linux only; other targets use the
# scalar `send` loop fallback.
[target.'cfg(target_os = "linux")'.dependencies]
# `libc` for batched UDP syscalls: `sendmmsg`/`recvmmsg` on Linux (the 1 Gbps+ lever) and the
# `recv(MSG_DONTWAIT)` drain on the other unix (Apple/BSD) targets, which have no `recvmmsg`
# (see transport/udp.rs `recv_batch`). Needed on every unix target — non-unix (Windows) uses
# the scalar fallbacks. Cross-compiles (iOS/tvOS) don't pull libc transitively the way the
# macOS host build does, so it must be a direct dep here or those slices fail to link `libc::`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
+6
View File
@@ -12,6 +12,12 @@ documentation_style = "c99"
[parse]
parse_deps = false
[export]
# Internal Apple-only FFI (transport/udp.rs `recvmsg_x` batched recv + its `MsghdrX`) — NOT part of
# the C ABI. cbindgen otherwise sweeps the foreign import and its #[repr(C)] struct into the header,
# where socklen_t/ssize_t/iovec are undefined and the C harness fails to compile.
exclude = ["MsghdrX", "recvmsg_x"]
[export.rename]
"InputEvent" = "PunktfunkInputEvent"
"InputKind" = "PunktfunkInputKind"
+26
View File
@@ -1383,6 +1383,32 @@ pub unsafe extern "C" fn punktfunk_connection_request_mode(
})
}
/// Ask the host's encoder to emit a fresh IDR keyframe now — client recovery when the
/// decoder has stalled (the infinite-GOP stream sends one opening IDR then P-frames only, so
/// a wedged decoder would otherwise freeze until the next loss-triggered recovery keyframe).
/// Non-blocking, fire-and-forget; the recovered keyframe is the only ack. The caller should
/// THROTTLE — the decode stays wedged for several frames until the IDR lands, so requesting
/// every frame would flood the control stream.
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
c: *const PunktfunkConnection,
) -> PunktfunkStatus {
guard(|| {
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
match c.inner.request_keyframe() {
Ok(()) => PunktfunkStatus::Ok,
Err(e) => e.status(),
}
})
}
/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
/// measured goodput to drive a bitrate choice from; `loss_pct` is the delivery loss at that rate.
+32 -13
View File
@@ -17,7 +17,7 @@ use crate::input::InputEvent;
use crate::packet::FLAG_PROBE;
use crate::quic::{
endpoint, io, Hello, HidOutput, ProbeRequest, ProbeResult, Reconfigure, Reconfigured,
RichInput, Start, Welcome,
RequestKeyframe, RichInput, Start, Welcome,
};
use crate::session::{Frame, Session};
use crate::transport::UdpTransport;
@@ -32,6 +32,7 @@ use std::time::{Duration, Instant};
enum CtrlRequest {
Mode(Mode),
Probe(ProbeRequest),
Keyframe,
}
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the negotiated
@@ -108,11 +109,15 @@ pub struct AudioPacket {
}
pub struct NativeClient {
frames: Receiver<Frame>,
audio: Receiver<AudioPacket>,
rumble: Receiver<(u16, u16, u16)>,
// Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust
// embedders can share one `Arc<NativeClient>` across their plane threads (the same
// one-thread-per-plane contract the C ABI documents — the lock is uncontended there,
// and two threads racing one plane now serialize instead of being undefined).
frames: Mutex<Receiver<Frame>>,
audio: Mutex<Receiver<AudioPacket>>,
rumble: Mutex<Receiver<(u16, u16, u16)>>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
hidout: Receiver<HidOutput>,
hidout: Mutex<Receiver<HidOutput>>,
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
@@ -234,10 +239,10 @@ impl NativeClient {
};
*mode_slot.lock().unwrap() = negotiated;
Ok(NativeClient {
frames: frame_rx,
audio: audio_rx,
rumble: rumble_rx,
hidout: hidout_rx,
frames: Mutex::new(frame_rx),
audio: Mutex::new(audio_rx),
rumble: Mutex::new(rumble_rx),
hidout: Mutex::new(hidout_rx),
input_tx,
mic_tx,
rich_input_tx,
@@ -361,6 +366,16 @@ impl NativeClient {
.map_err(|_| PunktfunkError::Closed)
}
/// Ask the host's encoder to emit a fresh IDR keyframe now (client recovery on a stalled
/// decode). Non-blocking, fire-and-forget — the recovered keyframe is the only ack. The
/// caller should throttle (the decode stays wedged across several frames until the IDR
/// lands, so requesting on every frame would flood the control stream).
pub fn request_keyframe(&self) -> Result<()> {
self.ctrl_tx
.send(CtrlRequest::Keyframe)
.map_err(|_| PunktfunkError::Closed)
}
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
@@ -419,7 +434,7 @@ impl NativeClient {
/// (`&self` here supports the cross-plane sharing; a plane's queue is still
/// single-consumer by contract).
pub fn next_frame(&self, timeout: Duration) -> Result<Frame> {
match self.frames.recv_timeout(timeout) {
match self.frames.lock().unwrap().recv_timeout(timeout) {
Ok(f) => Ok(f),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -430,7 +445,7 @@ impl NativeClient {
/// [`PunktfunkError::Closed`] once the session ended. Drain on a dedicated audio thread —
/// packets arrive every 5 ms.
pub fn next_audio(&self, timeout: Duration) -> Result<AudioPacket> {
match self.audio.recv_timeout(timeout) {
match self.audio.lock().unwrap().recv_timeout(timeout) {
Ok(p) => Ok(p),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -440,7 +455,7 @@ impl NativeClient {
/// Pull the next rumble update `(pad, low, high)`; same semantics as
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop.
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
match self.rumble.recv_timeout(timeout) {
match self.rumble.lock().unwrap().recv_timeout(timeout) {
Ok(r) => Ok(r),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -452,7 +467,7 @@ impl NativeClient {
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
/// `GCDualSenseAdaptiveTrigger` API). Only the DualSense host backend emits these.
pub fn next_hidout(&self, timeout: Duration) -> Result<HidOutput> {
match self.hidout.recv_timeout(timeout) {
match self.hidout.lock().unwrap().recv_timeout(timeout) {
Ok(h) => Ok(h),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -579,6 +594,9 @@ async fn worker_main(args: WorkerArgs) {
compositor,
gamepad,
bitrate_kbps,
// No device name yet: the connect ABI has no name parameter (pairing does). The
// host falls back to a fingerprint-derived label in its pending-approval list.
name: None,
}
.encode(),
)
@@ -709,6 +727,7 @@ async fn worker_main(args: WorkerArgs) {
let bytes = match req {
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
CtrlRequest::Probe(p) => p.encode(),
CtrlRequest::Keyframe => RequestKeyframe.encode(),
};
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
break;
+42 -1
View File
@@ -20,7 +20,7 @@
use crate::config::Role;
use crate::error::{PunktfunkError, Result};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};
use aes_gcm::{Aes128Gcm, Key, Nonce};
/// 16-byte AEAD authentication tag appended by GCM.
@@ -60,6 +60,23 @@ impl SessionCrypto {
.map_err(|_| PunktfunkError::Crypto)
}
/// Seal in place, no per-packet allocation: `buf` is laid out as `[plaintext .. ][TAG_LEN]` (the
/// last `TAG_LEN` bytes are scratch); on return it holds `[ciphertext .. ][tag]` — byte-identical
/// to `seal`'s `ciphertext || tag`, just written in place. The hot-path sealer (`Session`) uses
/// this to avoid the `Vec` that `seal`'s convenience API allocates for every packet.
pub fn seal_in_place(&self, seq: u64, buf: &mut [u8]) -> Result<()> {
debug_assert!(buf.len() >= TAG_LEN);
let nonce = nonce(self.send_salt, seq);
let split = buf.len() - TAG_LEN;
let (plaintext, tag_slot) = buf.split_at_mut(split);
let tag = self
.cipher
.encrypt_in_place_detached(Nonce::from_slice(&nonce), &seq.to_be_bytes(), plaintext)
.map_err(|_| PunktfunkError::Crypto)?;
tag_slot.copy_from_slice(&tag);
Ok(())
}
/// Open `ciphertext || tag` for sequence `seq` (also bound as associated data).
pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> {
let nonce = nonce(self.recv_salt, seq);
@@ -146,4 +163,28 @@ mod tests {
client.seal(0, b"abc").unwrap()
);
}
#[test]
fn seal_in_place_matches_seal_and_opens() {
let key = random_key();
let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client);
for msg in [
&b""[..],
b"x",
b"the quick brown fox jumps over 13 lazy dogs!!",
] {
let reference = host.seal(7, msg).unwrap(); // ciphertext || tag
// In-place: [plaintext .. ][TAG_LEN scratch].
let mut buf = msg.to_vec();
buf.resize(msg.len() + TAG_LEN, 0);
host.seal_in_place(7, &mut buf).unwrap();
assert_eq!(
buf, reference,
"in-place seal must be byte-identical to seal"
);
assert_eq!(client.open(7, &buf).unwrap(), msg);
}
}
}
+4 -1
View File
@@ -17,7 +17,10 @@ pub enum InputKind {
KeyUp = 1,
/// Relative motion: `x`/`y` carry `dx`/`dy`.
MouseMove = 2,
/// Absolute motion: `x`/`y` carry pixel coordinates.
/// Absolute motion: `x`/`y` carry pixel coordinates and `flags` packs the client's
/// coordinate-space size as `(width << 16) | height` (the same contract as
/// [`TouchDown`](Self::TouchDown)) — injectors normalize against it before mapping
/// into the output region and **drop the event when it is zero**.
MouseMoveAbs = 3,
MouseButtonDown = 4,
MouseButtonUp = 5,
+155 -3
View File
@@ -38,7 +38,7 @@ pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
/// `client → host`: open the session, requesting a display mode (the host creates its
/// virtual output at exactly this size/refresh — native resolution end to end).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hello {
pub abi_version: u32,
pub mode: Mode,
@@ -57,8 +57,17 @@ pub struct Hello {
/// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form —
/// omitted by older clients (decodes to `0`, i.e. host default).
pub bitrate_kbps: u32,
/// Human-readable device name ("Enrico's MacBook"), shown by the host when this device knocks
/// on a pairing-required host (the delegated-approval pending list) and stored on approval.
/// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by
/// older clients (decodes to `None`; the host falls back to a fingerprint-derived label).
pub name: Option<String>,
}
/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
pub const HELLO_NAME_MAX: usize = 64;
/// `host → client`: the complete session offer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Welcome {
@@ -117,6 +126,16 @@ pub struct Reconfigured {
pub mode: Mode,
}
/// `client → host`, any time after [`Start`]: ask the host's encoder to emit a fresh IDR
/// keyframe NOW. The infinite-GOP stream opens with one IDR then sends P-frames only, so a
/// decoder that wedges (a lost/corrupt opening IDR, a bad early P-frame — most likely on the
/// cold first session) would otherwise stay frozen until the next loss-triggered recovery
/// keyframe, which may be far off. The client sends this when it detects a stalled decode;
/// the host forces the next frame to be an IDR with in-band parameter sets, recovering the
/// picture in ~one frame. Fire-and-forget — no reply (the recovered IDR is the ack).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RequestKeyframe;
/// `client → host`, any time after [`Start`]: run a bandwidth speed test. The host bursts
/// filler access units (flagged [`crate::packet::FLAG_PROBE`]) over the data plane at
/// `target_kbps` of application goodput for `duration_ms`, *pausing video for the duration*, then
@@ -186,6 +205,8 @@ pub fn clock_offset_ns(samples: &[(u64, u64, u64, u64)]) -> Option<(i64, u64)> {
pub const MSG_RECONFIGURE: u8 = 0x01;
/// Type byte of [`Reconfigured`].
pub const MSG_RECONFIGURED: u8 = 0x02;
/// Type byte of [`RequestKeyframe`].
pub const MSG_REQUEST_KEYFRAME: u8 = 0x03;
/// Type byte of [`ProbeRequest`].
pub const MSG_PROBE_REQUEST: u8 = 0x20;
/// Type byte of [`ProbeResult`].
@@ -463,6 +484,22 @@ impl Hello {
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26
if let Some(name) = &self.name {
// Appended at offset 26: len u8 || UTF-8. This is the LAST trailing field — `None`
// emits nothing (so a no-name Hello is byte-identical to the bitrate-era form), which
// means a *future* field can't simply follow `name` at a fixed offset; it would need
// its own presence flag. Truncate to a char boundary within HELLO_NAME_MAX.
let mut n = name.as_str();
while n.len() > HELLO_NAME_MAX {
let mut cut = HELLO_NAME_MAX;
while !n.is_char_boundary(cut) {
cut -= 1;
}
n = &n[..cut];
}
b.push(n.len() as u8);
b.extend_from_slice(n.as_bytes());
}
b
}
@@ -492,6 +529,17 @@ impl Hello {
.get(22..26)
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0),
// Optional trailing device name: len u8 || UTF-8. Absent / oversized / non-UTF-8 →
// `None` (never fail the handshake over a label).
name: b.get(26).and_then(|&len| {
let len = len as usize;
if len == 0 || len > HELLO_NAME_MAX {
return None;
}
b.get(27..27 + len)
.and_then(|s| std::str::from_utf8(s).ok())
.map(String::from)
}),
})
}
}
@@ -663,6 +711,23 @@ impl Reconfigured {
}
}
impl RequestKeyframe {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] — no payload
let mut b = Vec::with_capacity(5);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_REQUEST_KEYFRAME);
b
}
pub fn decode(b: &[u8]) -> Result<RequestKeyframe> {
if b.len() != 5 || &b[0..4] != CTL_MAGIC || b[4] != MSG_REQUEST_KEYFRAME {
return Err(PunktfunkError::InvalidArg("bad RequestKeyframe"));
}
Ok(RequestKeyframe)
}
}
impl ProbeRequest {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] target_kbps[5..9] duration_ms[9..13]
@@ -1064,6 +1129,26 @@ pub async fn clock_sync(
pub mod endpoint {
use std::sync::{Arc, Mutex};
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
/// load-bearing setting: with quinn's defaults it is OFF, so any quiet stretch on the
/// connection (no input, audio muted or stalled, a capture hiccup, a mode change) lets the
/// idle timer run out and quinn closes the session — surfacing to the embedder as
/// `next_au` → Closed. The native equivalent of Moonlight's ENet keepalive: a small PING
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
fn stream_transport() -> Arc<quinn::TransportConfig> {
use std::time::Duration;
const MAX_IDLE: Duration = Duration::from_secs(20);
const KEEP_ALIVE: Duration = Duration::from_secs(4);
let mut t = quinn::TransportConfig::default();
t.max_idle_timeout(Some(
quinn::IdleTimeout::try_from(MAX_IDLE).expect("20s is a valid QUIC idle timeout"),
));
t.keep_alive_interval(Some(KEEP_ALIVE));
Arc::new(t)
}
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
/// persist an identity and use [`server_with_identity`] so clients can pin it).
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
@@ -1106,7 +1191,8 @@ pub mod endpoint {
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
server_config.transport_config(stream_transport()); // keep-alive — see stream_transport
Ok(quinn::Endpoint::server(server_config, addr)?)
}
@@ -1197,8 +1283,10 @@ pub mod endpoint {
};
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
ep.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic_cfg)));
ep.set_default_client_config(client_cfg);
Ok(ep)
})();
(ep, observed)
@@ -1406,6 +1494,7 @@ mod tests {
compositor: CompositorPref::Kwin,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 25_000,
name: Some("Test Device".into()),
};
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start {
@@ -1470,6 +1559,7 @@ mod tests {
compositor: CompositorPref::Mutter,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 80_000,
name: None,
};
let enc = h.encode();
assert_eq!(enc.len(), 26);
@@ -1526,6 +1616,51 @@ mod tests {
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
}
#[test]
fn hello_name_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: Some("Enrico's MacBook".into()),
};
let enc = base.encode();
assert_eq!(
Hello::decode(&enc).unwrap().name.as_deref(),
Some("Enrico's MacBook")
);
// A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
// host reading a bitrate-era Hello decodes name = None.
assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
// No name → wire form is byte-identical to the bitrate-era message (26 bytes).
let unnamed = Hello {
name: None,
..base.clone()
};
assert_eq!(unnamed.encode().len(), 26);
// Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
let long = Hello {
name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
..base.clone()
};
let dec = Hello::decode(&long.encode()).unwrap();
let n = dec.name.expect("truncated name still present");
assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
// A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
let mut bad_len = unnamed.encode();
bad_len.push(40); // claims 40 name bytes, none follow
assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
let mut bad_utf8 = unnamed.encode();
bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
}
#[test]
fn reconfigure_roundtrip() {
let rq = Reconfigure {
@@ -1554,6 +1689,22 @@ mod tests {
.is_err());
}
#[test]
fn request_keyframe_roundtrip() {
let bytes = RequestKeyframe.encode();
assert!(RequestKeyframe::decode(&bytes).is_ok());
// Distinct from the other control messages — its type byte must not collide.
let mode = Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
assert!(RequestKeyframe::decode(&Reconfigure { mode }.encode()).is_err());
assert!(Reconfigure::decode(&bytes).is_err());
// Length is exact (no trailing bytes accepted).
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
}
#[test]
fn probe_messages_roundtrip() {
let req = ProbeRequest {
@@ -1632,6 +1783,7 @@ mod tests {
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
}
.encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
+38 -14
View File
@@ -51,6 +51,10 @@ pub struct Session {
recv_lens: Vec<usize>,
recv_count: usize,
recv_idx: usize,
/// Host send pool: reused wire buffers (`seal_frame` seals in place into these, the caller sends
/// then returns them via [`reclaim_wires`](Self::reclaim_wires)). After warmup each buffer keeps
/// its capacity, so the per-packet ciphertext + wire `Vec` allocations vanish from the hot path.
wire_pool: Vec<Vec<u8>>,
}
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). At ~125k
@@ -78,6 +82,7 @@ impl Session {
recv_lens: Vec::new(),
recv_count: 0,
recv_idx: 0,
wire_pool: Vec::new(),
config,
})
}
@@ -92,19 +97,23 @@ impl Session {
/// Wrap a packet for the wire: when encrypting, prepend the 8-byte big-endian
/// sequence (the receiver derives the GCM nonce from it) then the ciphertext.
fn seal_for_wire(&mut self, packet: &[u8]) -> Result<Vec<u8>> {
/// Seal one plaintext packet into the reused `wire` buffer in place (no allocation): the wire is
/// `seq(8) || ciphertext || tag` with crypto on, or just the packet with crypto off (probe).
/// Byte-identical to the previous `seal` + concat path; `clear()` keeps the buffer's capacity.
fn seal_into(&mut self, packet: &[u8], wire: &mut Vec<u8>) -> Result<()> {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
wire.clear();
match &self.crypto {
Some(c) => {
let ct = c.seal(seq, packet)?;
let mut wire = Vec::with_capacity(8 + ct.len());
wire.extend_from_slice(&seq.to_be_bytes());
wire.extend_from_slice(&ct);
Ok(wire)
wire.extend_from_slice(&seq.to_be_bytes()); // [0..8] plaintext seq prefix
wire.extend_from_slice(packet); // [8..8+n] plaintext to encrypt
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0); // tag scratch
c.seal_in_place(seq, &mut wire[8..])?; // encrypt [8..] in place, tag written at the end
}
None => Ok(packet.to_vec()),
None => wire.extend_from_slice(packet),
}
Ok(())
}
/// Unwrap a wire datagram back into a plaintext packet.
@@ -144,9 +153,13 @@ impl Session {
.packetizer
.packetize(data, pts_ns, user_flags, self.coder.as_ref())?;
StatsCounters::add(&self.stats.frames_submitted, 1);
let mut wires: Vec<Vec<u8>> = Vec::with_capacity(packets.len());
for pkt in &packets {
wires.push(self.seal_for_wire(pkt)?);
// Reuse the wire-buffer pool the caller returns via `reclaim_wires`: one buffer per packet,
// sealed in place — after warmup there is no per-packet ciphertext/wire allocation. (`wires`
// is a local, so `seal_into`'s `&mut self` doesn't alias the `&mut` iteration over it.)
let mut wires = std::mem::take(&mut self.wire_pool);
wires.resize_with(packets.len(), Vec::new);
for (wire, pkt) in wires.iter_mut().zip(packets.iter()) {
self.seal_into(pkt, wire)?;
}
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
@@ -154,11 +167,19 @@ impl Session {
Ok(wires)
}
/// Return the wire buffers from [`seal_frame`](Self::seal_frame) to the reuse pool once the caller
/// has finished sending them, so the next frame reseals in place with no allocation. Optional —
/// dropping the buffers instead just forfeits the reuse (correctness is unaffected).
pub fn reclaim_wires(&mut self, wires: Vec<Vec<u8>>) {
self.wire_pool = wires;
}
/// Host: transmit one chunk of already-[`seal_frame`](Self::seal_frame)ed packets in a single
/// batched `sendmmsg`, returning how many the kernel accepted. The rest (`packets.len() - n`)
/// are counted as send-buffer drops. Call once for the whole frame, or per paced chunk.
pub fn send_sealed(&self, packets: &[&[u8]]) -> Result<usize> {
let sent = self.transport.send_batch(packets)?;
// GSO when enabled (UdpTransport/Linux), else sendmmsg — same short-count drop contract.
let sent = self.transport.send_gso(packets)?;
if sent < packets.len() {
StatsCounters::add(
&self.stats.packets_send_dropped,
@@ -174,8 +195,10 @@ impl Session {
pub fn submit_frame(&mut self, data: &[u8], pts_ns: u64, user_flags: u32) -> Result<()> {
let wires = self.seal_frame(data, pts_ns, user_flags)?;
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
self.send_sealed(&refs)?;
Ok(())
let r = self.send_sealed(&refs);
drop(refs); // release the borrow of `wires` before returning the buffers to the pool
self.reclaim_wires(wires);
r.map(|_| ())
}
/// Host: drain one pending input event from the client, if any.
@@ -263,7 +286,8 @@ impl Session {
));
}
let pkt = event.encode();
let wire = self.seal_for_wire(&pkt)?;
let mut wire = Vec::new(); // input is rare + per-event; no pool needed
self.seal_into(&pkt, &mut wire)?;
StatsCounters::add(&self.stats.packets_sent, 1);
StatsCounters::add(&self.stats.bytes_sent, wire.len() as u64);
if !self.transport.send(&wire)? {
@@ -33,6 +33,18 @@ pub trait Transport: Send + Sync {
Ok(sent)
}
/// Send a frame's equal-size packets using UDP Generic Segmentation Offload where available:
/// one `sendmsg` hands the kernel a big buffer it splits into `gso_size` UDP datagrams, building
/// ~1 GSO skb per ≤64 segments instead of one skb per packet. This is the multi-Gbps lever —
/// research shows ~2.4× throughput at equal CPU and ~40× fewer syscalls, and that `sendmmsg`
/// batching alone is insufficient (it still builds one skb per datagram). The
/// [`UdpTransport`](super::UdpTransport) Linux override implements it (opt-in via `PUNKTFUNK_GSO`,
/// auto-fallback on any GSO error); the default just delegates to [`send_batch`](Self::send_batch),
/// correct for loopback and non-Linux. Same lossy, FEC-protected short-count contract as `send_batch`.
fn send_gso(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
self.send_batch(packets)
}
fn recv(&self) -> std::io::Result<Option<Vec<u8>>>;
/// Receive up to `out.len()` datagrams in as few syscalls as possible, writing each into its
+311 -1
View File
@@ -33,6 +33,132 @@ fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<libc::mmsghdr> {
.collect()
}
/// UDP GSO enable state (process-wide). Opt-in via `PUNKTFUNK_GSO` — it's new unsafe hot-path code,
/// and the auto-fallback (latch off on any GSO syscall error) covers kernels/paths without support.
#[cfg(target_os = "linux")]
mod gso {
use std::sync::atomic::{AtomicU8, Ordering};
static STATE: AtomicU8 = AtomicU8::new(0); // 0 = uninit, 1 = on, 2 = off
pub fn active() -> bool {
match STATE.load(Ordering::Relaxed) {
1 => true,
2 => false,
_ => {
let on = std::env::var_os("PUNKTFUNK_GSO").is_some();
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
on
}
}
}
/// Latch GSO off for the process after a GSO syscall error (unsupported kernel/path).
pub fn disable() {
STATE.store(2, Ordering::Relaxed);
}
}
/// True if the send error means UDP GSO isn't supported here (vs a transient/real failure) — so we
/// latch GSO off and fall back to `sendmmsg` rather than tear the stream down.
#[cfg(target_os = "linux")]
fn gso_unsupported(e: &std::io::Error) -> bool {
matches!(
e.raw_os_error(),
Some(libc::ENOPROTOOPT) | Some(libc::EOPNOTSUPP) | Some(libc::EINVAL) | Some(libc::EIO)
)
}
/// One `sendmsg` carrying a `UDP_SEGMENT` control message: the kernel splits `buf` (a back-to-back
/// concatenation of equal-size datagrams, only the final one allowed shorter) into `gso_size`-byte
/// UDP datagrams to the connected peer — one large GSO skb instead of N. `EAGAIN` (full send buffer)
/// surfaces as a `WouldBlock` error; the caller treats it as a lossy drop.
#[cfg(target_os = "linux")]
fn send_one_gso(fd: libc::c_int, buf: &[u8], gso_size: u16) -> std::io::Result<()> {
let mut iov = libc::iovec {
iov_base: buf.as_ptr() as *mut libc::c_void,
iov_len: buf.len(),
};
// Aligned control buffer for one cmsg(UDP_SEGMENT = u16). 64 B > CMSG_SPACE(2); the union forces
// cmsghdr alignment (CMSG_FIRSTHDR requires it).
#[repr(C)]
union CmsgBuf {
_align: libc::cmsghdr,
bytes: [u8; 64],
}
let mut control = CmsgBuf { bytes: [0u8; 64] };
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
let rc = unsafe {
msg.msg_control = control.bytes.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::<u16>() as u32) as _;
let cmsg = libc::CMSG_FIRSTHDR(&msg);
(*cmsg).cmsg_level = libc::SOL_UDP;
(*cmsg).cmsg_type = libc::UDP_SEGMENT;
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<u16>() as u32) as _;
std::ptr::copy_nonoverlapping(
(&gso_size as *const u16) as *const u8,
libc::CMSG_DATA(cmsg),
std::mem::size_of::<u16>(),
);
libc::sendmsg(fd, &msg, 0)
};
if rc < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// Apple (macOS/iOS) batched-receive enable state. Darwin has no `recvmmsg(2)`, so our macOS client
/// does one `recv` per packet (non-allocating, but a syscall each); `recvmsg_x(2)` is the batched
/// equivalent. Opt-in via `PUNKTFUNK_RECVMSG_X` (it's FFI we can't exercise off-Apple — the scalar
/// recv-loop is the tested default), with auto-fallback if the syscall ever errors unexpectedly.
#[cfg(target_vendor = "apple")]
mod recvx {
use std::sync::atomic::{AtomicU8, Ordering};
static STATE: AtomicU8 = AtomicU8::new(0); // 0 = uninit, 1 = on, 2 = off
pub fn active() -> bool {
match STATE.load(Ordering::Relaxed) {
1 => true,
2 => false,
_ => {
let on = std::env::var_os("PUNKTFUNK_RECVMSG_X").is_some();
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
on
}
}
}
pub fn disable() {
STATE.store(2, Ordering::Relaxed);
}
}
/// `struct msghdr_x` from Darwin `<sys/socket.h>` (the batched-I/O variant — not in the `libc` crate).
#[cfg(target_vendor = "apple")]
#[repr(C)]
struct MsghdrX {
msg_name: *mut libc::c_void,
msg_namelen: libc::socklen_t,
msg_iov: *mut libc::iovec,
msg_iovlen: libc::c_int,
msg_control: *mut libc::c_void,
msg_controllen: libc::socklen_t,
msg_flags: libc::c_int,
msg_datalen: libc::size_t,
}
#[cfg(target_vendor = "apple")]
extern "C" {
/// Darwin batched receive: up to `cnt` datagrams in one syscall; returns the count received and
/// sets each `msg_datalen` to its byte length. Present in libSystem on all macOS/iOS.
fn recvmsg_x(
s: libc::c_int,
msgp: *mut MsghdrX,
cnt: libc::c_uint,
flags: libc::c_int,
) -> libc::ssize_t;
}
pub struct UdpTransport {
socket: UdpSocket,
}
@@ -87,6 +213,55 @@ impl UdpTransport {
);
}
}
/// Apple batched receive via `recvmsg_x` — drains up to `out.len()` datagrams in one syscall into
/// the caller's reused buffers (the recv counterpart of Linux `recvmmsg`, which Darwin lacks).
/// SAFETY: each `MsghdrX` holds a raw pointer into `iovs`, which holds raw pointers into `out`'s
/// buffers; both `iovs` and `msgs` stay alive and unmoved through the syscall.
#[cfg(target_vendor = "apple")]
fn recv_batch_x(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
let fd = self.socket.as_raw_fd();
let n_bufs = out.len().min(lens.len());
if n_bufs == 0 {
return Ok(0);
}
let mut iovs: Vec<libc::iovec> = out[..n_bufs]
.iter_mut()
.map(|b| libc::iovec {
iov_base: b.as_mut_ptr() as *mut libc::c_void,
iov_len: b.len(),
})
.collect();
let mut msgs: Vec<MsghdrX> = iovs
.iter_mut()
.map(|iov| {
let mut m: MsghdrX = unsafe { std::mem::zeroed() };
m.msg_iov = iov as *mut libc::iovec;
m.msg_iovlen = 1;
m
})
.collect();
let n = unsafe {
recvmsg_x(
fd,
msgs.as_mut_ptr(),
n_bufs as libc::c_uint,
libc::MSG_DONTWAIT,
)
};
if n < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
return Ok(0);
}
return Err(err);
}
for (i, m) in msgs[..n as usize].iter().enumerate() {
lens[i] = m.msg_datalen;
}
Ok(n as usize)
}
}
impl Transport for UdpTransport {
@@ -146,6 +321,52 @@ impl Transport for UdpTransport {
Ok(total_sent)
}
/// UDP GSO send (see [`Transport::send_gso`]). Coalesces the frame's equal-size packets into a
/// reused scratch buffer and hands the kernel ≤64-segment super-buffers via `sendmsg(UDP_SEGMENT)`
/// — one GSO skb per chunk instead of one per packet, the multi-Gbps lever. Opt-in
/// (`PUNKTFUNK_GSO`); falls back to `send_batch` when off, when packets aren't uniform-size, or on
/// any GSO error (which also latches it off for the process). Same lossy short-count contract.
#[cfg(target_os = "linux")]
fn send_gso(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
if packets.is_empty() {
return Ok(0);
}
if !gso::active() {
return self.send_batch(packets);
}
// GSO needs every segment but the last to be exactly `seg` bytes. Our wire packets are all
// identical size (shards zero-padded to shard_payload), but guard and fall back if not.
let seg = packets[0].len();
let last = packets.len() - 1;
if seg == 0 || packets[..last].iter().any(|p| p.len() != seg) || packets[last].len() > seg {
return self.send_batch(packets);
}
let fd = self.socket.as_raw_fd();
// A GSO super-buffer is capped at 64 segments AND 65535 payload bytes (kernel limits).
let max_seg = (65535 / seg).clamp(1, 64);
let mut scratch: Vec<u8> = Vec::with_capacity(seg * max_seg);
let mut sent = 0usize;
for chunk in packets.chunks(max_seg) {
scratch.clear();
for p in chunk {
scratch.extend_from_slice(p);
}
match send_one_gso(fd, &scratch, seg as u16) {
Ok(()) => sent += chunk.len(),
// Send buffer momentarily full — drop the rest (counted by the caller), never block.
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
// GSO unsupported on this kernel/path — latch off and finish via sendmmsg.
Err(e) if gso_unsupported(&e) => {
gso::disable();
return Ok(sent + self.send_batch(&packets[sent..])?);
}
Err(e) => return Err(e),
}
}
Ok(sent)
}
fn recv(&self) -> std::io::Result<Option<Vec<u8>>> {
let mut buf = vec![0u8; RECV_BUF];
match self.socket.recv(&mut buf) {
@@ -165,7 +386,8 @@ impl Transport for UdpTransport {
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
/// `recv`'s oversized-drop. Non-Linux falls back to the trait's scalar `recv` default.
/// `recv`'s oversized-drop. Apple/BSD use the `recv`-loop override below; other non-unix the
/// trait's scalar default.
#[cfg(target_os = "linux")]
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
@@ -204,6 +426,55 @@ impl Transport for UdpTransport {
}
Ok(n as usize)
}
/// Batched receive for Apple/BSD targets, which have no `recvmmsg(2)`. Drains up to `out.len()`
/// datagrams per call with `libc::recv(MSG_DONTWAIT)` straight into the caller's reused `out[i]`
/// buffers — eliminating the per-packet 2 KB `vec!` allocation (and its zeroing + a copy) that
/// the scalar `recv` + trait-default `recv_batch` incur. THIS is the macOS-client throughput
/// fix: at line rate the alloc/free churn — not the syscall — was the single-core wall (Moonlight
/// batches; our client per-packet-allocated). It is still one syscall per datagram (a future
/// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set
/// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`.
#[cfg(all(unix, not(target_os = "linux")))]
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
// and falls through to the always-correct scalar loop below.
#[cfg(target_vendor = "apple")]
if recvx::active() {
match self.recv_batch_x(out, lens) {
Ok(n) => return Ok(n),
Err(_) => recvx::disable(),
}
}
use std::os::fd::AsRawFd;
let fd = self.socket.as_raw_fd();
let n_bufs = out.len().min(lens.len());
let mut got = 0usize;
while got < n_bufs {
let buf = &mut out[got];
let r = unsafe {
libc::recv(
fd,
buf.as_mut_ptr() as *mut libc::c_void,
buf.len(),
libc::MSG_DONTWAIT,
)
};
if r < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
break; // socket drained
}
if got > 0 {
break; // report what we have; surface the error on the next empty poll
}
return Err(err);
}
lens[got] = r as usize;
got += 1;
}
Ok(got)
}
}
#[cfg(test)]
@@ -211,6 +482,45 @@ mod tests {
use super::*;
use crate::transport::Transport;
/// `send_one_gso` must split one buffer into N separate UDP datagrams of `gso_size` bytes each
/// (the kernel UDP GSO segmentation) — the multi-Gbps send lever. Loopback supports GSO; if the
/// CI kernel doesn't, skip rather than fail.
#[cfg(target_os = "linux")]
#[test]
fn gso_segments_into_separate_datagrams() {
use std::os::fd::AsRawFd;
let rx = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let rx_addr = rx.local_addr().unwrap();
let tx = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
tx.connect(rx_addr).unwrap();
let seg = 1000usize;
let segs = 5usize;
let mut buf = vec![0u8; seg * segs];
for i in 0..segs {
buf[i * seg..(i + 1) * seg].fill(i as u8 + 1);
}
if let Err(e) = send_one_gso(tx.as_raw_fd(), &buf, seg as u16) {
if gso_unsupported(&e) {
eprintln!("UDP GSO unsupported on this kernel — skipping");
return;
}
panic!("gso sendmsg failed: {e}");
}
// Each segment arrives as its own datagram, full size, content intact.
let mut rbuf = vec![0u8; 4096];
for i in 0..segs {
let n = rx.recv(&mut rbuf).expect("recv GSO segment");
assert_eq!(n, seg, "segment {i} should be a full {seg}-byte datagram");
assert!(
rbuf[..n].iter().all(|&b| b == i as u8 + 1),
"segment {i} content"
);
}
}
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
+310 -149
View File
@@ -466,6 +466,8 @@ mod pipewire {
negotiated: Arc<AtomicBool>,
/// Present when zero-copy is enabled: imports a dmabuf → CUDA device buffer.
importer: Option<crate::zerocopy::EglImporter>,
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
dbg_log_n: u64,
}
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
@@ -665,6 +667,29 @@ mod pipewire {
})
}
/// Build a Buffers param for a TRUE SHM path: MemPtr + MemFd only, NO DmaBuf. Forces the
/// producer to download into mappable memory (Mutter's `glReadPixels`), which orders against its
/// render — so the frame is complete and current by construction. This is the only race-free
/// capture of Mutter's virtual monitor on NVIDIA: the compositor renders straight into the buffer
/// pool, NVIDIA attaches no implicit dmabuf fence (verified: `EXPORT_SYNC_FILE` waited=false) and
/// can't produce an explicit sync_fd, so any dmabuf read (zero-copy OR mmap) races the render and
/// flashes the buffer's previous frame. Excluding DmaBuf is what makes the difference vs.
/// `build_mappable_buffers` (which still let Mutter hand dmabufs).
fn build_shm_only_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(
(1i32 << pw::spa::sys::SPA_DATA_MemPtr)
| (1i32 << pw::spa::sys::SPA_DATA_MemFd),
),
}],
})
}
/// Build a Buffers param requesting dmabuf-only buffers.
fn build_dmabuf_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
@@ -678,6 +703,205 @@ mod pipewire {
})
}
/// De-pad / import a single PipeWire buffer and push it to the encoder. Called from the
/// `.process` callback with the NEWEST drained buffer (latest-frame-only). `datas` is sourced
/// via the same transparent cast libspa's `Buffer::datas_mut` performs, so the safe `Data`
/// accessors (`.type_()`, `.chunk()`, `.data()`, `.fd()`, `.as_raw()`) keep working.
fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
// No active stream: release the buffer without the (expensive at 5K) de-pad.
if !ud.active.load(Ordering::Relaxed) {
return;
}
let datas: &mut [pw::spa::buffer::Data] = unsafe {
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
&mut []
} else {
std::slice::from_raw_parts_mut(
(*spa_buf).datas as *mut pw::spa::buffer::Data,
(*spa_buf).n_datas as usize,
)
}
};
if datas.is_empty() {
return;
}
let sz = ud.info.size();
let (w, h) = (sz.width as usize, sz.height as usize);
if w == 0 || h == 0 {
return; // format not negotiated yet
}
// Implicit-fence wait: Mutter renders into the dmabuf and hands it over at
// GPU-submit time; with no producer explicit sync (Mutter+NVIDIA can't) we snapshot
// the buffer's implicit fence and wait the producer's render before sampling —
// closing the stale/old-frame race on NVIDIA. No-op for shm buffers or drivers that
// attach no fence. Covers both the GPU import and the CPU mmap read below.
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
match crate::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) {
Ok(waited) => {
static F1: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
if F1.swap(false, Ordering::Relaxed) {
tracing::info!(
waited,
"dmabuf implicit-fence sync active (waited=true → driver fences \
the render, race closed; false → no implicit fence, zero-copy \
may still show stale frames)"
);
}
}
Err(e) => {
static F2: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
if F2.swap(false, Ordering::Relaxed) {
tracing::warn!(
error = %format!("{e}"),
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
zero-copy may show stale frames (no producer explicit sync)"
);
}
}
}
}
// Zero-copy path: if the buffer is a dmabuf and we have an importer, import it
// into a CUDA device buffer (no CPU touch) and deliver that. Otherwise fall
// through to the shm de-pad copy below.
let mut gpu_import_broken = false;
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
let plane = crate::zerocopy::DmabufPlane {
fd: datas[0].fd(),
offset: datas[0].chunk().offset(),
stride: datas[0].chunk().stride().max(0) as u32,
};
// Tiled modifier → EGL/GL de-tile import; LINEAR (0/unset, e.g.
// gamescope) → direct CUDA external-memory import (NVIDIA EGL can't
// sample LINEAR).
let modifier = (ud.modifier != 0).then_some(ud.modifier);
if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) {
let imported = if modifier.is_some() {
importer.import(&plane, w as u32, h as u32, fourcc, modifier)
} else {
importer.import_linear(&plane, w as u32, h as u32)
};
match imported {
Ok(devbuf) => {
static ONCE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::info!(
w,
h,
modifier = ud.modifier,
"zero-copy: dmabuf imported to CUDA (no CPU copy)"
);
}
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let _ = ud.tx.try_send(CapturedFrame {
width: w as u32,
height: h as u32,
pts_ns,
format: fmt,
payload: FramePayload::Cuda(devbuf),
});
return;
}
Err(e) => {
// GPU import unavailable for this buffer kind (e.g. the
// driver rejects LINEAR external-memory import). Disable
// the importer and fall through to the CPU mmap path —
// degraded, not dead.
tracing::warn!(error = %format!("{e:#}"),
"dmabuf GPU import failed — falling back to the CPU copy path");
gpu_import_broken = true;
}
}
} else {
return; // format has no DRM fourcc mapping — skip the frame
}
}
}
if gpu_import_broken {
ud.importer = None;
}
let d = &mut datas[0];
// CPU path may also receive LINEAR dmabufs (gamescope offers only those once its
// modifier-bearing format pod wins); capture the fd before `data()` borrows `d`.
let dmabuf_fd = (d.type_() == pw::spa::buffer::DataType::DmaBuf).then(|| d.fd());
let (size, offset, stride) = {
let c = d.chunk();
(
c.size() as usize,
c.offset() as usize,
c.stride().max(0) as usize,
)
};
let Some(fmt) = ud.format else { return }; // unsupported/not negotiated
let bpp = fmt.bytes_per_pixel();
let row = w * bpp;
let stride = if stride == 0 { row } else { stride };
if stride < row {
warn_once("chunk stride < row — frames dropped");
return;
}
let needed = stride * (h - 1) + row;
// dmabuf chunks commonly report size 0; fall back to the computed span.
let size = if size == 0 { needed } else { size };
// MAP_BUFFERS only maps buffers flagged mappable; Vulkan-exported dmabufs
// (gamescope) usually aren't, so mmap the fd ourselves for the de-pad read.
let _mapping; // keeps a manual mmap alive for the copy below
let buf: &[u8] = if let Some(data) = d.data() {
data
} else if let Some(fd) = dmabuf_fd.filter(|&fd| fd > 0) {
match DmabufMap::new(fd, offset + needed) {
Some(m) => {
_mapping = m;
unsafe { std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len) }
}
None => {
warn_once("mmap(dmabuf) failed — frames dropped");
return;
}
}
} else {
warn_once("buffer has no mappable data — frames dropped");
return;
};
// Need stride*(h-1)+row valid bytes within [offset, offset+size).
if offset > buf.len() {
return;
}
let avail = buf.len() - offset;
if needed > avail || needed > size {
warn_once("buffer smaller than frame span — frames dropped");
return;
}
let region = &buf[offset..offset + size.min(avail)];
// De-pad into a tightly-packed buffer (chunk stride may exceed w*bpp).
let mut tight = vec![0u8; row * h];
for y in 0..h {
tight[y * row..y * row + row].copy_from_slice(&region[y * stride..y * stride + row]);
}
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let frame = CapturedFrame {
width: w as u32,
height: h as u32,
pts_ns,
format: fmt,
payload: FramePayload::Cpu(tight),
};
// Drop if the encoder is behind — never block the pipewire loop.
let _ = ud.tx.try_send(frame);
}
#[allow(clippy::too_many_arguments)]
pub fn pipewire_thread(
fd: Option<OwnedFd>,
@@ -736,8 +960,16 @@ mod pipewire {
if importer.is_some() && !modifiers.contains(&0) {
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
}
let want_dmabuf = importer.is_some() && !modifiers.is_empty();
if zerocopy && !want_dmabuf {
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — required on
// Mutter+NVIDIA where dmabuf capture has no working sync and shows stale frames. KWin/
// gamescope don't need it (they blit into the buffer, so no read-before-render race).
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
let want_dmabuf = importer.is_some() && !modifiers.is_empty() && !force_shm;
if force_shm {
tracing::info!(
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
);
} else if zerocopy && !want_dmabuf {
tracing::warn!("zero-copy: no EGL-importable dmabuf modifiers — using CPU path");
} else if want_dmabuf {
tracing::info!(
@@ -755,6 +987,7 @@ mod pipewire {
active,
negotiated,
importer,
dbg_log_n: 0,
};
let stream = pw::stream::StreamBox::new(
@@ -818,159 +1051,84 @@ mod pipewire {
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
// panic crossing that FFI boundary would abort the whole host. Contain it.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
// No active stream: release the buffer without the (expensive at 5K) de-pad.
if !ud.active.load(Ordering::Relaxed) {
return;
}
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let sz = ud.info.size();
let (w, h) = (sz.width as usize, sz.height as usize);
if w == 0 || h == 0 {
return; // format not negotiated yet
}
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
// queued buffers, requeue the older ones, keep only the newest.
let mut newest = unsafe { stream.dequeue_raw_buffer() };
if newest.is_null() {
return;
}
let mut drained = 1u32;
loop {
let next = unsafe { stream.dequeue_raw_buffer() };
if next.is_null() {
break;
}
unsafe { stream.queue_raw_buffer(newest) };
newest = next;
drained += 1;
}
let spa_buf = unsafe { (*newest).buffer };
// Zero-copy path: if the buffer is a dmabuf and we have an importer, import it
// into a CUDA device buffer (no CPU touch) and deliver that. Otherwise fall
// through to the shm de-pad copy below.
let mut gpu_import_broken = false;
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
let plane = crate::zerocopy::DmabufPlane {
fd: datas[0].fd(),
offset: datas[0].chunk().offset(),
stride: datas[0].chunk().stride().max(0) as u32,
};
// Tiled modifier → EGL/GL de-tile import; LINEAR (0/unset, e.g.
// gamescope) → direct CUDA external-memory import (NVIDIA EGL can't
// sample LINEAR).
let modifier = (ud.modifier != 0).then_some(ud.modifier);
if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) {
let imported = if modifier.is_some() {
importer.import(&plane, w as u32, h as u32, fourcc, modifier)
} else {
importer.import_linear(&plane, w as u32, h as u32)
};
match imported {
Ok(devbuf) => {
static ONCE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::info!(w, h, modifier = ud.modifier,
"zero-copy: dmabuf imported to CUDA (no CPU copy)");
}
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let _ = ud.tx.try_send(CapturedFrame {
width: w as u32,
height: h as u32,
pts_ns,
format: fmt,
payload: FramePayload::Cuda(devbuf),
});
return;
}
Err(e) => {
// GPU import unavailable for this buffer kind (e.g. the
// driver rejects LINEAR external-memory import). Disable
// the importer and fall through to the CPU mmap path —
// degraded, not dead.
tracing::warn!(error = %format!("{e:#}"),
"dmabuf GPU import failed — falling back to the CPU copy path");
gpu_import_broken = true;
}
}
// Inspect the newest buffer's header + first chunk for the diagnostic and the
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
let hdr = unsafe {
spa::sys::spa_buffer_find_meta_data(
spa_buf,
spa::sys::SPA_META_Header,
std::mem::size_of::<spa::sys::spa_meta_header>(),
) as *const spa::sys::spa_meta_header
};
let hdr_flags = if hdr.is_null() {
0u32
} else {
unsafe { (*hdr).flags }
};
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
// stale skip only applies to mappable SHM buffers).
let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
if !spa_buf.is_null()
&& (*spa_buf).n_datas > 0
&& !(*spa_buf).datas.is_null()
&& !(*(*spa_buf).datas).chunk.is_null()
{
let d0 = (*spa_buf).datas;
let c = (*d0).chunk;
let is_dmabuf =
(*d0).type_ == spa::sys::SPA_DATA_DmaBuf;
((*c).size, (*c).flags, is_dmabuf)
} else {
return; // format has no DRM fourcc mapping — skip the frame
(0u32, 0i32, false)
}
}
}
if gpu_import_broken {
ud.importer = None;
}
};
let d = &mut datas[0];
// CPU path may also receive LINEAR dmabufs (gamescope offers only those once its
// modifier-bearing format pod wins); capture the fd before `data()` borrows `d`.
let dmabuf_fd =
(d.type_() == pw::spa::buffer::DataType::DmaBuf).then(|| d.fd());
let (size, offset, stride) = {
let c = d.chunk();
(
c.size() as usize,
c.offset() as usize,
c.stride().max(0) as usize,
)
};
let Some(fmt) = ud.format else { return }; // unsupported/not negotiated
let bpp = fmt.bytes_per_pixel();
let row = w * bpp;
let stride = if stride == 0 { row } else { stride };
if stride < row {
warn_once("chunk stride < row — frames dropped");
return;
}
let needed = stride * (h - 1) + row;
// dmabuf chunks commonly report size 0; fall back to the computed span.
let size = if size == 0 { needed } else { size };
// MAP_BUFFERS only maps buffers flagged mappable; Vulkan-exported dmabufs
// (gamescope) usually aren't, so mmap the fd ourselves for the de-pad read.
let _mapping; // keeps a manual mmap alive for the copy below
let buf: &[u8] = if let Some(data) = d.data() {
data
} else if let Some(fd) = dmabuf_fd.filter(|&fd| fd > 0) {
match DmabufMap::new(fd, offset + needed) {
Some(m) => {
_mapping = m;
unsafe {
std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len)
}
}
None => {
warn_once("mmap(dmabuf) failed — frames dropped");
return;
let corrupted = (hdr_flags & spa::sys::SPA_META_HEADER_FLAG_CORRUPTED) != 0
|| (chunk_flags & spa::sys::SPA_CHUNK_FLAG_CORRUPTED as i32) != 0;
// THE GNOME FLASH FIX: skip Mutter's CORRUPTED / size-0 cursor-update buffers.
// When the pointer moves (e.g. dragging a window) Mutter sends metadata-only
// buffers flagged CORRUPTED (chunk size 0) that still reference a RECYCLED old
// frame; consuming them encodes "the window at its old position" — the flash.
// Confirmed live on worker-3 (chunk_flags=CORRUPTED, size 0) for both the zero-copy
// and SHM paths. The size-0 half is SHM-only (a real dmabuf legitimately reports
// chunk size 0). `drained` is the latest-frame-only depth — a cheap extra defense
// against bursty delivery, though here Mutter sends one buffer per callback.
if corrupted || (chunk_size == 0 && !is_dmabuf) {
ud.dbg_log_n += 1;
if ud.dbg_log_n.is_power_of_two() {
tracing::debug!(
skipped = ud.dbg_log_n,
drained,
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
);
}
unsafe { stream.queue_raw_buffer(newest) };
return;
}
} else {
warn_once("buffer has no mappable data — frames dropped");
return;
};
// Need stride*(h-1)+row valid bytes within [offset, offset+size).
if offset > buf.len() {
return;
}
let avail = buf.len() - offset;
if needed > avail || needed > size {
warn_once("buffer smaller than frame span — frames dropped");
return;
}
let region = &buf[offset..offset + size.min(avail)];
// De-pad into a tightly-packed buffer (chunk stride may exceed w*bpp).
let mut tight = vec![0u8; row * h];
for y in 0..h {
tight[y * row..y * row + row]
.copy_from_slice(&region[y * stride..y * stride + row]);
}
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let frame = CapturedFrame {
width: w as u32,
height: h as u32,
pts_ns,
format: fmt,
payload: FramePayload::Cpu(tight),
};
// Drop if the encoder is behind — never block the pipewire loop.
let _ = ud.tx.try_send(frame);
consume_frame(ud, spa_buf);
unsafe { stream.queue_raw_buffer(newest) };
}));
if outcome.is_err() {
tracing::error!("panic in pipewire process callback — frame dropped");
@@ -1036,6 +1194,9 @@ mod pipewire {
Some(build_dmabuf_format(&modifiers, preferred)?),
Some(build_dmabuf_buffers()?),
)
} else if force_shm {
// True SHM: exclude DmaBuf so Mutter MUST download (glReadPixels orders against render).
(None, Some(build_shm_only_buffers()?))
} else {
// CPU path still accepts mappable dmabufs (gamescope offers only those once its
// modifier-bearing format pod wins the intersection).
+75
View File
@@ -0,0 +1,75 @@
//! Consumer-side implicit-fence wait for dmabuf capture (`DMA_BUF_IOCTL_EXPORT_SYNC_FILE`).
//!
//! Mutter renders its virtual monitor DIRECTLY into the PipeWire dmabuf and hands the buffer over
//! at GPU-submit time. With no fencing the consumer can sample mid-render and encode the buffer's
//! *previous* contents — the "stale/old frame" flashing on NVIDIA (KWin/gamescope blit into the
//! buffer so they don't hit this). The producer-driven fix is PipeWire explicit sync, but
//! Mutter+NVIDIA can't produce a sync_fd (`error alloc buffers` / no cogl sync_fd).
//!
//! So sync from the *consumer* side instead: a dmabuf carries its in-flight GPU work as an implicit
//! fence on its reservation object. `DMA_BUF_IOCTL_EXPORT_SYNC_FILE` snapshots that into a sync_file
//! fd we can `poll()` — readable once the producer's writes complete. This makes zero-copy capture
//! race-free WITHOUT the producer doing anything, *iff* the driver actually attaches the fence. If it
//! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no
//! wait, no harm, and `waited=false` tells us the driver doesn't fence (so zero-copy would still race).
use std::os::fd::RawFd;
// linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr.
const DMA_BUF_BASE: u64 = 0x62;
const fn iowr(nr: u32, size: usize) -> u64 {
(3u64 << 30) | ((size as u64) << 16) | (DMA_BUF_BASE << 8) | nr as u64
}
#[repr(C)]
struct DmaBufExportSyncFile {
flags: u32,
fd: i32,
}
const DMA_BUF_IOCTL_EXPORT_SYNC_FILE: u64 = iowr(2, std::mem::size_of::<DmaBufExportSyncFile>());
/// We will READ the buffer → export the fence(s) we must wait for before reading (the producer's writes).
const DMA_BUF_SYNC_READ: u32 = 1 << 0;
/// Wait until the producer's writes to `dmabuf_fd` complete (or `timeout_ms` elapses). Returns:
/// - `Ok(true)` — a render was still in flight and we waited on its fence (the race was real, now closed).
/// - `Ok(false)` — no fence / already signaled (the driver attaches no implicit fence; zero-copy can race).
/// - `Err` — the ioctl failed (e.g. the kernel/driver lacks `EXPORT_SYNC_FILE`).
pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<bool> {
let mut req = DmaBufExportSyncFile {
flags: DMA_BUF_SYNC_READ,
fd: -1,
};
let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) };
if r < 0 {
return Err(std::io::Error::last_os_error());
}
let sync_fd = req.fd;
if sync_fd < 0 {
return Ok(false); // no sync_file exported
}
let mut pfd = libc::pollfd {
fd: sync_fd,
events: libc::POLLIN,
revents: 0,
};
// Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering.
let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0;
if pending {
pfd.revents = 0;
unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals
}
unsafe { libc::close(sync_fd) };
Ok(pending)
}
#[cfg(test)]
mod tests {
use super::*;
/// The ioctl number must match linux/dma-buf.h exactly — it's computed, so lock it down.
#[test]
fn ioctl_number_matches_dma_buf_h() {
assert_eq!(DMA_BUF_IOCTL_EXPORT_SYNC_FILE, 0xC008_6202);
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Minimal DRM timeline-syncobj operations — the consumer side of PipeWire explicit sync
//! (`SPA_META_SyncTimeline`).
//!
//! RETAINED BUT CURRENTLY UNUSED: producer-driven explicit sync is the "right" fix, but no
//! compositor we target produces a usable sync_fd today — Mutter+NVIDIA fails buffer allocation
//! (`error alloc buffers`, no cogl sync_fd), KWin/gamescope blit so they don't race at all. We sync
//! zero-copy from the consumer side instead (see [`crate::dmabuf_fence`]). This module is kept,
//! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer
//! gains working `SPA_META_SyncTimeline`.
#![allow(dead_code)]
//!
//! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual
//! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf
//! fencing (NVIDIA) reading immediately races the render and shows the buffer's
//! *previous* contents. With explicit sync the producer attaches a timeline syncobj:
//! wait the acquire point before touching the buffer, signal the release point when done.
//!
//! Syncobjs are DRM-core objects: any render node can import and wait them, so this
//! opens its own fd independent of the capture GPU path.
use anyhow::{bail, Result};
use std::os::fd::RawFd;
// drm.h ioctls on the 'd' (0x64) magic. _IOWR = dir(3)<<30 | size<<16 | 0x64<<8 | nr.
const fn iowr(nr: u32, size: usize) -> u64 {
(3u64 << 30) | ((size as u64) << 16) | (0x64u64 << 8) | nr as u64
}
#[repr(C)]
#[derive(Default)]
struct DrmSyncobjHandle {
handle: u32,
flags: u32,
fd: i32,
pad: u32,
}
#[repr(C)]
#[derive(Default)]
struct DrmSyncobjDestroy {
handle: u32,
pad: u32,
}
#[repr(C)]
#[derive(Default)]
struct DrmSyncobjTimelineWait {
handles: u64,
points: u64,
/// Absolute CLOCK_MONOTONIC deadline, nanoseconds.
timeout_nsec: i64,
count_handles: u32,
flags: u32,
first_signaled: u32,
pad: u32,
}
#[repr(C)]
#[derive(Default)]
struct DrmSyncobjTimelineArray {
handles: u64,
points: u64,
count_handles: u32,
flags: u32,
}
const DRM_IOCTL_SYNCOBJ_DESTROY: u64 = iowr(0xC0, std::mem::size_of::<DrmSyncobjDestroy>());
const DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE: u64 = iowr(0xC2, std::mem::size_of::<DrmSyncobjHandle>());
const DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT: u64 =
iowr(0xCA, std::mem::size_of::<DrmSyncobjTimelineWait>());
const DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL: u64 =
iowr(0xCD, std::mem::size_of::<DrmSyncobjTimelineArray>());
/// The producer's point may not be attached yet when the buffer reaches us.
const DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT: u32 = 1 << 1;
pub struct DrmSync {
fd: RawFd,
}
impl DrmSync {
pub fn open() -> Result<DrmSync> {
let path = c"/dev/dri/renderD128";
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
if fd < 0 {
bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno());
}
Ok(DrmSync { fd })
}
/// Import a syncobj fd into a (temporary) handle on our device.
fn import(&self, syncobj_fd: RawFd) -> Result<u32> {
let mut req = DrmSyncobjHandle {
fd: syncobj_fd,
..Default::default()
};
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) };
if r < 0 {
bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno());
}
Ok(req.handle)
}
fn destroy(&self, handle: u32) {
let mut req = DrmSyncobjDestroy {
handle,
..Default::default()
};
unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) };
}
/// Block until `point` on the producer's timeline is signaled (the buffer's contents
/// are ready), or `timeout_ms` passes.
pub fn wait_point(&self, syncobj_fd: RawFd, point: u64, timeout_ms: u64) -> Result<()> {
let handle = self.import(syncobj_fd)?;
let mut now = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) };
let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000;
let handles = [handle];
let points = [point];
let mut req = DrmSyncobjTimelineWait {
handles: handles.as_ptr() as u64,
points: points.as_ptr() as u64,
timeout_nsec: deadline,
count_handles: 1,
flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT,
..Default::default()
};
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) };
let saved = errno();
self.destroy(handle);
if r < 0 {
bail!("SYNCOBJ_TIMELINE_WAIT(point {point}): {saved}");
}
Ok(())
}
/// Signal `point` on the consumer release timeline — the producer may reuse the
/// buffer. Must be called for every buffer that carried sync metadata, even when the
/// frame was skipped, or the producer stalls waiting for it.
pub fn signal_point(&self, syncobj_fd: RawFd, point: u64) -> Result<()> {
let handle = self.import(syncobj_fd)?;
let handles = [handle];
let points = [point];
let mut req = DrmSyncobjTimelineArray {
handles: handles.as_ptr() as u64,
points: points.as_ptr() as u64,
count_handles: 1,
flags: 0,
};
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) };
let saved = errno();
self.destroy(handle);
if r < 0 {
bail!("SYNCOBJ_TIMELINE_SIGNAL(point {point}): {saved}");
}
Ok(())
}
}
impl Drop for DrmSync {
fn drop(&mut self) {
unsafe { libc::close(self.fd) };
}
}
fn errno() -> std::io::Error {
std::io::Error::last_os_error()
}
// `DrmSync::open` must not panic the PipeWire thread; everything is Result-based and the
// caller degrades to unsynchronized capture (with a loud warning) when it fails.
#[cfg(test)]
mod tests {
use super::*;
/// The ioctl numbers must match drm.h exactly — computed, so lock them down.
#[test]
fn ioctl_numbers_match_drm_h() {
assert_eq!(DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, 0xC010_64C2);
assert_eq!(DRM_IOCTL_SYNCOBJ_DESTROY, 0xC008_64C0);
assert_eq!(DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, 0xC028_64CA);
assert_eq!(DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, 0xC018_64CD);
}
/// Round-trip against the real DRM device when one exists (CI containers skip).
#[test]
fn signal_then_wait_roundtrip() {
let Ok(sync) = DrmSync::open() else {
eprintln!("no render node — skipping");
return;
};
// Create a fresh syncobj (CREATE = 0xBF), export it, signal point 1, wait point 1.
#[repr(C)]
#[derive(Default)]
struct Create {
handle: u32,
flags: u32,
}
const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>());
const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>());
let mut c = Create::default();
assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0);
let mut h = DrmSyncobjHandle {
handle: c.handle,
..Default::default()
};
assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0);
sync.signal_point(h.fd, 1).expect("signal");
sync.wait_point(h.fd, 1, 100).expect("wait after signal");
unsafe { libc::close(h.fd) };
sync.destroy(c.handle);
}
}
+18
View File
@@ -160,6 +160,24 @@ impl NvencEncoder {
video.set_frame_rate(Some(Rational(fps as i32, 1)));
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize);
// VBV/HRD buffer — bound the SIZE of any single frame. Under CBR with no buffer set, NVENC
// uses a loose default VBV, so a high-motion P-frame is allowed to balloon to many times the
// average; those extra packets overflow the bounded send queue + kernel socket buffer and
// get dropped, which the client sees as framedrops/jitter (and, on the infinite-GOP path, as
// old/stale frames flashing until the next RFI). A tight ~1-frame buffer makes the encoder
// hold frame size roughly constant and absorb motion as a momentary QP (quality) dip instead
// — the trade we want. Default = 1 frame of bits (bitrate/fps); PUNKTFUNK_VBV_FRAMES tunes it
// (larger = better motion quality but bigger per-frame bursts).
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0);
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
.clamp(1.0, i32::MAX as f64);
unsafe {
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
}
video.set_max_b_frames(0);
// Infinite GOP — NO periodic IDR. A keyframe at 5120x1440 is ~20-40x a P-frame, so a
// periodic IDR is a recurring multi-millisecond encode+packetize+send spike — the ~2s
+85 -5
View File
@@ -163,6 +163,11 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
}
}
}
// A client that vanished mid-press must not leave keys/buttons latched in the
// compositor — Mutter keeps the implicit grab of a destroyed device's button and the
// focused app stops taking clicks until it is restarted. Release everything still
// held before the EIS connection (and its devices) go away.
state.release_all(&context);
}
/// Tie down the verbose tuple the connect step returns. The keep-alive must stay alive for the
@@ -360,6 +365,14 @@ struct EiState {
/// kind a client sends + whether it emitted, so an unexpected client — e.g. a touch-only
/// tablet hitting a compositor without ei_touchscreen — is immediately diagnosable).
seen_kinds: u32,
/// Wire codes currently held down (keys = VK, buttons = GameStream ids, touches = ids)
/// — synthesized back up at session end ([`EiState::release_all`]). A client that
/// vanishes mid-press must not leave the compositor with a latched key or an implicit
/// pointer grab: observed on Mutter, a button held by a destroyed EIS device wedges
/// click delivery to the focused app until that app is restarted.
held_keys: Vec<u32>,
held_buttons: Vec<u32>,
held_touches: Vec<u32>,
}
/// Stable small index per [`InputKind`] for the `seen_kinds` bitmask.
@@ -390,6 +403,47 @@ impl EiState {
start: Instant::now(),
injected: 0,
seen_kinds: 0,
held_keys: Vec::new(),
held_buttons: Vec::new(),
held_touches: Vec::new(),
}
}
/// Release everything the remote client still holds — called when the session ends
/// (client gone, EIS closing). Synthesizes wire-level release events through the
/// normal [`EiState::inject`] path so the compositor sees proper key-up / button-up /
/// touch-up frames before the devices disappear.
fn release_all(&mut self, ctx: &ei::Context) {
let (keys, buttons, touches) = (
std::mem::take(&mut self.held_keys),
std::mem::take(&mut self.held_buttons),
std::mem::take(&mut self.held_touches),
);
if keys.is_empty() && buttons.is_empty() && touches.is_empty() {
return;
}
tracing::info!(
keys = keys.len(),
buttons = buttons.len(),
touches = touches.len(),
"libei: releasing input still held at session end"
);
let release = |kind: InputKind, code: u32| InputEvent {
kind,
_pad: [0; 3],
code,
x: 0,
y: 0,
flags: 0,
};
for code in buttons {
self.inject(&release(InputKind::MouseButtonUp, code), ctx);
}
for code in keys {
self.inject(&release(InputKind::KeyUp, code), ctx);
}
for id in touches {
self.inject(&release(InputKind::TouchUp, id), ctx);
}
}
@@ -561,15 +615,24 @@ impl EiState {
}
InputKind::MouseScroll => match slot.interface::<ei::Scroll>() {
Some(s) => {
// GameStream sends WHEEL_DELTA(120)-scaled deltas in `x`; ei scroll_discrete
// uses the same 120-per-detent unit. Positive GameStream = up (vertical),
// which is negative on the ei axis, but = RIGHT (horizontal), which is
// already positive there (moonlight-qt/Sunshine pass horizontal through
// unnegated) — only the vertical axis flips.
// Wire deltas are WHEEL_DELTA(120)-scaled in `x`. Emit BOTH ei scroll axes
// from it: `scroll_discrete` (120-per-detent — drives line/page scrolling)
// AND the continuous `scroll` axis in logical px (≈15 px/detent). Without
// the continuous axis Mutter floors a sub-detent delta (trackpad / precise
// wheel / fractional smooth scroll) to zero whole clicks, so small scrolls
// never register and you have to spin the wheel a lot — emitting the pixel
// axis too makes every delta move proportionally (matches the wlr backend's
// 15 px/notch). Positive wire = up (vertical, negated on the ei axis) /
// RIGHT (horizontal, already positive — moonlight-qt/Sunshine pass it
// through unnegated); only the vertical axis flips.
const PX_PER_DETENT: f32 = 15.0;
let px = ev.x as f32 / 120.0 * PX_PER_DETENT;
if ev.code == SCROLL_HORIZONTAL {
s.scroll_discrete(ev.x, 0);
s.scroll(px, 0.0);
} else {
s.scroll_discrete(0, -ev.x);
s.scroll(0.0, -px);
}
}
None => emitted = false,
@@ -620,6 +683,23 @@ impl EiState {
}
if emitted {
// Track held state on the wire codes so `release_all` can undo it at
// session end (vanished clients must not leave anything latched).
match ev.kind {
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
self.held_keys.push(ev.code);
}
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
self.held_buttons.push(ev.code);
}
InputKind::MouseButtonUp => self.held_buttons.retain(|&c| c != ev.code),
InputKind::TouchDown if !self.held_touches.contains(&ev.code) => {
self.held_touches.push(ev.code);
}
InputKind::TouchUp => self.held_touches.retain(|&c| c != ev.code),
_ => {}
}
dev.frame(self.last_serial, self.now_us());
}
if let Err(e) = ctx.flush() {
+224 -15
View File
@@ -28,7 +28,7 @@ use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
endpoint, io, ClockEcho, ClockProbe, Hello, PairChallenge, PairProof, PairRequest, PairResult,
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, Start, Welcome,
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::Session;
@@ -313,7 +313,11 @@ const DEFAULT_BITRATE_KBPS: u32 = 20_000;
/// clean 1 Gbps with zero send-buffer drops; sustained overruns are still counted as
/// `packets_send_dropped`.
const MIN_BITRATE_KBPS: u32 = 500;
const MAX_BITRATE_KBPS: u32 = 2_000_000;
// 8 Gbps ceiling — headroom for a 2.5 Gbps link and the 5 Gbps path (home-worker-3 → Mac Studio,
// Mac is 10G). The encoder is pixel-rate bound, not bitrate bound (NVENC emits multi-Gbps trivially;
// ~1 Gpix/s per engine, ~2 with the auto 2-way split), so the real ceiling is the transport send
// path (UDP GSO + per-packet alloc removal), not this number.
const MAX_BITRATE_KBPS: u32 = 8_000_000;
/// Resolve a client's [`Hello::bitrate_kbps`] request to the rate the host will configure:
/// `0` → host default; anything else clamped into `[MIN, MAX]`.
@@ -325,6 +329,17 @@ fn resolve_bitrate_kbps(requested: u32) -> u32 {
}
}
/// FEC recovery percent for the session's Welcome. Default 20% (Sunshine's default too); a clean
/// wired LAN can lower it (every recovery shard is wire bytes + packets), so `PUNKTFUNK_FEC_PCT`
/// overrides it — e.g. `0` disables FEC entirely, `10` halves the overhead. Clamped to ≤ 90.
fn fec_percent_from_env() -> u8 {
std::env::var("PUNKTFUNK_FEC_PCT")
.ok()
.and_then(|s| s.trim().parse::<u8>().ok())
.map(|p| p.min(90))
.unwrap_or(20)
}
/// Persistent audio-capturer slot, reused across sessions (same pattern as the GameStream
/// path): keeps one warm PipeWire capture stream instead of a connect/negotiate cycle —
/// and a daemon-side node churn — per session. (Drop now tears a capturer down cleanly.)
@@ -454,13 +469,34 @@ async fn serve_session(
punktfunk_core::ABI_VERSION
);
if opts.require_pairing {
let known = endpoint::peer_fingerprint(&conn)
.map(|fp| np.is_paired(&fingerprint_hex(&fp)))
let fp = endpoint::peer_fingerprint(&conn);
let known = fp
.as_ref()
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
.unwrap_or(false);
anyhow::ensure!(
known,
"unpaired client rejected (this host requires pairing — run the PIN ceremony first)"
);
if !known {
// Delegated approval (§8b-1): an identified-but-unpaired knock becomes a pending
// request the operator can approve from the console — no PIN fetched out of band.
// The label is the client's Hello name, else fingerprint-derived. An anonymous
// client (no certificate) has no identity to approve, so nothing is recorded.
if let Some(fp) = &fp {
let fp_hex = fingerprint_hex(fp);
// Sanitize the wire-supplied name before it reaches the log (untrusted: an
// unpaired device could embed terminal escapes / bidi overrides); note_pending
// stores the same sanitized form and derives a fingerprint label when empty.
let label = crate::native_pairing::sanitize_device_name(
hello.name.as_deref().unwrap_or(""),
&fp_hex,
);
tracing::info!(name = %label, fingerprint = %fp_hex,
"unpaired device knocked — held for approval in the console");
np.note_pending(&label, &fp_hex);
}
anyhow::bail!(
"unpaired client rejected (this host requires pairing — approve the device \
in the console, or run the PIN ceremony)"
);
}
}
crate::encode::validate_dimensions(
crate::encode::Codec::H265,
@@ -510,10 +546,14 @@ async fn serve_session(
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 20,
fec_percent: fec_percent_from_env(),
max_data_per_block: 4096,
},
shard_payload: 1200,
// ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24
// crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even
// size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a
// future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
shard_payload: 1452,
encrypt: true,
key,
salt: *b"pkf1",
@@ -557,6 +597,7 @@ async fn serve_session(
// hands back a ProbeResult that this task writes to the client. The two control directions
// (inbound requests, outbound probe results) are multiplexed with `select!`.
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
let (keyframe_tx, keyframe_rx) = std::sync::mpsc::channel::<()>();
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
let (probe_result_tx, mut probe_result_rx) =
tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
@@ -587,6 +628,14 @@ async fn serve_session(
if ok && reconfig_tx.send(req.mode).is_err() {
break; // data plane gone
}
} else if RequestKeyframe::decode(&msg).is_ok() {
// Client recovery: its decoder wedged — force the next encoded frame to
// be an IDR. Coalesced in the encode loop (a wedge fires several before
// the IDR lands); a send error just means the data plane is gone.
tracing::debug!("client requested keyframe (decode recovery)");
if keyframe_tx.send(()).is_err() {
break; // data plane gone
}
} else if let Ok(req) = ProbeRequest::decode(&msg) {
tracing::info!(
target_kbps = req.target_kbps,
@@ -761,6 +810,7 @@ async fn serve_session(
seconds,
stop_stream,
&reconfig_rx,
&keyframe_rx,
compositor,
bitrate_kbps,
probe_rx,
@@ -1117,6 +1167,14 @@ fn input_thread(
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
let mut rumble_seen = [false; MAX_WIRE_PADS];
let mut last_refresh = std::time::Instant::now();
// Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a
// press left dangling by an abrupt client disconnect stays latched in the compositor across the
// reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
// see the release loop after the `break`.
let mut held_buttons: Vec<u32> = Vec::new();
let mut held_keys: Vec<u32> = Vec::new();
loop {
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
Ok(ev) => match ev.kind {
@@ -1132,6 +1190,18 @@ fn input_thread(
}
}
_ => {
// Track press/release so a mid-press disconnect can be undone below.
match ev.kind {
InputKind::MouseButtonDown if !held_buttons.contains(&ev.code) => {
held_buttons.push(ev.code)
}
InputKind::MouseButtonUp => held_buttons.retain(|&c| c != ev.code),
InputKind::KeyDown if !held_keys.contains(&ev.code) => {
held_keys.push(ev.code)
}
InputKind::KeyUp => held_keys.retain(|&c| c != ev.code),
_ => {}
}
// Pointer/keyboard → the host-lifetime injector service (one persistent
// portal session for every punktfunk/1 session). A send error only means the
// service thread is gone (host shutting down) — dropping the event is fine,
@@ -1172,6 +1242,38 @@ fn input_thread(
}
}
}
// Session ended (client gone). Release anything still held through the host-lifetime injector —
// its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this
// session, so without this a button pressed at disconnect stays latched and breaks clicks for
// the next session. Mirror of the injector's own release_all, but keyed off the session, which
// is where a client actually vanishes mid-press.
if !held_buttons.is_empty() || !held_keys.is_empty() {
tracing::debug!(
buttons = held_buttons.len(),
keys = held_keys.len(),
"input: releasing held buttons/keys at session end"
);
}
for code in held_buttons {
let _ = inj_tx.send(InputEvent {
kind: InputKind::MouseButtonUp,
_pad: [0; 3],
code,
x: 0,
y: 0,
flags: 0,
});
}
for code in held_keys {
let _ = inj_tx.send(InputEvent {
kind: InputKind::KeyUp,
_pad: [0; 3],
code,
x: 0,
y: 0,
flags: 0,
});
}
}
/// The audio thread: desktop capture → Opus (48 kHz stereo, 5 ms, CBR — same tuning as the
@@ -1388,7 +1490,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
/// bitrate cap ([`MAX_BITRATE_KBPS`], 2 Gbps) on purpose — a probe should be able to demonstrate
/// headroom past the rate a session will actually be configured to use, so the client can pick a
/// confident 1 Gbps+ bitrate. GF(2¹⁶) FEC makes multi-Gbps reachable on a LAN.
const MAX_PROBE_KBPS: u32 = 3_000_000;
const MAX_PROBE_KBPS: u32 = 10_000_000;
const MAX_PROBE_MS: u32 = 5_000;
/// Run a bandwidth probe over `session`: burst zero-filled access units flagged [`FLAG_PROBE`] at
@@ -1538,10 +1640,10 @@ fn paced_submit(
}
}
}
Ok(PaceStat {
spread_us: start.elapsed().as_micros() as u32,
paced,
})
let spread_us = start.elapsed().as_micros() as u32;
drop(refs); // release the borrow of `wires` so it can return to the seal pool
session.reclaim_wires(wires);
Ok(PaceStat { spread_us, paced })
}
/// Percentile of a slice (sorts it in place first). `q` in 0.0..=1.0.
@@ -1667,6 +1769,7 @@ fn virtual_stream(
seconds: u32,
stop: Arc<AtomicBool>,
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
keyframe: &std::sync::mpsc::Receiver<()>,
compositor: crate::vdisplay::Compositor,
bitrate_kbps: u32,
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
@@ -1741,6 +1844,18 @@ fn virtual_stream(
}
}
}
// Client recovery: it asked for a fresh IDR (its decoder wedged on the cold opening
// GOP). Coalesce the backlog — several requests fire before the IDR lands — and force
// the next encoded frame to be a keyframe. (A reconfig rebuild above already opens with
// an IDR, so this is for the steady-state wedge, not mode switches.)
let mut want_kf = false;
while keyframe.try_recv().is_ok() {
want_kf = true;
}
if want_kf {
tracing::debug!("forcing keyframe (client decode recovery)");
enc.request_keyframe();
}
if let Some(f) = capturer.try_latest().context("capture")? {
frame = f;
}
@@ -2206,6 +2321,100 @@ mod tests {
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
}
/// Delegated approval (§8b-1) end to end in-process: an identified-but-unpaired client's
/// knock on a pairing-required host is held as a pending request (fingerprint-derived label —
/// the connector sends no Hello name); approving it pairs the fingerprint, and the same
/// identity then gets a session with no PIN ceremony.
#[test]
fn delegated_approval_admits_after_knock() {
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
let store =
std::env::temp_dir().join(format!("pf-approval-test-{}.json", std::process::id()));
let _ = std::fs::remove_file(&store);
let np = Arc::new(NativePairing::load_with(Some(store.clone()), None, false).unwrap());
let np_host = np.clone();
let host = std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
rt.block_on(serve(
M3Options {
port: 19779,
source: M3Source::Synthetic,
seconds: 0,
frames: 25,
max_sessions: 2, // the knock + the post-approval session
max_concurrent: 1,
require_pairing: true,
allow_pairing: false,
pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle
},
np_host,
))
});
std::thread::sleep(std::time::Duration::from_millis(500));
let timeout = std::time::Duration::from_secs(10);
let (cert, key) = endpoint::generate_identity().unwrap();
let mode = punktfunk_core::Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
// 1: the knock — an identified-but-unpaired connect is rejected, but lands in pending.
assert!(
NativeClient::connect(
"127.0.0.1",
19779,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
None,
Some((cert.clone(), key.clone())),
timeout
)
.is_err(),
"unpaired knock must still be rejected"
);
let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
let pend = np.pending();
assert_eq!(pend.len(), 1, "the knock must be held for approval");
assert_eq!(pend[0].fingerprint, expected_fp);
assert!(
pend[0].name.starts_with("device "),
"no Hello name → fingerprint-derived label, got {:?}",
pend[0].name
);
// 2: approve (with an operator label) → the same identity now gets a session, no PIN.
let approved = np
.approve_pending(pend[0].id, Some("Approved Device"))
.unwrap()
.expect("pending id must approve");
assert_eq!(approved.fingerprint, expected_fp);
let client = NativeClient::connect(
"127.0.0.1",
19779,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0,
None,
Some((cert, key)),
timeout,
)
.expect("approved identity gets a session");
drop(client);
let _ = std::fs::remove_file(&store);
host.join().unwrap().unwrap();
}
/// The PIN pairing ceremony + the --require-pairing gate, end to end in-process:
/// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired
/// identity gets a session on a pairing-required host; an anonymous client does not.
+2
View File
@@ -16,6 +16,8 @@
mod audio;
mod capture;
mod discovery;
mod dmabuf_fence;
mod drm_sync;
mod encode;
mod gamestream;
mod inject;
+224
View File
@@ -145,6 +145,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(disarm_native_pairing))
.routes(routes!(list_native_clients))
.routes(routes!(unpair_native_client))
.routes(routes!(list_pending_devices))
.routes(routes!(approve_pending_device))
.routes(routes!(deny_pending_device))
.routes(routes!(stop_session))
.routes(routes!(request_idr)),
)
@@ -379,6 +382,29 @@ struct NativeClient {
fingerprint: String,
}
/// An unpaired device that tried to connect while the host requires pairing — awaiting
/// **delegated approval** (approve it here instead of fetching the host PIN out of band).
#[derive(Serialize, ToSchema)]
struct PendingDevice {
/// Id to address approve/deny (per-process; entries expire after ~10 minutes).
id: u32,
/// Best-effort device label (the client's own name, else fingerprint-derived).
#[schema(example = "Enrico's MacBook")]
name: String,
/// Hex SHA-256 of the device's certificate — what approval pins.
fingerprint: String,
/// Seconds since the device last knocked.
age_secs: u64,
}
/// Approve-pending-device request body. Send `{}` to keep the device's own name.
#[derive(Deserialize, ToSchema)]
struct ApprovePending {
/// Operator-chosen label for the device (defaults to the name it knocked with).
#[schema(example = "Living Room TV")]
name: Option<String>,
}
/// Error envelope for every non-2xx response.
#[derive(Serialize, Deserialize, ToSchema)]
struct ApiError {
@@ -885,6 +911,116 @@ async fn unpair_native_client(
}
}
/// List devices awaiting pairing approval
///
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
/// it without a PIN (delegated approval); entries expire after ~10 minutes.
#[utoipa::path(
get,
path = "/native/pending",
tag = "native",
operation_id = "listPendingDevices",
responses(
(status = OK, description = "Devices awaiting approval (empty when none, or when the \
native host is not enabled)", body = Vec<PendingDevice>),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn list_pending_devices(State(st): State<Arc<MgmtState>>) -> Json<Vec<PendingDevice>> {
let pending = st
.native
.as_ref()
.map(|np| np.pending())
.unwrap_or_default();
Json(
pending
.into_iter()
.map(|p| PendingDevice {
id: p.id,
name: p.name,
fingerprint: p.fingerprint,
age_secs: p.age_secs,
})
.collect(),
)
}
/// Approve a pending device
///
/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
/// relabel it via the body; send `{}` to keep the name it knocked with.
#[utoipa::path(
post,
path = "/native/pending/{id}/approve",
tag = "native",
operation_id = "approvePendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
request_body = ApprovePending,
responses(
(status = OK, description = "Device paired", body = NativeClient),
(status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn approve_pending_device(
State(st): State<Arc<MgmtState>>,
Path(id): Path<u32>,
ApiJson(req): ApiJson<ApprovePending>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.approve_pending(id, req.name.as_deref()) {
Ok(Some(client)) => {
tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
"management API: pending device approved (delegated pairing)");
Json(NativeClient {
name: client.name,
fingerprint: client.fingerprint,
})
.into_response()
}
Ok(None) => api_error(
StatusCode::NOT_FOUND,
"no pending request with that id (it may have expired — have the device retry)",
),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// Deny a pending device
///
/// Drops the request. Not a blocklist — the device's next attempt knocks again.
#[utoipa::path(
post,
path = "/native/pending/{id}/deny",
tag = "native",
operation_id = "denyPendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
responses(
(status = NO_CONTENT, description = "Request dropped"),
(status = NOT_FOUND, description = "No pending request with that id", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn deny_pending_device(State(st): State<Arc<MgmtState>>, Path(id): Path<u32>) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
if np.deny_pending(id) {
tracing::info!(id, "management API: pending device denied");
StatusCode::NO_CONTENT.into_response()
} else {
api_error(StatusCode::NOT_FOUND, "no pending request with that id")
}
}
/// Stop the active session
///
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
@@ -1344,6 +1480,77 @@ mod tests {
assert_eq!(b["armed"], false);
}
#[tokio::test]
async fn pending_devices_approve_and_deny() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-pending-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// Empty queue.
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b.as_array().unwrap().len(), 0);
// Two devices knock (what the QUIC gate records); they appear in the list.
np.note_pending("Enrico's MacBook", "aa11");
np.note_pending("device bb22cc33", "bb22");
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 2);
assert_eq!(b[0]["name"], "Enrico's MacBook");
let approve_id = b[0]["id"].as_u64().unwrap();
let deny_id = b[1]["id"].as_u64().unwrap();
// Approve the first with an operator label → paired under that name, gone from pending.
let (s, b) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{approve_id}/approve"),
serde_json::json!({"name": "Office MacBook"}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["name"], "Office MacBook");
assert_eq!(b["fingerprint"], "aa11");
assert!(np.is_paired("AA11"), "approval pins the fingerprint");
// Deny the second → dropped, not paired; a re-deny is 404.
let deny = post_json(
&format!("/api/v1/native/pending/{deny_id}/deny"),
serde_json::json!({}),
);
assert_eq!(send(&app, deny).await.0, StatusCode::NO_CONTENT);
assert!(!np.is_paired("bb22"));
let (s, _) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{deny_id}/deny"),
serde_json::json!({}),
),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
// Queue is empty again; approving a stale id is 404 (keep `{}` = device's own name).
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 0);
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/123/approve", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn native_endpoints_report_disabled_without_native_host() {
let app = test_app(test_state(), None);
@@ -1357,5 +1564,22 @@ mod tests {
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
// Pending list reads as an empty array (like /native/clients), not a 503.
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b.as_array().unwrap().len(), 0);
// Approve/deny without a native host are 503.
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/0/approve", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/0/deny", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
}
}
+307 -11
View File
@@ -47,10 +47,47 @@ struct Armed {
expires_at: Option<Instant>,
}
/// Shared native-pairing state: the arming PIN window + the persistent trust store.
/// An unpaired (but identified) device that knocked on a pairing-required host — held for
/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
/// again), and they expire after [`PENDING_TTL`].
struct Pending {
id: u32,
name: String,
fp_hex: String,
requested_at: Instant,
}
#[derive(Default)]
struct PendingState {
next_id: u32,
items: Vec<Pending>,
}
/// A pending-approval snapshot for the management API / web console.
pub struct PendingRequest {
/// Per-process id used to address approve/deny (stable for the entry's lifetime).
pub id: u32,
/// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
pub name: String,
/// Hex SHA-256 of the knocking client's certificate — what approval pins.
pub fingerprint: String,
/// Seconds since the (most recent) knock.
pub age_secs: u64,
}
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
/// approvable days later when the operator no longer remembers the context).
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
const PENDING_CAP: usize = 32;
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
/// pending-approval queue.
pub struct NativePairing {
arm: Mutex<Armed>,
paired: Mutex<PairedState>,
pending: Mutex<PendingState>,
}
/// A snapshot for the management API / web console.
@@ -92,6 +129,48 @@ fn random_pin() -> String {
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
}
/// Sanitize a client-supplied device name before it's stored, listed, or logged. The name comes
/// straight off the wire (the `Hello`/`PairRequest` of an *unpaired* device), so it's untrusted: a
/// hostile LAN device could embed terminal escapes / control characters (log + console injection) or
/// bidi overrides (`U+202E` etc.) to make a malicious device *look* like a trusted one in the
/// approval UI. Strip C0/C1 controls and Unicode bidi/format controls, collapse whitespace, trim, and
/// cap the length; an empty/all-control name falls back to a fingerprint-derived label.
pub(crate) fn sanitize_device_name(name: &str, fp_hex: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| if c == '\t' || c == '\n' { ' ' } else { c })
.filter(|&c| {
!c.is_control()
// Bidi/format controls that could spoof or reorder the displayed name.
&& !('\u{202A}'..='\u{202E}').contains(&c) // LRE..RLO/PDF
&& !('\u{2066}'..='\u{2069}').contains(&c) // LRI..PDI
&& c != '\u{200E}' // LRM
&& c != '\u{200F}' // RLM
&& c != '\u{061C}' // ALM
&& c != '\u{FEFF}' // BOM / zero-width no-break space
})
.collect();
// Collapse internal whitespace runs, trim, cap at the wire limit.
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let mut trimmed = collapsed.as_str();
while trimmed.len() > NAME_MAX {
let mut cut = NAME_MAX;
while !trimmed.is_char_boundary(cut) {
cut -= 1;
}
trimmed = &trimmed[..cut];
}
let trimmed = trimmed.trim();
if trimmed.is_empty() {
format!("device {}", &fp_hex[..8.min(fp_hex.len())])
} else {
trimmed.to_string()
}
}
/// Max stored device-name length (matches the `Hello` wire cap, `quic::HELLO_NAME_MAX`).
const NAME_MAX: usize = 64;
impl NativePairing {
/// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start`
/// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin`
@@ -117,6 +196,7 @@ impl NativePairing {
Ok(NativePairing {
arm: Mutex::new(arm),
paired: Mutex::new(PairedState { path, clients }),
pending: Mutex::new(PendingState::default()),
})
}
@@ -172,15 +252,33 @@ impl NativePairing {
self.paired.lock().unwrap().clients.contains(fp_hex)
}
/// Record a successful pairing (re-pairing the same fingerprint just updates the name).
/// Record a successful pairing (re-pairing the same fingerprint just updates the name
/// matched case-insensitively, like every other fingerprint comparison here). The name is
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
/// diverges from disk. Also clears any pending knock for this fingerprint (it's now paired).
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
let mut p = self.paired.lock().unwrap();
p.clients.clients.retain(|c| c.fingerprint != fp_hex);
p.clients.clients.push(PairedClient {
name: name.to_string(),
fingerprint: fp_hex.to_string(),
});
save(&p)
let name = sanitize_device_name(name, fp_hex);
{
let mut p = self.paired.lock().unwrap();
let snapshot = p.clients.clients.clone(); // restore on a failed save
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
p.clients.clients.push(PairedClient {
name,
fingerprint: fp_hex.to_string(),
});
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
}
// A device that knocked and is now paired shouldn't linger in the approval list.
let mut pending = self.pending.lock().unwrap();
pending
.items
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
Ok(())
}
/// The paired clients (for the management API's device list).
@@ -188,19 +286,122 @@ impl NativePairing {
self.paired.lock().unwrap().clients.clients.clone()
}
/// Remove a paired client by fingerprint. Returns whether one was removed.
/// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
/// failure the in-memory store is rolled back (it never diverges from disk).
pub fn remove(&self, fp_hex: &str) -> Result<bool> {
let mut p = self.paired.lock().unwrap();
let before = p.clients.clients.len();
let snapshot = p.clients.clients.clone();
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
let removed = p.clients.clients.len() != before;
if removed {
save(&p)?;
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
}
Ok(removed)
}
// -- Delegated approval (roadmap §8b-1) --------------------------------
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]).
fn expire_pending(pending: &mut PendingState) {
pending
.items
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
}
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same
/// fingerprint refresh the existing entry in place (same id; a connect-retry loop must not spam
/// the list); a fresh fingerprint gets a new id, evicting the **least-recently-active** entry
/// past [`PENDING_CAP`]. The name is sanitized (untrusted; see [`sanitize_device_name`]).
pub fn note_pending(&self, name: &str, fp_hex: &str) {
let name = sanitize_device_name(name, fp_hex);
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
if let Some(p) = pending
.items
.iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
{
p.requested_at = Instant::now();
p.name = name;
return;
}
if pending.items.len() >= PENDING_CAP {
// Evict the least-recently-active entry. NOT index 0: the in-place refresh above means
// Vec order no longer tracks recency, so pick the minimum `requested_at` explicitly.
if let Some(at) = pending
.items
.iter()
.enumerate()
.min_by_key(|(_, p)| p.requested_at)
.map(|(i, _)| i)
{
pending.items.remove(at);
}
}
let id = pending.next_id;
pending.next_id = pending.next_id.wrapping_add(1);
pending.items.push(Pending {
id,
name,
fp_hex: fp_hex.to_string(),
requested_at: Instant::now(),
});
}
/// The devices currently awaiting approval (for the management API).
pub fn pending(&self) -> Vec<PendingRequest> {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
pending
.items
.iter()
.map(|p| PendingRequest {
id: p.id,
name: p.name.clone(),
fingerprint: p.fp_hex.clone(),
age_secs: p.requested_at.elapsed().as_secs(),
})
.collect()
}
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
/// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
/// (or expired) id.
pub fn approve_pending(
&self,
id: u32,
name_override: Option<&str>,
) -> Result<Option<PairedClient>> {
let entry = {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
let Some(at) = pending.items.iter().position(|p| p.id == id) else {
return Ok(None);
};
pending.items.remove(at)
}; // pending lock released — add() takes the paired lock
let name = name_override.unwrap_or(&entry.name);
self.add(name, &entry.fp_hex)?;
Ok(Some(PairedClient {
name: name.to_string(),
fingerprint: entry.fp_hex,
}))
}
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
/// re-creates an entry — deny is "not now", not a blocklist.
pub fn deny_pending(&self, id: u32) -> bool {
let mut pending = self.pending.lock().unwrap();
let before = pending.items.len();
pending.items.retain(|p| p.id != id);
pending.items.len() != before
}
}
#[cfg(test)]
@@ -250,6 +451,101 @@ mod tests {
let _ = std::fs::remove_file(&p);
}
#[test]
fn pending_knock_approve_and_deny() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
assert!(np.pending().is_empty());
// A knock appears; a re-knock from the same fingerprint refreshes (same id, new name)
// instead of duplicating.
np.note_pending("device aa11", "AA11");
np.note_pending("Bedroom TV", "aa11");
let pend = np.pending();
assert_eq!(pend.len(), 1, "re-knock dedups by fingerprint");
assert_eq!(pend[0].name, "Bedroom TV");
let id = pend[0].id;
// Deny drops it without pairing; the next knock gets a fresh id.
assert!(np.deny_pending(id));
assert!(!np.deny_pending(id));
assert!(np.pending().is_empty());
assert!(!np.is_paired("aa11"));
// Approve pairs the fingerprint (operator label wins) and clears the entry.
np.note_pending("device bb22", "BB22");
let id = np.pending()[0].id;
assert!(
np.approve_pending(9999, None).unwrap().is_none(),
"unknown id"
);
let client = np
.approve_pending(id, Some("Living Room"))
.unwrap()
.unwrap();
assert_eq!(client.name, "Living Room");
assert!(np.is_paired("bb22"), "approval pins the fingerprint");
assert!(np.pending().is_empty());
assert_eq!(np.list()[0].name, "Living Room");
// The cap evicts the oldest knock.
for i in 0..(PENDING_CAP + 3) {
np.note_pending("flood", &format!("f{i:03}"));
}
let pend = np.pending();
assert_eq!(pend.len(), PENDING_CAP);
assert_eq!(pend[0].fingerprint, "f003", "oldest entries evicted first");
let _ = std::fs::remove_file(&p);
}
#[test]
fn sanitize_strips_control_and_bidi() {
// ANSI escape + newline + a bidi override that could spoof the displayed name.
let dirty = "\u{1b}]0;evil\u{07}Good\nDevice\u{202E}xfp";
let clean = sanitize_device_name(dirty, "deadbeef00");
assert!(!clean.contains('\u{1b}') && !clean.contains('\n') && !clean.contains('\u{202E}'));
// ESC dropped (']' survives), BEL dropped, '\n'→space (Good Device), RLO dropped (no space).
assert_eq!(clean, "]0;evilGood Devicexfp");
// All-control / empty → fingerprint-derived fallback.
assert_eq!(
sanitize_device_name("\u{1b}\u{07}", "deadbeef00"),
"device deadbeef"
);
assert_eq!(sanitize_device_name(" ", "abc"), "device abc");
// Over-long names cap at a char boundary.
assert!(sanitize_device_name(&"x".repeat(200), "ab").len() <= 64);
}
#[test]
fn pairing_clears_a_pending_knock() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.note_pending("Knocker", "cc44");
assert_eq!(np.pending().len(), 1);
// Pairing the same fingerprint (e.g. via the PIN ceremony) drops the stale pending entry.
np.add("Knocker", "CC44").unwrap();
assert!(
np.pending().is_empty(),
"a now-paired device must leave the approval list"
);
assert!(np.is_paired("cc44"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn add_replaces_case_insensitively() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.add("First", "AB12").unwrap();
np.add("Second", "ab12").unwrap(); // same device, different hex case
assert_eq!(np.list().len(), 1, "re-add must replace, not duplicate");
assert_eq!(np.list()[0].name, "Second");
let _ = std::fs::remove_file(&p);
}
#[test]
fn cli_flag_arms_with_no_expiry() {
let p = temp();
+11 -56
View File
@@ -151,13 +151,9 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
// windows land on the surface we stream. Without this, on a host that also has a physical
// monitor attached, the virtual output is an empty extended desktop — you stream only the
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
let mut restore: Option<(zbus::Proxy<'static>, Vec<ApplyLogical>)> = None;
if let Some((dc, pre)) = &dc_pre {
match make_virtual_primary(dc, mode, pre).await {
Ok(()) => {
restore = Some((dc.clone(), to_apply_logicals(pre)));
tracing::info!("mutter: virtual output set as the primary monitor");
}
Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"),
Err(e) => tracing::warn!(
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
),
@@ -169,19 +165,17 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
tokio::time::sleep(Duration::from_millis(200)).await;
}
// Tear down: STOP the screencast FIRST so Mutter removes the virtual output and auto-reverts
// the temporary monitor config (physical → primary). Reconfiguring an *actively-captured*
// high-refresh virtual output via ApplyMonitorsConfig was SIGSEGVing gnome-shell on teardown,
// so we never touch the layout while the virtual output is still live.
// Tear down: STOP the screencast so Mutter removes the virtual output. We deliberately do NOT
// re-assert the physical layout with our own ApplyMonitorsConfig. Issuing a monitor reconfig
// while the just-removed high-refresh virtual output is still tearing down SIGSEGVs gnome-shell
// on Mutter 50 + NVIDIA — observed live on home-worker-3: the teardown ApplyMonitorsConfig
// returned "recipient disconnected from message bus" because the shell crashed mid-call, after
// which GDM's crash-loop guard dropped to the greeter and wedged EVERY subsequent reconnect.
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
// the virtual output disappears and our DisplayConfig connection (`dc_pre`) closes — so we just
// drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
let _ = session.rd_session.call_method("Stop", &()).await;
if let Some((dc, original)) = restore {
// Let Mutter drop the virtual output, then re-assert the physical layout deterministically
// (a no-op if the temporary config already auto-reverted) — safe now: no live virtual.
tokio::time::sleep(Duration::from_millis(300)).await;
if let Err(e) = apply_config(&dc, &original).await {
tracing::warn!("mutter: monitor-layout restore after stop failed ({e:#}); Mutter auto-reverts the temporary config on teardown");
}
}
drop(dc_pre);
});
}
@@ -480,42 +474,3 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
)]
}
/// Convert a captured `GetCurrentState` layout back into an `ApplyMonitorsConfig` argument (used
/// to restore the physical-primary layout on teardown).
fn to_apply_logicals(state: &CurrentState) -> Vec<ApplyLogical> {
state
.2
.iter()
.filter_map(|lm| {
let mons: Vec<ApplyMon> = lm
.5
.iter()
.filter_map(|s| {
current_mode(state, &s.0).map(|(id, _, _)| (s.0.clone(), id, HashMap::new()))
})
.collect();
if mons.is_empty() {
return None;
}
Some((lm.0, lm.1, lm.2, lm.3, lm.4, mons))
})
.collect()
}
async fn apply_config(dc: &zbus::Proxy<'_>, logicals: &[ApplyLogical]) -> Result<()> {
let state = get_state(dc).await?;
let _: () = dc
.call(
"ApplyMonitorsConfig",
&(
state.0,
APPLY_TEMPORARY,
logicals.to_vec(),
HashMap::<String, Value<'static>>::new(),
),
)
.await
.context("DisplayConfig.ApplyMonitorsConfig (restore)")?;
Ok(())
}
+22 -4
View File
@@ -3,9 +3,10 @@ title: Pairing & Trust
description: How a client and host establish trust — PIN pairing once, pinned reconnects after.
---
punktfunk has no accounts and no cloud. Trust is established directly between a client and a host, on
your network, with a one-time **PIN pairing**. After that, the device reconnects automatically on a
pinned cryptographic identity.
punktfunk has no accounts and no cloud. Trust is established directly between a client and a host,
on your network, with a one-time pairing — either an **approval click in the host's console** or a
**PIN ceremony**. After that, the device reconnects automatically on a pinned cryptographic
identity.
## How it works
@@ -17,7 +18,24 @@ pinned cryptographic identity.
- After pairing, the host stores the client's identity in its allow-list, and the client stores the
host's fingerprint. Reconnects are automatic — no PIN.
## Arming pairing on the host
## Approving a device from the console (no PIN)
The fastest way to admit a new device: just **try to connect** from it. On a pairing-required host,
the attempt shows up in the web console's Pairing page under **Waiting for approval** — with the
device's name and identity fingerprint. Click **Approve** (and optionally give it a label like
"Living Room TV"), and the device is paired on the spot: its next connect goes straight through. No
PIN to read or type.
**Deny** just dismisses the request (the device can knock again later — it's "not now", not a
blocklist). Requests expire on their own after a few minutes.
This works because approval happens on the host's authenticated management surface — only someone
with console access can admit a device.
## Pairing with a PIN
The PIN ceremony is the other path — useful for the *first* device (before the console has admitted
anything) or when you're at the client and the console isn't handy.
Pairing has to be **armed** on the host before a client can pair (so a random device can't pair
itself). Two ways:
+18 -19
View File
@@ -144,7 +144,7 @@ mostly-mechanical port. Recommended start: **Phase 0** — capture an existing m
stack end to end; **Phase 1** wires SudoVDA for the native-resolution output. Deferred only because
it's unbuildable on the Linux dev box; the trait boundaries are already in the right places.
## 8. Pairing & trust hardening *(next)*
## 8. Pairing & trust hardening *(§8a + §8b-1 done; §8b-2 next)*
The unified host + web-console pairing (arm a window → display the host PIN → user enters it on the
client) is built and live. Two changes harden it from "works" to "secure by default":
@@ -154,24 +154,23 @@ client) is built and live. Two changes harden it from "works" to "secure by defa
is via the SPAKE2 PIN ceremony (one online guess, no offline attack) armed from the web console.
Validated live: unpaired → "this host requires pairing", then web-armed PIN → "client trusted".
Deployed to the dev box + Bazzite.
- **Delegated pairing approval** *(next — the ergonomic enabler for "mandatory": pair a device
without fetching the host PIN out of band).* Target flow:
1. Device A is already paired (authenticated) to Host X.
2. The user tries to connect Device B to Host X.
3. Host X surfaces a request: *"Allow Device B to pair with Host X?"*
4. The user approves/denies; on approve, Host X admits Device B — binding B's certificate
fingerprint — with no PIN typed.
Two buildable layers:
- **§8b-1 (host + web — achievable now):** an unpaired B that connects to an approval-enabled host
is held as a **pending request** `{id, name, fingerprint, requested_at}` in `NativePairing`
instead of a flat reject; mgmt gains `GET /native/pending` + `POST /native/pending/{id}/{approve,
deny}`; the web console lists pending requests with Approve/Deny. The **operator approves from
the console** — delegated approval via the management surface.
- **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired
**Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and
replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI
is a client-agent task.
- ✅ **§8b-1 Delegated approval via the console — done (2026-06-12)** *(the ergonomic enabler for
"mandatory": pair a device without fetching the host PIN out of band).* An identified-but-unpaired
device that knocks on a pairing-required host is held as a **pending request** in `NativePairing`
(in-memory, deduped by fingerprint, 32-entry cap, 10-min expiry — a LAN scanner can't grow it,
and an anonymous client with no certificate records nothing). The mgmt API gains
`GET /native/pending` + `POST /native/pending/{id}/approve` (optional `{name}` to relabel) +
`POST /native/pending/{id}/deny`; the web console's Pairing page shows a **Waiting for approval**
section (live-polling) with Approve/Deny — approve pins the fingerprint on the spot, no PIN.
The `Hello` carries an optional trailing **device name** (same back-compat pattern as
compositor/gamepad/bitrate; `client-rs --name` sends it, fingerprint-derived label otherwise) so
the pending list is human-readable. End-to-end tested (knock → pending → approve → same identity
streams) + unit/mgmt tests.
- **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired
**Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and
replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI
is a client-agent task. The Apple connector should also start sending the `Hello` device name
(needs a connect-ABI name parameter; the wire field is already live).
PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online.
+23 -1
View File
@@ -14,7 +14,7 @@ and the design in the [Implementation Plan](/docs/implementation-plan); this pag
| **M1**`punktfunk-core` + C ABI (protocol · FEC · crypto) | ✅ complete & hardened |
| **M2** — GameStream host (Moonlight-compatible) | ✅ working end-to-end; HDR/surround-audio polish open |
| **M3**`punktfunk/1` native protocol (QUIC control + UDP data) | ✅ full session planes, validated live |
| **M4** — native client decode + present (Apple first) | 🟡 stage 1 live; stage-2 presenter built + decode-tested (opt-in, present needs live validation) |
| **M4** — native client decode + present (Apple first) | 🟡 macOS stage 1 live; stage-2 presenter built + decode-tested (opt-in, present needs live validation). **Linux GTK client stage 1 live** (2026-06-12) |
## Live on the boxes
@@ -29,6 +29,28 @@ All three appliances advertise over mDNS (`_punktfunk._udp`) and require PIN pai
## Progress log
### 2026-06-12
- **Native Linux client — stage 1, first light** (`crates/punktfunk-client-linux`, binary
`punktfunk-client`). GTK4/libadwaita app on the **Option A** architecture picked after a
six-angle research pass (toolkits / hw decode / Wayland presentation / input capture /
prior art / codebase): links `punktfunk-core` directly as a crate (no C ABI;
`NativeClient` is `Sync` now), mDNS host list, TOFU + SPAKE2 PIN pairing dialogs
(identity shared with `client-rs`), FFmpeg software HEVC decode (`LOW_DELAY` + slice
threads) into a `GtkGraphicsOffload`-wrapped picture, PipeWire playback with the host
mic-player's jitter ring inverted, SDL3 gamepad capture + rumble/lightbar feedback,
layout-independent keyboard (exact inverse of the host's VK table), absolute mouse +
WHEEL_DELTA scroll, compositor-shortcut inhibition, fullscreen, stats overlay.
**Validated live** against this box's `serve --native`: 1080p60 at a locked 60 fps,
capture→decoded **p50 ≈ 6.4 ms** (software decode, debug build). Next: VAAPI dmabuf →
`GdkDmabufTexture` (Tier-1 zero-copy on Intel/AMD clients), DualSense
touchpad/motion/trigger replay over SDL3, then the stage-2 raw-Wayland presenter
(wp_presentation feedback, tearing-control, Vulkan Video for NVIDIA clients).
- **Delegated pairing approval (§8b-1)** — an unpaired device that tries to connect to a
pairing-required host now shows up as a **pending request** in the web console's Pairing page;
one click approves it (optionally relabeling) and pairs its certificate fingerprint — no PIN
fetched out of band. New mgmt endpoints (`/native/pending` + approve/deny), an in-memory pending
queue in `NativePairing` (fp-deduped, capped, 10-min expiry), and an optional **device name** in
the `Hello` (back-compat trailing field; `client-rs --name` sends it). End-to-end tested.
§8b-2 (approve from a paired device's own app) is the client-side follow-up.
- **CI + deployment landed** (see the [CI & Docker](/docs/ci) guide). Gitea Actions, three
workflows: Rust workspace checks inside the new `punktfunk-rust-ci` builder image (Ubuntu 26.04,
full link-dep stack incl. a libcuda stub — 141/141 tests green in-container), web + docs-site
+225
View File
@@ -401,6 +401,184 @@
}
}
},
"/api/v1/native/pending": {
"get": {
"tags": [
"native"
],
"summary": "List devices awaiting pairing approval",
"description": "Unpaired devices that tried to connect while the host requires pairing. Approve one to pair\nit without a PIN (delegated approval); entries expire after ~10 minutes.",
"operationId": "listPendingDevices",
"responses": {
"200": {
"description": "Devices awaiting approval (empty when none, or when the native host is not enabled)",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PendingDevice"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pending/{id}/approve": {
"post": {
"tags": [
"native"
],
"summary": "Approve a pending device",
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
"operationId": "approvePendingDevice",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Pending-request id from the pending list",
"required": true,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApprovePending"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Device paired",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NativeClient"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No pending request with that id (expired?)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the trust store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pending/{id}/deny": {
"post": {
"tags": [
"native"
],
"summary": "Deny a pending device",
"description": "Drops the request. Not a blocklist — the device's next attempt knocks again.",
"operationId": "denyPendingDevice",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Pending-request id from the pending list",
"required": true,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
],
"responses": {
"204": {
"description": "Request dropped"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No pending request with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/pair": {
"get": {
"tags": [
@@ -623,6 +801,20 @@
}
}
},
"ApprovePending": {
"type": "object",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
"properties": {
"name": {
"type": [
"string",
"null"
],
"description": "Operator-chosen label for the device (defaults to the name it knocked with).",
"example": "Living Room TV"
}
}
},
"ArmNativePairing": {
"type": "object",
"description": "Arm-native-pairing request body.",
@@ -860,6 +1052,39 @@
}
}
},
"PendingDevice": {
"type": "object",
"description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band).",
"required": [
"id",
"name",
"fingerprint",
"age_secs"
],
"properties": {
"age_secs": {
"type": "integer",
"format": "int64",
"description": "Seconds since the device last knocked.",
"minimum": 0
},
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the device's certificate — what approval pins."
},
"id": {
"type": "integer",
"format": "int32",
"description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).",
"minimum": 0
},
"name": {
"type": "string",
"description": "Best-effort device label (the client's own name, else fingerprint-derived).",
"example": "Enrico's MacBook"
}
}
},
"PortMap": {
"type": "object",
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
+28 -1
View File
@@ -146,6 +146,12 @@
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
#define MAX_DATAGRAM_BYTES 2048
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
#define HELLO_NAME_MAX 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`Reconfigure`] (first byte after the magic).
#define MSG_RECONFIGURE 1
@@ -156,6 +162,11 @@
#define MSG_RECONFIGURED 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`RequestKeyframe`].
#define MSG_REQUEST_KEYFRAME 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeRequest`].
#define MSG_PROBE_REQUEST 32
@@ -267,7 +278,10 @@ enum PunktfunkInputKind
PUNKTFUNK_INPUT_KIND_KEY_UP = 1,
// Relative motion: `x`/`y` carry `dx`/`dy`.
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE = 2,
// Absolute motion: `x`/`y` carry pixel coordinates.
// Absolute motion: `x`/`y` carry pixel coordinates and `flags` packs the client's
// coordinate-space size as `(width << 16) | height` (the same contract as
// [`TouchDown`](Self::TouchDown)) — injectors normalize against it before mapping
// into the output region and **drop the event when it is zero**.
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS = 3,
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN = 4,
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP = 5,
@@ -828,6 +842,19 @@ PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c,
uint32_t refresh_hz);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Ask the host's encoder to emit a fresh IDR keyframe now — client recovery when the
// decoder has stalled (the infinite-GOP stream sends one opening IDR then P-frames only, so
// a wedged decoder would otherwise freeze until the next loss-triggered recovery keyframe).
// Non-blocking, fire-and-forget; the recovered keyframe is the only ack. The caller should
// THROTTLE — the decode stays wedged for several frames until the IDR lands, so requesting
// every frame would flood the control stream.
//
// # Safety
// `c` is a valid connection handle.
PunktfunkStatus punktfunk_connection_request_keyframe(const PunktfunkConnection *c);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Start a bandwidth speed test: ask the host to burst filler over the data plane at
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
+25 -1
View File
@@ -4,6 +4,9 @@ The punktfunk host is Linux-only and links system FFmpeg (NVENC), PipeWire, Opus
the NVIDIA driver. This directory packages it for the **Fedora Atomic / Bazzite** world
(rpm-ostree + bootc), where most of those deps are already present.
> 👉 **Ubuntu/Debian hosts** install via `apt` from Gitea's package registry — see
> [`debian/README.md`](debian/README.md) (`apt update && apt upgrade` for new builds).
> 👉 **End-to-end Bazzite setup walkthrough** (install → udev/group → `host.env` → service →
> firewall → verify → troubleshooting): [`bazzite/README.md`](bazzite/README.md). This file is the
> higher-level packaging rationale.
@@ -30,7 +33,28 @@ On **Bazzite** the only genuinely new runtime bits are `ffmpeg-libs` (RPM Fusion
`libei` — the rest of the stack is already there. The default backend is **gamescope**
(`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login.
## Option A — COPR (per-host, `rpm-ostree install`)
## Option A — Gitea RPM registry (recommended; per-host, `rpm-ostree`)
The host's RPM is published to **unom's self-hosted Gitea RPM registry** (CI builds it on every
push), mirroring the [Debian/apt](debian/README.md) setup. Add one repo file, install, and track
updates with `rpm-ostree upgrade` — no COPR account needed. Full guide: [`rpm/README.md`](rpm/README.md).
```sh
# unsigned pkgs + Gitea-signed metadata → repo_gpgcheck=1, gpgcheck=0 (see rpm/README.md)
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
[gitea-unom-bazzite]
name=punktfunk (unom, Bazzite)
baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite
enabled=1
gpgcheck=0
repo_gpgcheck=1
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
REPO
rpm-ostree install punktfunk && systemctl reboot
# updates: rpm-ostree upgrade && systemctl reboot
```
## Option B — COPR (per-host, `rpm-ostree install`)
1. Create a COPR project, enable **build-from-SCM** pointing at this repo, spec path
`packaging/rpm/punktfunk.spec` (see `copr/README.md`). Under *External Repositories* add
+55
View File
@@ -0,0 +1,55 @@
# punktfunk-host — Debian/Ubuntu package (apt)
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
and publishes on every push to `main` (a rolling `0.0.1~ciN.<sha>` build) and on `v*` tags
(a clean `X.Y.Z`).
Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput`
udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself (built in the Ubuntu
26.04 rust-ci image, so the lib soname package names match the target). The NVIDIA driver
(`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of
band, like on the RPM side.
## Install on a host (one-time)
The registry is public, so no apt auth is needed — just trust the repo's signing key:
```sh
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
| sudo tee /etc/apt/sources.list.d/punktfunk.list
sudo apt update
sudo apt install punktfunk-host
```
Then, as the desktop user:
```sh
sudo usermod -aG input "$USER" # virtual gamepads (re-login to take effect)
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env # then edit
systemctl --user enable --now punktfunk-host
```
## Updates
```sh
sudo apt update && sudo apt upgrade # picks up the newest published build
systemctl --user restart punktfunk-host # if the unit was already running
```
## Build a `.deb` locally
```sh
VERSION=0.0.1 bash packaging/debian/build-deb.sh # -> dist/punktfunk-host_0.0.1_amd64.deb
```
Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing.
Build it in the rust-ci image (or on an Ubuntu 26.04 box) so the resolved `Depends` match the
hosts; building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Build the punktfunk-client .deb (the native GTK4 client) for Ubuntu/Debian desktops.
#
# Counterpart to build-deb.sh (the host package); same conventions: runtime Depends are
# computed by dpkg-shlibdeps from the binary's DT_NEEDED (GTK4/libadwaita, SDL3, the
# FFmpeg/PipeWire/Opus sonames), so build inside the Ubuntu 26.04 rust-ci image to pin
# the package names the target boxes ship. The client links no NVIDIA libs — no filter
# needed.
#
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-client-deb.sh
# Output: dist/punktfunk-client_<version>_<arch>.deb
set -euo pipefail
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
ARCH="${ARCH:-amd64}"
PKG="punktfunk-client"
CRATE="punktfunk-client-linux"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
BIN="target/release/$PKG"
if [ ! -x "$BIN" ]; then
echo "==> building $CRATE (release)"
cargo build --release -p "$CRATE" --locked
fi
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
DOCDIR="$STAGE/usr/share/doc/$PKG"
# --- file layout --------------------------------------------------------------
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
"$STAGE/usr/share/applications/io.unom.Punktfunk.desktop"
# DualSense hidraw access (full pad fidelity through SDL's HIDAPI driver).
install -Dm0644 scripts/70-punktfunk-client.rules \
"$STAGE/usr/lib/udev/rules.d/70-punktfunk-client.rules"
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
install -Dm0644 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"
# --- dependencies --------------------------------------------------------------
SHLIB_TMP="$(mktemp -d)"
mkdir -p "$SHLIB_TMP/debian"
cat > "$SHLIB_TMP/debian/control" <<EOF
Source: $PKG
Package: $PKG
Architecture: any
Depends: \${shlibs:Depends}
EOF
SHDEPS="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \
| sed -n 's/^shlibs:Depends=//p')"
rm -rf "$SHLIB_TMP"
[ -n "$SHDEPS" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
# Manual additions shlibdeps can't see: the PipeWire daemon + session manager are runtime
# services (audio playback / mic capture degrade gracefully without them — Recommends).
RECOMMENDS="pipewire, wireplumber, pipewire-pulse"
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
install -d "$STAGE/DEBIAN"
cat > "$STAGE/DEBIAN/control" <<EOF
Package: $PKG
Version: $VERSION
Architecture: $ARCH
Maintainer: unom <noreply@anthropic.com>
Installed-Size: $INSTALLED_KB
Section: net
Priority: optional
Homepage: https://git.unom.io/unom/punktfunk
Depends: $SHDEPS
Recommends: $RECOMMENDS
Description: Low-latency desktop/game streaming client (punktfunk/1, GTK4)
The native Linux client for punktfunk, a Linux-first low-latency desktop and
game streaming stack. Discovers hosts on the LAN (mDNS), trusts them via
certificate pinning with a SPAKE2 PIN pairing ceremony, and streams HEVC video
(GF(2^16) Leopard FEC + AES-GCM over UDP, QUIC control plane) with Opus audio,
microphone passthrough, and full gamepad support including DualSense touchpad,
motion, adaptive triggers and lightbar through SDL3.
.
The host creates a virtual output at exactly this client's resolution and
refresh rate — no scaling. See the punktfunk-host package for the host side.
EOF
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
# Pick up the DualSense hidraw rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=hidraw 2>/dev/null || true
update-desktop-database /usr/share/applications 2>/dev/null || true
fi
exit 0
EOF
chmod 0755 "$STAGE/DEBIAN/postinst"
mkdir -p dist
OUT="dist/${PKG}_${VERSION}_${ARCH}.deb"
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
echo "built $OUT"
echo " Depends: $SHDEPS"
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size' || true
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Build a punktfunk-host .deb for Ubuntu/Debian hosts.
#
# Mirrors the Fedora RPM (../rpm/punktfunk.spec): the host binary + the uinput udev rule
# + the systemd *user* unit + headless session helpers + example config + the OpenAPI doc.
#
# Runtime Depends are computed by `dpkg-shlibdeps` from the binary's actual DT_NEEDED, NOT
# hand-listed: the binary pulls a large transitive lib closure (most of it via ffmpeg) and
# the exact soname package names (libavcodec62, libpipewire-0.3-0t64, …) drift across distro
# releases — shlibdeps tracks them automatically and pins them to whatever the BUILD distro
# ships. Build this inside the Ubuntu 26.04 rust-ci image so those names match the target
# boxes exactly. `--ignore-missing-info` drops libcuda.so.1 (the NVIDIA driver lib, linked via
# FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a
# specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band.
#
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-deb.sh
# Output: dist/punktfunk-host_<version>_<arch>.deb
set -euo pipefail
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
ARCH="${ARCH:-amd64}"
PKG="punktfunk-host"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
BIN="target/release/$PKG"
if [ ! -x "$BIN" ]; then
echo "==> building $PKG (release)"
cargo build --release -p "$PKG" --locked
fi
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
DOCDIR="$STAGE/usr/share/doc/$PKG"
SHAREDIR="$STAGE/usr/share/$PKG"
# --- file layout (matches the RPM %install) ----------------------------------
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
# and high-bitrate frames overflow it (send-side packet loss). systemd-sysctl applies it at boot.
install -Dm0644 scripts/99-punktfunk-net.conf "$STAGE/usr/lib/sysctl.d/99-punktfunk-net.conf"
install -Dm0644 scripts/punktfunk-host.service "$STAGE/usr/lib/systemd/user/punktfunk-host.service"
# The source unit's ExecStart points at the dev source tree; a packaged install has the binary at
# /usr/bin. Rewrite it so a fresh apt install (no hand-rolled unit) starts the installed binary.
sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
"$STAGE/usr/lib/systemd/user/punktfunk-host.service"
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
install -Dm0644 scripts/host.env.example "$SHAREDIR/host.env.example"
install -Dm0644 packaging/bazzite/host.env "$SHAREDIR/host.env.bazzite"
install -Dm0644 docs/api/openapi.json "$SHAREDIR/openapi.json"
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
install -Dm0644 README.md "$DOCDIR/README.md"
# Debian copyright + changelog (cheap, keeps the package well-formed).
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"
# --- dependencies ------------------------------------------------------------
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header).
SHLIB_TMP="$(mktemp -d)"
mkdir -p "$SHLIB_TMP/debian"
cat > "$SHLIB_TMP/debian/control" <<EOF
Source: $PKG
Package: $PKG
Architecture: any
Depends: \${shlibs:Depends}
EOF
SHDEPS_RAW="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \
| sed -n 's/^shlibs:Depends=//p')"
rm -rf "$SHLIB_TMP"
[ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
# Drop the NVIDIA driver lib unconditionally. --ignore-missing-info already skips libcuda on a
# GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves
# libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are
# provided by whatever driver the host runs, so this must never be a package dependency.
SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \
| grep -ivE '^(libnvidia-compute|libcuda)' | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
[ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; }
# Manual additions shlibdeps can't see:
# - libei1: input injection (libei) is loaded at runtime, not in DT_NEEDED.
# - pipewire/wireplumber: runtime services (the daemon + session manager), not linked libs.
DEPENDS="$SHDEPS, libei1, pipewire, wireplumber"
# ffmpeg: Ubuntu's ffmpeg ships the NVENC-enabled libav* the binary links AND is the encoder
# runtime; the libav* sonames are already hard Depends via shlibdeps, so the ffmpeg metapackage
# is a Recommends. gamescope = a ready compositor backend; pipewire-pulse = desktop audio.
RECOMMENDS="ffmpeg, gamescope, pipewire-pulse"
SUGGESTS="kwin-wayland, mutter"
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
install -d "$STAGE/DEBIAN"
cat > "$STAGE/DEBIAN/control" <<EOF
Package: $PKG
Version: $VERSION
Architecture: $ARCH
Maintainer: unom <noreply@anthropic.com>
Installed-Size: $INSTALLED_KB
Section: net
Priority: optional
Homepage: https://git.unom.io/unom/punktfunk
Depends: $DEPENDS
Recommends: $RECOMMENDS
Suggests: $SUGGESTS
Description: Low-latency desktop/game streaming host (Moonlight + punktfunk/1)
punktfunk is a Linux-first, low-latency desktop and game streaming host. It speaks
the Moonlight/GameStream protocol (pair a stock Moonlight client) and its own native
punktfunk/1 protocol (GF(2^16) Leopard FEC + AES-GCM, mid-stream mode renegotiation,
client microphone passthrough). Each session gets a virtual output at the client's
exact resolution and refresh via a per-compositor backend (KWin, gamescope, Mutter,
Sway/wlroots), captured zero-copy (dmabuf -> CUDA -> NVENC). Input (mouse, keyboard,
gamepads) is injected back into the session.
.
NVENC + GPU EGL come from the NVIDIA driver (libnvidia-encode / libEGL_nvidia),
installed out of band. After install: add yourself to the 'input' group for virtual
gamepads, then enable the systemd user service punktfunk-host.
EOF
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:"
echo " sudo usermod -aG input \"\$USER\" # then re-login"
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
echo "Enable: systemctl --user enable --now punktfunk-host"
fi
exit 0
EOF
chmod 0755 "$STAGE/DEBIAN/postinst"
mkdir -p dist
OUT="dist/${PKG}_${VERSION}_${ARCH}.deb"
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
echo "built $OUT"
echo " Depends: $DEPENDS"
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size' || true
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=Punktfunk
Comment=Stream a remote punktfunk host
Exec=punktfunk-client
Icon=video-display
Terminal=false
Categories=Network;Game;
Keywords=streaming;remote;game;moonlight;
StartupNotify=true
+79
View File
@@ -0,0 +1,79 @@
# punktfunk-host — RPM (Bazzite / Fedora Atomic) via the Gitea registry
`punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom`
org (group `bazzite`), so Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`.
CI (`.gitea/workflows/rpm.yml`) builds and publishes on every push to `main` (a rolling
`0.0.1-0.ciN.<sha>` build) and on `v*` tags (a clean `X.Y.Z-1`). The RPM is built in the
Fedora 43 image (`ci/fedora-rpm.Dockerfile`) so its auto-generated library Requires
(`libavcodec.so.NN`, …) match Bazzite's sonames; the NVIDIA driver lib (`libcuda.so.1`) is
excluded — NVENC/EGL come from whatever NVIDIA stack the host runs (a weak Recommends).
This is the same package as the [COPR](../copr/README.md) / [bootc](../bootc/Containerfile)
paths — same spec (`punktfunk.spec`) — just self-hosted in Gitea instead of COPR, mirroring the
[Debian/apt](../debian/README.md) setup.
## Install on a Bazzite host (one-time)
```sh
# Add the repo. Our RPMs are unsigned, but Gitea GPG-signs the repo METADATA — so verify that
# (repo_gpgcheck=1) and skip the per-package signature check (gpgcheck=0). The signed metadata
# carries each package's SHA256, so authenticity still holds. (Don't just curl Gitea's served
# bazzite.repo — it sets gpgcheck=1, which fails on unsigned packages.)
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
[gitea-unom-bazzite]
name=punktfunk (unom, Bazzite)
baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite
enabled=1
gpgcheck=0
repo_gpgcheck=1
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
REPO
# Layer the package, then reboot into the new deployment.
rpm-ostree install punktfunk
systemctl reboot
```
> If `rpm-ostree` can't complete the metadata GPG check non-interactively, set `repo_gpgcheck=0`
> (TLS-only trust to the self-hosted registry). Proper per-package signing (`gpgcheck=1`) would
> need a CI signing key + `rpm --addsign` — future hardening, not wired up.
After reboot, as the desktop user:
```sh
ujust add-user-to-input-group # virtual gamepads need /dev/uinput (re-login).
# Bazzite is atomic — use ujust, NOT `usermod -aG input`.
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamescope defaults
systemctl --user enable --now punktfunk-host
```
(See [`../bazzite/README.md`](../bazzite/README.md) for the full appliance walkthrough —
udev/group, `host.env`, the Steam session unit, firewall, verify.)
## Updates
```sh
rpm-ostree upgrade # pulls the newest punktfunk with the system update
systemctl reboot # rpm-ostree changes apply on reboot
```
Layered packages are re-resolved against their repos on every `rpm-ostree upgrade`, so the box
tracks new builds automatically (Bazzite's auto-update timer does this for you). To pin or stop
tracking: `rpm-ostree override` / `rpm-ostree uninstall punktfunk`.
## Build an RPM locally
```sh
PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh # -> dist/punktfunk-0.0.1-1.fcNN.x86_64.rpm
```
Run it inside the Fedora 43 builder image so the deps resolve and match Bazzite:
```sh
docker build -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm ci
docker run --rm -v "$PWD:/src" -w /src punktfunk-fedora-rpm \
bash -lc 'git config --global --add safe.directory /src && PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh'
```
A plain `rpmbuild`/COPR build with no `pf_version`/`pf_release` defines produces `0.0.1-1`.
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Build the punktfunk-host RPM from the committed tree, for the Gitea RPM registry (Bazzite).
#
# Counterpart to ../debian/build-deb.sh. The library Requires (libavcodec.so.NN, …) are
# auto-generated by rpmbuild from the binary it links — so build this in the Fedora 43 image
# (ci/fedora-rpm.Dockerfile) to match Bazzite's sonames. libcuda is excluded in the spec.
#
# Usage: PF_VERSION=0.0.1 [PF_RELEASE=0.ci42.gdeadbee] bash packaging/rpm/build-rpm.sh
# Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs)
set -euo pipefail
PF_VERSION="${PF_VERSION:-0.0.1}"
PF_RELEASE="${PF_RELEASE:-1}"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
TOP="$(mktemp -d)"
trap 'rm -rf "$TOP"' EXIT
mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS}
# Source tarball with the prefix %autosetup expects (punktfunk-<version>/). From HEAD so the
# build is reproducible from a commit (CI checks one out); the spec is read from the working
# tree directly, so spec edits apply without a re-commit.
git archive --format=tar.gz --prefix="punktfunk-${PF_VERSION}/" \
-o "$TOP/SOURCES/punktfunk-${PF_VERSION}.tar.gz" HEAD
# --nodeps: the spec's BuildRequires (cargo, rust, *-devel) are for COPR's mock chroot, which
# 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 \
--define "_topdir $TOP" \
--define "pf_version ${PF_VERSION}" \
--define "pf_release ${PF_RELEASE}" \
packaging/rpm/punktfunk.spec
mkdir -p dist
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} dist/ \;
echo "== Requires (must NOT contain libcuda) =="
rpm -qp --requires dist/punktfunk-${PF_VERSION}-*.rpm 2>/dev/null | grep -iE 'cuda|nvidia' \
&& echo " !! NVIDIA/CUDA leak !!" || echo " clean"
+64 -5
View File
@@ -17,8 +17,12 @@
################################################################################
Name: punktfunk
Version: 0.0.1
Release: 1%{?dist}
# Version/Release are overridable so CI can stamp a rolling snapshot: a main build passes
# --define "pf_version 0.0.1" --define "pf_release 0.ci42.gdeadbee"
# (Release starting "0." sorts BEFORE the eventual "1" release), a v* tag passes the clean
# version with "pf_release 1". A plain `rpmbuild` (or COPR) with no defines builds 0.0.1-1.
Version: %{?pf_version}%{!?pf_version:0.0.1}
Release: %{?pf_release}%{!?pf_release:1}%{?dist}
Summary: Low-latency desktop/game streaming host (Moonlight-compatible + punktfunk/1)
License: MIT OR Apache-2.0
@@ -29,6 +33,12 @@ Source0: %{name}-%{version}.tar.gz
# punktfunk-host is Linux-only and links system FFmpeg/PipeWire/Opus.
ExclusiveArch: x86_64 aarch64
# The zerocopy FFI links the NVIDIA driver's libcuda.so.1; rpm's auto-dep generator would turn
# that into a hard Requires on libcuda.so.1 (and we never want to pin the driver — NVENC/EGL come
# from whatever NVIDIA stack the host runs, expressed below as the weak xorg-x11-drv-nvidia-cuda
# Recommends). Drop it from the auto-Requires, mirroring the Debian package's NVIDIA filter.
%global __requires_exclude ^libcuda\\.so.*$
# --- Build toolchain ---------------------------------------------------------
BuildRequires: cargo
BuildRequires: rust
@@ -55,6 +65,10 @@ BuildRequires: pkgconfig(libavutil)
# Zero-copy GPU path: src/zerocopy/ links libGL + libgbm (mesa) via hand-rolled FFI.
BuildRequires: pkgconfig(gl)
BuildRequires: pkgconfig(gbm)
# The client subpackage (GTK4 shell + SDL3 gamepads).
BuildRequires: pkgconfig(gtk4)
BuildRequires: pkgconfig(libadwaita-1)
BuildRequires: pkgconfig(sdl3)
# It ALSO links the NVIDIA CUDA driver lib (-lcuda) via FFI, so libcuda.so must be present
# at LINK time. A normal NVIDIA host (or Bazzite -nvidia) has it; a headless COPR/koji builder
# without a GPU does NOT — point %build at the CUDA toolkit stub (…/stubs/libcuda.so) there,
@@ -86,14 +100,28 @@ exact resolution and refresh via a per-compositor backend (KWin, gamescope, Mutt
Sway/wlroots), captured zero-copy (dmabuf -> CUDA -> NVENC) and split-encoded above
~1 Gpix/s. Input (mouse/keyboard/gamepads) is injected back into the session.
%package client
Summary: Low-latency desktop/game streaming client (punktfunk/1, GTK4)
# Audio playback / mic capture want the PipeWire daemon; degrade gracefully without it.
Recommends: pipewire
Recommends: wireplumber
%description client
The native Linux client for punktfunk. Discovers hosts on the LAN (mDNS), trusts
them via certificate pinning with a SPAKE2 PIN pairing ceremony, and streams HEVC
video (GF(2^16) Leopard FEC + AES-GCM over UDP, QUIC control plane) with Opus
audio, microphone passthrough, and full gamepad support including DualSense
touchpad, motion, adaptive triggers and lightbar through SDL3. The host creates a
virtual output at exactly this client's resolution and refresh rate no scaling.
%prep
%autosetup -n %{name}-%{version}
%build
# Release build of the host binary only (the workspace also has the core lib + clients).
# Release build of the host + client binaries (the workspace also has the core lib).
# cargo fetches crates over the network; COPR build hosts allow this.
export RUSTFLAGS="%{?build_rustflags}"
cargo build --release -p punktfunk-host
cargo build --release -p punktfunk-host -p punktfunk-client-linux
%install
# Binary
@@ -102,8 +130,23 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
# udev rule — /dev/uinput access for virtual gamepads (input group).
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
# and high-bitrate frames overflow it (send-side loss). systemd-sysctl applies it at boot.
install -Dm0644 scripts/99-punktfunk-net.conf %{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
# systemd *user* unit (the host runs in the graphical session, not as root).
install -Dm0644 scripts/punktfunk-host.service %{buildroot}%{_userunitdir}/punktfunk-host.service
# The source unit's ExecStart points at the dev source tree; a packaged install has the binary at
# %{_bindir}. Rewrite it so a fresh install (no hand-rolled unit) starts the installed binary.
sed -i 's#%h/punktfunk/target/release/punktfunk-host#%{_bindir}/punktfunk-host#' %{buildroot}%{_userunitdir}/punktfunk-host.service
# --- client subpackage ---
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.desktop
# DualSense hidraw access (full pad fidelity through SDL's HIDAPI driver).
install -Dm0644 scripts/70-punktfunk-client.rules \
%{buildroot}%{_udevrulesdir}/70-punktfunk-client.rules
# Headless session helpers + example config + OpenAPI doc (reference material).
install -d %{buildroot}%{_datadir}/%{name}/headless
@@ -118,18 +161,34 @@ install -Dm0644 docs/api/openapi.json %{buildroot}%{_datadir}/%
%doc README.md docs/implementation-plan.md packaging/README.md
%{_bindir}/punktfunk-host
%{_udevrulesdir}/60-punktfunk.rules
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
%{_userunitdir}/punktfunk-host.service
%dir %{_datadir}/%{name}
%{_datadir}/%{name}/*
%files client
%license LICENSE-MIT LICENSE-APACHE
%{_bindir}/punktfunk-client
%{_datadir}/applications/io.unom.Punktfunk.desktop
%{_udevrulesdir}/70-punktfunk-client.rules
%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).
udevadm control --reload-rules 2>/dev/null || :
udevadm trigger --subsystem-match=hidraw 2>/dev/null || :
%post
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
udevadm control --reload-rules 2>/dev/null || :
udevadm trigger --subsystem-match=misc 2>/dev/null || :
# Apply the UDP socket-buffer tuning (also auto-applied at boot by systemd-sysctl; on rpm-ostree
# it takes effect on the next boot into the layered deployment).
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || :
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
echo "then enable the host: systemctl --user enable --now punktfunk-host"
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
%changelog
* Tue Jun 10 2026 punktfunk <noreply@anthropic.com> - 0.0.1-1
* Wed Jun 10 2026 punktfunk <noreply@anthropic.com> - 0.0.1-1
- Initial RPM: punktfunk-host + udev rule + systemd user unit + headless helpers.
+12
View File
@@ -0,0 +1,12 @@
# punktfunk-client: hidraw access for the seated user's DualSense (SDL's HIDAPI driver
# needs it for touchpad / motion / lightbar / player LEDs / adaptive triggers — without it
# SDL silently degrades to plain evdev, which has none of those). evdev joystick nodes are
# already uaccess-tagged by systemd; hidraw nodes are root-only by default and systemd
# declined a generic gamepad hwdb (systemd#22681), so we ship the rule, steam-devices
# style: the ATTRS match covers USB, the KERNELS match covers Bluetooth.
# DualSense (054c:0ce6)
KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0ce6", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", MODE="0660", TAG+="uaccess"
# DualSense Edge (054c:0df2)
KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0df2", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", MODE="0660", TAG+="uaccess"
+8
View File
@@ -58,6 +58,14 @@
"pairing_native_devices": "Gekoppelte Geräte",
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
"pairing_pending_title": "Warten auf Freigabe",
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
"pairing_pending_approve": "Freigeben",
"pairing_pending_deny": "Ablehnen",
"pairing_pending_name_prompt": "Gerät benennen:",
"pairing_pending_age_just_now": "gerade eben",
"pairing_pending_age_secs": "vor {s}s",
"pairing_pending_age_mins": "vor {min} min",
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
"settings_title": "Einstellungen",
"settings_token_label": "API-Token",
+8
View File
@@ -58,6 +58,14 @@
"pairing_native_devices": "Paired devices",
"pairing_native_empty": "No devices paired yet.",
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
"pairing_pending_title": "Waiting for approval",
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
"pairing_pending_approve": "Approve",
"pairing_pending_deny": "Deny",
"pairing_pending_name_prompt": "Name this device:",
"pairing_pending_age_just_now": "just now",
"pairing_pending_age_secs": "{s}s ago",
"pairing_pending_age_mins": "{min} min ago",
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
"settings_title": "Settings",
"settings_token_label": "API token",
+197 -56
View File
@@ -1,21 +1,33 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { KeyRound, CheckCircle2, Smartphone, Timer, Trash2 } from 'lucide-react'
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import {
KeyRound,
CheckCircle2,
Smartphone,
Timer,
Trash2,
UserPlus,
X,
} from "lucide-react";
import {
useGetNativePairing,
useArmNativePairing,
useDisarmNativePairing,
useListNativeClients,
useUnpairNativeClient,
useListPendingDevices,
useApprovePendingDevice,
useDenyPendingDevice,
getGetNativePairingQueryKey,
getListNativeClientsQueryKey,
} from '@/api/gen/native/native'
getListPendingDevicesQueryKey,
} from "@/api/gen/native/native";
import {
useGetPairingStatus,
useSubmitPairingPin,
getGetPairingStatusQueryKey,
} from '@/api/gen/pairing/pairing'
} from "@/api/gen/pairing/pairing";
import {
Table,
TableBody,
@@ -23,49 +35,151 @@ import {
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { QueryState } from '@/components/query-state'
import { m } from '@/paraglide/messages'
import { useLocale } from '@/lib/i18n'
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { QueryState } from "@/components/query-state";
import { m } from "@/paraglide/messages";
import { useLocale } from "@/lib/i18n";
export const Route = createFileRoute('/pairing')({ component: PairingPage })
export const Route = createFileRoute("/pairing")({ component: PairingPage });
/** Seconds → `m:ss`. */
function fmtTime(secs: number): string {
const s = Math.max(0, Math.floor(secs))
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`
const s = Math.max(0, Math.floor(secs));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
function PairingPage() {
useLocale()
useLocale();
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
<PendingDevices />
<NativePairingCard />
<NativeDevices />
<MoonlightPairingCard />
</div>
)
);
}
/** Seconds since a knock → a short relative label. */
function fmtAge(secs: number): string {
if (secs < 10) return m.pairing_pending_age_just_now();
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
}
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect shows up here,
* and Approve pairs it on the spot — no PIN fetched out of band. Renders nothing while empty
* (the common case); polls so a knock appears while the operator is looking at the page.
*/
function PendingDevices() {
const qc = useQueryClient();
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
const rows = pending.data ?? [];
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
if (rows.length === 0 && !pending.error) return null;
const refresh = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = (id: number, currentName: string) => {
const name = prompt(m.pairing_pending_name_prompt(), currentName);
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
{ onSuccess: refresh },
);
};
return (
<div className="space-y-2">
<h2 className="flex items-center gap-2 text-lg font-medium">
<UserPlus className="size-4" />
{m.pairing_pending_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.pairing_pending_desc()}
</p>
<QueryState
isLoading={pending.isLoading}
error={pending.error}
refetch={pending.refetch}
>
<Card>
<CardContent className="p-0">
<Table>
<TableBody>
{rows.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{p.fingerprint.slice(0, 16)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{fmtAge(p.age_secs)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={approve.isPending || deny.isPending}
onClick={() => onApprove(p.id, p.name)}
>
{m.pairing_pending_approve()}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={m.pairing_pending_deny()}
disabled={approve.isPending || deny.isPending}
onClick={() =>
deny.mutate({ id: p.id }, { onSuccess: refresh })
}
>
<X className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</QueryState>
</div>
);
}
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
function NativePairingCard() {
const qc = useQueryClient()
const qc = useQueryClient();
// Poll fast while armed (live countdown), slow otherwise.
const status = useGetNativePairing({
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
})
const arm = useArmNativePairing()
const disarm = useDisarmNativePairing()
const d = status.data
const refresh = () => qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() })
});
const arm = useArmNativePairing();
const disarm = useDisarmNativePairing();
const d = status.data;
const refresh = () =>
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
return (
<QueryState isLoading={status.isLoading} error={status.error} refetch={status.refetch}>
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -75,7 +189,9 @@ function NativePairingCard() {
</CardHeader>
<CardContent className="space-y-4">
{!d?.enabled ? (
<p className="text-sm text-muted-foreground">{m.pairing_native_disabled()}</p>
<p className="text-sm text-muted-foreground">
{m.pairing_native_disabled()}
</p>
) : d.armed && d.pin ? (
<div className="space-y-3">
<p className="text-sm">{m.pairing_native_enter()}</p>
@@ -99,10 +215,17 @@ function NativePairingCard() {
</div>
) : (
<>
<p className="text-sm text-muted-foreground">{m.pairing_native_desc()}</p>
<p className="text-sm text-muted-foreground">
{m.pairing_native_desc()}
</p>
<Button
disabled={arm.isPending}
onClick={() => arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refresh })}
onClick={() =>
arm.mutate(
{ data: { ttl_secs: 120 } },
{ onSuccess: refresh },
)
}
>
<KeyRound className="size-4" />
{m.pairing_native_arm()}
@@ -112,28 +235,35 @@ function NativePairingCard() {
</CardContent>
</Card>
</QueryState>
)
);
}
/** The paired native (punktfunk/1) devices, with unpair. */
function NativeDevices() {
const qc = useQueryClient()
const clients = useListNativeClients()
const unpair = useUnpairNativeClient()
const rows = clients.data ?? []
const qc = useQueryClient();
const clients = useListNativeClients();
const unpair = useUnpairNativeClient();
const rows = clients.data ?? [];
const onUnpair = (fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return
if (!confirm(m.pairing_native_unpair_confirm())) return;
unpair.mutate(
{ fingerprint },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }) },
)
}
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
},
);
};
return (
<div className="space-y-2">
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
<QueryState isLoading={clients.isLoading} error={clients.error} refetch={clients.refetch}>
<QueryState
isLoading={clients.isLoading}
error={clients.error}
refetch={clients.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-sm text-muted-foreground">
@@ -154,7 +284,9 @@ function NativeDevices() {
<TableBody>
{rows.map((c) => (
<TableRow key={c.fingerprint}>
<TableCell className="font-medium">{c.name || '—'}</TableCell>
<TableCell className="font-medium">
{c.name || "—"}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{c.fingerprint.slice(0, 16)}
</TableCell>
@@ -178,32 +310,36 @@ function NativeDevices() {
)}
</QueryState>
</div>
)
);
}
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
function MoonlightPairingCard() {
const qc = useQueryClient()
const [pin, setPin] = useState('')
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } })
const submit = useSubmitPairingPin()
const pending = pairing.data?.pin_pending ?? false
const qc = useQueryClient();
const [pin, setPin] = useState("");
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
const submit = useSubmitPairingPin();
const pending = pairing.data?.pin_pending ?? false;
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
submit.mutate(
{ data: { pin } },
{
onSuccess: () => {
setPin('')
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() })
setPin("");
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
},
},
)
}
);
};
return (
<QueryState isLoading={pairing.isLoading} error={pairing.error} refetch={pairing.refetch}>
<QueryState
isLoading={pairing.isLoading}
error={pairing.error}
refetch={pairing.refetch}
>
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -225,12 +361,15 @@ function MoonlightPairingCard() {
autoComplete="off"
maxLength={8}
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
placeholder="0000"
className="font-mono text-lg tracking-widest"
/>
</div>
<Button type="submit" disabled={pin.length < 4 || submit.isPending}>
<Button
type="submit"
disabled={pin.length < 4 || submit.isPending}
>
{m.pairing_submit()}
</Button>
{submit.isSuccess && (
@@ -239,11 +378,13 @@ function MoonlightPairingCard() {
{m.pairing_success()}
</p>
)}
{submit.isError && <p className="text-sm text-destructive">{m.pairing_failed()}</p>}
{submit.isError && (
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
)}
</form>
)}
</CardContent>
</Card>
</QueryState>
)
);
}