Commit Graph

98 Commits

Author SHA1 Message Date
enricobuehler 1bcb786382 fix(android): request NEARBY_WIFI_DEVICES at runtime so mDNS discovery works on real devices
apple / swift (push) Successful in 53s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 38s
android / android (push) Successful in 3m23s
deb / build-publish (push) Successful in 2m4s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 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 4s
ci / bench (push) Successful in 4m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m8s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m58s
ci / rust (push) Successful in 4m4s
NsdManager service discovery needs NEARBY_WIFI_DEVICES on Android 13+. The app DECLARED it but
never REQUESTED it, so on a real device the permission stayed denied and discoverServices silently
found nothing — no prompt, no hosts. (It only worked on the emulator because the permission was
granted via `adb pm grant`.) Request it (mirroring the mic RECORD_AUDIO flow) when the connect
screen appears, and start/restart discovery once granted; on API < 33 discovery starts immediately
(the permission doesn't apply there). The advertised hosts the Apple clients already see will then
appear here too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:14:57 +02:00
enricobuehler 0c1afeefea fix(android): shrink the colored launcher-icon foreground to match the themed layer
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 40s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 28s
android / android (push) Successful in 5m41s
ci / bench (push) Successful in 4m28s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
deb / build-publish (push) Successful in 2m8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m27s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m38s
docker / deploy-docs (push) Successful in 18s
On the test phone's launcher the standard (colored) adaptive foreground rendered noticeably larger
than the themed (monochrome) layer — identical geometry, but the launcher insets/scales the two
differently — so the colored circles overflowed the circle mask. Shrink only the foreground group
(scale 0.105 → 0.073, re-centred) to match the correctly-sized monochrome; the monochrome layer is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:08:26 +02:00
enricobuehler 18ec32d21e feat(android): adaptive launcher icon with Material You themed-icon support
apple / swift (push) Successful in 53s
ci / rust (push) Failing after 1m36s
ci / web (push) Successful in 36s
ci / docs-site (push) Successful in 36s
deb / build-publish (push) Successful in 2m4s
decky / build-publish (push) Successful in 10s
android / android (push) Successful in 3m14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 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 3s
ci / bench (push) Successful in 4m46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m46s
docker / deploy-docs (push) Successful in 19s
Replace the placeholder system icon with the Punktfunk brand mark (two overlapping violet circles,
from the shared logo in clients/apple/.../punktfunk_Logo.icon).

- drawable/ic_launcher_foreground.xml: the violet logo (3 exact paths) scaled + centered into the
  108dp adaptive-icon safe zone via a group transform.
- drawable/ic_launcher_monochrome.xml: single-tone silhouette for Android 13+ themed icons
  (Material You) — the launcher recolors it to the wallpaper.
- mipmap-anydpi-v26/ic_launcher{,_round}.xml: adaptive-icon (background + foreground + monochrome);
  dark-indigo background (@color/ic_launcher_background) so the violet pops.
- Manifest: android:icon=@mipmap/ic_launcher + roundIcon (was @android:drawable/sym_def_app_icon).

minSdk 31 → anydpi-v26 covers every device (no legacy PNG mipmaps needed). Verified on a physical
phone (Android 16): the icon renders centered + circle-masked; the themed-icon layer is wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:02:49 +02:00
enricobuehler ef30afcf0b fix(apple): fill the notch in macOS fullscreen — stop letterboxing below the camera housing
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m11s
android / android (push) Successful in 1m55s
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 32s
ci / bench (push) Successful in 1m48s
decky / build-publish (push) Successful in 23s
deb / build-publish (push) Successful in 2m21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m56s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m42s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m51s
docker / deploy-docs (push) Successful in 21s
The macOS sessionView branch was missing the .ignoresSafeArea() its iOS/tvOS
siblings have, so in fullscreen the stream was laid out in the safe area below the
notch; the aspect-fit video then scaled down to that smaller area and left black
borders. Add .ignoresSafeArea() so the stream fills the whole display including
behind the camera housing (a thin top-center strip occluded — normal fullscreen-
video behavior); at the display's native mode it's now a 1:1 fill. Inert in
windowed mode and on non-notched displays. NSPrefersDisplaySafeAreaCompatibilityMode
is deliberately not used (it shrinks the whole window with borders on all sides).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:57:06 +02:00
enricobuehler 4b0b775e8e fix(apple): allow CoreHaptics audioanalyticsd mach-lookup under the macOS sandbox
GCDeviceHaptics.createEngine returns a CHHapticEngine (the only controller-rumble
API on Apple platforms); starting it spins up CoreHaptics, which looks up the
system audio-analytics daemon over Mach. The App Sandbox denies that global-name
lookup and the framework's precondition turns the denial into a hard crash
("Process is sandboxed but com.apple.security.exception.mach-lookup.global-name
doesn't contain com.apple.audioanalyticsd") the moment a controller's rumble
engine starts.

Add the documented, App-Store-acceptable temporary-exception whitelisting exactly
that one service. Verified embedded into the signed binary (codesign -d
--entitlements) alongside the existing entitlements. macOS-only (iOS/tvOS reject
temporary-exception keys and don't need it). App Store: declare it in App Sandbox
Entitlement Usage Information.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:57:06 +02:00
enricobuehler e99a1aea43 fix(apple): resolve QoS priority inversions + two Swift concurrency warnings
apple / swift (push) Successful in 55s
ci / rust (push) Successful in 1m31s
android / android (push) Successful in 1m48s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 1m35s
decky / build-publish (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 2m19s
flatpak / build-publish (push) Successful in 4m2s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m22s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m39s
Priority inversions (Thread Performance Checker): the Apple client drains every
plane on .userInteractive threads (video pump, audio, gamepad feedback) and
connects on a .userInitiated Task, but the connector's producer threads ran at
the default QoS — so a high-QoS consumer parked waiting on a lower-QoS producer.
Pin the connector's producers (outer worker thread, all tokio runtime threads via
on_thread_start, and the data-plane spawn_blocking pump) to .userInteractive on
Apple so they match the consumers. #[cfg(target_vendor = "apple")] helper using
the existing libc dep; no-op off Apple, no Swift-side change (no latency
regression).

GamepadFeedback.swift: the init's MainActor hop captured self implicitly-strong
while the inner $active sink captured it weakly — capture [weak self] in the hop
too (the sink stays weak to avoid the retain cycle).

StreamPump.swift: the @Sendable pump-thread closure captured the non-Sendable
AVSampleBufferDisplayLayer. enqueue/flush are documented thread-safe and only the
pump thread drives it after start(), so assert that with nonisolated(unsafe).

cargo build/test/clippy/fmt green (core + host); xcframework rebuilt; swift build
+ iOS/tvOS targets clean with both warnings gone. Runtime confirmation of the
inversion warnings needs a GUI run under Xcode's Thread Performance Checker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:48:10 +02:00
enricobuehler bbabc04bca feat(hdr): Windows HDR10 + 10-bit end-to-end, negotiated; non-blocking capture recovery
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m32s
android / android (push) Successful in 1m49s
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 30s
ci / bench (push) Successful in 1m36s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 2m20s
flatpak / build-publish (push) Successful in 4m6s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m11s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m32s
Adds true HDR (BT.2020 PQ) and 10-bit (HEVC Main10) streaming, negotiated so an
8-bit/SDR client is never sent a stream it can't decode, plus a robust fix for the
capture losing the stream across a secure-desktop transition.

Protocol (punktfunk-core/quic.rs):
- Hello gains `video_caps` (VIDEO_CAP_10BIT / VIDEO_CAP_HDR), Welcome gains `bit_depth`,
  both as optional trailing bytes (back-compat). client-rs advertises 10-bit via
  PUNKTFUNK_CLIENT_10BIT; the connector advertises 0 for now (in-band detection drives
  the native clients). Regenerated punktfunk_core.h.

Windows host:
- 10-bit Main10: host enables it only when the client advertised VIDEO_CAP_10BIT AND
  PUNKTFUNK_10BIT is set; threaded through open_video → NVENC (profile Main10,
  pixelBitDepthMinus8).
- HDR: when the captured desktop is scRGB FP16 (R16G16B16A16_FLOAT, HDR on), copy it to
  an FP16 surface, composite the cursor there, convert scRGB → BT.2020 PQ 10-bit
  (R10G10B10A2) via a shader, and encode HEVC Main10 with the BT.2020/PQ colour VUI
  (ABGR10 input). Fixes the freeze + cursor-trail that came from feeding FP16 into the
  BGRA path. Reacts dynamically to the HDR toggle.
- Capture recovery: rebuild is now a single NON-BLOCKING attempt, throttled to ~4×/s,
  repeating the last good frame between attempts (format-tagged last_present). During a
  secure-desktop dwell SudoVDA's output is gone; the old blocking 12 s retry starved the
  send loop for seconds so the client timed out and disconnected — now the session stays
  fed (frozen) until the desktop returns. Also seeds a black frame on recovery.

Apple client (PunktfunkKit):
- Detects HDR in-band from the stream VUI (PQ transfer function), decodes to 10-bit P010,
  and presents via an rgba16Float + BT.2020 PQ CAMetalLayer with EDR; SDR path unchanged.
  Switches automatically on a mid-session HDR toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:28:52 +00:00
enricobuehler f5eae24c87 feat(apple): tabbed macOS Settings + stats-overlay placement/toggle + Stream menu
ci / rust (push) Failing after 42s
apple / swift (push) Successful in 54s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 32s
android / android (push) Successful in 1m47s
ci / bench (push) Successful in 1m35s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m27s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m28s
docker / deploy-docs (push) Successful in 20s
The macOS Settings window had outgrown one scrolling pane — split it into a tabbed
preferences window (General / Display / Audio / Controllers / Advanced). Each
settings group is now a shared @ViewBuilder section, so iOS keeps its single
grouped Form and tvOS its pushed-picker layout, each defined once. No setting
moved or dropped.

New statistics-overlay controls (Settings → Display → Statistics): a show/hide
toggle (DefaultsKey.hudEnabled) and a corner picker (HUDPlacement /
DefaultsKey.hudPlacement) — the HUD moves to the chosen corner and aligns its text
to that edge.

A Scene-level "Stream" menu (StreamCommands) carries Show/Hide Statistics (⌘⇧S)
and Disconnect (⌘D). Disconnect moved off the HUD button into the menu so it
survives the overlay being hidden, wired via .focusedSceneValue. On iOS a
material-backed exit chip appears when the HUD is hidden (touch users have no
menu/⌘D); tvOS disconnect is unchanged (Siri-Remote Menu button).

Builds on macOS/iOS/tvOS; swift test green. Adversarially reviewed (8 findings
refuted, 2 minor — the iOS exit-chip contrast fix is included here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:11:39 +02:00
enricobuehler 5dcb72f5af feat(android): rename display name to "Punktfunk" + drop the Settings "Done" button
ci / rust (push) Failing after 40s
apple / swift (push) Successful in 54s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 31s
android / android (push) Successful in 1m55s
ci / bench (push) Successful in 1m44s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 3m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m11s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m52s
- Display name capitalized: app_name (launcher label + permission dialogs) and the connect-screen
  header are now "Punktfunk". Package/applicationId/service names stay lowercase.
- Settings: removed the redundant "Done" button (the bottom tab bar is the navigation; system Back
  still returns to Connect). Dropped the now-unused imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:30:29 +02:00
enricobuehler 49cdafc042 feat(android): connect-screen redesign — Apple-style cards, FAB + bottom sheet, fixed status bar
Polish pass on the connect screen.

- Host cards: ElevatedCard with a colored letter-avatar (Apple-contact style), name + address, a
  colored status pill (Paired / PIN pairing / Trust on first use), and an overflow menu with Forget
  on saved hosts. Tapping a card connects. Unifies the old saved/discovered rows into one HostCard.
- Manual connect moved behind an "Add host" ExtendedFloatingActionButton that opens a
  ModalBottomSheet with the Host/Port form (the current M3 pattern) — declutters the list.
- Empty state when there are no saved/discovered hosts; single scrollable column; removed the
  "core ABI v2" footer.
- Status bar: enableEdgeToEdge driven explicitly dark (transparent bars + light icons) so the
  status/nav bars blend with our always-dark surface instead of showing a black band (the no-arg
  edge-to-edge had picked the system light/dark theme).

Verified live (emulator screenshots): cards render with avatars + status pills + Forget menu; the
FAB opens the bottom-sheet form; the status bar blends with light icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:30:29 +02:00
enricobuehler ecd7d4a7e3 feat(android): mic uplink + connect-screen redesign
ci / web (push) Successful in 29s
android / android (push) Successful in 1m50s
ci / bench (push) Successful in 1m42s
apple / swift (push) Successful in 53s
ci / rust (push) Successful in 1m4s
ci / docs-site (push) Successful in 31s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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 4s
deb / build-publish (push) Successful in 3m21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m15s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m1s
Microphone uplink (client → host's virtual mic, 0xCB) and a cleaner connect screen.

Mic (Rust-heavy, mirrors the audio playback path in reverse):
- crates/punktfunk-android/src/mic.rs: AAudio LowLatency **input** → realtime callback hands
  captured f32 to a channel → a worker thread Opus-encodes 20 ms stereo frames (48 kHz, VOIP,
  64 kbps) and calls NativeClient::send_mic. MicCapture owns the stream + encode thread (RAII stop).
- session.rs: SessionHandle gains a `mic` slot; nativeStartMic/nativeStopMic JNI (mirror of audio);
  stopped in Drop. NativeBridge: the two externs.
- Settings: a `micEnabled` flag + a Microphone toggle in SettingsScreen that requests RECORD_AUDIO
  (denied → stays off). StreamScreen starts the mic only if enabled AND the permission is held.

Connect-screen redesign:
- One scrollable Column (was a fixed centered layout that could clip with the new tab bar);
  host rows render via forEach (no nested LazyColumn). Colored section labels ("Saved hosts",
  "Discovered on the network", "Connect manually"), full-width host cards / fields / Connect button,
  a header + subtitle, and a muted footer.

Verified live (emulator pf_phone -> home-worker-2): toggling mic requests RECORD_AUDIO; with it
granted, a session sends mic frames (client "mic: sent=250 … peak=0.439" — real audio) and the host
logs "client datagram stream ended … mic=276". Redesigned screen confirmed via screenshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:05:25 +02:00
enricobuehler 14fe450b72 feat(android): bottom tab bar (Connect / Settings)
apple / swift (push) Successful in 53s
ci / web (push) Successful in 35s
ci / docs-site (push) Successful in 35s
ci / bench (push) Successful in 1m48s
deb / build-publish (push) Successful in 3m28s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / rust (push) Successful in 6m59s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m46s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m44s
docker / deploy-docs (push) Successful in 19s
android / android (push) Successful in 2m41s
Replace the ad-hoc screen switching with a Material3 bottom NavigationBar. Two top-level
destinations — Connect (Home icon) and Settings (gear) — persist across tab switches; the
immersive stream view is shown full-screen, outside the bar. Settings is now a tab, so its
button is dropped from the Connect screen.

- app/build.gradle.kts: + androidx.compose.material:material-icons-core (tab icons).
- MainActivity: Screen sealed interface -> Tab enum; App() wraps the tabs in a Scaffold with a
  NavigationBar bottomBar (streamHandle != 0 -> StreamScreen full-screen); ConnectScreen drops
  the onOpenSettings param + the Settings button.

Verified live (emulator): the bar renders with Connect/Settings; tapping a tab swaps content and
moves the selected indicator; the bar persists on both tabs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:39:55 +02:00
enricobuehler 9ff5951cb2 feat(android): saved-hosts list + unify trust key on address:port
A managed list of known/paired hosts on the connect screen — one-tap reconnect + forget —
and a fix for the discovered-vs-manual trust-key split.

- kit/security: KnownHostStore (replaces the fp-only PinStore) stores KnownHost{address, port,
  name, fpHex, paired} keyed by address:port, persisted as JSON in SharedPreferences. So a
  discovered and a manually-typed connection to the same host now share ONE trust record (the old
  PinStore keyed discovered hosts by the mDNS instance id, manual by host:port — pairing via one
  path wasn't seen by the other).
- MainActivity: connect() looks up trust by (address, port); on a successful TOFU or PIN pairing
  the host is saved (paired flag set for the PIN path). A "Saved hosts" section lists them (name,
  address:port · paired/trusted, fp) with tap-to-reconnect (silent, pinned) and a Forget button.

Verified live (emulator -> home-worker-2): pair -> host appears under "Saved hosts" as paired;
tap -> silent reconnect (new host session, no dialog); Forget -> removed. Trust now shared across
the discovered + manual paths by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:31:46 +02:00
enricobuehler 3bcc36c801 feat(android): native display resolution + Settings screen
apple / swift (push) Successful in 53s
android / android (push) Failing after 1m15s
ci / rust (push) Failing after 43s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 30s
ci / bench (push) Successful in 1m43s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m53s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m44s
deb / build-publish (push) Successful in 6m52s
docker / deploy-docs (push) Successful in 22s
The connect mode was hardcoded to 720p60 — violating the "native client resolution, no
scaling" invariant. Derive the device's real display mode (landscape, long edge = width) and
add a Settings screen to tune the stream, mirroring the Linux/Apple clients.

- crates/punktfunk-android: nativeConnect gains bitrateKbps + compositorPref + gamepadPref
  (CompositorPref/GamepadPref wire bytes via from_u8); these were hardcoded Auto/Auto/0.
- app/Settings.kt: Settings (width/height/hz/bitrate/compositor/gamepad; 0 = native/auto) +
  a SharedPreferences store + nativeDisplayMode (Display.mode, landscape-swapped) +
  effectiveMode + the UI option tables.
- app/SettingsScreen.kt: dropdowns for resolution / refresh / bitrate / compositor / controller.
- MainActivity: App owns the settings + a Settings screen; ConnectScreen resolves the effective
  mode (Native = the display), shows it on the Connect button, and threads the prefs through
  nativeConnect.

Mic + codec selection deferred (mic uplink isn't wired yet; the decoder is HEVC-only).

Verified live (emulator pf_phone -> home-worker-2): default -> host mode=2400x1080@60 (the
emulator's native display, was 720p); Settings 1920x1080 + 20 Mbps + DualSense -> host
mode=1920x1080, requested_kbps=20000, gamepad=dualsense (host created a UHID DualSense).
Settings persist across screens; pinned reconnect stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:14:01 +02:00
enricobuehler 8ab262f8f8 feat(trust): host-gated trust-on-first-use — PIN pairing mandatory by default
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 1m12s
ci / web (push) Successful in 29s
android / android (push) Failing after 1m49s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 1m48s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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 19s
flatpak / build-publish (push) Failing after 3s
deb / build-publish (push) Failing after 2m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m22s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m20s
TOFU let anyone who could reach the host click "Trust" and stream, which defeats the point
on a LAN. Make SPAKE2 PIN pairing the default and only way to trust a NEW host; TOFU survives
as an explicit HOST opt-in (for fully trusted networks), advertised over mDNS so clients render
their trust UI from the host's policy rather than offering trust on faith.

Contract:
- Host advertises pair=required (default) or pair=optional. pair=required rejects unpaired
  clients at the handshake; pair=optional accepts them (TOFU).
- Clients: a pinned host whose fingerprint matches connects silently; a pinned host whose
  fingerprint CHANGED forces re-pairing via PIN (no re-trust shortcut); a NEW host is offered
  TOFU only if it advertised pair=optional, otherwise PIN pairing is mandatory; a manually-typed
  or unknown-policy host is always PIN.

Host (crates/punktfunk-host/src/main.rs):
- m3-host now REQUIRES pairing by default (was open by default). New --allow-tofu opts into
  accepting unpaired clients + advertising pair=optional; pairing is always armed (PIN logged at
  startup). serve --native was already secure-by-default (serve --open). The mDNS advert and the
  accept loop already mapped require_pairing -> pair=required + reject; only the m3-host CLI
  default + help text changed.

Clients honor the advertised policy:
- Android (MainActivity.kt): TOFU only for a discovered pair=optional host; manual/unknown -> PIN;
  fp-change -> re-pair only (dropped the "Forget & re-TOFU" shortcut).
- Apple (HostDiscovery/SessionModel/ContentView/HostCards/HostStore): new allowsTofu
  (pair==optional, distinct from unknown); connect() gates .awaitingTrust on it; unpinned
  non-optional hosts route to the PIN sheet; "Forget Identity" re-pairs rather than re-TOFUs.
- Linux (app.rs/ui_hosts.rs/session.rs): ConnectRequest.pair_required -> pair_optional;
  initiate_connect routes pinned/fp-changed/optional/else; manual + --connect unknown -> PIN; a
  pinned connect rejected on trust grounds re-pairs.

Docs (CLAUDE.md, README.md, docs-site/content/docs/pairing.md): describe the gated model — PIN is
the default, TOFU an explicit opt-in with an impostor warning.

Verified: host cargo check/clippy/fmt clean; Android built + live (emulator -> home-worker-2):
a manual connect now opens the PIN dialog (no Trust button) and the PIN ceremony streams; Apple
swift build clean; Linux clippy -D warnings + fmt clean on the Linux box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:27:52 +02:00
enricobuehler b0df291ffe feat(android): pairing/identity — persistent identity, TOFU pinning, SPAKE2 PIN ceremony
apple / swift (push) Successful in 55s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 28s
android / android (push) Failing after 1m55s
ci / docs-site (push) Successful in 33s
ci / bench (push) Successful in 1m45s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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 4s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Failing after 2m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m15s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m9s
docker / deploy-docs (push) Successful in 18s
M4 Android stage 1 (trust). The client now presents a persistent self-signed identity on
every connect, pins host certs trust-on-first-use, and runs the SPAKE2 PIN pairing
ceremony — parity with the Apple/Linux clients. The Rust connector already exposed this;
this wires it through the JNI + a Keystore-backed Kotlin store + the connect UI.

- crates/punktfunk-android: nativeGenerateIdentity (mint), nativeConnect gains
  certPem/keyPem/pinHex (identity + TOFU/pinned), nativeHostFingerprint, nativePair
  (SPAKE2). hex32/parse_hex32 helpers.
- kit/security: IdentityStore (AndroidKeyStore AES-256-GCM-wrapped PEM blob; StrongBox
  with TEE fallback; four-state load so a decrypt failure never shadow-mints), PinStore
  (host-id -> fp-hex in SharedPreferences). obtainIdentity mints once on genuine first run.
- app: ConnectScreen loads/mints the identity, looks up the stored pin, and gates connect
  on a trust decision — TOFU prompt (first connect), fingerprint-changed warning, PIN dialog.
- AndroidManifest: allowBackup=false (Keystore keys don't restore; a restored device
  re-mints rather than carrying a dead blob).

Verified live (emulator -> home-worker-2, synthetic m3-host):
- identity: host logs the presented client fingerprint; stable across an app restart.
- TOFU: first-connect prompt -> Trust -> pins the observed host fp -> pinned reconnect
  skips the prompt.
- SPAKE2: PIN ceremony -> "pairing complete — client trusted" -> auto-connect under
  --require-pairing; wrong PIN / host down -> "Pairing failed".

Known follow-up: trust is keyed by mDNS instance id for discovered hosts but by
"host:port" for manually-typed ones, so pairing via one path isn't recognized by the other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:29:04 +02:00
enricobuehler 3167c936c0 feat(android): mDNS host discovery (NsdManager) in the connect screen
apple / swift (push) Successful in 53s
ci / rust (push) Successful in 1m12s
android / android (push) Failing after 1m42s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 1m44s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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 4s
flatpak / build-publish (push) Failing after 1s
deb / build-publish (push) Failing after 2m45s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m49s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m0s
M4 Android stage 1 (discovery). Kotlin-only — browse _punktfunk._udp and present a
tappable host list above the manual Host/Port fields.

- clients/android/kit: HostDiscovery — NsdManager browse + resolve (registerServiceInfoCallback
  on API 34+ for reliable TXT, legacy resolveService on 31-33), MulticastLock while running, and
  a pure parseTxt(proto/fp/pair/id). Exposes the live host set via an onChange callback (NSD
  callbacks land on the main thread). DiscoveredHost(name, host, port, fingerprint?, pairingRequired).
  + a JVM unit test of parseTxt.
- clients/android/app: ConnectScreen renders discovered hosts (tap -> fill host/port + connect);
  discovery scoped to the screen (start on enter, stop on connect/leave). Manifest adds
  CHANGE_WIFI_MULTICAST_STATE + ACCESS_WIFI_STATE (NEARBY_WIFI_DEVICES already declared). Trust
  stays TOFU (pin=None); fp shown advisory; pairingRequired shown (SPAKE2 PIN wiring is later).

Verified: parseTxt unit test (5/5 green); on the emulator a loopback NsdManager.registerService of
a fake _punktfunk._udp host was discovered + resolved + TXT-parsed and rendered as a card
(name/host:port/TOFU/fp) -- the full browse->resolve->parse->UI path. Real cross-LAN discovery
needs a physical device on the host LAN (the emulator's SLIRP NAT drops mDNS multicast).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:32:09 +02:00
enricobuehler 104639bcc1 feat(android): DualSense host->client feedback — rumble + lightbar/LEDs/triggers
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 54s
android / android (push) Failing after 1m39s
ci / rust (push) Failing after 1m44s
ci / web (push) Successful in 27s
ci / bench (push) Successful in 1m44s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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 4s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Successful in 3m10s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 1m18s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m31s
M4 Android stage 1 (DualSense feedback, host->client). Two Kotlin poll threads drain the
connector's rumble (0xCA) + HID-output (0xCD) planes via blocking native pulls and render
in Kotlin (Option B — no JNI upcalls, Android APIs stay in Kotlin).

- crates/punktfunk-android: feedback.rs — nativeNextRumble (returns (low<<16)|high, or -1)
  + nativeNextHidout (writes [kind][fields] into a caller's direct ByteBuffer). Ungated; no
  new Cargo deps (next_rumble/next_hidout are on the quic feature already).
- clients/android: GamepadFeedback.kt — rumble -> VibratorManager (two-motor amplitude),
  HID Led -> lightbar + PlayerLeds -> player LED via LightsManager (API 33+), adaptive
  triggers parsed + logged (no public Android API); resolves the connected pad, emulator ->
  logged no-op. Started/stopped in the StreamScreen lifecycle (stop + join before nativeClose).

Verified live (emulator -> synthetic host, PUNKTFUNK_TEST_FEEDBACK=1): client received +
decoded the full burst -- rumble low=16384 high=32768, Led r=10 g=20 b=30, PlayerLeds bits=4
player=1, Trigger which=1 mode=0x21 -- matching the host hook exactly. Rendering is a logged
no-op on the emulator (no controller); real haptics/lightbar/player-LED need a physical pad.
Deferred (need a physical DualSense + device enumeration): client->host rich input
(touchpad/motion send_rich_input) and DualSense controller-type negotiation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:30:32 +02:00
enricobuehler 1e871854cd feat(android): gamepad forwarding — buttons + sticks/triggers/dpad → send_input
apple / swift (push) Successful in 54s
android / android (push) Failing after 21s
ci / web (push) Failing after 12s
ci / docs-site (push) Failing after 0s
ci / bench (push) Failing after 1s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 1s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 1s
docker / deploy-docs (push) Has been skipped
flatpak / build-publish (push) Failing after 0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1s
ci / rust (push) Failing after 2m35s
M4 Android stage 1 (gamepad). One controller forwarded as pad 0; mirrors the
Linux/Apple gamepad mapping (byte-identical GamepadButton/GamepadAxis events).

- crates/punktfunk-android: 2 JNI fns (nativeSendGamepadButton/Axis) building the
  GamepadButton/GamepadAxis InputEvents (flags = pad index 0).
- clients/android: Gamepad.kt — BTN_*/AXIS_* wire constants, KEYCODE_*->BTN_* map, and
  an AxisMapper (joystick MotionEvent -> sticks +-32767 +y-up / triggers 0..255 /
  HAT->BTN_DPAD_* with on-change gating + release-all reset). MainActivity routes
  gamepad-source KeyEvents in dispatchKeyEvent (DPAD only when from a gamepad, so
  keyboard arrows still map to VK) and adds dispatchGenericMotionEvent for joystick axes.

Verified live (emulator -> gamescope host, `adb input gamepad keyevent`): host created
the virtual X-Box 360 uinput pad (index=0) and received the gamepad datagrams (input=22).
Axes can't be adb-injected (joystick MotionEvents) -- build/clippy + code-review this
increment; live stick/trigger test deferred to a physical controller. Deferred: device
enumeration/selection, controller-type negotiation, DualSense rich input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:06:57 +02:00
enricobuehler ff1cc6c6d9 feat(android): input forwarding — keyboard + touch trackpad → send_input
ci / rust (push) Failing after 0s
ci / web (push) Failing after 0s
ci / docs-site (push) Failing after 0s
ci / bench (push) Failing after 1s
android / android (push) Failing after 0s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Has been skipped
flatpak / build-publish (push) Failing after 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 40s
apple / swift (push) Successful in 53s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1m21s
M4 Android stage 1 (input). Kotlin captures input and forwards it over JNI to
NativeClient::send_input (the connector is linked as a Rust crate).

- crates/punktfunk-android: 4 JNI send fns (pointer move / button / scroll / key)
  building InputEvent with the GameStream wire codes — ungated, &self on the Sync
  connector (safe from the UI thread).
- clients/android: Keymap.kt (Android KEYCODE_* -> Windows VK, the host's wire
  contract, mirroring the Linux/Apple tables); Activity-level dispatchKeyEvent forwards
  hardware keys to the active session (above the Compose focus system, so it's reliable);
  a Compose touch-trackpad overlay -- 1-finger drag -> relative move, tap -> left click,
  2-finger drag -> scroll.

Verified live (emulator -> gamescope host on the LAN box, synthetic `adb input`): host
received 31 input datagrams (input=31) and libei injected KeyDown/KeyUp, MouseButtonDown/Up
and MouseMove all emitted=true. Physical-mouse pointer capture + gamepad are next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:49:27 +02:00
enricobuehler 01305c67a7 fix(apple/gamepad): resolve DualSense type reliably at connect (no Auto race)
apple / swift (push) Successful in 54s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 1s
docker / deploy-docs (push) Has been skipped
android / android (push) Failing after 0s
ci / rust (push) Failing after 1s
ci / web (push) Failing after 0s
ci / docs-site (push) Failing after 0s
ci / bench (push) Failing after 0s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 1s
flatpak / build-publish (push) Failing after 0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 0s
The DualSense intermittently showed up as an Xbox 360 pad on the host: the
client's `.auto` gamepad-type resolution read `GamepadManager.active`, which is
populated only by the async `.GCControllerDidConnect` notification (or the
init-time snapshot). At connect time `active` could still be nil with a DualSense
attached, so the client sent `.auto` and the host's pick_gamepad mapped that to
Xbox 360. Confirmed live: same box, two connects minutes apart logged
`gamepad="xbox360"` (auto) vs `honoring client gamepad request gamepad="dualsense"`.

resolveType() now calls rebuild() first to re-read GCController.controllers()
synchronously before reading `active`, closing the race for the common case
(controller attached before connecting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:31:39 +00:00
enricobuehler 8c8d576e52 feat(android): host→client audio — Opus → AAudio (LowLatency)
apple / swift (push) Successful in 53s
android / android (push) Failing after 1m21s
ci / rust (push) Failing after 1m32s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 30s
decky / build-publish (push) Successful in 11s
ci / bench (push) Successful in 1m46s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Failing after 3m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 1m20s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1m43s
M4 Android stage 1 (audio). An audio thread pulls Opus packets from the connector
(next_audio), decodes to interleaved f32 stereo, and feeds AAudio via its realtime
data callback through a jitter ring ported from the Linux client (prime ~3 quanta,
drop-oldest cap, re-prime on drain). All in Rust on native threads — symmetric with
the video decode path.

- crates/punktfunk-android: audio.rs (Opus decode + jitter ring + AAudio callback);
  SessionHandle gains an audio slot; nativeStartAudio/nativeStopAudio JNI; Drop stops it.
  Android-only deps: opus 0.3 (libopus via cmake, static) + ndk "audio" (AAudio) — pure
  C/NDK, no libc++_shared to bundle.
- clients/android: NativeBridge start/stop audio, called in the SurfaceView lifecycle.
- kit/build.gradle.kts: cargo-ndk env for the libopus cmake build (NDK root, Ninja,
  LIBOPUS_STATIC/NO_PKG) + --platform 31 (libaaudio is API 26+).

Verified live (emulator -> gamescope host on the LAN box): AAudio opened 48k/stereo/f32;
a 440 Hz tone played into the host capture sink reached the client decoded -- opus ~200/s,
pcm_frames climbing in lockstep, peak=0.089 (real content, not silence), with video
streaming concurrently. Some underruns under emulator jitter (verify on hardware).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:25:24 +02:00
enricobuehler de7b8ac282 feat(android): video decode pipeline — NDK AMediaCodec → SurfaceView
apple / swift (push) Successful in 53s
ci / rust (push) Failing after 55s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 33s
android / android (push) Successful in 2m25s
ci / bench (push) Successful in 1m37s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Failing after 1s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m49s
deb / build-publish (push) Successful in 5m55s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m38s
docker / deploy-docs (push) Successful in 8s
M4 Android stage 1 (video). Pull HEVC access units from the connector and render
them to the SurfaceView entirely in Rust (NDK AMediaCodec → ANativeWindow) — no
per-frame JNI, honoring the native-thread hot-path invariant.

- crates/punktfunk-android: decode.rs (one-in/one-out AMediaCodec loop; in-band
  VPS/SPS/PPS so no out-of-band csd; dims from NativeClient::mode). SessionHandle
  now holds an Arc<NativeClient> + the decode thread; nativeStartVideo/nativeStopVideo.
- clients/android: connect screen (host/port) + full-screen SurfaceView stream
  screen — surfaceCreated -> nativeStartVideo, leaving -> stop + close.

Verified live (Android emulator -> m3-host on the LAN box, ABI v2): QUIC handshake,
8-round clock-skew sync, HEVC decoder configured at 1280x720, and the data plane
delivered + fed all 299 access units (the punktfunk/1 NAT hole-punch worked through
the emulator's SLIRP). Real-pixel render is pending a non-synthetic source:
`m3-host --source synthetic` emits dummy transport payloads (not HEVC), so the
decoder correctly produces nothing; `--source virtual` (a compositor on the host)
is needed to verify decode-to-screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 02:03:48 +02:00
enricobuehler 8956bc14de feat(packaging/flatpak,decky): Steam Deck client flatpak + plugin deploy + CI
apple / swift (push) Successful in 53s
android / android (push) Successful in 3m48s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 34s
ci / rust (push) Successful in 2m21s
ci / bench (push) Successful in 1m36s
decky / build-publish (push) Successful in 31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
flatpak / build-publish (push) Failing after 4s
deb / build-publish (push) Successful in 2m38s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m9s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m42s
docker / deploy-docs (push) Successful in 16s
Ship the punktfunk Linux client to the Steam Deck as a Flatpak — the only viable
SteamOS install path, since /usr is read-only and lacks libadwaita/SDL3 — and
publish both it and the Decky plugin through Gitea. Built and validated live on a
Steam Deck (SteamOS 3.7): bundle installs user-scope, all libs resolve, libavcodec
resolves to the codecs-extra HEVC build, devices=all for DualSense hidraw.

packaging/flatpak (new):
- io.unom.Punktfunk.yml on GNOME 50 / freedesktop-sdk 25.08. rust-stable//25.08
  (rustc 1.96 — the GTK4 chain needs >=1.92; the EOL GNOME-48/24.08 rust-stable at
  1.89 could not build it) + llvm20 (libclang for bindgen in ffmpeg-sys-next/sdl3-sys).
  HEVC libavcodec comes from the runtime's auto codecs-extra extension point (no
  app-side codec declaration). Bundled SDL3 3.4.10 (matches sdl3-sys 0.6.6+SDL-3.4.10).
  finish-args: wayland/fallback-x11, --device=all (GPU/VAAPI + evdev + hidraw — flatpak
  cannot bind /dev/hidrawN char devices via --filesystem), pulseaudio, network,
  ~/.config/punktfunk.
- metainfo.xml, desktop, square SVG icon, build-flatpak.sh (offline cargo-sources;
  on-Deck org.flatpak.Builder or CI), README.

clients/decky:
- add LICENSE (MIT), fix package.json license (BSD-3-Clause -> Apache-2.0 OR MIT),
  add scripts/{package.sh,deploy.sh} (the plugins dir is root-owned: stage to /tmp,
  sudo install, restart plugin_loader), align the launcher fallback to the real
  flatpak app id io.unom.Punktfunk, rewrite the install section.

.gitea/workflows:
- flatpak.yml: privileged Fedora container builds the bundle and publishes to the
  Gitea generic registry (+ release attachment on tags).
- decky.yml: pnpm build -> store-layout zip -> registry (stable latest/ URL for
  Decky "install from URL").

docs: packaging/README + packaging/flatpak/README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:43:35 +02:00
enricobuehler 79217eb93d feat(android): scaffold the native Android client (Rust-heavy JNI bridge)
apple / swift (push) Successful in 52s
ci / docs-site (push) Successful in 27s
android / android (push) Successful in 4m52s
ci / web (push) Successful in 26s
ci / bench (push) Successful in 1m33s
ci / rust (push) Successful in 6m56s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 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 4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m54s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m29s
deb / build-publish (push) Successful in 6m46s
docker / deploy-docs (push) Successful in 22s
Rust-heavy client model (like punktfunk-client-linux): a new cdylib crate
crates/punktfunk-android links punktfunk-core and exposes the JNI seam;
Kotlin (clients/android) owns only the Android-framework surface. Kotlin can't
import the C header the way Swift can, so the bridge is written in Rust to reuse
the Linux client's orchestration rather than re-port it.

- crates/punktfunk-android: JNI bridge — abiVersion/coreVersion native-link
  proof + session connect/close handle; plane pumps stubbed for M4 stage 1.
- clients/android: Gradle project — :app (Compose) + :kit (Android library with
  a cargo-ndk Exec task -> jniLibs). AGP 9.2 / Gradle 9.4.1 / Kotlin 2.3.21 /
  Compose BOM 2026.05.01 / compileSdk 37 / targetSdk 36 / minSdk 31, shipping
  arm64-v8a + x86_64. Phone + TV (leanback) installable. README rewritten.
- .gitea/workflows/android.yml: CI mirroring apple.yml on a Linux runner.
- punktfunk-core: switch rcgen to the ring backend so the whole quic tree is
  aws-lc-free (smaller client .so, cmake-free cross-compile; a win for all targets).

Validated on this box: :app:assembleDebug -> APK with both ABIs; emulator
first-light renders the bridge linked (core ABI v2) with logcat confirmation;
clippy -D warnings + cargo fmt clean; core tests green on the ring backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:38:35 +02:00
enricobuehler a59abe2e3e fix(apple/gamepad): reclaim the PS/Home button from the macOS system gesture
ci / docs-site (push) Successful in 31s
ci / rust (push) Successful in 6m30s
deb / build-publish (push) Successful in 3m58s
ci / web (push) Successful in 27s
apple / swift (push) Successful in 1m16s
ci / bench (push) Successful in 1m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m13s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m18s
docker / deploy-docs (push) Successful in 17s
The earlier buttonHome handler wasn't enough: on macOS the SYSTEM grabs the DualSense
Home/PS button by default (opens Launchpad's Games folder), so it never reached the app.
The fix is to disable the system gesture on the element —
`physicalInputProfile.buttons[GCInputButtonHome].preferredSystemGestureState = .disabled`
(Apple's documented mechanism) — which hands the button to us.

Then drive `guide` DIRECTLY from that element's pressedChangedHandler instead of via
buttonMask: the legacy `extendedGamepad.buttonHome` is unreliable/often nil even when the
physical element exists, so reading it in the mask dropped presses. `sendGuide` folds the
bit into `buttons` so a held PS button still releases on focus loss. On tvOS the element
is reserved (nil) → the block no-ops.

The host already maps BTN_GUIDE → the DualSense PS bit, so this completes the chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:05:24 +00:00
enricobuehler 36107018a8 feat(apple/library): mTLS — authenticate by the paired identity, drop the token
apple / swift (push) Successful in 1m16s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 29s
ci / bench (push) Successful in 1m40s
ci / rust (push) Successful in 6m42s
deb / build-publish (push) Successful in 3m50s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m22s
docker / deploy-docs (push) Successful in 17s
Phase 3: the Apple library now talks to the host's HTTPS mgmt API (b4a85a8) over mTLS
using this client's persistent identity — the SAME cert the host paired over QUIC — so
there is NO manual token anymore.

- ClientTLS: builds a SecIdentity from the stored PEM (CryptoKit parses the rcgen P-256
  PKCS#8 key → x963 → SecKey; the cert PEM → SecCertificate; SecIdentityCreateWithCertificate
  pairs them via the Keychain). macOS-only for now (that API is unavailable on iOS — a
  PKCS#12 path would be needed there; the client is macOS-first).
- LibraryTLSDelegate: pins the host's self-signed cert by the fingerprint the client
  already trusts, and presents the identity for the client-cert challenge.
- LibraryClient.fetch now does GET https://…/library with the identity + host fingerprint;
  the whole connection form (port + token) and StoredHost.mgmtToken/setMgmt are gone — the
  library "just works" for a paired host. 401 → "pair with the host first".

Can't compile Swift on the Linux box; CI (apple.yml) compiles the macOS path incl. the
Security/CryptoKit code. Runtime (SecIdentity build + the mTLS handshake) needs Mac
validation. Pairs with the host mTLS already landed + live-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:47:19 +00:00
enricobuehler 8c2e245c8b fix(apple/cursor): disable the client-side cursor (gamescope traps input)
ci / docs-site (push) Successful in 31s
ci / web (push) Successful in 29s
apple / swift (push) Successful in 1m16s
ci / rust (push) Successful in 2m9s
ci / bench (push) Successful in 1m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 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
deb / build-publish (push) Successful in 2m24s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m54s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m26s
The client-side cursor positions the host pointer with ABSOLUTE events, but
gamescope's input socket (EIS) grants only a relative pointer — the host drops the
absolute events (libei.rs: no PointerAbsolute → not emitted), so the pointer never
moves and clicks/scroll land on the stuck position. Auto-mode enabled exactly this on
gamescope, making all input appear dead until toggled off.

Force `cursorVisible = false`, neuter the ⌘⇧C toggle, and hide the now-inert Settings
picker. The resolution logic + handlers are kept (commented) for when per-compositor
gating (KWin/GNOME/Sway have an absolute pointer) or a synthetic-cursor-over-relative
path lands. Relative capture (the working path) is now always used.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:14:57 +00:00
enricobuehler 36a04e667c fix(apple): capture the PS/Home button + fullscreen only while streaming
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m16s
ci / bench (push) Successful in 1m34s
ci / rust (push) Successful in 2m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m26s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m53s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m21s
Two issues from live Mac testing, plus a requested fullscreen option:

- PS button: the Home/PS button (→ guide; the host maps it to the DualSense PS bit)
  does not reliably fire GCExtendedGamepad.valueChangedHandler on macOS, so its presses
  were dropped. Add a dedicated buttonHome.pressedChangedHandler that re-syncs. The host
  already maps BTN_GUIDE→PS, so this is the missing client half.
- Fullscreen: a macOS FullscreenController (NSViewRepresentable) takes the window
  fullscreen while a session is up (incl. the trust prompt over the blurred stream) and
  restores it on the host list — so only the stream is fullscreen, not the picker. New
  `fullscreenWhileStreaming` setting (default on) + a Settings "Window" toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:14:37 +00:00
enricobuehler 5706e7ebf4 feat(apple/library): launch a picked title (step 4 client side)
apple / swift (push) Successful in 1m17s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 30s
ci / rust (push) Successful in 2m2s
ci / bench (push) Successful in 1m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (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 4s
deb / build-publish (push) Successful in 2m4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m13s
docker / deploy-docs (push) Successful in 17s
Tapping a game in the (flagged) library now starts a session that asks the host to
launch it — the picked GameEntry id rides the connect down to the host, which resolves
it against its own library (27e5865).

- PunktfunkConnection.init gains `launchID` and calls the new punktfunk_connect_ex4
  (wrapping it in withOptionalCString; nil = host default).
- Threaded SessionModel.connect(launchID:) → ContentView.connect(_:launchID:) →
  a `launchTitle(host, id)` helper that dismisses the browser and connects.
- LibraryView gains `onLaunch`; cards become buttons that fire it. Wired on every
  platform (ContentView sheet on macOS/iOS, HomeView destination on tvOS) via a new
  `onLaunchTitle` closure on HomeView. Settings footer updated (launch is live now).

Can't compile Swift on the Linux box; CI (apple.yml) verifies. The host side of this
chain is live-validated on the dev box: a client `--launch custom:<id>` made the host
resolve the id and spawn gamescope running the title (see 27e5865).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:00:58 +00:00
enricobuehler 1b610d6bf5 feat(apple/library): experimental game-library browser (flagged off)
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m15s
ci / rust (push) Successful in 2m4s
ci / bench (push) Successful in 1m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m23s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m55s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m28s
Plan step 3 — the Apple client surfaces the host's game library, behind a feature
flag (`DefaultsKey.libraryEnabled`, default OFF). Browsing only; launching a chosen
title is step 4.

- PunktfunkKit `LibraryClient`: Codable GameEntry/Artwork/LaunchSpec mirroring
  crates/punktfunk-host/src/library.rs, and an async fetch of GET /api/v1/library
  with a bearer token. Typed LibraryError guides setup (the common case is "needs a
  --mgmt-token"). `Artwork.posterCandidates` = portrait → header → hero.
- `LibraryView`: cross-platform poster grid (LazyVGrid, AsyncImage that walks the art
  candidates past load failures to a text placeholder), a store badge, and an inline
  Connection form (mgmt port + token) that surfaces when the API is unreachable / 401
  / no token set. Read-only.
- StoredHost gains `mgmtPort`/`mgmtToken` (the mgmt API is a distinct port from the
  data plane and needs a token off-loopback). Both OPTIONAL — synthesized Decodable
  ignores property defaults but treats a missing Optional as nil, so older saved
  hosts decode unchanged (a defaulted non-optional would wipe the list). HostStore.setMgmt.
- Entry point: a flag-gated "Browse Library…" host-card context action → LibraryView
  (sheet on macOS/iOS, pushed on tvOS), mirroring the pair/speed-test plumbing. Plus a
  Settings "Experimental" toggle.

Can't compile Swift on the Linux dev box; CI (apple.yml: swift build + swift test on
the mac mini) verifies the macOS path. Added LibraryClientTests (decode + art order)
for `swift test`. iOS/tvOS-only branches mirror existing patterns. Live-verify on the
Mac pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:28:16 +00:00
enricobuehler b3f98a5d7d feat(clients/decky): SteamOS Gaming-Mode launcher plugin (spike)
ci / rust (push) Successful in 2m7s
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 1m15s
ci / bench (push) Successful in 1m35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m52s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m23s
A Decky Loader plugin so a Steam Deck / SteamOS box can launch the punktfunk
client from Gaming Mode using REAL Steam UI components (it runs inside Steam's
CEF, so the panel is built from @decky/ui — the literal Big Picture primitives,
not a replica).

- Frontend (src/index.tsx, @decky/api + @decky/ui): a Quick Access Menu panel —
  Refresh → discover hosts, a native list (name, ip:port, pairing flag), tap to
  connect with a status toast, Disconnect.
- Backend (main.py): discover() shells `avahi-browse -rpt _punktfunk._udp` and
  parses the host's advertised TXT keys (proto/fp/pair/id from discovery.rs),
  dedup by id preferring IPv4; connect() resolves + spawns
  `punktfunk-client --connect host:port` (gamescope composites its video like a
  game), tracking the child; disconnect() terminates it.
- Mirrors the current official Decky template (the API moved to @decky/ui +
  @decky/api). Frontend builds clean (pnpm build → dist/index.js); main.py
  py_compiles. dist/ + node_modules gitignored — build on the Deck per README.

Spike scope: launcher only, runtime untested (no Deck here). Next on this track:
the in-stream Quick-Access overlay (volume/disconnect/stats over the running
stream) and a fuller real-components UI. Client decode on the AMD Deck is the
existing VAAPI path; the host-encode VAAPI gap is separate (NVIDIA host = NVENC).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:50:57 +00:00
enricobuehler c64816c70a feat(apple): client-side cursor for gamescope sessions (toggle + shortcut)
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 31s
apple / swift (push) Successful in 1m14s
ci / rust (push) Successful in 2m9s
ci / bench (push) Successful in 1m42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 2m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m51s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m24s
gamescope's PipeWire capture carries no cursor (verified upstream — it never
composites the cursor or adds SPA_META_Cursor), so the cursor must be drawn on the
client. New macOS "cursor-visible" capture mode: instead of disassociating+hiding
the system cursor and sending relative deltas (the game path, unchanged), it keeps
the system cursor visible over the stream and sends ABSOLUTE positions
(MouseMoveAbs), mapped through the video's aspect-fit (AVMakeRect) to host pixels
with the letterbox bars dropped. The visible system cursor IS the client cursor —
zero added latency, no double cursor (gamescope draws none), accurate (the client
drives the host's absolute mouse).

- Default: on iff the session's resolved compositor is gamescope (via the new
  punktfunk_connection_compositor getter, fc30307).
- Settings: "Cursor in stream" → Auto (gamescope) / Always / Never.
- Shortcut: ⌘⇧C toggles it live mid-session (re-engages capture so disassociation
  + abs/rel forwarding swap atomically); shown in the HUD.

macOS-only (the visible-cursor mode lives in the macOS StreamView). Verified to
compile + link via xcodebuild Release on the Mac; runtime behavior (cursor landing,
hover forwarding) to be confirmed live. Rust ABI side committed separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:07:15 +00:00
enricobuehler b140cd6837 feat(apple/macos): App Sandbox + entitlements, wire Mac App Store TestFlight
ci / bench (push) Successful in 1m33s
apple / swift (push) Successful in 1m15s
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 30s
ci / rust (push) Successful in 2m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
deb / build-publish (push) Successful in 2m1s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m7s
docker / deploy-docs (push) Successful in 17s
The Mac App Store requires App Sandbox, which the macOS app didn't declare.
App Sandbox is macOS-only (invalid on iOS/tvOS, fails upload validation), so
the macOS target now uses a dedicated Config/Punktfunk-macOS.entitlements while
iOS/tvOS keep the shared Config/Punktfunk.entitlements (unchanged). The single
macOS app is sandboxed for BOTH channels — the Developer ID DMG is codesigned
with the same file — so the local build equals what App Store users get.

Entitlement set (verified against the code + Apple docs):
- app-sandbox, network.client.
- network.server: NOT optional despite the client being outbound-only — the
  sandbox gates the bind() syscall as network-bind, and quinn (quic.rs) + the
  raw-UDP plane (transport/udp.rs) both bind explicitly, so host->client
  datagrams never arrive without it (the classic QUIC-under-sandbox trap).
- device.audio-input (mic uplink), device.bluetooth + device.usb (Xbox/DualSense
  controllers over BT/USB via GameController), keychain-access-groups (existing).
Omitted: device.hid (undocumented), files.user-selected.* (no pickers),
networking.multicast (Bonjour browse is exempt; requesting it breaks signing).

CI (release.yml): add a macOS App Store archive+upload-to-TestFlight step
mirroring the iOS lane (manual Apple Distribution signing + the 'Punktfunk macOS
App Store Distribution' profile, app-store-connect/upload, installer-signed pkg),
continue-on-error until the portal prereqs exist; point the Developer ID DMG
codesign at the sandboxed entitlements. Docs (ci.md) + clients/apple README
updated; the runner additionally needs the macOS platform on the App Store
Connect record + the '3rd Party Mac Developer Installer' cert.

Verified: signed Debug build embeds exactly the intended entitlements
(codesign -d --entitlements), swift build green against the rebuilt xcframework.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:39:06 +02:00
enricobuehler c2ae40ef9e feat(net/mac): default-on recvmsg_x batched Mac recv + GSO host + longer probe
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 31s
ci / rust (push) Successful in 2m6s
ci / bench (push) Successful in 1m35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
apple / swift (push) Successful in 1m17s
docker / deploy-docs (push) Successful in 17s
deb / build-publish (push) Successful in 2m18s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m27s
The Mac/iOS client's wall around ~380 Mbps on a 2.5 G path is the receive
drain, not the transport: a loopback speed-test pushes 380/600/1000 Mbps at
0.0% loss, but Darwin has no recvmmsg(2), so the macOS client was doing one
recv() syscall per packet — ~40-90k syscalls/s on one core. When the recv loop
can't drain fast enough the kernel socket buffer backs up and drops, which the
client sees as a sustained stream stalling/freezing in the 300-400 Mbps range
(and an immediate "session ended" when a 500 Mbps+ first keyframe bursts in).

- core/transport: flip recvmsg_x (the batched Darwin recv, ~30x fewer syscalls)
  from opt-in to default ON, opt-out via PUNKTFUNK_RECVMSG_X=0. Keeps the
  auto-fallback to the scalar loop on any unexpected syscall error. The Apple CI
  swift-test loopback now exercises this path by default.
- packaging/kde host.env: enable PUNKTFUNK_GSO=1 — UDP segmentation offload on
  the host send path (one sendmsg per ~64 packets), the dominant lever above
  ~1 Gbps. Already wired (send_sealed -> send_gso) with sendmmsg auto-fallback.
- apple SpeedTestSheet: lengthen the bandwidth probe 2 s -> 5 s so the measured
  number stops swinging wildly (50 vs 900 Mbps on the same link) — long enough
  for steady-state send + recv drain to settle. Matches host MAX_PROBE_MS.
- host capture: PUNKTFUNK_SYNTH_NOISE synthetic high-entropy source for
  reproducible throughput testing of the encode->FEC->send->recv path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:35:26 +00:00
enricobuehler 47112f44b7 feat(apple): surface host online status on the home grid
ci / web (push) Failing after 36s
ci / docs-site (push) Failing after 39s
ci / rust (push) Successful in 1m19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
apple / swift (push) Successful in 1m24s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m52s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 5m25s
Saved host cards now show a presence dot — green when the host is advertising on
the LAN right now, grey when not seen. Cross-references each StoredHost against the
live mDNS discovery set (HostDiscovery). No host changes: the host already
advertises _punktfunk._udp with a stable id + cert fingerprint, which the client
already browses.

- StoredHost.matches(DiscoveredHost): fingerprint-first (survives a DHCP address
  change), address:port fallback. The discovered-section dedup now uses the same
  match, so a saved host whose IP changed no longer also shows up as a stranger.
- HostCardView gains an isOnline presence dot (accessibility-labelled).
- HomeView.isOnline recomputes on every @Published discovery change, so the dot
  tracks hosts joining/leaving the network live.

Online detection is LAN-scoped by design: a remote/cross-subnet host that doesn't
advertise here shows grey ("not seen"), not a false "offline". Swift-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:32:57 +02:00
enricobuehler 67a32711b3 chore(apple): Xcode 27 project upgrade + hardened runtime
apple / swift (push) Failing after 27s
ci / web (push) Failing after 9s
ci / docs-site (push) Failing after 44s
ci / rust (push) Failing after 1m15s
deb / build-publish (push) Failing after 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 36s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 57s
Applied via Xcode's recommended-settings upgrade and distribution prep:
- LastUpgradeCheck / scheme LastUpgradeVersion 2650 -> 2700.
- DEVELOPMENT_TEAM (F4H37KF6WC) hoisted to the project-level build configs; the
  now-redundant per-target copies are dropped (all targets inherit it).
- ENABLE_HARDENED_RUNTIME = YES on the macOS app target (required for Developer ID
  notarization). Signing stays Apple Development + Config/Punktfunk.entitlements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:09:16 +02:00
enricobuehler 4be993df87 fix(apple/stage2): disable layer vsync wait to kill fullscreen stutter
apple / swift (push) Failing after 28s
ci / web (push) Failing after 47s
ci / rust (push) Failing after 1m19s
ci / docs-site (push) Failing after 33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 13s
deb / build-publish (push) Failing after 44s
The experimental stage-2 presenter (CAMetalLayer + display link) stuttered badly
in fullscreen but ran fine windowed. render() runs on the display-link / MAIN
thread and calls layer.nextDrawable(), which blocks that thread until a drawable
frees. With the layer's own displaySyncEnabled left on (default), present also
waits for the hardware vsync, so the block serializes the main thread to the
display — windowed, the WindowServer's looser compositing hides it; fullscreen's
tighter, more-direct path exposes it as judder. (Apple dev-forum guidance:
displaySync off measurably reduces nextDrawable() blocking.)

- displaySyncEnabled = false (macOS-only): the display link is already the per-
  vsync pacing source, so the layer's redundant vsync wait only adds the stall.
- maximumDrawableCount = 3 (explicit): more in-flight headroom before
  nextDrawable() has to block on the main thread.

Swift-only (no core/ABI change → no xcframework rebuild). Validated: swift build;
swift test (39 passed, 0 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:07:57 +02:00
enricobuehler c56b1b455a feat(punktfunk/1): request-IDR recovery for a wedged client decode
apple / swift (push) Successful in 1m17s
ci / rust (push) Failing after 31s
ci / web (push) Failing after 42s
ci / docs-site (push) Failing after 40s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (push) Failing after 15s
deb / build-publish (push) Failing after 43s
Fixes the intermittent first-connect freeze. The host streams infinite GOP — one
opening IDR, then P-frames only (recovery keyframes just on loss) — so when the
client's decoder wedges on the cold first session (a lost/corrupt opening IDR, a
bad early P-frame) the picture stays frozen until the far-off next keyframe. The
client had no way to ask for one; now it does.

Add a RequestKeyframe control message (client -> host, reliable control stream),
mirroring Reconfigure:
- core: quic.rs RequestKeyframe (type 0x03) + roundtrip test; client.rs
  CtrlRequest::Keyframe + NativeClient::request_keyframe; abi.rs
  punktfunk_connection_request_keyframe (header regenerated).
- host: m3.rs decodes it in the control loop and signals the encode loop, which
  coalesces a burst and calls enc.request_keyframe() — wiring the existing
  NvencEncoder hook (force_kf -> next frame pict_type=I), the same recovery the
  GameStream path already had via force_idr.
- apple: PunktfunkConnection.requestKeyframe(); StreamPump (stage-1) requests on
  layer.status==.failed; Stage2Pipeline (stage-2) on a sync submit failure and on
  the async decode-error callback via a thread-safe KeyframeRecovery. All
  throttled to <=1/250ms (the decode stays wedged for several frames until the IDR
  lands, so per-frame requests would flood the control stream).

Self-healing: a lost recovery IDR is re-requested after the throttle; the host
coalesces bursts into a single IDR.

Validated: cargo fmt + clippy clean; core + host test suites green (incl. new
request_keyframe_roundtrip); swift build + test (39 passed); xcframework rebuilt
(all 5 slices), header regenerated with no unrelated drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:48:24 +02:00
enricobuehler e2257a6158 fix(apple): persist Keychain trust — sign macOS + data-protection keychain
ci / web (push) Failing after 34s
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m17s
ci / rust (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / deploy-docs (push) Successful in 19s
deb / build-publish (push) Failing after 2m19s
The client identity prompted for Keychain access on every launch/rebuild. Root
cause: the macOS app target was ad-hoc signed (CODE_SIGN_IDENTITY = "-"), and
the identity lived in the file keychain whose "Always Allow" ACL is bound to the
app's exact code signature (cdhash for ad-hoc). Every rebuild changed the binary
-> changed the cdhash -> the ACL no longer matched -> re-prompt.

- Sign the macOS target with Apple Development (team already set) instead of
  ad-hoc, so the designated requirement is identity-based and stable across
  rebuilds.
- Move the identity to the data-protection keychain (kSecUseDataProtectionKeychain)
  gated by a team-scoped keychain-access-group entitlement — access is granted by
  the app's entitlement, not a per-binary ACL, so it's prompt-free and survives
  rebuilds. Add Config/Punktfunk.entitlements and wire CODE_SIGN_ENTITLEMENTS into
  all six app configs (macOS/iOS/tvOS).
- Unsigned / ad-hoc builds (e.g. `swift run`) lack the entitlement
  (errSecMissingEntitlement) — fall back to the legacy file keychain so they still
  work (with the old prompt), no hard failure.

macOS re-mints the identity on first run (the old file-keychain copy isn't in the
data-protection keychain) -> one re-pair, which is acceptable. iOS keeps its
identity (the explicit access group equals the prior default).

Validated: swift build; swift test (39 passed, 0 failures); xcodebuild
-showBuildSettings confirms Apple Development + Config/Punktfunk.entitlements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:25:51 +02:00
enricobuehler dea749186d fix(quic/apple): QUIC keep-alive + reconnect input re-engage
ci / rust (push) Failing after 36s
ci / web (push) Failing after 51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m16s
docker / deploy-docs (push) Successful in 17s
Three native-client bugs isolated against a stock Moonlight client (which
stays connected / keeps input working under the same actions):

- Connection drops mid-stream: the quinn endpoints (host + client) ran with
  default transport config, so keep_alive_interval was OFF. Any quiet stretch
  (no input, audio muted/stalled, a capture hiccup, a mode change) let the
  idle timer expire and quinn closed the session -> next_au=Closed -> "Session
  ended". Moonlight's ENet sends keepalive pings; we sent nothing. Add a shared
  TransportConfig (keep-alive 4s under an explicit 20s idle timeout) to both
  endpoint::server_from_der and endpoint::client_pinned_with_identity.

- Reconnect input dead (macOS): the session-start auto-capture one-shot was
  consumed even when engageCapture(fromClick:false) was refused (window not key
  yet at the instant of reconnect), with no retry -> capture stayed off and
  input never forwarded. Clear the one-shot only on a successful engage, and
  retry on NSWindow.didBecomeKey. Stays scoped to session start, so it does not
  resurrect the rejected auto-grab-on-activation behavior.

- Reconnect input dead (iOS): wasCapturedOnResign leaked stale state across
  sessions and the foreground-restore could fire before this session's
  InputCapture was wired (setForwarding no-ops on nil). Reset it per session in
  start() and guard the didBecomeActive restore on inputCapture != nil.

Validated: cargo build -p punktfunk-core --features quic; swift build;
swift test (39 passed, 0 failures); xcframework rebuilt (all 5 slices), no
ABI/header drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:07:54 +02:00
enricobuehler 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 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 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 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