Commit Graph

184 Commits

Author SHA1 Message Date
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
enricobuehler 1293b7e001 feat(ci): deploy the docs site to home-main-2 (docs.punktfunk.unom.io)
ci / web (push) Failing after 29s
ci / rust (push) Successful in 53s
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
ci / docs-site (push) Failing after 44s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / deploy-docs (push) Successful in 9s
apple / swift (push) Successful in 1m14s
docker.yml gains a deploy-docs job after the image pushes: scp
compose.production.yml to ~/punktfunk-docs on home-main-2, then
docker compose pull + up over SSH — the unom/website / unom/cms
deploy pattern, same DEPLOY_* secret set (unom-ci-deploy key). Docs
bind host port 3220; the docs.punktfunk.unom.io vhost lives in
unom/reverse-proxy (306d9c0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:03:16 +00:00
enricobuehler 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>
2026-06-12 12:45:10 +00:00
enricobuehler 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>
2026-06-12 12:42:09 +00:00
enricobuehler 47a69a0063 fix(ci): match real runner labels + survivable Mac runner daemon
ci / web (push) Failing after 51s
ci / docs-site (push) Failing after 34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
ci / rust (push) Successful in 6m23s
apple / swift (push) Successful in 1m24s
runs-on: ubuntu-24.04 (the label the existing Linux runner actually
advertises — ubuntu-latest queued forever). Mac runner: strip the
docker:// default labels generate-config seeds (they override the
host-mode registration labels and make the daemon demand a Docker
engine), and ship the service as a root LaunchDaemon — macOS Local
Network privacy silently blocks LAN dials from unbundled CLI binaries
in gui/user launchd domains ("no route to host"), system daemons are
exempt. Without sudo the script leaves an interim nohup daemon. CI
surface documented in CLAUDE.md + docs-site ci.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:40:36 +00:00
enricobuehler f1af74b403 feat(ci): Gitea Actions — dockerized web/docs/rust-ci images, Apple client CI, Mac runner
apple / swift (push) Failing after 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
Three workflows: ci.yml (Rust workspace inside the punktfunk-rust-ci
builder image + web/docs-site build+typecheck), docker.yml (build+push
punktfunk-web, punktfunk-docs, punktfunk-rust-ci to git.unom.io — host
and native clients stay un-dockerized by design), apple.yml (host-mode
macos-arm64 runner: Rust core -> PunktfunkCore.xcframework ->
swift build + swift test).

ci/rust-ci.Dockerfile: Ubuntu 26.04 with the workspace's link deps
(FFmpeg 8, PipeWire, Opus, GL/EGL/GBM, xkbcommon, libcuda via the
580-server userspace as a link stub) + pinned rustup + node for the JS
actions. Verified end to end in-container: build, 141/141 tests, C ABI
harness; all three images seeded to the registry manually.

scripts/ci/setup-macos-runner.sh provisions the Mac (rustup + darwin
targets, Node tarball, gitea-runner 1.0.8 host mode, LaunchAgent with
DEVELOPER_DIR auto-detect for sudo-free Xcode selection). Docs in
docs-site/content/docs/ci.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:28:13 +00:00
enricobuehler 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>
2026-06-12 12:28:13 +00:00
enricobuehler 7f234420c7 docs(apple): pickup-ready stage-2 presenter implementation plan
ci / rust (push) Has been cancelled
Stage-2 was a one-line "next" in the README. Add a full, actionable spec
(docs-site apple-stage2-presenter.md) a Mac agent can execute: VTDecompressionSession
decode (with decode-completion stamping) -> CAMetalLayer + display-link present, the
exact integration points against the existing StreamPump/StreamView/AnnexB/LatencyMeter,
the three-stage measurement wiring (capture->decoded / decode->present / capture->present
= glass-to-glass, using the already-wired PunktfunkConnection.clockOffsetNs), a cheaper
decode-only intermediate, validation, and gotchas. Link it from the Apple README's
Stage 2 item. (meta.json nav entry left in the working tree to land with the CI docs WIP.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:21:08 +00:00
enricobuehler bdcc88f5fc Merge remote-tracking branch 'origin/main'
ci / rust (push) Has been cancelled
2026-06-12 12:18:32 +00:00
enricobuehler 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>
2026-06-12 12:18:32 +00:00
enricobuehler 8f596ba6c5 fix(apple): latency HUD — interpolate the (same-host) suffix, don't concat
ci / rust (push) Has been cancelled
The capture->client latency line concatenated a String onto a LocalizedStringKey
(Text("...\(x, specifier:)..." + (cond ? "" : "...")), which doesn't type-check:
the specifier: interpolation makes the literal a LocalizedStringKey, which has no
'+'. Fold the conditional suffix into the interpolation instead — the Apple
client didn't build on the latency-HUD commit (e04328f).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:11:45 +02:00
enricobuehler 6d3ff37d9e feat(client): cross-target input handling + LAN mDNS discovery
Input handling, building on macOS/iOS/tvOS:
- macOS recapture after navigating out: engageCapture no longer latches
  captured=true when the cursor grab is refused mid app-activation (which left
  a free cursor that no later click could re-grab); cursorCapture.capture() now
  reports success. + canBecomeKeyView.
- iOS/iPadOS recapture: restore the prior capture on didBecomeActive (nothing
  re-grabbed mouse/keyboard on return before).
- iPad indirect pointer (no lock) is forwarded as an absolute MOUSE (move +
  buttons + scroll via hover / UITouch.indirectPointer), not as touch, with the
  local cursor visible; GCMouse owns the locked regime, gated so the two never
  double-send. Adds the MouseMoveAbs wire helper.
- Trackpad scroll on iOS (was entirely missing): GCMouse scroll dpad when
  locked + a scroll-only UIPanGestureRecognizer otherwise.
- tvOS: no focusable control during play (a focusable Disconnect button ate the
  controller's A in the focus engine); Siri Remote Menu disconnects.
- Don't leak touch to the host under the TOFU trust prompt (gate on
  captureEnabled).

LAN discovery: HostDiscovery (NWBrowser over _punktfunk._udp, the host's
crate::discovery advert) resolves each service to IP:port and parses the TXT
(fp advisory, pair, id); an "On this network" section in the grid (tap to save
+ connect, or pair if required). iOS/tvOS get NSBonjourServices via a merged
Config/Info.plist. Integration-tested end to end against a fake NWListener advert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:08:19 +02:00
enricobuehler 6b4de5d738 feat(client/speedtest): request the host's full 3 Gbps probe ceiling
The Apple speed test asked for only 400 Mbps, capping the measured throughput
there and hiding the link's real headroom. Request the host's full
MAX_PROBE_KBPS (3 Gbps) instead, and raise the recommended-bitrate clamp from
500 Mbps to the host's 2 Gbps session ceiling so a fast measurement yields a
usable recommendation.

Also fix the stale caps left when the host clamps were raised (b8a33e2): the
resolved-bitrate range and the probe doc comments (abi.rs, client.rs,
regenerated header), plus the section 9 roadmap copy, now read 3 Gbps probe /
2 Gbps session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:08:19 +02:00
enricobuehler 1c94f46be8 style(quic): format-stable clock test assert (message to comment)
ci / rust (push) Has been cancelled
The clock_offset test's assert_eq! carried an inline message that newer rustfmt
wants to wrap while the repo's committed style keeps such asserts on one line.
Move the message to a comment and use bare assert_eq! so it formats identically
under any rustfmt version — no new fmt-check ambiguity from this addition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:01:56 +00:00
enricobuehler e04328f086 feat(apple): capture->client latency HUD (skew-corrected) via the connect offset
ci / rust (push) Has been cancelled
The Apple client now consumes the connector's clock offset. PunktfunkConnection
reads punktfunk_connection_clock_offset_ns into clockOffsetNs at connect; a new
LatencyMeter (PunktfunkKit, NSLock + percentiles, mirrors FrameMeter) records each
AU's capture->client-receipt latency = now(CLOCK_REALTIME) + offset - pts_ns, and
SessionModel drains p50/p95 into the macOS HUD ("capture->client N/N ms p50/p95",
"(same-host)" when the host didn't answer the skew handshake). Wired at the
existing onFrame hook in ContentView — additive, no change to the decode/present
path. Unit test for the meter (percentiles, skew flag, absurd-value guard).

This is the first cross-machine latency the real Apple client reports. SCOPE:
stage-1 AVSampleBufferDisplayLayer decodes+presents compressed samples internally
with no per-frame callback, so this excludes decode+present; true decode->present
needs the stage-2 presenter (VTDecompressionSession + CAMetalLayer). Rebuild
PunktfunkCore.xcframework (for the new C getter) before swift build/test on a Mac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:58:54 +00:00
enricobuehler 7eb9a927cf feat(connector): expose host clock offset over the C ABI for glass-to-glass
ci / rust (push) Has been cancelled
Factor the client-side skew handshake into a shared core helper (quic::clock_sync
-> ClockSkew) so both the reference client and the embeddable connector use one
implementation. NativeClient now runs the handshake at connect (right after Start,
before the control task takes the stream) and stores the host-client offset; it's
read over the C ABI via punktfunk_connection_clock_offset_ns (i64 ns, host minus
client; 0 = no correction / old host).

This is the substrate the Apple client needs for the decode->present (glass-to-
glass) term: stamp present time, add the offset to express it in the host's
capture clock, subtract the AU pts_ns. client-rs drops its local clock_sync copy
and uses the shared helper (behavior unchanged; validated locally).

Regenerates include/punktfunk_core.h. Roadmap section 12 + status updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:44:54 +00:00
enricobuehler e586961e0b docs(site): make docs-site the knowledge base — status tracker + setup guides
ci / rust (push) Has been cancelled
Per the new docs workflow (docs-site = KB layer; repo docs/ keeps design notes):
- Add a canonical Status & Progress tracker (status.md): milestones, per-box live
  state, and a dated progress log — the go-forward place to track progress.
- Add setup guides: GNOME/Mutter host (gnome-box — Secure Boot MOK enroll, the
  libnvidia-gl EGL fix, autologin, screen-lock disable, appliance unit), headless
  KDE box, and Bazzite host (ujust input group, gamescope session, gotchas).
- Roadmap is now canonical in docs-site (synced the skew-handshake section 12
  update); removed the repo docs/roadmap.md copy and repointed README to docs-site.
- Nav (meta.json) + landing cards updated; site builds (bun run build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:33:39 +00:00
enricobuehler 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>
2026-06-12 11:20:20 +00:00
enricobuehler 50c9db785a docs(site): Fumadocs documentation site on TanStack Start
ci / rust (push) Has been cancelled
New standalone app at docs-site/ — Fumadocs (fumadocs-core/ui 16, fumadocs-mdx
15) on TanStack Start (Vite 7 + nitro-v2 bun preset, React 19, Tailwind 4),
mirroring the web/ console stack but with no auth/i18n/orval — docs stay public.

- catch-all docs route (routes/docs/$.tsx), Orama search (routes/api/search.ts),
  RootProvider shell, MDX component map, shared nav, custom 404
- content/docs/: hand-written index.mdx + meta.json nav, plus 7 pages imported
  from repo docs/ + README (leading H1 stripped, YAML frontmatter added; kept as
  .md so existing </{ don't trip MDX JSX). Content is a one-time snapshot.
- mdx() is plugins[0]; tsconfig collections/* -> ./.source/*; SSR search variant;
  @source for fumadocs-ui classes. Generated .source/routeTree/dist/.output ignored.

Verified: bun run build (client+SSR+nitro) green, tsc clean, dev + prod servers
serve all routes 200 with SSR content + nav, search returns hits, 404 works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:17:59 +00:00
enricobuehler 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>
2026-06-12 10:37:12 +00:00
enricobuehler a9e974d50d docs(host.env): GNOME/Mutter example for an Ubuntu desktop host
ci / rust (push) Has been cancelled
Prep for a third (Ubuntu) test host: document the Mutter backend env — wayland-0
(not wayland-kde), XDG_CURRENT_DESKTOP=GNOME, PUNKTFUNK_COMPOSITOR=mutter, virtual
source via RecordVirtual, libei input via the RemoteDesktop portal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:43:18 +00:00
enricobuehler 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>
2026-06-12 09:42:16 +00:00
enricobuehler 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>
2026-06-11 23:04:23 +00:00
enricobuehler 761ccace25 docs(roadmap): §12 glass-to-glass latency — quick wins landed, bigger bets scoped
ci / rust (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:54:30 +00:00
enricobuehler 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>
2026-06-11 22:53:52 +00:00
enricobuehler 2f4f92a804 feat(1gbps): batched client recv via recvmmsg (increment C)
ci / rust (push) Has been cancelled
Final increment of the 1 Gbps data-plane rework — the recv counterpart of the
sendmmsg work. The client recv path did one recvfrom + one Vec allocation per
packet (and the pump's 300µs idle sleep could let packets pile up at line rate).

- Transport gains recv_batch(&mut [Vec<u8>], &mut [usize]) -> count; default is
  a single scalar recv into out[0] (loopback + non-Linux).
- UdpTransport overrides it on Linux with recvmmsg (MSG_DONTWAIT) draining up to
  N datagrams per syscall into the caller's reused buffers — no per-packet alloc.
- Session::poll_frame owns a lazily-allocated recv ring (RECV_BATCH=32) and
  consumes it one packet at a time across calls, refilling with one recvmmsg when
  drained. Encapsulated: the punktfunk-client-rs + NativeClient pumps are
  unchanged, and draining a batch per syscall means the 300µs sleep no longer
  underdrains. Added UdpTransport::local_addr (used by the test, generally handy).

~125k → ~4k recv syscalls/sec at line rate, zero per-packet recv allocation.
Verified: new recv_batch_drains_over_loopback test (50 datagrams drained intact
via recvmmsg) + the existing loopback round-trip now runs through the batched
poll_frame; full suite (35 + round-trip + 6) + clippy + fmt green.

Decode-in-place (kill the per-packet open_from_wire alloc) is a separate later
optimization. With A (sendmmsg) + B (paced send) + C (recvmmsg), the native data
plane is batched + paced end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:39:51 +00:00
enricobuehler 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>
2026-06-11 22:15:52 +00:00
enricobuehler c70db56115 build: lock libc (sendmmsg dep from the previous commit)
ci / rust (push) Has been cancelled
Cargo.lock update for the Linux-only `libc` dependency added in c24b571
(batched sendmmsg send). Keeps the lockfile in sync with Cargo.toml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:55:40 +00:00
enricobuehler c24b571e37 feat(1gbps): batched send via sendmmsg (Transport::send_batch)
ci / rust (push) Has been cancelled
First increment of the 1 Gbps send-path rework (the measured bottleneck): the
native data plane did one send() syscall per packet — at ~125k pkt/s (1 Gbps
wire) that burns a core on syscalls. Port the proven GameStream sendmmsg path
into the core Transport seam.

- Transport gains `send_batch(&[&[u8]]) -> usize` (count handed to the kernel;
  caller counts the rest as send-buffer drops). Default = the scalar send loop
  (loopback transport + non-Linux).
- UdpTransport overrides it on Linux with `sendmmsg` (64 datagrams/syscall);
  the connected socket needs no per-message address. Non-blocking-aware: a full
  send buffer yields a short count / EAGAIN, and we stop + report what went out
  rather than block or retry (same lossy, FEC-protected contract as send()).
- Session::submit_frame seals every shard then hands the whole frame to
  send_batch in ONE call instead of looping send() — ~64x fewer syscalls per
  frame on the native + GameStream-over-core paths; send_dropped accounting
  preserved (total - sent).

~125k → ~2k syscalls/sec at 1 Gbps line rate. Verified: new loopback-UDP test
send_batch_delivers_over_loopback (100 batched packets arrive intact, datagram
boundaries preserved); full core suite + clippy + fmt green.

Next increments: a paced send thread (microburst shaping so a real NIC doesn't
drop line-rate bursts) and recvmmsg on the client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:55:22 +00:00
enricobuehler e1242546f2 fix(headless-kde): don't let set -e abort the session when Xwayland isn't up yet
ci / rust (push) Has been cancelled
The Xwayland-DISPLAY poll did `d=$(pgrep -a Xwayland | grep … | head -1)`, but
under `set -euo pipefail` pgrep/grep exit non-zero when Xwayland isn't running,
so the command substitution failed and `set -e` aborted the WHOLE script —
killing KWin with it — on the loop's first iteration instead of polling.

It only ever worked when launched from an interactive shell where Xwayland
happened to already be up (so pgrep matched on try 1). Under the systemd boot
appliance (punktfunk-kde-session.service) Xwayland isn't up that early, so the
session crash-looped (restart counter climbing, KWin never staying), the host
had no compositor, and clients couldn't connect.

Append `|| true` to the substitution so the loop polls as intended and a session
with no Xwayland at all still proceeds (DISPLAY just stays unset → warn).
Verified live: the unit now stays active (0 restarts), KWin + the wayland-kde
socket persist, probe-compositor reports ready, and a real client session
captured 4.8 MB of H.265 off the running serve --native host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:25:28 +00:00
enricobuehler 8c58afa2ac feat(headless): boot-appliance systemd units (KDE session + host, no login)
ci / rust (push) Has been cancelled
Make a headless box a self-contained streaming appliance: after boot, with no
display manager / login / manual script, the headless KWin Plasma session and
the punktfunk host both come up so a client can just connect and stream the
desktop.

- New scripts/punktfunk-kde-session.service: a Type=simple user unit that runs
  run-headless-kde.sh (kwin --virtual on wayland-kde + Plasma + portals + a
  supervised plasmashell). The script foregrounds on `wait $KWIN_PID`, so
  Restart=always keeps the desktop alive across a KWin crash.
- scripts/punktfunk-host.service: ExecStart now `serve --native` (the unified
  GameStream + punktfunk/1 host, matching how it's actually run), After= the
  kde-session unit (soft ordering — the host listens immediately and only needs
  the compositor per session, so a missing unit on the gamescope backend is
  harmless), and appliance install docs (kwin vs gamescope backend).

Boot still requires `sudo loginctl enable-linger $USER` (the one thing that
starts user units without a login) — documented in both unit headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:16:12 +00:00
enricobuehler 2557ce1ee5 docs(roadmap): §11 1 Gbps+ data plane — foundation landed, batched send next
ci / rust (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:47:36 +00:00