Commit Graph

743 Commits

Author SHA1 Message Date
enricobuehler 3faec8415a fix(apple/iOS): stock header + edge-aligned host grid — drop the custom title mode
ci / rust (push) Has been cancelled
The "title looks off" report traced to the GRID, not the title: the Mac-tuned
adaptive(180–240) columns yielded a single max-width card, centered, so nothing aligned
with the leading large title. The header is now entirely stock primitives — default
.navigationTitle large-title behavior (the inlineLarge experiment is gone), default
.padding() so content sits on the system 16 pt margins — and the grid columns are
platform-tuned: iOS drops the max so columns FILL the width and the cards stay
edge-aligned with the title; macOS keeps the 180–240 cap (huge windows shouldn't grow
huge cards).

Verified in the iPhone 17 simulator with seeded hosts: pill top-right, large title at
system metrics, two full-width-filling cards flush with the title's leading edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:59:05 +02:00
enricobuehler 12cf2e4e16 docs: refresh README/CLAUDE status; roadmap pairing-hardening + SudoVDA Windows
ci / rust (push) Has been cancelled
- README: replace the stale M0/M2-in-flight status with reality — M1 hardened, M2
  GameStream host live to stock Moonlight, M3 punktfunk/1 validated, M4 Apple first
  light, web console + unified host; FFmpeg 7/8; Bazzite-deployed. Layout adds
  web/, packaging/, native_pairing, dualsense.
- CLAUDE: protocol-growth item now reflects the unified host + web-console native
  pairing (done) and flags the next steps; layout updated.
- roadmap §7 Windows: de-risked via SudoVDA (the Sunshine Virtual Display Adapter) —
  no self-signed kernel IDD needed; the virtual-display backend drops XL→M.
- roadmap §8 (new) Pairing & trust hardening: mandatory PIN pairing by default
  (TOFU-open is insecure on a LAN) + delegated pairing approval (an already-paired
  device approves a new one, no out-of-band PIN).
- windows-host.md: SudoVDA path throughout (status, table, phasing, effort M not L).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:55:30 +00:00
enricobuehler 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>
2026-06-11 09:55:30 +00:00
enricobuehler 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>
2026-06-11 09:55:30 +00:00
enricobuehler fa553b1e2a fix(apple/iOS): action buttons back into one shared glass pill
ci / rust (push) Has been cancelled
The ToolbarSpacer split into separate circles was the wrong read — with the
inline-large title row in place, the expected header is the single grouped pill
(the system default for adjacent trailing items). Dropped the spacer and the
availability fork; the two trailing items now share one pill next to the title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:55:26 +02:00
enricobuehler 1d35df201c fix(apple/iOS): inline-large header — title and action circles share the bar row
ci / rust (push) Has been cancelled
The home screen stacked the toolbar row above the large title; the modern (iOS 26
Liquid Glass) header puts the large title leading and the glass action circles trailing
on the SAME row. That's exactly .toolbarTitleDisplayMode(.inlineLarge) — applied on iOS
only, macOS keeps its window chrome untouched.

Verified in the iPhone 17 simulator: "punktfunk" large title left, gear/+ circles
right, one row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:53:56 +02:00
enricobuehler 7c24832ad0 fix(apple/iOS): touch-first control sizing — toolbar circles + large sheet buttons
ci / rust (push) Has been cancelled
The iOS chrome inherited macOS dialog sizing and read as undersized on a phone:

- Toolbar: the two trailing actions shared one compact glass pill; on iOS 26+ each now
  gets its own full-size circle (explicit .topBarTrailing placements split by a fixed
  ToolbarSpacer — the system-app look, e.g. Files), with the grouped-pill fallback on
  iOS 17–18. The buttons are extracted so macOS keeps SettingsLink + .help untouched.
- Sheets and CTAs (AddHostSheet, PairSheet, trust card, empty-state Add Host) get
  .controlSize(.large) on iOS — proper touch targets instead of macOS dialog buttons.

Verified in the iPhone 17 simulator: two ~44 pt glass circles matching the Files app's
toolbar sizing; macOS suite and app build unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:47:30 +02:00
enricobuehler e1af4d57c6 feat(apple): iOS/iPadOS client — touch, pointer lock, shared SwiftUI shell
ci / rust (push) Has been cancelled
The whole client now runs on iPadOS/iOS from the same sources, first-lit live in the
iPad simulator against the real host at 1280x720@60 (60 fps on the HUD, capture state
machine active, mic permission flow shown).

- PunktfunkCore.xcframework grows iOS device + universal-simulator slices
  (BUILD_IOS=1; rustup targets aarch64-apple-ios{,-sim} + x86_64-apple-ios).
- The decode pump is extracted into a shared StreamPump (identical IDR re-gate logic on
  both platforms); the iOS StreamView (StreamViewIOS.swift) has the same name/signature
  as the macOS one, so ContentView & co. are byte-identical across platforms — hosted
  in a UIViewController for prefersPointerLocked (the iPadOS cursor capture; see README
  note 9 for the UIHostingController forwarding caveat).
- Touch is always forwarded: per-finger wire ids, coordinates mapped through the
  aspect-fit letterbox into LIVE host-mode pixels (surface == host mode, identity
  rescale host-side; follows mid-stream requestMode switches).
- InputCapture is cross-platform: GC works the same on iPadOS, ⌘⎋ is detected from the
  HID stream there; stale-⌘ tracking after focus loss fixed on both platforms
  (releaseAll now drops the modifier/latch state — a ⌘ released in another app
  otherwise hijacked Esc forever).
- SessionAudio: AVAudioSession on iOS (.playAndRecord + .defaultToSpeaker — without it
  iPhones route host audio to the EARPIECE; deactivated with
  notifyOthersOnDeactivation on stop so interrupted background audio resumes); HAL
  device pinning + the Settings pickers stay macOS-only.
- New Punktfunk-iOS app target (shared synchronized sources, generated Info.plist with
  mic + local-network usage descriptions — QUIC to a LAN host trips local network
  privacy on real devices — scene manifest + indirect input events for Stage Manager /
  external displays), shared scheme, macOS min-window frames gated off iOS.

For the iPad-on-an-external-screen idea: with multiple scenes + indirect input enabled,
Stage Manager iPads can drag the punktfunk window onto the external display and drive
the PC with keyboard/mouse/touch. Known gaps (README note 9): the pointer-lock
preference isn't consulted through UIHostingController (relative mouse works, the local
cursor just stays visible) and AVAudioSession interruptions don't auto-restart audio.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:18:25 +02:00
enricobuehler 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>
2026-06-11 09:12:59 +00:00
enricobuehler 12b047b0ae docs(packaging): add end-to-end Bazzite setup guide
ci / rust (push) Has been cancelled
A step-by-step walkthrough for running the host on Bazzite (the immutable
Fedora-Atomic gaming distro): the two install paths (rpm-ostree layering vs the
bootc image), udev + the `input` group, host.env knobs (gamescope-default), the
systemd --user service, firewall ports, verification, and troubleshooting — all
grounded in the packaging/ files. Flags the operator-run COPR, the loopback-only
mgmt port, and that the bundled unit runs the GameStream `serve` host (not m3-host).
Linked from packaging/README.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:40:16 +00:00
enricobuehler 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>
2026-06-11 08:36:12 +00:00
enricobuehler e5b15353c7 docs: scope advanced (audio-driven) DualSense haptics — NO-GO for now
ci / rust (push) Has been cancelled
4-agent feasibility read converged on three independent walls, any one fatal:
- host capture needs a kernel rebuild (CONFIG_USB_DUMMY_HCD off → no UDC for an
  f_uac2 composite gadget; everything else for the gadget IS present);
- near-zero Linux supply (only ~5-10 Proton titles via custom Wine patches emit
  it; hid-playstation/Steam-Input/RPCS3 don't);
- Apple client can't faithfully replay PCM haptics (CoreHaptics is discrete
  pattern-based; no public CoreAudio channel-3/4 routing).

Advanced haptics ride the DualSense USB *audio* interface, not HID, so the UHID
backend structurally can't carry them. Defer; the reachable 80% ("real DualSense
feel") is adaptive triggers over the HID 0x02 path we already parse + two-motor
rumble. New docs/dualsense-haptics.md records the walls + conditions for a future
go; roadmap §5 updated (HID DualSense backend built & live-validated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:52:40 +00:00
enricobuehler b26f138699 feat(apple): session audio — host playback + mic uplink, device pickers in Settings
ci / rust (push) Has been cancelled
Both directions of the audio plane, on CoreAudio's built-in Opus codec
(kAudioFormatOpus — no bundled libopus; OpusCodec.swift, round trip unit-tested):

- Playback: a drain thread pulls nextAudio() packets, decodes, and writes a priming
  jitter ring feeding an AVAudioSourceNode (~20 ms prefill, adaptive to the device's
  render quantum so large-buffer devices don't oscillate prime/dropout; a high-water
  clamp sheds stall backlog so one network hiccup can't permanently lag audio behind
  video; underrun re-primes — one dip, not sustained crackle).
- Mic: a second engine taps the input device, resamples to 48 kHz stereo, Opus-encodes
  20 ms chunks and sendMic()s them into the host's virtual PipeWire source. Permission
  via AVCaptureDevice (NSMicrophoneUsageDescription added to the Xcode target).
- Settings: Speaker + Microphone pickers (CoreAudio HAL enumeration, persisted by
  device UID — "System default" leaves the engine unpinned so it follows macOS device
  changes) and a "Send microphone" toggle (default on). Applies from the next session.
- Audio starts with streaming, never during the trust prompt (no host sound — and no
  mic uplink — before the user trusted the host); teardown stops audio before close().

Adversarial-review fixes baked in: stop() and the dangling mic-permission callback
share one lock+flag protocol (no hot mic with no owner), the connect-success handler
bails when the attempt was abandoned mid-handshake (no session/mic for a dead window),
SessionAudio gets a deinit backstop (a dropped instance can't pin the connection via
its drain thread), and the render scratch buffer is block-owned (was leaked per
session).

Verified live against the box: remote test decodes 100 host Opus packets to PCM and
the host opens its virtual mic on the first uplinked frame ("punktfunk/1 virtual mic
ready"); on-glass session runs with both engines up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:39:15 +02:00
enricobuehler 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>
2026-06-11 07:27:28 +00:00
enricobuehler 3a51551f97 feat(apple): mic uplink + touch events in PunktfunkKit
ci / rust (push) Has been cancelled
Adopts the new ABI surface (still v2, additive):

- PunktfunkConnection.sendMic(_:seq:ptsNs:) — Opus mic frames (48 kHz) to the host's
  virtual PipeWire source; enqueue-only, empty data = DTX silence. Wiring the actual
  Mac microphone (AVAudioEngine input → Opus) into the app is the follow-up, alongside
  audio playback (README note 5).
- PunktfunkInputEvent.touchDown/touchMove/touchUp — absolute pixels + surface size in
  flags, host injects via libei ei_touchscreen. Built for the iOS variant; nothing on
  macOS emits them yet.
- Loopback round trip now also sends touch events and mic frames (incl. a DTX frame)
  through the wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:08:04 +02:00
enricobuehler 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>
2026-06-11 09:07:48 +02:00
enricobuehler 5f6d2cb88b feat(proto): variable-length rich-input (0xCC) + HID-output (0xCD) datagrams
ci / rust (push) Has been cancelled
Foundation for rich DualSense support (roadmap #5). The fixed 18-byte InputEvent (0xC8) can't
hold the DualSense touchpad/motion or HID feedback, so two new variable-length, kind-tagged
datagram families join the side-plane (mouse/keyboard/gamepad/touch keep the fixed InputEvent):

- RICH_INPUT_MAGIC 0xCC, client→host: `[0xCC][kind][fields]`
    Touchpad{pad,finger,active,x,y}  (x/y normalized 0..65535; host scales to the pad)
    Motion{pad, gyro[3], accel[3]}   (raw i16, straight into the DualSense report)
- HIDOUT_MAGIC 0xCD, host→client: `[0xCD][kind][pad][fields]` — the rich analog of the 0xCA
  rumble datagram (rumble stays on 0xCA):
    Led{rgb}  PlayerLeds{bits}  Trigger{which, effect}  (adaptive-trigger params to replay)

`RichInput`/`HidOutput` enums with encode/decode; unknown kinds + truncation decode to None
(forward-compatible). +2 round-trip/disjointness tests; quic suite green, clippy/fmt clean.
Wiring (host UHID device, capture, C ABI, client) lands in following commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:55:04 +00:00
enricobuehler 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>
2026-06-10 22:38:44 +00:00
enricobuehler e07e359b6d docs: scope Windows-as-host (deferred) + update roadmap status
ci / rust (push) Has been cancelled
A 4-agent read of the host crate: a Windows host is an "add a backend" job, not a parallel
port — ~95% reuse (core/protocol/FEC/crypto/C-ABI, QUIC, GameStream, mgmt, m3/pipeline are all
platform-agnostic and already cfg-isolated). New cfg(windows) backends behind the existing
traits: DXGI Desktop Duplication (capture), Media Foundation / NVENC-SDK (encode), SendInput +
ViGEm (input), WASAPI loopback + virtual mic (audio). The blocker is the virtual-display
feature — no user-mode Windows API; it needs a signed kernel-mode IDD driver (XL).

docs/windows-host.md records the per-subsystem effort + a phased plan (Phase 0 = a "basic
Windows host" capturing an existing monitor, smallest surface). Deferred: large and unbuildable
on the Linux dev box, per the request to only take it on if manageable. roadmap.md marks
#1/#2/#4 done, #3 packaged, and adds #7 Windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:29:01 +00:00
enricobuehler 23bb814bac feat(packaging): Fedora/Bazzite packaging — COPR RPM, bootc image, gamescope-default config
Roadmap #3 (install on other devices). Bazzite already ships gamescope + PipeWire + the
NVIDIA stack, so the host slots in with minimal new deps (ffmpeg-libs from RPM Fusion + opus
+ libei).

- packaging/rpm/punktfunk.spec — builds punktfunk-host from source (cargo), installs the
  binary + udev rule + systemd user unit + headless helpers; Requires/Recommends mapped from
  the Ubuntu bootstrap deps to Fedora.
- packaging/bootc/Containerfile — layer punktfunk into a bazzite-nvidia bootc image for
  atomic, image-based installs.
- packaging/bazzite/host.env — gamescope-default appliance config (spawned per session).
- packaging/copr/ + packaging/README.md — COPR build-from-SCM settings + install docs
  (rpm-ostree and bootc paths), and why not Flatpak.
- LICENSE-MIT + LICENSE-APACHE — materialize the declared `MIT OR Apache-2.0` (was unfiled);
  the RPM ships them.

Not buildable on the Ubuntu dev box (no rpm tooling) — the COPR/Fedora build is operator-run;
all spec-referenced files verified present and the cargo build is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:29:01 +00:00
enricobuehler 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>
2026-06-10 22:15:07 +00:00
enricobuehler f3ff5f648a fix(headless-kde): complete the bare session — export DISPLAY, polkit agent, supervise plasmashell
ci / rust (push) Has been cancelled
A full Plasma login starts several pieces our bare headless session was missing, which
surfaced as three separate failures while streaming the KDE desktop:

- Steam (and other X11 apps) failed "can't open display": Xwayland runs, but KWin only
  sets DISPLAY for its own children — apps launched via the plasma menu / D-Bus activation
  never saw it. Detect the Xwayland display after KWin is ready and export it into the
  systemd/D-Bus activation environment.
- Discover / PackageKit couldn't install apps: polkitd (the policy engine) was running but
  no authentication *agent* (the prompt) was — so privileged installs got no authorization.
  Start polkit-kde-authentication-agent-1 (forcing the Qt Wayland platform, or it exits).
- The streamed desktop showed app windows but no wallpaper/panels: plasmashell had crashed
  and the old unsupervised `plasmashell &` never brought it back. Supervise it — restart for
  as long as KWin lives, so the desktop shell self-heals.

Validated live on this box: DISPLAY=:0 now in the --user environment (xdpyinfo on :0 works),
the polkit agent registers ("Listener online"), and plasmashell stays up under the supervisor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:44:33 +00:00
enricobuehler 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>
2026-06-10 21:31:11 +00:00
enricobuehler a730ca8557 fix(apple): scroll from trackpads/Magic Mouse — forward NSEvent scrollWheel, drop GC scroll
ci / rust (push) Has been cancelled
Scroll was wired to GCMouse's scroll dpad, which only fires for plain HID wheel
deltas — trackpad and Magic Mouse scrolling are gesture events that never reach
GameController, so scrolling was dead on the default Mac setups. The stream view now
overrides scrollWheel (while captured the cursor is parked mid-view, so it receives
every scroll event) and feeds InputCapture.sendScroll: precise gesture deltas are
pixels (~0.1 notch/px, SDL's factor → ×12 for WHEEL_DELTA(120)), classic wheels are
lines (×120), fractional remainders accumulate, and the GC scroll handler is gone so
wheel mice can't double-deliver. Signs pass through as-is, preserving the local
(natural-)scrolling preference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:17:23 +02:00
enricobuehler a9d1c16067 feat(apple): client-selectable compositor in the macOS client
ci / rust (push) Has been cancelled
Adopts punktfunk_connect_ex from the compositor-selection batch: a Compositor enum on
PunktfunkConnection (auto/kwin/wlroots/mutter/gamescope, with the host's name aliases
for env parsing), a "Host compositor" picker in Settings (default Automatic — a
concrete choice is honored only if that backend is available host-side), and
PUNKTFUNK_COMPOSITOR / PUNKTFUNK_REMOTE_COMPOSITOR pass-throughs for the autoconnect
dev hook and the remote first-light test. The wire change is backward-compatible
(optional trailing byte), so no behavior changes at the default.

Validated live against the box: host with no compositor env (auto-detect = KWin)
logged "honoring client compositor request compositor=gamescope" and streamed 60/60
decoded frames from the spawned gamescope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:51:42 +02:00
enricobuehler 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>
2026-06-10 22:45:41 +02:00
enricobuehler 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>
2026-06-10 22:45:41 +02:00
enricobuehler a4eacabecd feat(apple): explicit input-capture state machine — no more cursor grabs on window chrome
ci / rust (push) Has been cancelled
Capture used to engage whenever the app became active, so the click that activates the
window — on the title bar (a drag) or a resize edge — got the cursor warped away
mid-gesture, and raw deltas kept streaming to the host while the user fought the window.
Reworked Moonlight-style, with capture as a deliberate, reversible state owned by
StreamLayerView:

- Engage: automatically once when the stream starts / trust is confirmed (one-shot, can
  never fire surprisingly later), or by clicking into the video (that click's
  press/release are suppressed toward the host; acceptsFirstMouse makes it one click
  from another app). NEVER on app re-activation.
- Release: ⌘⎋ (toggles, key-window-scoped), focus loss — now including same-app window
  switches (⌘, / ⌘N / ⌘M resign key without resigning the app; previously the new
  window inherited a hidden frozen cursor and its typing was double-delivered to the
  host) — and disconnect.
- While released: nothing is forwarded (InputCapture.forwarding gates the GC handlers;
  held keys/buttons are flushed host-side so nothing sticks), the cursor is free, and
  the HUD (now showing the capture state) is clickable.
- The no-beep behavior moved from the NSEvent monitor to first-responder key
  consumption — swallowing at the monitor risked starving GC's own delivery (the
  "input broken altogether" report). The monitor now only intercepts ⌘⎋.
- Adversarial-review fixes: a second session preempts the previous one cleanly instead
  of leaving it captured with dead GC handlers (onPreempted); the engage click's
  suppression latch can't outlive the click (mouseUp backstop); ⌘⎋'s physical Esc can't
  type into the host in either toggle direction (suppressedVK latch + Esc-while-⌘
  guard); capture callbacks defer out of the SwiftUI update pass.

Validated live against the box: 16185 input datagrams injected during a captured
session (gamescope EIS), title-bar drag/resize free while released, and visible
cursor + typing on a streamed KWin desktop, all user-confirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:42:44 +02:00
enricobuehler acf44eed5f fix(apple): stop the macOS beep on every keystroke while streaming
ci / rust (push) Has been cancelled
GCKeyboard reads the HID state directly, so the key NSEvents kept traveling the
responder chain unhandled — and an unhandled keyDown makes NSWindow play the
"invalid input" sound on every keystroke. InputCapture now installs a local event
monitor for its lifetime that swallows key events, except ⌘-combos, which still
reach the local app (the HUD's ⌘D disconnect, ⌘Q) in addition to the host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:03:56 +02:00
enricobuehler 0494e0200a feat(apple): adapt the macOS client to ABI v2 — client identity + SPAKE2 PIN pairing
ci / rust (push) Has been cancelled
The pairing/renegotiation batch bumped the punktfunk/1 ABI to v2 and the host now
hard-rejects v1 Hellos (m3.rs), so streaming from the Mac was dead until the bundled
PunktfunkCore.xcframework is rebuilt — it is gitignored, so that is a per-checkout step:
bash scripts/build-xcframework.sh. The Swift wrapper itself was already adapted upstream;
this lands the app on top of it.

- ClientIdentityStore: persistent client identity in the login Keychain, presented on
  every connect so paired hosts recognize this Mac. Keychain access failure throws
  instead of regenerating (a fresh identity would silently un-pair this Mac from every
  --require-pairing host); a lost first-run race resolves toward the stored identity;
  pairing uses the strict loadForPairing() so a memory-only identity can't strand a
  ceremony.
- PairSheet: the SPAKE2 PIN ceremony, reachable from a host card's context menu and from
  the trust prompt's "Pair with PIN instead…" (which drops the live session first — the
  host's accept loop is sequential). Success pins the verified fingerprint and connects;
  an in-flight ceremony self-discards when the sheet is dismissed, so a late success
  can't pin + auto-connect behind the user's back. Wrong PIN and Keychain failures get
  distinct, actionable error text.
- Tests: identity unit tests; the full pairing ceremony + --require-pairing gate on
  loopback (test-loopback.sh arms a second host, parses its PIN from the log, and gives
  both hosts throwaway config homes — no more writes to the real ~/.config/punktfunk);
  remote pairing + pinned stream over the LAN (PUNKTFUNK_REMOTE_PIN, _PORT).

Validated live against the box: SPAKE2 ceremony with the host's arming PIN → verified
fingerprint → pinned + identified 720p60 session (host persisted the client identity);
first light 60/60 AUs decoded to pixels; vkcube on glass through the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:49:43 +02:00
enricobuehler 49d31b9cad fix(headless-kde): --no-block the portal restart so bring-up isn't blocked ~30s
ci / rust (push) Has been cancelled
A synchronous systemctl try-restart of the portal chain (xdg-desktop-portal is Type=dbus,
waits for its bus name) blocked the script ~30-40s before plasmashell started. --no-block
queues the restart and returns immediately — the portal only needs to be ready before the
first client streams (seconds later), not before plasmashell. Validated: plasmashell up in
1s (was ~30s); a virtual capture session against the fresh session streamed 720/720 frames
@720p120, zero-copy CUDA, no black screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:33:54 +00:00
enricobuehler 9fdc3c3246 feat(headless-kde): reliable bring-up — readiness probe, fix portal ordering/env (roadmap #1 phase 1)
ci / rust (push) Has been cancelled
Headless KDE startup was a chain of timing-sensitive handoffs gated by a blind `sleep 2`,
the dominant source of black screens. Phase-1 fixes:

- New `punktfunk-host probe-compositor` subcommand: exits 0 iff the detected compositor is
  up AND ready to create a virtual output now. KWin gets a real check (connect + registry
  roundtrip + the privileged zkde_screencast global must be advertised — what the backend
  needs); gamescope/Mutter/wlroots create on demand so the probe just confirms Linux.
  (vdisplay::probe dispatcher + kwin::probe; reuses kwin.rs's existing roundtrip path.)
- run-headless-kde.sh: replace `sleep 2` with an active readiness wait (poll probe-compositor
  until ready, 30s deadline, and bail with kwin's log if kwin_wayland exits during init).
  Move the portal restart to AFTER readiness, and precede it with `systemctl --user
  import-environment` + `dbus-update-activation-environment` (the missing env import — the
  Sway script does this; without it a restarted portal inherits a stale/empty WAYLAND_DISPLAY,
  which is the "streams but eats no input/audio" failure). kwin's stderr → a log file.

Validated: probe-compositor exits 0 "Kwin ready" against the live session, exit 1 with a
clear diagnostic when the compositor is absent. 114 tests green, clippy/fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:25:52 +00:00
enricobuehler aa5cee57bd docs: next-goals roadmap (KDE reliability → options → mic → Bazzite → touch → UHID DualSense)
ci / rust (push) Has been cancelled
Research-grounded sequence + per-goal approach/effort. Decisions: start with KDE startup
reliability; Bazzite via COPR RPM then bootc image; commit to full UHID DualSense.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:22:03 +00:00
enricobuehler c9ad74a620 fix(web): harden BFF auth — adversarial-review fixes
ci / rust (push) Has been cancelled
Multi-agent security review of 9856c04 (4 dimensions, 2-skeptic verification):

- CRITICAL functional+security: the session cookie inherited h3's Secure=true default;
  browsers DROP Secure cookies over plain http://, so login silently failed on a LAN HTTP
  client (worked only on localhost, a secure context — which is why the live test passed).
  Now set the cookie attributes explicitly: HttpOnly + SameSite=Lax + Path=/, and Secure
  only when PUNKTFUNK_UI_SECURE=1 (behind TLS). Verified: Set-Cookie no longer has Secure.
- Gate bypass: isPublicPath allowlisted any path ending in .json/.css/.png/etc., so
  /api/v1/openapi.json (served unauthenticated on the mgmt side too) leaked the whole API
  schema through the token-injecting proxy. Now /api is ALWAYS gated and the generic
  extension allowlist is gone (client assets are all under /assets/, still allowlisted).
  Verified: /api/v1/openapi.json and /api/v1/status.json → 401.
- Session lifetime: added maxAge (7d) — bounds a stolen cookie (cookie Max-Age + iron seal
  TTL); previously never expired.
- Open redirect: the post-login `next` accepted protocol-relative `//evil.com`. Hardened
  client + added safeNextPath() (same-origin path only).

Re-validated end to end: login assets public (200), /api/openapi.json gated (401), authed
/api/v1/status (200), unauth /→302. tsc + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:55:41 +00:00
enricobuehler 9856c04b75 feat(web): login-gated BFF auth — sealed session cookie + server-side token injection
ci / rust (push) Has been cancelled
Single-user, LAN-reachable-but-gated. The web server is a backend-for-frontend:

- Login: POST /_auth/login {password} checks PUNKTFUNK_UI_PASSWORD (constant-time) and
  sets a SEALED session cookie (h3 useSession / AES-GCM). server/middleware/auth.ts gates
  every request — pages 302 → /login, /api → 401 — and FAILS CLOSED (503) when
  PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
- The management API stays loopback-only + token (never LAN-exposed). The proxy
  (server/routes/api/[...].ts) injects PUNKTFUNK_MGMT_TOKEN server-side and drops the
  browser's cookie before forwarding — the token never reaches the browser, which only
  holds the session cookie.

Nitro doesn't auto-scan a server/ dir, so the Nitro plugin gets an explicit scanDirs to
pick up middleware + routes. Client: removed the localStorage token (server injects it);
the fetcher bounces to /login on 401; new /login page (bare, no shell); Settings drops the
token field and gains a Sign-out button; en/de strings.

Validated live end to end: unauth /→302, /api→401; wrong pw→401; right pw→200+cookie;
authed /api/v1/status→200 (proxied, mgmt token injected — the host required it); logout→
session cleared→401. tsc + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:43:14 +00:00
enricobuehler 7e4ae05944 refactor(web): switch to Bun + Nitro v2 (bun preset) — proper TanStack Start deploy
ci / rust (push) Has been cancelled
The earlier "render the shell with a custom script" was a hack. The real issues were a
version matrix and a missing server target:

- TanStack Start's start-plugin-core peer-requires Vite >= 7; on Vite 6 the build's
  prerender/post-build buildApp plugin hook silently doesn't run (Vite 6 lets a
  config-level builder.buildApp suppress plugin buildApp hooks; Vite 7 runs both). Pinned
  Vite ^7 + @vitejs/plugin-react ^5 (v5 ↔ Vite 7; v6 needs Vite 8 / vite/internal).
- Added @tanstack/nitro-v2-vite-plugin with the `bun` preset — the server/deploy target.
  `bun run build` → .output/ (bun-runnable server + .output/public). `bun run start` =
  `bun run .output/server/index.mjs`.
- Full SSR instead of SPA mode: SPA-shell prerender points its preview server at the old
  dist/server/server.js path that Nitro relocates, breaking the build. The Nitro server
  renders the shell per request; React Query fetches client-side after hydration.
- Nitro routeRules proxy /api/** → PUNKTFUNK_MGMT_URL (default 127.0.0.1:47990), so the
  browser stays same-origin (bearer token rides along, no CORS).

Toolchain is now Bun (package manager + runtime): bun.lock replaces pnpm-lock.yaml;
scripts/prepare/start use bun. Validated live: bun build → .output, bun server SSR-renders
the console on :3000 and proxies the API (health/host return through it). tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:46:47 +00:00
enricobuehler 381b059852 fix(web): add @vitejs/plugin-react — dev mode needs the React Refresh runtime
ci / rust (push) Has been cancelled
TanStack Start's dev server requires a React Refresh plugin; without it `/@react-refresh`
404s, the client entry 500s, and nothing hydrates (blank screen — the production build was
unaffected since rollup handles JSX there). Pinned to the v4 line: plugin-react 6 imports
`vite/internal` (Vite 7 only) and we're on Vite 6. Must sit after tanstackStart() in the
plugin list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:08:43 +00:00
enricobuehler e0b166ad60 feat(web): management console — TanStack Start + orval + shadcn + Paraglide
ci / rust (push) Has been cancelled
Browser UI for the host's management REST API (mgmt.rs / docs/api/openapi.json).

Stack, exactly as specified:
- TanStack Start (Vite, SPA mode) — file-based routes, SSR shell + client hydration.
- React Query via orval codegen from the checked-in OpenAPI spec: a custom fetch mutator
  (src/api/fetcher.ts) centralizes the base URL, the bearer token (Settings → localStorage),
  JSON, and a throwing ApiError; the query client skips retries on 4xx. orval returns the
  response body directly (includeHttpResponseReturnType:false) so a query's `.data` is the
  typed payload; GET→useQuery, POST/DELETE→useMutation by method.
- shadcn/ui on Tailwind v4 (CSS-first tokens, dark-first) — button/card/badge/input/label/
  table/skeleton primitives hand-authored from the canonical source.
- Paraglide i18n (en + de) with a reactive useLocale() hook and a language switcher.

Pages: dashboard (live status — video/audio/session/stream, stop-session + request-IDR,
2s polling), host (identity/codecs/ports), clients (paired list + unpair), pairing (PIN
submit, polls pin_pending), settings (API token + language).

Dev server proxies /api → 127.0.0.1:47990 (same-origin, no CORS; PUNKTFUNK_MGMT_URL to
override). Generated code (orval client, paraglide runtime, routeTree) is gitignored and
reproduced by `pnpm codegen` (prepare/pre* scripts). Validated live against `serve`: API
shapes match, dev proxy works, SSR shell renders the localized nav, build + tsc green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:00:12 +00:00
enricobuehler ff4fe197be fix(punktfunk/1): adversarial-review fixes — SPAKE2 pairing, renegotiation hardening, +more
ci / rust (push) Has been cancelled
Triaged the multi-agent review of the renegotiation + pairing + Sway + AV1/surround batch
(1 critical, 11 major/minor confirmed). Fixes:

CRITICAL — PIN pairing was offline-brute-forceable. The HMAC-of-PIN proof let an active
MITM who terminates the TOFU ceremony recover the 4-digit PIN by offline dictionary search
(all other inputs observable) and forge a correctly-bound proof. Replaced with **SPAKE2**
(balanced PAKE, `spake2` crate) + key-confirmation MACs, binding both cert fingerprints as
the SPAKE2 identities: an attacker gets exactly ONE online guess, no offline search, and
mismatched cert views (a real MITM) never reach a shared key. Also reworked the UX to an
"arming PIN" — one PIN per arming window shown at host startup (the SPAKE2 client needs the
PIN to build its first message, so it can't be minted per-connection). Validated live:
wrong PIN rejected in 0.1s, right PIN pairs + persists + the paired identity streams.

Pairing hardening: `--allow-pairing`/`--require-pairing` must arm pairing (default rejects
unsolicited ceremonies); per-host cooldown bounds online guessing; the client flushes its
CONNECTION_CLOSE so a refused ceremony can't wedge the sequential host for the full timeout;
atomic (temp+rename) paired-store writes.

Protocol: control/pairing messages use a distinct CTL_MAGIC (PKFc) — fully disjoint from
the positional Hello namespace (a future abi_version can't be misparsed as a control
message); all typed decodes are length-exact. ABI_VERSION → 2 (punktfunk_connect signature
gained the identity params; header regenerated).

Renegotiation: drain the reconfig channel to the NEWEST mode (one rebuild, not one per
stale step); validate refresh_hz; build the new pipeline BEFORE dropping the old so a
rebuild failure keeps the session on its current mode instead of killing it.

GameStream: packetDuration snaps to {5,10} (an in-between value isn't a legal Opus frame
size and would kill audio). Sway: chooser file moved to $XDG_RUNTIME_DIR (was a fixed
world-writable /tmp path — DoS / capture-misdirection by another local user).

Swift: fixed two compile breakers in the new pairing/identity APIs (Int32 status .rawValue,
UInt cap cast). New SPAKE2 + namespace-disjointness + pairing-roundtrip unit tests; the
in-process pairing test now also exercises the arming PIN + cooldown. 114 tests green,
clippy -D warnings clean (both feature sets), fmt, C-ABI harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:26:48 +00:00
enricobuehler 429bd1e6ac Merge branch 'worktree-agent-a6fe98c40d55fd284' into m1-lumen-core
# Conflicts:
#	CLAUDE.md
2026-06-10 15:42:48 +00:00
enricobuehler 4d26ac5c85 feat: punktfunk/1 — mid-stream mode renegotiation + PIN pairing ceremony
Renegotiation (no reconnect on resize): the handshake bi-stream stays open; the client
sends Reconfigure{mode} (typed post-handshake message), the host validates + acks
Reconfigured and rebuilds capture/encoder/virtual output at the new mode while the data
plane (keys, ports, FEC) runs untouched — the first new-mode AU is an IDR with in-band
parameter sets. NativeClient::request_mode / punktfunk_connection_request_mode; mode()
reflects the active mode. Validated live on KWin: one continuous stream, 225 frames
@1280x720 then 395 @1920x1080, ~90 ms pipeline rebuild (ffprobe shows both resolutions).

PIN pairing (mutual trust, kills TOFU MITM): clients get persistent self-signed
identities presented via QUIC client auth (generate_identity / client auth offered but
optional server-side — legacy clients still connect). Ceremony on the control stream:
PairRequest{name} → host shows a 4-digit PIN (log) + PairChallenge{salt} → client proves
with HMAC-SHA256(PIN‖salt, client_fp‖host_fp) — binding both certs means a MITM can't
forward a proof, single attempt per PIN, constant-time compare → PairResult; host
persists the fingerprint (~/.config/punktfunk/punktfunk1-paired.json), client pins the
host's. m3-host --require-pairing gates sessions on the paired set.
NativeClient::pair + punktfunk_pair/punktfunk_generate_identity in the ABI; reference
client: --pair PIN --name LABEL + auto-generated persistent identity, --remode for live
renegotiation testing. Swift wrapper: ClientIdentity/generateIdentity()/pair(),
requestMode()/currentMode(); README handoff updated.

Tested: reconfigure/pairing wire roundtrips, C-ABI mode switch ack, full in-process
ceremony (wrong PIN → Crypto, anonymous-vs-gate rejection, success → pinned session);
live wrong-PIN ceremony against the serving host (PIN logged, proof rejected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:42:29 +00:00
enricobuehler 3cc3c02b42 feat(gamestream): AV1 negotiation + 5.1/7.1 surround audio
Codec negotiation (M2 polish):
- ServerCodecModeSupport now advertises what we encode: H264|HEVC|AV1_MAIN8
  = 65793 (flags verified against moonlight-common-c Limelight.h). The old
  placeholder 3843 wrongly claimed HEVC Main10 + 4:4:4 and no AV1. Main10
  bits stay off on purpose: Moonlight ties 10-bit to HDR, and capture is
  8-bit SDR BGRx with no HDR metadata path (av1_nvenc -highbitdepth was
  validated working for later).
- RTSP ANNOUNCE: bitStreamFormat 0/1/2 -> H264/HEVC/AV1 (already plumbed to
  av1_nvenc; validated e2e via `m0 --codec av1` + ffprobe av01), and a
  dynamicRangeMode!=0 request now logs + falls back to 8-bit SDR.

Surround audio (M2 polish):
- ANNOUNCE x-nv-audio.surround.{numChannels,AudioQuality} +
  x-nv-aqos.packetDuration -> per-session AudioParams; DESCRIBE advertises
  all six Opus configs (normal before HQ per channel count). Normal-quality
  mappings are pre-rotated for the client's GFE-order LFE swap
  (RtspConnection.c, verified verbatim) so its derived decoder mapping
  equals our encoder mapping — including 7.1, where Sunshine's rotate only
  covers [3,6) and scrambles LFE/SL/SR.
- 5.1/7.1 encode via libopus multistream (audiopus_sys, the sys layer the
  opus crate already links) with Sunshine's layouts/bitrates, RAII wrapper;
  the live-validated stereo wire is byte-identical (plain Opus, no FEC).
- Surround sessions add Sunshine-style RS(4,2) audio FEC (packetType 127 +
  AUDIO_FEC_HEADER, the OpenFEC parity matrix both ends hardcode, nanors
  gemm semantics verified from nanors/rs.c).
- PipeWire capture generalized to the negotiated channel count with explicit
  FL FR FC LFE RL RR [SL SR] positions; missing sink channels are zero-
  filled by the channel-mixer. PwAudioCapturer now tears down cleanly on
  Drop (pipewire channel -> loop quit), so a channel-count change can
  reopen without leaking a capture stream.

Tests: serverinfo mask, RTSP codec/audio param parsing, DESCRIBE contents,
surround-params strings + client-swap round trip, FEC parity self-recovery
and packet layout, real-codec 5.1 channel-identity round trip, and an
ignored live test (ran green against a 6ch null sink monitor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:41:15 +00:00
enricobuehler 7381ba8218 feat(vdisplay): wlroots/Sway backend — swaymsg headless output + xdpw chooser
The fourth VirtualDisplay backend: `swaymsg create_output` adds a HEADLESS-N
output (name found by diffing get_outputs), `output <NAME> mode --custom
WxH@HzHz` sets the client's exact mode (and the refresh clock a fresh headless
output needs to produce frames at all), and the PipeWire node comes from the
ScreenCast portal. Headless output selection is non-interactive via
xdg-desktop-portal-wlr's chooser hook: a managed config (chooser_type=simple,
chooser_cmd cats /tmp/punktfunk-xdpw-output; portal try-restarted when the
config changes) plus a per-session `Monitor: <NAME>` written to that file.
Teardown is RAII: drop ends the portal thread (zbus connection drop ends the
cast) then `swaymsg output <NAME> unplug`. swaymsg commands go after `--` so
tokens like `--custom` reach sway instead of swaymsg's getopt.

Validated live on headless sway 1.11 (gles2-on-NVIDIA, xdpw 0.8.1), zero-copy
dmabuf→CUDA on both runs: 720p60 257 frames p50 0.77 ms, 1080p60 480/480
frames p50 1.18 ms, output unplugged with the session both times. The
checked-in xdpw.config sample now matches the managed config (the old
chooser_type=none/HEADLESS-1 form would pin capture to the wrong output).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:23:28 +00:00
enricobuehler 977c792b4b fix: keep the stream view's identity stable across the trust prompt
ci / rust (push) Has been cancelled
The awaiting-trust and streaming phases rendered StreamView in different switch
branches, so confirming trust dismantled and recreated the NSView — the fresh pump had
already missed the opening IDR (infinite GOP: no other keyframe ever comes) and decoded
nothing. One session branch now hosts a single StreamView; the trust card is an overlay
on the blurred stream and only the capturesCursor flag flips on confirmation.

Verified live against the box (gamescope+vkcube at 720p60, 11.7 Mb/s on glass). Note for
host runs: without PUNKTFUNK_COMPOSITOR=gamescope + PUNKTFUNK_GAMESCOPE_APP, m3-host
auto-picks KWin and streams its (black, empty) session — looks identical to a client
bug but isn't one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:44:59 +02:00
enricobuehler 187c173e01 fix(headless-kde): restart xdg-desktop-portal after kwin comes up
ci / rust (push) Has been cancelled
The portal processes bind to the compositor that existed when they started; after a kwin
restart the stale instances point at a dead socket and RemoteDesktop/EIS input injection
times out ("EIS setup timed out"). Hit live: a fresh session streamed fine but ate no
mouse/keyboard until the portals were restarted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:29:31 +00:00
enricobuehler 5e77731da0 feat: hosts grid + trust-on-first-use UX + settings pane
ci / rust (push) Has been cancelled
The app grows from a dev connect form into a real client shell:

- Home is a grid of saved hosts (UserDefaults-persisted; context menu: Remove / Forget
  Identity), "+" in the toolbar opens the add-host sheet, the stream mode moved into
  Settings (⌘, / gear) — native resolution stays the only mode, no scaling.
- Trust is now explicit: the protocol always supported certificate pinning, but the app
  passed no pin and discarded the observed fingerprint — silently trusting any host.
  First connect now shows the host's SHA-256 fingerprint (compare with the "clients pin
  this fingerprint" line in the host log) over the live-but-blurred stream; the stream
  must pump immediately (the opening IDR is the only guaranteed one), so StreamView gains
  a capturesCursor switch to keep the cursor free while the prompt needs clicking, and
  input capture starts only after confirmation. Trusting pins the fingerprint per host;
  a changed host identity then refuses to connect.
- PUNKTFUNK_AUTOCONNECT keeps working (auto-trusts, doesn't touch the saved hosts).

Host→client authorization (pairing PIN) remains a punktfunk-core roadmap item — the host
still accepts any client that can reach its port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:15:37 +02:00
enricobuehler dc42d6a375 feat: app icon (Icon Composer) + Xcode project settings for it
punktfunk_Logo.icon (Icon Composer 2.0) in App/, ASSETCATALOG_COMPILER_APPICON_NAME set.
Compiles with Xcode 27 beta's actool; Xcode 26.5's actool crashes on EVERY .icon file
(known regression, Apple FB20183399, expo/expo#46121) — build with the beta (or 26.4.1)
until a 26.x fix lands. The icon itself is fine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:15:15 +02:00
enricobuehler b21fffc3d1 feat: Xcode app project for the macOS client (Punktfunk.app)
ci / rust (push) Has been cancelled
clients/apple was a bare Swift package — fine for swift run/test, but app icons, a real
bundle (Info.plist, signing identity, TCC), and the normal Xcode build/run flow need an
app target. Punktfunk.xcodeproj (synchronized-folder format) wraps the SAME sources as
the CLI dev shell (Sources/PunktfunkClient) plus App/Assets.xcassets, and links
PunktfunkKit from the local package — no source duplication, both flows stay green:
swift build / swift test / swift run PunktfunkClient, and xcodebuild -scheme Punktfunk.

The asset catalog ships an empty AppIcon slot ready for the Icon Composer .icon
(drag in + set as App Icon + drop the placeholder; see README — including the actool
crash observed with the current icon bundle). Package tests on ⌘U need one GUI step
(Edit Scheme → Test → +); a hand-written package-test scheme reference doesn't resolve
headlessly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:50:50 +02:00
enricobuehler 9aa2d71f49 fix: hide + freeze the local cursor while streaming
ci / rust (push) Has been cancelled
The host renders its own cursor from our raw deltas, so the local macOS cursor both
stays visible and drifts away from the remote one — and it can wander out of the window,
where a click focuses another app. While the stream has focus, do what Moonlight does:
warp the cursor mid-view, disconnect it from mouse movement
(CGAssociateMouseAndMouseCursorPosition(false) — GCMouse still delivers raw HID deltas),
and hide it. Released on app deactivation (Cmd+Tab is the escape hatch), view teardown,
and disconnect; re-captured when the stream regains focus. The HUD's Disconnect gains ⌘D
since a hidden, frozen cursor can't click it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:38:03 +02:00
enricobuehler bfd64ce871 rename: lumen → punktfunk, everywhere
ci / rust (push) Has been cancelled
Full project rename, decided 2026-06-10:
- Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs.
- C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h,
  PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl.
  PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants).
- Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1.
  WIRE BREAK: clients must be rebuilt from this revision.
- Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / ….
- Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the
  persistent identity is unchanged, pinned fingerprints stay valid).
- Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection
  (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated.
- scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated.

Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of
"desktop but no apps/settings" over the stream: plasmashell launched without
XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and
rendered an empty menu. The script sets the complete KDE session env (menu prefix,
KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell.

Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS,
zero lumen references left outside .git.

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