Commit Graph

215 Commits

Author SHA1 Message Date
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
enricobuehler 9758751a4d ci(release): make the throwaway keychain the default keychain
ci / web (push) Failing after 44s
ci / rust (push) Successful in 54s
apple / swift (push) Successful in 1m19s
ci / docs-site (push) Failing after 42s
docker / deploy-docs (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
release / apple (push) Failing after 2m34s
exportArchive's signing lookup consults the default keychain; search
list membership alone leaves the (valid) identity invisible to it.
Restored to login.keychain in cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v0.1.0
2026-06-12 16:06:04 +00:00
enricobuehler 343cb544d9 ci(release): manual Developer ID export — cloud signing has no fallback
ci / web (push) Failing after 34s
ci / rust (push) Successful in 55s
ci / docs-site (push) Failing after 34s
apple / swift (push) Successful in 1m18s
docker / deploy-docs (push) Failing after 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
release / apple (push) Failing after 2m35s
With -allowProvisioningUpdates, exportArchive prefers cloud-managed
Developer ID signing; the App-Manager API key can't ("Cloud signing
permission error") and the valid local identity is never tried.
signingStyle=manual + explicit signingCertificate, cloud flags off
this step (archive keeps them for profile fetch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:01:12 +00:00
enricobuehler 6b49279c32 ci(release): stage Apple intermediate CAs in the signing keychain
ci / web (push) Failing after 34s
ci / rust (push) Successful in 55s
ci / docs-site (push) Failing after 32s
apple / swift (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 12s
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 3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
release / apple (push) Failing after 2m43s
Fresh boxes lack the Developer ID / WWDR intermediates; without the
issuing chain the imported identity is invalid and xcodebuild says
"No signing certificate Developer ID Application found".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:55:09 +00:00
enricobuehler d0f8896570 fix(web): mobile navigation — add a bottom tab bar + top bar
ci / web (push) Failing after 49s
ci / rust (push) Successful in 55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
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
apple / swift (push) Successful in 1m19s
ci / docs-site (push) Failing after 37s
docker / deploy-docs (push) Successful in 16s
The app shell's only navigation was the desktop sidebar (`hidden … sm:flex`), so
on phones (< sm) it was hidden with no replacement — you couldn't navigate at all.

Add a responsive mobile layout shown only below `sm`: a top bar (brand + language
switcher) and a fixed bottom tab bar with the five nav items (icon + label). The
desktop sidebar is unchanged. Page content gets bottom padding so the fixed bar
doesn't cover it, and the bar respects the iOS `safe-area-inset-bottom`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:59:54 +00:00
enricobuehler 02bcf41803 ci(release): TestFlight upload best-effort until the ASC app record exists
ci / web (push) Failing after 41s
ci / rust (push) Successful in 56s
ci / docs-site (push) Failing after 35s
apple / swift (push) Successful in 1m19s
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 3s
docker / deploy-docs (push) Successful in 16s
release / apple (push) Failing after 2m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:39:19 +00:00
enricobuehler 0733eae361 Merge remote-tracking branch 'origin/main'
ci / web (push) Failing after 37s
ci / rust (push) Successful in 54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m18s
2026-06-12 14:34:45 +00:00
enricobuehler 57e7f9fe25 feat(release): production Apple builds — notarized macOS dmg + iOS TestFlight
release.yml (v* tags / dispatch, macos-arm64 runner): universal mac +
iOS xcframework -> xcodebuild archive -> Developer ID export ->
notarytool + staple -> dmg on the Gitea release; iOS archive uploads
to TestFlight (app-store-connect/upload). Per-run throwaway keychain;
ASC API key authenticates notarization, upload, and automatic-signing
profile fetch. macOS App Store lane deferred (needs App Sandbox);
tvOS deferred (tier-3 Rust targets).

All app targets now share bundle ID io.unom.punktfunk — ONE App Store
listing with universal purchase (decided pre-submission; effectively
unchangeable after). ITSAppUsesNonExemptEncryption=false declared
(standard-algorithm AES-GCM, exempt).

build-xcframework.sh resolves Apple toolchains itself: cargo's HOST
artifacts (proc-macros, build scripts) are loaded by the running OS,
and a newer-than-OS beta Xcode ld emits LINKEDIT layouts dyld rejects
("mis-aligned LINKEDIT string pool" -> misleading E0463) — so prefer
a non-beta Xcode for everything, fall back to CLT for mac-only slices
(env untouched: an explicit DEVELOPER_DIR=<CLT> trips xcrun's license
check), refuse iOS/tvOS without a real Xcode (CLT has no iOS SDK).
The runner plist no longer injects DEVELOPER_DIR for the same reason.

punktfunk_Logo.icon: dropped the Xcode-27-beta-only Icon Composer
features (refractivity, specular-location) — 26.5's actool crashes on
them, and store builds must use release Xcode. Visual delta is the
refraction/specular nuance only; re-author when 27 ships.

Validated on home-mac-mini-1 with Xcode 26.5: mac+iOS xcframework
slices, unified bundle IDs, signing-free app build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:34:45 +00:00
enricobuehler 9291568ce0 refactor(apple): decompose ContentView (735 -> 272 lines)
ci / web (push) Failing after 35s
ci / rust (push) Successful in 54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
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 4s
ci / docs-site (push) Failing after 40s
docker / deploy-docs (push) Successful in 16s
apple / swift (push) Successful in 1m20s
Split the monolithic ContentView into focused view files — a pure structural refactor
with no behavior change (verified: builds macOS/iOS/tvOS, the test suite is green, and a
fidelity review against the original found no discrepancies):

- ContentView (272): the coordinator — owns the session model / host store / discovery,
  switches home<->session, holds the connect logic (it reads @AppStorage) + the dev
  hooks, and the stream builder (whose stable identity across awaiting-trust->streaming
  must NOT move — it stays here).
- HomeView (251): the hosts grid + navigation + toolbar + sheets + "On this network"
  discovery section + empty state.
- HostCards (158): HostCardView + DiscoveredCardView, sharing a CardMetrics struct
  (dedupes the platform-tuned sizing the two cards had copy-pasted).
- TrustCardView (80): the TOFU prompt + fingerprint formatting.
- StreamHUDView (67): the streaming overlay HUD.

State flows idiomatically: @StateObject (ContentView) -> @ObservedObject in subviews,
@State -> @Binding; the connect logic is passed as closures. Sheet placement is
preserved — the pairing/speed-test sheets stay on the outer body so they survive the
trust->home transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:30:34 +02:00
enricobuehler 9e8135ccec refactor(apple): code-quality pass — audit fixes + centralized defaults keys
A 6-agent adversarial audit of the client (11 confirmed of 39 findings, the rest
filtered) drove these:

- fix: SessionAudio ring buffer — guard a write larger than the ring (would push
  readIdx past writeIdx and corrupt the buffer; never happens, but guard not corrupt).
- fix: CADisplayLink retain cycle (stage-2 presenter) — a weak-target DisplayLinkProxy
  so the view can deallocate (the link retains its target); stage-2 teardown added to
  both StreamView/StreamViewController deinits as a safety net.
- fix: GamepadFeedback deinit { flag.stop() } — the drain thread holds the connection
  strongly and self weakly, so an abrupt teardown without stop() would leak it.
- refactor: centralize the 12 UserDefaults/@AppStorage key literals (scattered across
  8 files) into one DefaultsKey enum — a typo silently splits a setting's reader from
  its writer.
- docs: RumbleRenderer @unchecked Sendable invariant; the HID digit-row table; the
  stage-2 layer compositing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:30:34 +02:00
enricobuehler c8099c0125 fix(vdisplay/mutter): stop screencast before monitor reconfig — fixes >60Hz teardown crash
ci / web (push) Failing after 45s
ci / rust (push) Successful in 57s
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 29s
docker / deploy-docs (push) Successful in 16s
apple / swift (push) Successful in 1m19s
The high-refresh teardown SIGSEGV was caused by ApplyMonitorsConfig disabling the
still-actively-captured high-refresh virtual output. Reorder teardown: Stop the screencast
FIRST (Mutter removes the virtual + auto-reverts the temporary config), then re-assert the
physical layout once the virtual is gone. Never reconfigure a live virtual CRTC.

With this, PUNKTFUNK_MUTTER_VIRTUAL_REFRESH=1 is stable: validated at 5120x1440@240 on
Mutter 50 + NVIDIA — virtual output Meta-0@240, real 240fps, gnome-shell survives back-to-back
sessions + teardowns, physical (HDMI-1) restored each time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:08:05 +00:00
enricobuehler 91d5874e94 docs: user-facing docs revamp — structured product docs + per-platform setup
ci / web (push) Failing after 47s
ci / rust (push) Successful in 54s
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 17s
ci / docs-site (push) Failing after 37s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m19s
Replace the dev/agent-log pages with a proper user-facing doc set:

- Getting Started: Introduction (rewritten), How It Works, Quick Start.
- Host Setup: Requirements, then clean per-platform guides — Ubuntu GNOME,
  Ubuntu KDE, Fedora KDE (new), Bazzite (rewritten) — plus Running as a Service
  (desktop / headless GNOME / headless KDE).
- Connecting: Clients overview, Moonlight, Pairing & Trust.
- Configuration: host.env reference, Host CLI, Troubleshooting.
- The dev/design notes (architecture, roadmap, the deferred design specs, CI)
  move to a clearly-separated "Project & Internals" nav section.

Removes the superseded box-specific pages (gnome-box, headless-box, linux-setup,
overview). status.md (the internal progress tracker, with box IPs) is kept as a
file but dropped from the public nav. Site builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:01:19 +00:00
enricobuehler 015f2ee47b fix(vdisplay/mutter): gate >60Hz virtual mode behind an env flag (teardown SIGSEGV)
ci / web (push) Failing after 34s
ci / rust (push) Successful in 55s
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 41s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m20s
Pinning the virtual output to a high client refresh via RecordVirtual "modes" works
mid-stream, but a high-refresh virtual CRTC SIGSEGVs gnome-shell on session TEARDOWN
(observed at 5120x1440@240) — taking down the whole GNOME session, so subsequent connects
fail with RemoteDesktop ServiceUnknown.

Gate it behind PUNKTFUNK_MUTTER_VIRTUAL_REFRESH, default OFF — Mutter then derives the
virtual monitor's refresh from the PipeWire framerate (60Hz, stable). The >60Hz path stays
in-tree for investigation; re-enable once the teardown crash is understood.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:59:29 +00:00
enricobuehler ecb4e6e1d5 Merge remote-tracking branch 'origin/main'
ci / rust (push) Successful in 55s
ci / web (push) Failing after 48s
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 17s
ci / docs-site (push) Failing after 36s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m19s
2026-06-12 13:45:29 +00:00
enricobuehler f6a7f3c12d feat(vdisplay/mutter): pin the virtual output to the client's refresh (>60 Hz)
RecordVirtual without a "modes" property makes Mutter derive the virtual monitor's refresh
from the PipeWire stream framerate and default to 60 Hz — so a 240 Hz client mode rendered
at 60 (the encoder just padded to 240 with duplicate frames). Pass an explicit "modes" entry
(size + refresh-rate + is-preferred) so Mutter creates the virtual monitor at the client's
exact WxH@Hz. Mutter >= 47; older Mutter ignores the unknown key (60 Hz fallback, no regression).

Confirmed first via raw D-Bus on the box, then validated end-to-end: the virtual output
Meta-0 reports 1920x1080@240.00 and the host encodes 480 *immediate* (real, not paced)
frames per 2 s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:45:29 +00:00
enricobuehler fa407700e0 docs(roadmap): gamescope multi-user research (deferred); render->capture parked
Document the gamescope multi-user (independent-desktops) research and defer it:
the current shared host-lifetime input/audio/mic vs the per-session plumbing it
would need — per-instance EIS sockets + a per-session injector + per-session
null-sink audio routing + per-session mic — and why it's not worth it now (a large
multi-file refactor for the niche multi-user-on-one-box case, while the common
multi-device scenario is already covered by the shared-desktop multi-view
concurrency that landed). New gamescope-multiuser.md + roadmap section 14
(concurrent sessions: multi-view done, multi-user deferred).

Also park render->capture in section 12: pipewire-rs 0.9.2 exposes no
buffer-meta / raw-pointer / stream-timing API, so reading SPA_META_Header.pts
would need raw spa_sys FFI into the working capture hot path — disproportionate
for the smallest glass-to-glass term; g2g is effectively complete as
capture->present (the stage-2 presenter measures it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:45:23 +00:00
enricobuehler 7b10714b62 feat(apple): stage-2 presenter — explicit decode + Metal present + glass-to-glass
ci / web (push) Failing after 38s
ci / rust (push) Successful in 53s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
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 16s
ci / docs-site (push) Failing after 39s
docker / deploy-docs (push) Successful in 16s
apple / swift (push) Successful in 1m17s
Opt-in (Settings -> Presenter; `punktfunk.presenter`, default stage-1). Stage-1's
AVSampleBufferDisplayLayer decodes AND presents internally with no per-frame
callback, so neither decode nor present can be stamped or hand-paced. Stage-2
takes explicit control:

- VideoDecoder: VTDecompressionSession, async output callback stamps
  decode-completion, session rebuilt on every IDR / format change. Unit-tested
  (testVideoDecoderAsyncCallbackDeliversPixels).
- MetalVideoPresenter: CAMetalLayer + CVMetalTextureCache + a runtime-compiled
  BT.709 limited-range NV12->RGB shader, present at the next vsync. The
  CVMetalTextures + pixel buffer are held until the GPU completes.
- Stage2Pipeline: pump thread -> decoder -> newest-ready 1-slot ring; the hosting
  view's display link drains it once per vsync and stamps capture->present
  (the display-link target time projected into CLOCK_REALTIME).
- LatencyMeter gains record(ptsNs:atNs:offsetNs:); the HUD shows a capture->present
  (glass-to-glass, modulo host render->capture) line, skew-corrected via
  clockOffsetNs. Measured live ~11 ms p50 vs ~2.2 ms capture->client.
- StreamView / StreamViewIOS host the CAMetalLayer as a sublayer + a CADisplayLink
  (NSView.displayLink on macOS) when stage-2; input capture + HUD unchanged. The
  session-active gates switch from `pump != nil` to `connection != nil` so capture
  engages without a StreamPump.

Validated: builds macOS/iOS/tvOS; the decode half is unit-tested; the Metal
present is live-validated on glass (correct image + the capture->present number).
Colorspace is BT.709 SDR for now; 10-bit/HDR + a pacing policy are later.
Plan: docs-site/content/docs/apple-stage2-presenter.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:29:23 +02:00
enricobuehler 848738ed00 docs(site): status log — CI + automatic docs deployment landed
ci / web (push) Failing after 35s
ci / rust (push) Successful in 54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 39s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:21:21 +00:00
enricobuehler 2226031577 fix(ci): deploy target is unom-1, not home-main-2
ci / web (push) Failing after 36s
ci / rust (push) Successful in 54s
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 5s
ci / docs-site (push) Failing after 37s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
docker / deploy-docs (push) Successful in 16s
apple / swift (push) Successful in 1m18s
website/cms deploy to the unom-1 DMZ VM (192.168.50.50) — the
website README's home-main-2 mention is stale. Caddy upstream fixed
in unom/reverse-proxy 6ae79b8, firewall port in unom/infra 9670aa8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:15:16 +00:00
enricobuehler 2ed755f0c3 fix(vdisplay/mutter): make the virtual output the SOLE display, not primary + secondary
ci / web (push) Failing after 38s
ci / rust (push) Successful in 55s
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
ci / docs-site (push) Failing after 43s
docker / deploy-docs (push) Successful in 16s
apple / swift (push) Successful in 1m13s
Keeping the physical monitor enabled as a secondary let the cursor, windows, and keyboard
focus land on it — relative pointer motion wandered off the streamed surface, so on the
client the cursor "disappeared" and clicks/keys went nowhere visible. Omit the physical
outputs from ApplyMonitorsConfig so Mutter disables them for the session; everything is
confined to the streamed virtual output. Restored on teardown.

Validated on-box: mid-session DisplayConfig shows only the virtual output (Meta-0) as the
sole primary; the physical (HDMI-1) is restored after the session ends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:05:02 +00:00