afed2206ab3ea7ed14476abc16bc9060bbec558f
57 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8970cfe188 |
style(vdisplay/mutter): drop trailing blank line (rustfmt --check)
The stray blank line after build_primary_config tripped cargo fmt --all --check in CI. Formatting only, no code change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
263eab31e3 |
fix(m3): release held mouse buttons/keys when a session ends (stuck-click after reconnect)
ci / rust (push) Failing after 34s
ci / web (push) Failing after 46s
ci / docs-site (push) Failing after 38s
apple / swift (push) Successful in 1m18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m42s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (push) Successful in 5m17s
The pointer/keyboard injector is host-lifetime (one EIS connection for every punktfunk/1 session), so its existing release_all only fires on EIS disconnect — never when a *client* session ends. A button still down at an abrupt client disconnect therefore stayed latched in the compositor: Mutter keeps the destroyed press's implicit pointer grab, so after reconnect a stuck left-button-down turns every motion into a drag (windows move, text selects) while a fresh click's press is swallowed — clicking buttons and text inputs does nothing. Only the one held button is affected; keyboard and the other buttons are fine, exactly as reported. Fix: input_thread now tracks the buttons/keys the client holds and, when the session ends, synthesizes the matching up-events through the host-lifetime injector (whose EIS connection — and the dangling grab — outlive the session). Backend-agnostic (normal inject path), so it covers libei/EIS, wlr and uinput alike. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7ecf2d8dfd |
fix(inject/libei): emit the continuous scroll axis so small scrolls register
ci / rust (push) Failing after 40s
ci / web (push) Failing after 37s
apple / swift (push) Successful in 1m23s
ci / docs-site (push) Failing after 41s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 3m0s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (push) Successful in 4m18s
The libei backend forwarded mouse wheel only via scroll_discrete (120-per-detent). Mutter floors a sub-detent delta — a trackpad, a precise/high-res wheel, or a fractional smooth-scroll event — to zero whole clicks, so small scrolls never land and you have to spin the wheel a lot before anything moves. Emit the continuous `scroll` axis (logical px, ~15 px/detent) alongside the discrete steps, matching the wlroots backend's 15-px/notch behaviour, so every delta moves proportionally while full detents still drive line/page scrolling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
55dfb4800f |
fix(vdisplay/mutter): stop the teardown layout-restore from SIGSEGVing gnome-shell
After a session ends, the Mutter backend (with PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY=1) re-asserted the physical monitor layout with an explicit ApplyMonitorsConfig. On Mutter 50 + NVIDIA that monitor reconfig — issued while the just-removed high-refresh virtual output is still tearing down — SIGSEGVs gnome-shell. Observed live on home-worker-3: the teardown ApplyMonitorsConfig returns "recipient disconnected from message bus" (the shell died mid-call), GDM's crash-loop guard then drops to the greeter and STAYS there, so org.gnome.Mutter.RemoteDesktop/DisplayConfig vanish and every subsequent reconnect fails with RemoteDesktop.CreateSession ServiceUnknown — i.e. "after a disconnect I can't reconnect anymore." make_virtual_primary applies an APPLY_TEMPORARY config, which Mutter reverts on its own once the virtual output disappears and our DisplayConfig connection closes. So the explicit restore was both redundant and the crash trigger: drop it, drop the dc_pre connection at teardown, and let Mutter revert the temporary config itself. Setup is unchanged (the virtual output is still made primary so the desktop lands on the streamed surface). Removes the now-unused to_apply_logicals/apply_config helpers. Verified live on home-worker-3 (5120x1440@240, VIRTUAL_PRIMARY=1): 6/6 back-to-back connect/disconnect cycles streamed cleanly with gnome-shell holding the same PID throughout (previously it crashed within the first few disconnects). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
dad5a08c1f |
chore(capture): tidy the GNOME flash diagnostic — it's the CORRUPTED skip
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 1m20s
ci / web (push) Failing after 34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m52s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (push) Successful in 5m12s
Live confirmation on worker-3: the flash was Mutter's CORRUPTED, size-0 cursor-update buffers (chunk_flags=CORRUPTED) carrying recycled old frames — drained=1 always, so latest-frame-only draining wasn't the lever, the CORRUPTED skip was (OBS issue 8630). Demote the verbose drain diagnostic to a rate-limited debug line and document the root cause inline. Validated: zero-copy back on GNOME (dmabuf->CUDA, 5120x1440) AND flash-free with FORCE_SHM off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d8da12bbbd |
fix(capture/mutter): latest-frame-only dequeue (the real GNOME flash fix)
ci / web (push) Failing after 39s
apple / swift (push) Successful in 1m18s
ci / rust (push) Successful in 1m22s
ci / docs-site (push) Failing after 44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 3m17s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (push) Successful in 4m42s
Deep research (OBS Studio's linux-pipewire, Mutter bug tracker) found the GNOME stale-frame flash is a buffer-RECYCLING race, not damage (Mutter sends whole frames, no SPA_META_VideoDamage) and not buffer count. OBS's proven fix is latest-frame-only dequeue: each process callback, drain ALL queued PipeWire buffers, requeue the older ones, and consume only the NEWEST — plus skip CORRUPTED buffers. Our code dequeued one buffer per callback (oldest-first) and the bounded channel dropped the NEWEST when full, so during Mutter's bursty delivery the encoder got stale frames → the flash. Switch the process callback to raw dequeue_raw_buffer + drain-to-newest (requeue older), extract the consume logic into consume_frame(spa_buf) sourcing datas via the transparent Data cast, skip SPA_META_HEADER_FLAG_CORRUPTED / CORRUPTED-chunk buffers (size-0 skip kept SHM-only so dmabuf isn't regressed), and remove the earlier content-hash drop heuristic (it couldn't tell stale re-deliveries from legit repeating content). Diagnostic logs drain depth + chunk/header flags. Reverts none of the FORCE_SHM / dmabuf_fence work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
79508b2666 |
fix(capture/mutter): drop stale re-delivered frames (the GNOME flash)
ci / web (push) Failing after 40s
apple / swift (push) Successful in 1m17s
ci / docs-site (push) Failing after 37s
ci / rust (push) Successful in 1m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m53s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 5m11s
Instrumented worker-3: even on the ordered FORCE_SHM download path, Mutter re-delivers COMPLETE OLD pool buffers — 655 frames in a 15 s session whose content exactly matched an earlier frame (not damage-incremental; full old frames, in runs, ~45% during motion). NVIDIA gives no fence to prevent it, so the producer delivery can't be made clean from our side. Detect it and drop it: hash a spatial sample of each captured frame; a frame whose content equals an EARLIER distinct frame (vs the current one, whose duplicates pass through) is a stale re-delivery — skip it so the encoder never emits the flash (try_latest re-sends the last good frame; brief hold instead of a backward jump). Runs on the CPU/SHM path (where Mutter+NVIDIA capture lives); never triggers on static content or non-Mutter compositors (no reverts). PUNKTFUNK_KEEP_STALE=1 disables it for A/B. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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 ( |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
0c4cfa40be |
fix(inject/mutter): GNOME input via Mutter's direct EIS, not the xdg portal
ci / rust (push) Successful in 56s
ci / web (push) Failing after 35s
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 4s
ci / docs-site (push) Failing after 38s
apple / swift (push) Successful in 1m15s
On a headless GNOME host the xdg-desktop-portal RemoteDesktop Start() blocks on an
interactive "Allow remote control?" approval nobody can click, so libei input timed out
("EIS setup timed out") and neither mouse nor keyboard worked — even though video worked
(it uses Mutter's direct RemoteDesktop API).
Add EiSource::MutterEis: obtain the EIS fd from
org.gnome.Mutter.RemoteDesktop.Session.ConnectToEIS (CreateSession → Start → ConnectToEIS),
no portal and no approval. Selected for GNOME/Mutter; KWin keeps the RemoteDesktop portal,
gamescope keeps its own EIS socket.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
94552331ef |
feat(host): concurrent punktfunk/1 sessions (bounded by --max-concurrent)
ci / web (push) Failing after 32s
ci / docs-site (push) Failing after 34s
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 / rust (push) Successful in 5m25s
apple / swift (push) Successful in 1m23s
The accept loop no longer awaits each session inline — it spawns each onto a JoinSet, bounded by a semaphore (--max-concurrent, default 4: a NVENC session bound; overflow clients wait in QUIC's accept backlog until a slot frees). The QUIC handshake stays in the accept loop so a failed handshake (e.g. a pin mismatch where the client aborts) doesn't consume a session slot or block accepting the next client; the slow part (control handshake, pairing, the capture/encode pipeline) runs in the spawned task. Each session already had its own virtual output + NVENC encoder; the host-lifetime input/audio/mic services stay shared — the natural "multiple devices viewing/controlling the same desktop" semantic on kwin/mutter/wlroots. gamescope's independent-desktops (per-session input/audio) isolation is a follow-up. New M3Options.max_concurrent + the `--max-concurrent` CLI flag. Validated live (GNOME box): two clients connected at once -> two independent Mutter virtual outputs (720p60 + 1080p60) streaming simultaneously (39 MB + 48 MB). All 61 host tests green (the c_abi/pairing tests exercise the new loop + the failed-handshake-doesn't-count semantics). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
60ccbfdcf7 |
style: cargo fmt --all under rustfmt 1.9 (Rust 1.96)
Comment reflow only — the pinned "stable" channel moved and CI checks formatting with the current toolchain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9fe7b7877f |
feat(vdisplay/mutter): optional virtual-output-as-primary for monitored GNOME hosts
PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY=1: after RecordVirtual, promote the per-session virtual output to the primary monitor (physical kept on, secondary) via org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig, restoring on teardown. Without it, a GNOME host that also has a physical monitor attached keeps the physical primary, so the virtual output is an empty extended desktop — the client streams only the wallpaper. (The backend was validated on headless GNOME, where the virtual output is the only display.) Best-effort + opt-in: default behavior is unchanged; any DisplayConfig failure just logs and streaming continues. method=temporary, so nothing is written to monitors.xml and Mutter auto-reverts the layout when the virtual output is torn down. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
05bc9ab22c |
feat(latency): wall-clock skew handshake for cross-machine latency measurement
ci / rust (push) Has been cancelled
ClockProbe/ClockEcho on the QUIC control stream — 8 NTP-style rounds right after Start; the min-RTT sample gives the host-client clock offset (clock_offset_ns estimator in punktfunk-core). The client adds the offset to its receive instant before differencing against the AU pts_ns, so the capture->reassembled latency percentiles are valid across machines (skew_corrected=true), not just same-host. Back-compat: an old host that doesn't answer the probe times out and the client falls back to a shared-clock assumption (skew_corrected=false). Host adds one ClockProbe dispatch arm in the control task; the client runs clock_sync after Start, before the --remode/--speed-test tasks take the stream. Validated cross-LAN (GNOME box -> dev box): offset ~ -1.57 ms (reproducible), rtt ~140 us, p50 1.30 ms skew-corrected capture->reassembled — the offset is exactly the systematic error the handshake removes. Unit tests for the message codecs and the min-RTT offset estimator. Roadmap §12: skew handshake done; remaining for true glass-to-glass is the Apple client present-stamp (decode->present) plus the host render->capture term. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4fff4641bb |
feat(discovery): native-protocol LAN auto-discovery over mDNS
ci / rust (push) Has been cancelled
Both the unified host (serve --native) and standalone m3-host now advertise the native punktfunk/1 service over mDNS (_punktfunk._udp) — the analogue of the GameStream _nvstream._tcp advert. TXT records carry proto, the host cert fingerprint (fp, the value clients pin), the pairing requirement (pair=required|optional), and the host id. New crate::discovery module, wired into m3::serve so both host entry points get it; best-effort, never blocks streaming (--connect always works). Client gains `punktfunk-client-rs --discover [SECS]`: browses the LAN and prints each host (name, addr:port, pairing, fingerprint), then exits. Apple clients browse the same service natively via NWBrowser (service type + TXT keys are the contract). Validated cross-LAN: the dev box discovered the GNOME-box appliance (pair=required) and a standalone synthetic host (pair=optional); fingerprint and pairing state correct in both. Also refresh the now-stale sendmmsg caveat in the bitrate doc (batched/paced send landed + validated to 1 Gbps) and mark the encode|send thread split done in §12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b295a5b7a9 |
perf(latency): encode|send thread split on the native path
ci / rust (push) Has been cancelled
Bigger-bet #1 from the latency plan. virtual_stream ran capture+encode+seal+ paced-send on ONE thread, so frame N+1's capture/encode couldn't start until frame N's entire paced tail had left the wire — the pacing budget (~0.9×interval) was serialized in front of the next encode. Port GameStream's spawn_sender model to the native path: - A dedicated send thread (`send_loop`) owns the WHOLE Session (so no socket clone or shared/Arc stats needed — `seal_frame` mutates the nonce, `send_sealed` + the probe bursts all live there) and does FEC+seal + microburst-paced send. - The encode thread captures+encodes + handles reconfig and hands each AU over a bounded sync_channel(3) as a FrameMsg (data, capture_ns, flags, deadline, encode_us). It BLOCKS on backpressure if the send falls behind — frames slow down rather than a dropped frame freezing the infinite-GOP stream (we don't drop). Clean shutdown: drop the channel → send thread drains/exits → join. - Probes (run_probe_burst) move to the send thread since they need the Session; a burst naturally pauses video (the encode thread blocks on the full channel). - Per-frame encode_us/pace_us histogram moved to the send thread (carries encode_us in the FrameMsg) and now reflects the overlap. Removes the encode↔paced-tail serialization (~2-8 ms @60-120 fps), independent of the pacing policy, no quality cost. Substrate for the future NVENC slice wrapper. Verified live on this box (appliance restarted onto it): a client streamed the KWin desktop (1.49 MB H.265, clean, no panic) and a 200 Mbps speed-test probe completed through the send thread (0 drops). Build + clippy + fmt green. Real-NIC sustained soak (reconfig under load, line-rate, mode switches) pending the Ubuntu third host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
86f463cf71 |
fix(housekeeping): unaligned read UB + recv-drop parity; dedup mmsghdr; doc fixes
ci / rust (push) Has been cancelled
From a bug-hunt + unsafe-audit pass (4 reviewers + adversarial verify). It confirmed ZERO real bugs in the recent batched/paced data-plane work — these are the surfaced cleanups + one genuine soundness fix: - SOUNDNESS (reduce unsafe): inject/gamepad.rs::pump_ff did `ptr::read` of an InputEventRaw (align 8, holds a timeval) out of a 1-aligned [u8; N] buffer — UB per the reference (x86_64 tolerates it, but it can miscompile under LTO). Use ptr::read_unaligned + a SAFETY note. Zero behavior change. - recv parity: recv_batch (recvmmsg) didn't drop an oversized/truncated datagram the way scalar recv does — poll_frame now skips a message whose len fills the buffer (> MAX_DATAGRAM_BYTES), matching recv's `n >= RECV_BUF` drop. (AEAD already rejected these on encrypted sessions; this restores the documented invariant on the batched path.) - dedup unsafe FFI: factor the identical mmsghdr-from-iovec construction out of send_batch + recv_batch into one `mmsghdrs()` helper — the raw-pointer scaffolding + its lifetime SAFETY note now live in one place. - docs: TARGET_SOCKBUF no longer calls paced sending future work (it landed, m3.rs::paced_submit); gamescope.rs input is no longer "(TODO)" (wired + live-validated); the PUNKTFUNK_PERF `wire_mbps` field is renamed `tx_mbps` and noted as attempted/sealed bytes (send_dropped shows what didn't reach the wire). Full suite (35 + loopback round-trip + 6) + clippy + fmt green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
99f60b5b08 |
perf(latency): microburst-cap pacing + per-frame latency histogram
ci / rust (push) Has been cancelled
From the latency investigation: the freeze-fix pacing (paced_submit) was the single biggest software-controllable latency term — it unconditionally spread EVERY multi-chunk frame over ~90% of the frame interval, adding up to ~7.5 ms @120 / ~15 ms @60 to a frame's last packet even when the frame was small or the link idle. Recover that on the common case while keeping the freeze fix: - Microburst-cap pacing: a frame whose sealed size is <= a cap (default 128 KB, PUNKTFUNK_PACE_BURST_KB) goes out in ONE immediate burst — no pacing latency. Only the OVERFLOW of a bigger frame (IDR / sustained high bitrate, the bursts that actually overran the tx buffer and froze) is spread. 128 KB is well under the ~150 Mbps@60 frame size where drops began, so the default is safe; raise it after confirming send_dropped stays 0 on a given link. Still never slower than unpaced (budget collapses to 0 with no slack). seal-once/in-order nonce preserved — chunks are split, never reordered or re-sealed. - Per-frame instrumentation (PUNKTFUNK_PERF, zero-cost off): encode_us + pace_us (the pacing tail) p50/p99/max histograms + immediate-vs-paced frame counts in the periodic perf line, so the pacing tail is finally visible and the cap is tunable against real numbers. Host builds + clippy + fmt green. NOT yet deployed to the running hosts (still on the safe full-pacing A+B build) — needs the user's LAN soak to validate the cap doesn't reintroduce send_dropped before raising it. Deferred bigger bets (need real-NIC/GPU/Mac validation): encode|send thread split on the native path, CUDA stream+event (one redundant sync), NVENC slice wrapper, stage-2 Apple presenter, glass-to-glass probe — see docs/roadmap.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
10a932d013 |
feat(1gbps): pace per-frame sends so high-bitrate frames don't burst-drop
ci / rust (push) Has been cancelled
Increment B of the send-path rework — the actual fix for "freezes get more common over ~150 Mbps, no image at all at 400 Mbps" on the native path. Cause: the encoder emits a frame and submit_frame blasted ALL its packets at once into the NIC; a real link drops the line-rate burst (host send buffer EAGAINs), and under infinite GOP one dropped frame freezes the decode until the next keyframe. (The speed-test probe showed 0 drops at 400 Mbps because the probe is self-paced; real video wasn't.) Adaptive pacing, no extra thread, no regression: - Session splits into seal_frame (FEC + packetize + seal → wire packets, no send) and send_sealed (one batched sendmmsg of a chunk, counts drops); submit_frame is now their composition (synthetic + probe paths unchanged). - virtual_stream's paced_submit seals a frame then sends it in 16-packet chunks spread over ~90% of the time until the next frame is due. At 60 fps desktop (fast encode → lots of slack) the frame spreads across the interval → no NIC burst → no freeze. At 240 fps@5K (encode ≈ interval → ~0 slack) the budget collapses and every chunk goes out immediately → never slower than before. Core suite (34 + loopback round-trip + 6) + clippy + fmt green. The seal/send split is covered by the existing loopback tests; the pacing is host timing, verified by review (live-test needs a real NIC — your Mac at a raised bitrate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b8a33e21a2 |
feat(1gbps): raise bitrate/probe clamps + socket buffers, count send-buffer drops
ci / rust (push) Has been cancelled
First step of 1 Gbps+ readiness (the whole point of the GF(2^16) Leopard FEC): make 1 Gbps configurable and its dominant failure mode observable, before the real transport work (sendmmsg + paced encode|send split) lands. Investigation (6-way) verdict: we're ~halfway, and it's mostly clamps plus one real piece of work. The integer/type path, FEC (a 1 Gbps frame is only a few hundred shards in one GF(2^16) block, far under the 65535 ceiling), AES-GCM (AES-NI, ~10-25x headroom), and the M1 reassembler bounds (fully derived from the negotiated FecConfig) are ALL already 1 Gbps-ready and untouched. This commit (the configurable + observable foundation): - m3.rs: MAX_BITRATE_KBPS 500_000 -> 2_000_000 (2 Gbps headroom over the 1 Gbps+ target); MAX_PROBE_KBPS 1_000_000 -> 3_000_000 (probe can demonstrate headroom ABOVE the session cap so a client can confidently pick a 1 Gbps+ bitrate). - transport/udp.rs: TARGET_SOCKBUF 8 MB -> 32 MB (a multi-MB IDR keyframe burst no longer fills the buffer); scripts/99-punktfunk-net.conf bumped to match. - Observability: Transport::send now returns Ok(true|false) (false = WouldBlock send-buffer drop, previously a silent Ok(())). Session counts these as a new `packets_send_dropped` stat (distinct from recv-side packets_dropped) — in Stats, the C ABI PunktfunkStats (header regenerated), a PUNKTFUNK_PERF periodic wire-Mbps + drop dump in virtual_stream, and the speed-test probe completion log. This is the dominant 1 Gbps+ loss mode and was invisible. Loopback-verified: a probe now runs at 1.2 Gbps target (no longer truncated to 1 Gbps) with the drop counter live. NOT yet a sustained-1-Gbps proof — the single-send()-per-packet native path is the next, real piece of work (port the proven GameStream sendmmsg + paced send thread into the core Transport). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7cac1eb663 |
feat(host): log virtual DualSense pad creation (match the X-Box path)
ci / rust (push) Has been cancelled
The uinput X-Box 360 backend logs "virtual gamepad created" on success, but the UHID DualSense backend logged only on failure — so a working DualSense session was silent and indistinguishable in the logs from one where no pad was ever created. Add the matching success log. This makes a DualSense-not-working report self-diagnosing: the host now logs either "virtual DualSense created (UHID hid-playstation)" or the existing "virtual DualSense creation failed — controller input disabled" (which fires when /dev/uhid isn't writable — i.e. the 60-punktfunk.rules uhid rule isn't installed or the user isn't in the 'input' group). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
74819b1be8 |
feat(punktfunk/1): negotiable encoder bitrate + bandwidth speed-test probe
ci / rust (push) Has been cancelled
Two related additions to the native protocol, host-side (the client side of
each is exposed over the C ABI so the platform clients can wire it up).
Bitrate negotiation
- Hello/Welcome carry `bitrate_kbps` (appended trailing-byte field, back-compat:
old peers decode 0 = host default). The client requests a rate; the host
clamps it to [500 kbps, 500 Mbps] (or its 20 Mbps default when 0) and echoes
the resolved value in Welcome. Replaces the hardcoded 20 Mbps NVENC bitrate in
m3.rs — threaded through virtual_stream → build_pipeline → open_video, applied
on the initial mode and every reconfigure rebuild.
- C ABI: punktfunk_connect_ex3(..., bitrate_kbps, ...) (ex2 delegates with 0);
punktfunk_connection_bitrate() reads the resolved value.
Speed test (bandwidth probe)
- New typed control messages ProbeRequest{target_kbps,duration_ms} (0x20) /
ProbeResult{bytes_sent,packets_sent,duration_ms} (0x21), plus a FLAG_PROBE
packet flag. The client asks the host to burst zero-filled, FLAG_PROBE-tagged
access units over the data plane at a target goodput for a duration (clamped
≤ 1 Gbps / ≤ 5 s), pacing by a bytes-allowed budget; video pauses for the
burst. The host reports what it actually sent; the client measures received
bytes + window → goodput and loss. Probe filler is never fed to the decoder
(diverted in the connector pump and the reference client's poll loop).
- The host control task now multiplexes Reconfigure + ProbeRequest (inbound)
and ProbeResult (outbound) over select!; a probe channel reaches the
data-plane thread (both virtual and synthetic sources).
- Connector: NativeClient::request_probe()/probe_result() with an internal
accumulator; C ABI punktfunk_connection_speed_test() +
punktfunk_connection_probe_result() → PunktfunkProbeResult.
- punktfunk-client-rs gains `--bitrate KBPS` and `--speed-test KBPS:MS` (its own
loop measures + logs goodput/loss) for loopback verification.
Validated on loopback (synthetic source): a 20 Mbps / 2 s probe measured
20050 kbps at 0% loss, bitrate negotiated (0→20000 and 50000→50000), and the
interleaved probe AUs were correctly excluded from frame verification
(mismatched=0). Wire codecs + trailing-byte back-compat have unit tests. C
header regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
c894c6f897 |
feat(host): host-managed gamescope session at the client's mode (dynamic res + refresh)
ci / rust (push) Has been cancelled
Nested games on the Bazzite host saw the wrong display: refresh capped at 60 Hz, the box's connected TV's EDID modes leaking in (DOOM landed on 2560×1440@60), and the resolution fixed at whatever the always-on session was launched at — the client's requested mode never reached the game. Root causes: the session-plus gamescope command has no --nested-refresh (Xwayland advertises 59.96 Hz for every mode), --prefer-output HDMI-A-1 makes gamescope read the TV EDID, and the ATTACH model launches one fixed-resolution session. New vdisplay path: PUNKTFUNK_GAMESCOPE_SESSION=<client> — the host LAUNCHES gamescope-session-plus headless AT THE CLIENT'S mode and relaunches it when the mode changes. Injected via a host-written GAMESCOPE_BIN wrapper (--nested-refresh $PF_HZ, the flag session-plus doesn't expose) + DRM_MODE=cvt (gamescope generates clean CVT modes at that refresh instead of the TV's EDID). The session runs as a transient `systemd-run --user` unit (clean cgroup teardown of the Steam tree); state lives in a host-lifetime static (MANAGED_SESSION), NOT in GamescopeDisplay (which is per-client-session) — so a same-mode reconnect REUSES the running session instantly (no Steam restart) while a different mode RELAUNCHES it (games can't change output mode live; a game/Steam restart on a mode change is unavoidable and acceptable). Reuses the existing node + EIS auto-discovery (find_gamescope_node / find_gamescope_eis_socket, factored into point_injector_at_eis) and the existing mid-stream Reconfigure → vd.create(mode) machinery — no protocol or m3 control-flow change. Validated live on bazzite (RTX 4090): games' Xwayland now advertises 5120×1440 @ 239.90 Hz as the preferred mode (was 59.96), the TV's 3840×2160/4096×2160@60 modes are gone, frames stream; reconnect at 1920×1080@120 relaunches and games see that; same-mode reconnect reuses with no restart and frames flow instantly. scripts: host.env.example documents PUNKTFUNK_GAMESCOPE_SESSION (mutually exclusive with the legacy NODE=auto attach); punktfunk-steam-session.service marked deprecated (superseded — must not run alongside the host-managed path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1d605fb781 |
feat(gamepad): controller discovery + client-negotiated pad type + rich DualSense end to end
The Apple client grows full gamepad support and punktfunk/1 learns to negotiate the virtual pad type: - Protocol: Hello carries a GamepadPref byte (offset 21, the same trailing-byte back-compat pattern as the compositor; echoed resolved in Welcome at 54). Host precedence: explicit client choice > PUNKTFUNK_GAMEPAD env > Xbox 360, DualSense (UHID) only where available. ABI: punktfunk_connect_ex2 + punktfunk_connection_gamepad (connect_ex delegates; ABI_VERSION stays 2 — the trailing byte IS the compat mechanism). punktfunk-client-rs gets --gamepad. - Swift client: GamepadManager (app-lifetime discovery + selection — Settings lists every controller with capabilities/battery/"In use"; exactly ONE pad forwards as pad 0, auto = most recently connected, or pinned), GamepadCapture (snapshot-diff button/axis events, DualSense touchpad + ~250 Hz motion on the rich-input plane, held state released on switch/deactivate/stop), GamepadFeedback (rumble → CoreHaptics per-handle engines; lightbar → GCDeviceLight; player LEDs → playerIndex; adaptive-trigger blocks → the table-driven DualSenseTriggerEffect parser → GCDualSenseAdaptiveTrigger, exact for the 10-zone positional modes). The pad type auto-resolves from the physical controller at connect time, user-overridable in Settings. - Host DualSense fixes surfaced by adversarial review against hid-playstation / SDL / Nielk1 ground truth: input-report sensor/touch offsets were off by one (the kernel read garbage motion + phantom touches), the L2/R2 trigger blocks were swapped (the report is right-trigger-first), feedback now gates on the report's valid-flags (a plain rumble write no longer blanks lightbar/ triggers), and the touchpad rescale clamps to the advertised ABS_MT extents. - Tests: Hello/Welcome trailing-byte back-compat, pick_gamepad precedence, byte-exact input-report layout, valid-flag gating, per-mode trigger-parser table (incl. packed 3-bit zones), wire conversions, and a scripted loopback feedback burst (PUNKTFUNK_TEST_FEEDBACK=1) asserted through the xcframework on the rumble + HID-output planes. Validated: cargo test/clippy/fmt green on macOS + Linux (61 host tests), swift build/test green, test-loopback.sh green, tvOS/iOS targets compile. DualSense motion sign/scale is derived from the calibration blob, not yet live-verified (constants isolated in GamepadWire). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9128bc3836 |
feat(host): attach to a running gamescope session (Bazzite headless Steam)
Bazzite (and SteamOS-like hosts) run Steam Big Picture inside their OWN gamescope-session-plus session. Nesting a second gamescope+Steam can't work — the second Steam sees the first and exits, taking the nested gamescope down with it (crash in its exit handlers), killing both video and input. The robust model is to let punktfunk OWN that session: run gamescope-session-plus headless at the client's resolution (full Steam Deck UI polish: MangoApp, VRR, controller config) and have the host ATTACH to it rather than spawn its own. The video half already existed (PUNKTFUNK_GAMESCOPE_NODE=<id> attaches to a PipeWire node). This finishes it: - PUNKTFUNK_GAMESCOPE_NODE=auto discovers the gamescope Video/Source node, so the (dynamic) node id needn't be hand-wired. - The attach path now also points the libei injector at the running session's EIS socket: find_gamescope_eis_socket() scans XDG_RUNTIME_DIR for gamescope-<N>-ei, connect()-probes each (stale dead-session sockets refuse), and writes the newest live one to the relay file the injector reads. So input reaches the attached session with zero manual config. scripts/punktfunk-steam-session.service: a systemd --user unit that runs gamescope-session-plus headless at a configured resolution, with the one-time headless-appliance setup (linger + multi-user.target) documented inline. Validated live on bazzite (RTX 4090): the full Steam Big Picture session streams (1499 frames, p50 ~1ms) with mouse/keyboard injected into it (device resumed, all caps, emitted=true), node + EIS socket both auto-detected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6e1097da4f |
fix(inject): self-heal a stale/hung EIS connection + per-kind injection diagnostics
The host-lifetime libei injector could connect to a gamescope EIS socket whose listen socket exists but whose server never drives the EI handshake — a stale socket left by a SIGKILLed prior session, or one created early in a new gamescope's startup before its libei server is ready. `UnixStream::connect` to a socket *file* succeeds the moment the path exists, so the worker sailed past the connect and then hung forever in `handshake_tokio` (or sat connected with no device ever resumed). Because `LibeiInjector::inject` only enqueues onto a channel (the !Send worker owns the connection), the send never errors, so InjectorService never noticed the dead worker and never reopened — every input event for the whole session was silently swallowed. The 30s setup timeout didn't help: a typical session ends first, so input just died with no error logged. Reconnecting made it worse (more stale sockets to land on). Two self-heal bounds, both paths (gamescope socket + KWin/GNOME portal): - Bound the EI handshake at 8s — a non-responding EIS server now errors instead of hanging, so the worker exits and the next inject() reopens. - Watchdog: if no input device resumes within 5s of connecting, treat the connection as dead-on-arrival and exit (same reopen path). Healthy servers add+resume a device within a beat of the handshake. Verified on-box: clean gamescope + KWin paths connect/resume/emit unchanged; a stale listener that accepts-but-never-handshakes now errors in 8s; two back-to-back gamescope sessions both inject (session 2 reopens against the fresh socket). Independently confirmed end-to-end delivery on KWin — a focused wev got the injected motions/keys/buttons — i.e. injection itself was never broken, only its recovery from a bad connection. Also adds permanent low-volume diagnostics so the next "input dead" report is instantly triageable: log each EIS device's capabilities on resume, the first of each InputKind a client sends + whether it emitted, and no-resumed-device drops. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
609136cd2d |
fix(inject): make the gamescope EIS injector reconnect robustly across sessions
ci / rust (push) Has been cancelled
Root cause of "input doesn't work" on the unified host: a single fresh session injects fine (EIS connects, "Gamescope Virtual Input" device added), but the host-lifetime injector reused a STALE per-session EIS socket across sessions → "connect EIS socket …: Connection refused". (Headless gamescope is EIS-only — it ignores uinput — so libei/EIS is the one input path for both gamescope and KWin; no second path needed.) - connect_socket_file: re-READ the relay file and RETRY the connect on refused/missing (the live gamescope's EIS appears shortly), bounded at 15s, instead of connecting once and bubbling ECONNREFUSED. - GamescopeProc::drop: clear the relayed EIS socket name on teardown so a dead session can't hand a stale path to the next reconnect. Validated: two sessions back-to-back each reconnect (EIS connected + device added). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9a6058cd20 |
feat(host): §8a — require native pairing by default (serve --open to disable)
ci / rust (push) Has been cancelled
An open punktfunk/1 host any LAN device can trust-on-first-use and stream from is
insecure. The unified host now gates native sessions on pairing by DEFAULT: a client
must complete the SPAKE2 PIN ceremony (armed from the web console) before it's
admitted; paired devices persist. `serve --open` keeps the old TOFU behavior for
trusted single-user setups.
native_serve_opts now takes a NativeServe { port, require_pairing }; parse_serve
builds it with require_pairing = !--open. GameStream pairing (separate) is unchanged.
The require_pairing gate + ceremony are already covered by m3::pairing_ceremony_and_gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
19666ba57e |
feat(host): unified host + native pairing over the management API
`serve --native` now runs the GameStream host AND the native punktfunk/1 (QUIC) host in ONE process, sharing a single NativePairing handle with the management API — so native pairing is operable from the web console instead of journalctl. - gamestream::serve gains a native_port: spawns crate::m3::serve in the same runtime and passes the shared NativePairing to mgmt::run. Validated live: one process binds both RTSP 48010 and QUIC 9777. - mgmt API: new `native` endpoints — GET /native/pair (status), POST /native/pair/arm (mint a fresh, time-limited PIN to DISPLAY), DELETE /native/pair (disarm), GET/DELETE /native/clients (list/unpair). GameStream-only hosts report enabled:false. OpenAPI regenerated (checked-in doc + drift test). - main.rs: serve --native / --native-port flags. The native host arms pairing on demand (the operator reads the PIN from the console; the SPAKE2 ceremony is host-shows-PIN). New mgmt + native_pairing tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5ca860533e |
refactor(native-pairing): extract shared on-demand arming state
Groundwork for web-UI-driven native (punktfunk/1) pairing. Replaces m3's fixed startup PIN + local paired store with a shared `NativePairing` (new module): arm-on-demand with a fresh, time-limited PIN (`arm(ttl)`), `current_pin()` read per ceremony so a lapsed window stops pairing, plus the trust store (list/add/ remove/is_paired) and a `status()` snapshot. The management API (next commit) and the QUIC accept loop share one handle. CLI `--allow-pairing`/`--require-pairing` still arm at startup (no expiry, PIN logged) — back-compat. m3 pairing ceremony + gate and the C-ABI roundtrip stay green; new unit tests for arm/expire/pair. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
136390514d |
build: support FFmpeg 7.x and 8.x; fix RPM spec GPU link deps
ci / rust (push) Has been cancelled
punktfunk-host builds unchanged against either FFmpeg 7.x (libavcodec 61) or 8.x (libavcodec 62) — ffmpeg-sys-next auto-detects the system version, and the host's ffmpeg FFI only touches long-stable APIs. Confirmed by building + running live on a Bazzite F43 box (FFmpeg 7.1.3): full gamescope capture → zero-copy dmabuf→CUDA → NVENC H.265 at 1280x720x60, p50 ~0.96 ms. Just doc/spec accuracy, no code change: - encode/linux.rs + CLAUDE.md: drop the "FFmpeg 8 only" claim; note 7.x/8.x both work. - rpm spec: add the missing zero-copy GPU build deps the link actually needs — pkgconfig(gl) + pkgconfig(gbm) (mesa) — and document that -lcuda needs libcuda.so at link time (NVIDIA host, or the CUDA toolkit stub on a headless COPR/koji builder). Tracked for a proper fix: make the cuda/gbm/GL FFI dlopen-based like khronos-egl so the RPM builds on a GPU-less host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
59edeedf07 |
feat(dualsense): Phase C/D/E — virtual DualSense routing + 0xCC/0xCD planes + C ABI
ci / rust (push) Has been cancelled
PUNKTFUNK_GAMEPAD=dualsense now routes a session's gamepad through a real virtual
DualSense (UHID + hid-playstation) end to end:
- host: a `PadBackend` enum (m3.rs) selects `GamepadManager` (uinput xpad, default)
or the new `DualSenseManager` (dualsense.rs) per session. The manager keeps each
pad's full DsState so touchpad + motion (rich-input plane) persist across
button/stick frames, and services the !Send /dev/uhid fd only on the input thread
(which cycles <=4ms, so the GET_REPORT init handshake completes).
- feedback: `service()` now returns `DsFeedback { hidout, rumble }`. Motor rumble
stays on the universal 0xCA plane (so non-DualSense clients still feel it; manager
dedups change); lightbar / player LEDs / adaptive-trigger effects ride the new
0xCD HID-output plane (host->client) as `HidOutput`.
- rich input: touchpad contacts + motion ride the 0xCC plane (client->host) as
`RichInput`, applied via `DualSenseManager::apply_rich` (merged with button state;
touch normalized 0..65535 -> the touchpad resolution).
- connector + C ABI: `NativeClient::next_hidout` / `send_rich_input`, exported as
`punktfunk_connection_next_hidout` (-> PunktfunkHidOutput) and
`punktfunk_connection_send_rich_input` (<- PunktfunkRichInput); header regenerated.
- reference client: `--rich-input-test` drives the DualSense touchpad + motion and
logs the 0xCD feedback that comes back.
Validated live on-box: a synthetic-source m3-host + client-rs created the real
kernel DualSense, drove 0xCC, and decoded 12 live 0xCD events (the kernel's actual
lightbar/trigger init reports) with the data plane unaffected (600/600 frames).
Adversarial review fixes folded in: the input loop no longer skips the rich drain +
feedback pump on a dropped gamepad event, and the touch contact id is clamped to its
slot. Remaining: the Apple client renders triggers/rumble on a real DualSense.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
2372b02620 |
feat(host): virtual DualSense via UHID (hid-playstation) — device + report mapping
ci / rust (push) Has been cancelled
Roadmap #5 (rich DualSense). A UHID device presents a real Sony DualSense to the kernel's hid-playstation driver (matched by VID 054C/PID 0CE6), which exposes the full controller — gamepad, motion sensors, touchpad, lightbar/player LEDs, adaptive triggers — unlike the uinput X-Box-360 pad. - inject/dualsense.rs: hand-rolled /dev/uhid codec (no bindgen) mirroring the uinput style; the canonical inputtino 232-byte USB HID report descriptor + the feature-report replies (calibration 0x05 / pairing 0x09 / firmware 0x20) — answering hid-playstation's GET_REPORTs during init is REQUIRED or it creates no input devices. DsState::from_gamepad maps a GameStream/XInput frame → the DualSense input report (buttons/sticks/triggers/dpad, + touchpad/motion fields); service() answers GET_REPORTs and parses HID OUTPUT (rumble / lightbar RGB / player LEDs / adaptive triggers) into quic::HidOutput. - scripts/60-punktfunk.rules: grant /dev/uhid to the 'input' group (like /dev/uinput). - `punktfunk-host dualsense-test`: standalone validation (no streaming session). Validated live: `dualsense-test` → hid-playstation binds + loads ff_memless + led_class_ multicolor; the kernel creates "Punktfunk DualSense 0" (event/js gamepad + Motion Sensors + Touchpad + Headset Jack) at VID 054c/PID 0ce6, plus the lightbar at /sys/class/leds/ input*:rgb:indicator; js shows the Cross button firing + the left-stick sweep. Clippy/fmt clean, workspace tests green. Wiring into the session (pad-type select, touchpad/motion routing, HID-output back-channel) is the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6575dddac7 |
fix: keep the workspace green on macOS after the mic/touch/rich-input batch
The new features were Linux-built only and broke the documented macOS gate (cargo build/test/clippy --workspace) four ways, all fixed following the existing platform-gating conventions: - m3.rs: mic_service_thread split into the Linux worker and a non-Linux stub that drains and drops (sessions still count the datagrams) — opus/PipeWire are Linux-gated deps, same pattern as audio_thread. - punktfunk-client-rs: the new `opus` dependency moved into the Linux target table and --mic-test gated with a warn-and-skip stub (only the synthetic-tone test rig needs the encoder; the mic uplink itself is portable). - gamestream/audio.rs: SAMPLE_RATE import gated to any(linux, test) (the frame_sizing test uses it everywhere, the data plane only on Linux). - tests/c_abi.rs: the harness's macOS link flags gained Security + CoreFoundation — the quic feature now pulls rustls's platform verifier into the staticlib. Also: two clippy match-ref-pats lints in the new rich-input/HID-output decoders (clippy -D warnings is the repo gate), the regenerated punktfunk_core.h committed (the checked-in copy predated the rich-input/HID-output constants — CI fails on drift), and web's inlang cache dir gitignored. cargo build/test/clippy/fmt --workspace: green on macOS, 122 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dc375668ee |
feat: touch input — TouchDown/Move/Up + host libei ei_touchscreen injection
ci / rust (push) Has been cancelled
Roadmap #5 (touch, ahead of the XL UHID DualSense work). Touch fits the existing 18-byte InputEvent: code = touch id, x/y = client pixels, flags = (w<<16)|h — the same absolute mapping as MouseMoveAbs. - core: InputKind::{TouchDown=9, TouchMove=10, TouchUp=11} + from_u8 + roundtrip test. - host inject/libei.rs: request the RemoteDesktop Touchscreen device type, bind the Touch capability, and inject ei_touchscreen down/motion/up (one event = one frame, per the protocol rule), mapping coordinates into the device region like the abs pointer. wlroots has no virtual-touch protocol wired — no-ops there. - client-rs --touch-test: drags a synthetic finger (touch id 0) in a circle. Validated live on headless KWin: the portal GRANTS the Touchscreen device type (Keyboard|Pointer|Touchscreen), proving the request path — but KWin's EIS server creates no touchscreen *device*, so touch currently no-ops on this KWin (now logged once, not silent). The injection code is correct and will land on a backend that exposes ei_touchscreen (gamescope / a newer compositor / the real touch-client path). Workspace green, clippy/fmt clean, +1 unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0755c823a5 |
feat: mic passthrough — client microphone → host virtual PipeWire source
ci / rust (push) Has been cancelled
The inverse of the host→client audio path: the client's mic, Opus-encoded, rides a new 0xCB QUIC datagram to the host, which decodes it into a virtual PipeWire Audio/Source its apps can record from (voice chat, etc.). Protocol (punktfunk-core): - MIC_MAGIC 0xCB + encode/decode_mic_datagram (mirror of the 0xC9 audio datagram). - NativeClient::send_mic(seq, pts_ns, opus) over a new outbound channel + worker task (mirror of send_input); C ABI punktfunk_connection_send_mic for native clients. Host: - audio::VirtualMic + PwMicSource: a PipeWire output stream tagged media.class= Audio/Source (Direction::Output) — a recordable microphone node, fed decoded PCM. - MicService: host-lifetime owner of the source + Opus decoder (mirror of InjectorService / the audio capturer slot); lazily opened, persists across sessions, self-heals. The per-session datagram reader now demuxes 0xCB→mic / 0xC8→input over a single read_datagram loop (two loops would race). - Adaptive jitter buffer in the producer: primes to ~3 consumer quanta before emitting, so the 5 ms push / N ms pull clock skew never underruns — without it ~58% of output was silence; with it, glitch-free across consumer quanta. Client: punktfunk-client-rs --mic-test streams a synthetic 440 Hz Opus tone as the mic uplink (opus dep added) for end-to-end validation without a real microphone. Validated live on headless KWin: client tone → host source → pw-record shows the punktfunk-mic Audio/Source node, 440 Hz dominant (Goertzel power 20.7 vs <0.001 elsewhere), RMS 0.179 ≈ the ideal 0.177, 0.3–0.4% silence at both 256 ms and 10 ms consumer quanta. Tests +1 (mic datagram roundtrip); workspace green, clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a03aae891e |
fix(m3): persistent host-lifetime input injector — end the RemoteDesktop portal churn
ci / rust (push) Has been cancelled
Under rapid client reconnects, KWin's libei/EIS input setup intermittently wedged with "EIS setup timed out", causing total input loss for affected sessions. Root cause: each punktfunk/1 session opened (and tore down) its own RemoteDesktop-portal CreateSession for pointer/keyboard injection, and back-to-back reconnects raced a prior session's portal teardown before it settled. LibeiInjector is only a Send channel handle to a worker thread that owns the portal session, so the injector can live for the whole host run instead of per session. Adds InjectorService: one host-lifetime thread owns the (!Send) injector, opened ONCE (lazily, on the first event) and reused across every session — the portal grant is established a single time and held. Sessions forward pointer/keyboard events to it over a clonable Send channel; gamepads stay per-session (uinput, no portal). The service self-heals — reopen after a 2s backoff if open fails or the backend worker dies (covers a gamescope EIS socket that respawns with its nested session). Mirrors the existing host-lifetime audio-capturer slot; the audio capturer is Send (a slot works), the injector is !Send (needs the owning thread + channel). Validated live on headless KWin: 8 rapid back-to-back input sessions → "input injector ready (host-lifetime)" exactly once, ZERO "EIS setup timed out", 8/8 sessions injected input. Tests green, clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6fdf7d1511 |
feat: client-selectable compositor (protocol → host → client → C ABI → mgmt → web)
A client can now request which compositor backend the host drives its virtual
output on (gamescope/KWin/Mutter/wlroots). The host honors the request if that
backend is available, else falls back to auto-detect and reports the resolved
choice back — wire-compatible both directions (no ABI bump).
Protocol (punktfunk-core):
- New CompositorPref (config.rs): Auto|Kwin|Wlroots|Mutter|Gamescope with
u8/name mappings. Appended as one optional byte to Hello (client preference)
and Welcome (host's resolved choice). Both decoders already tolerate trailing
bytes, so old↔new interop is preserved — ABI_VERSION stays 2. Round-trip +
back-compat (truncated-message) tests.
- C ABI: punktfunk_connect_ex(compositor) + PUNKTFUNK_COMPOSITOR_* constants;
punktfunk_connect delegates with AUTO, so the existing symbol is unchanged.
NativeClient::connect / worker_main thread the preference through.
Host:
- vdisplay::available() enumerates usable backends via cheap, side-effect-free
probes (KWin zkde global, gamescope binary+version, GNOME/Sway env), plus
Compositor id/label/as_pref/from_pref/all helpers.
- m3 handshake resolves the preference to a concrete backend during the
handshake (pick_compositor pure + resolved logging), reports it in Welcome,
and threads it into virtual_stream (replacing the unconditional detect()).
- mgmt GET /v1/compositors lists every backend with availability + the
auto-detected default (OpenAPI regenerated).
Client:
- punktfunk-client-rs --compositor NAME; logs the host's resolved choice from
the Welcome ("session offer … compositor=…").
Web console:
- Host page gains a Compositors card (availability + default badges) via the
codegen'd useListCompositors hook; en/de strings added.
Also fixes a pre-existing, env-dependent test-isolation bug:
mgmt::tests::paired_clients_list_and_unpair seeded the real
~/.config/punktfunk/paired.json (AppState::new loads it), so a real
GameStream-paired client leaked into body[0] on a dev box — now cleared first.
Live-validated against headless KWin: --compositor kwin honored, --compositor
mutter falls back to kwin (available=[kwin, gamescope]), resolved choice
round-trips to the client. Tests: +6 (wire/back-compat, resolution precedence,
endpoint); workspace green, clippy/fmt clean, C ABI harness PASS at abi_version=2,
web typecheck + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
75eb8fa0d6 |
feat(host): KDE-reliability phase 2 — pipeline retry, graceful capture teardown, refresh reconcile
Hardens the virtual-display → capture → encode bring-up against the transient failures that surfaced as black screens / wrong refresh on cold KDE sessions. - m3: build_pipeline_with_retry wraps the initial vd.create() + first-frame with bounded exponential backoff (4 attempts, 500ms→2s). is_permanent_build_error classifies config/version/missing-tool failures so they fail fast instead of burning the retry budget. Encoder + frame clock now pace to the *achieved* refresh reported in VirtualOutput::preferred_mode, not the requested rate. - capture/linux: PortalCapturer::Drop sends a pipewire channel quit and joins the thread, so a dropped/failed/retried capturer releases its PipeWire thread + EGL/ CUDA context promptly instead of leaking it to process exit. First-frame timeout now reports the node id and distinguishes "format never negotiated" from "negotiated but no buffers arrived" via a negotiated flag set in param_changed. - vdisplay/kwin: set_custom_refresh reads back the active mode from kscreen-doctor and returns the refresh KWin actually gave us (a rejected custom mode silently leaves the output at 60Hz); create() carries it into preferred_mode. - vdisplay/gamescope: find_gamescope_node requires the Video/Source object (the node.name=gamescope tag is on two objects; the other wedges the link); a version check warns on <3.16.22 (the PipeWire-1.6 capture-deadlock signature). Live-validated against headless KWin: 720p120 build with requested=120 achieved=120, zero-copy CUDA frames, and no per-session thread accumulation across back-to-back sessions. Tests: +3 unit (retry classifier, gamescope version parse); 49 host tests green, clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |