Commit Graph

229 Commits

Author SHA1 Message Date
enricobuehler dd7e9ec0a8 fix(ci/release): robust iOS provisioning-profile extraction + diagnostics
The profile-name/UUID read used 'security cms -D ... || true' which masked a
failed decode, then PlistBuddy printed 'Error Reading File' to stdout and that
got captured as the UUID, producing a garbage cp path. Now: check the extracted
plist is non-empty, fall back to 'openssl smime' if 'security cms' fails,
validate the UUID is actually hex+dashes, and print the decoded byte count +
decoder stderr + first bytes so a bad IOS_PROFILE_B64 is obvious in-log. Still
non-fatal (skips iOS, never blocks the macOS release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:05:35 +00:00
enricobuehler 7633261f36 feat(punktfunk/1): cross-VLAN/NAT video via data-plane hole-punching
The video data plane is a raw UDP socket separate from the QUIC control connection. On a flat LAN
the host can send straight to the client, but across NAT or a stateful inter-VLAN firewall the
unsolicited host→client video is rejected (ICMP port-unreachable → the session dies immediately,
while control/audio/input keep working since they ride the client-initiated QUIC). Observed live:
a client on 192.168.6.2 streaming from a host on 192.168.1.48.

Fix: client-initiated hole-punching. The client sends PUNCH_MAGIC datagrams from its data socket
to the host's advertised data port (Welcome.udp_port); that opens the firewall/NAT return path and
lets the host learn the client's OBSERVED source (the NAT-translated address, not the client's
reported private one). The host (UdpTransport::connect_via_punch) waits ≤2.5s for the first punch
and streams there, falling back to the client-reported address for clients that don't punch
(flat-LAN behaviour unchanged). The client keeps a low-rate keepalive so a stateful firewall's idle
timeout can't close the path during a static, low-bitrate scene. Wired into client-rs and the
NativeClient connector (covers the Linux + Apple clients; the Apple app needs an xcframework rebuild
to pick up the new core).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:46:15 +00:00
enricobuehler 284f105be9 fix(headless/kde): find the probe binary on PATH for packaged installs
run-headless-kde.sh gated KWin readiness on `$ROOT/target/release/punktfunk-host
probe-compositor`, else `cargo run`. On an RPM/.deb install ROOT resolves to /usr/share (no
target/ tree) and there's no Cargo.toml either, so the probe could never succeed: the session
unit hit its 30s readiness timeout, exited, and systemd restart-looped it forever — KWin never
reached the plasmashell step, so the streamed virtual output was an empty black desktop.
Add a `command -v punktfunk-host` branch (the packaged /usr/bin binary) between the source-tree
and cargo-run fallbacks. Verified live on the Fedora 44 KDE host: session goes stable
(NRestarts 0), plasmashell comes up, and a client streams the real desktop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:21:31 +00:00
enricobuehler 07df27afe1 feat(ci/release): iOS App Store manual distribution signing + profile
Automatic signing during the iOS archive resolved to App *Development* (wanted
an Apple Development cert + tried to revoke the account's orphaned one, and no
dev profile) — wrong for App Store. Switch to MANUAL distribution signing:
import an App Store provisioning profile from IOS_PROFILE_B64, read its
Name/UUID, install it, and archive with CODE_SIGN_STYLE=Manual + Apple
Distribution + that profile; export with manual signingStyle +
provisioningProfiles map. Step self-skips until IOS_PROFILE_B64 is set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:09:46 +00:00
enricobuehler d78cf506a2 fix(ci/release): gate iOS signing on matching identity, not find-identity -v
The Apple Distribution identity has its key + intermediate + valid dates (it's
in 'Matching identities') but stayed out of 'Valid identities only' — a trust
strictness (most likely a pending online revocation check on an hour-old cert)
that codesign/xcodebuild do NOT enforce. Gate the iOS step on the MATCHING list
so the archive actually attempts signing, and print 'security verify-cert -p
codeSign' in the import step so the exact trust verdict shows if it still balks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:30:57 +00:00
enricobuehler e97385a9be fix(ci/release): retry Apple intermediate fetch + chain/clock diagnostic
The iOS Apple Distribution identity imported WITH its private key (it's a
'Matching identity') but was dropped from find-identity -v — i.e. an untrusted
chain: the WWDR G3 intermediate it chains through didn't land, while Developer
ID's DeveloperIDG2CA did. The fetch was a single 'curl || warn' with no retry, so
a transient miss silently breaks iOS only. Retry each intermediate 3x, and print
the runner UTC date + whether the WWDR intermediate is present, to separate a
chain miss from the cert's notBefore being ahead of the runner clock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:22:32 +00:00
enricobuehler 45dd1232e0 ci+docs: Fedora 44 RPM channel + reproducible Fedora KDE host guide
- docker.yml: build the punktfunk-fedora44-rpm builder image (parameterized Dockerfile,
  FEDORA_VERSION=44) alongside the F43/Bazzite one.
- rpm.yml: matrix the build/publish over both channels — fedora-fedora-rpm→bazzite (F43,
  libavcodec.so.61) and fedora44-rpm→fedora-44 (F44, libavcodec.so.62). fail-fast:false so one
  channel's break doesn't sink the other. (Bootstrap: the F44 builder image must be pushed by
  docker.yml once before rpm.yml's fedora-44 job can pull it — same dance as the other images.)
- fedora-kde.md: rewrite as the reproducible RPM-install guide validated live on a Fedora 44
  KDE box (RTX 4090): RPM Fusion + akmod-nvidia + the ffmpeg-free→RPM-Fusion swap for NVENC +
  Secure Boot MOK enroll; the fedora-44 dnf repo + `dnf install punktfunk`; and the headless
  punktfunk-kde-session.service (kwin --virtual with NO_PERMISSION_CHECKS — an interactive
  Plasma session won't hand its privileged zkde_screencast protocol to an external client).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:20:40 +00:00
enricobuehler 985c9dc9db fix(ci/release): self-diagnosing iOS cert import + non-fatal validity gate
The iOS Apple Distribution cert imported (1 identity imported) but never
appeared in find-identity -v, and the iOS step then silently skipped. Make the
import step explain itself without exposing secrets or blocking the macOS
release: print secret byte-lengths + decoded p12 size + import rc, strip
stray whitespace/newlines before base64 -d, and after the partition-list warn
(not fail) with the likely cause + an incl-invalid identity list when the iOS
secret is set but yields no valid Apple Distribution identity. The shared import
step must not hard-fail on an iOS-cert problem — that would also block the
proven macOS DMG path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:14:12 +00:00
enricobuehler 38bc0b2671 packaging(rpm): Fedora 44 build + ship the KDE session unit & host.env
Three changes to make a reproducible Fedora KDE host install:
- ci/fedora-rpm.Dockerfile: parameterize the Fedora base (ARG FEDORA_VERSION, default 43) so the
  same builder produces the Bazzite (F43, libavcodec.so.61) and Fedora 44 (libavcodec.so.62) RPMs.
  A binary RPM is soname-coupled to its base, so each target Fedora needs its own build/channel.
- spec: install punktfunk-kde-session.service (was in the tree but never packaged) with its
  ExecStart repointed from the dev source tree to the installed run-headless-kde.sh. This is the
  headless `kwin --virtual` session (KWIN_WAYLAND_NO_PERMISSION_CHECKS=1) the kwin backend needs —
  an interactive Plasma session refuses to hand its privileged zkde_screencast protocol to an
  external client, so a dedicated session is required. Not enabled by default (kwin hosts opt in).
- ship packaging/kde/host.env as host.env.kde — the ready KWin appliance config (wayland-kde).

Validated live on a Fedora 44 KDE box (RTX 4090): KWin virtual output + zero-copy dmabuf->CUDA->NVENC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:08:10 +00:00
enricobuehler 00739a273b feat(ci/release): wire iOS App Store signing via an Apple Distribution secret
deb / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 7s
rpm / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
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>
v0.1.1
2026-06-13 15:09:56 +00:00
enricobuehler df85e93510 fix(ci/release): skip iOS archive cleanly when the iOS SDK is absent
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 ba6e4e9bbb fix(ci/release): scope codesign to the throwaway keychain (--keychain)
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 ae6bdc7c6c fix(ci/release): archive unsigned + codesign Developer ID directly
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 8b008c7da6 fix(ci/release): sign the macOS archive with Developer ID, not auto dev signing
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 56ebf90967 fix(ci): install ca-certificates in the bun web/docs-site jobs
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 96f2cd651c 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 9d45960679 fix(m3): release held mouse buttons/keys when a session ends (stuck-click after reconnect)
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 4baf97343a fix(inject/libei): emit the continuous scroll axis so small scrolls register
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 c45716df7d 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 be91622ae7 feat(apple): surface host online status on the home grid
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 b9b362189b chore(capture): tidy the GNOME flash diagnostic — it's the CORRUPTED skip
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 e493d25636 fix(capture/mutter): latest-frame-only dequeue (the real GNOME flash fix)
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 731eb9ad8c fix(capture/mutter): drop stale re-delivered frames (the GNOME flash)
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 4b9dfc4185 fix(packaging): point the packaged systemd unit at /usr/bin/punktfunk-host
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 cc0f864e09 fix(abi): exclude internal Apple recvmsg_x FFI from the C header
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 a6b836cfe4 feat(capture): true SHM path (PUNKTFUNK_FORCE_SHM) for race-free Mutter+NVIDIA
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 5cb6029a93 fix(capture/mutter): restore zero-copy + sync via dmabuf implicit fence
The previous attempt (97ee75d) 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 97ee75d is kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:28:17 +00:00
enricobuehler 97ee75db3d fix(capture/mutter): stale-frame flashes + stuck input after disconnect on GNOME
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 50afa0467d perf(core): recvmsg_x batched receive on Apple (macOS client)
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 a3178d43ec perf(core): in-place AES-GCM seal + reused wire-buffer pool (host send)
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 6a630e919c perf(core): UDP GSO send path (the multi-Gbps lever)
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 32c418d002 feat(client-linux): VAAPI hardware decode — zero-copy dmabuf into GraphicsOffload
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 9ea4ca89dd 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 d21fc1f397 Merge remote-tracking branch 'origin/main' 2026-06-12 23:18:12 +00:00
enricobuehler 7bda3d8059 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
a601022.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:18:12 +00:00
enricobuehler 1f164b0d48 fix(core): libc is a unix-wide dep — unbreak iOS/tvOS xcframework slices
ac80bff 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 5d30850832 chore(apple): Xcode 27 project upgrade + hardened runtime
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 94fb7d1b24 fix(apple/stage2): disable layer vsync wait to kill fullscreen stutter
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 ac80bff9bb perf(core): batched non-allocating recv on Apple targets (macOS client wall)
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 eb9bc4c8a6 feat(punktfunk/1): request-IDR recovery for a wedged client decode
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 1fd4524510 fix(ci): POSIX shell in deb/rpm Version step (dash "Bad substitution")
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 b2ba2e708b fix(packaging): ship the UDP socket-buffer sysctl in the .deb and .rpm
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 2ddd2b572f docs(rpm): use repo_gpgcheck for the unsigned Gitea RPMs
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 eccf93e4f4 ci(rpm): publish punktfunk-host RPM to the Gitea registry (Bazzite)
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 a5a49184c0 fix(apple): persist Keychain trust — sign macOS + data-protection keychain
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 ba65febe84 ci(deb): publish punktfunk-host .deb to the Gitea apt registry
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 17065db3b2 Merge remote-tracking branch 'origin/main' 2026-06-12 21:12:02 +00:00
enricobuehler dd6ff27fcc 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 4fad9b3b2c fix(quic/apple): QUIC keep-alive + reconnect input re-engage
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 9a4ac6ff60 fix(encode): bound per-frame size with a tight VBV buffer
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