The app grows from a dev connect form into a real client shell: - Home is a grid of saved hosts (UserDefaults-persisted; context menu: Remove / Forget Identity), "+" in the toolbar opens the add-host sheet, the stream mode moved into Settings (⌘, / gear) — native resolution stays the only mode, no scaling. - Trust is now explicit: the protocol always supported certificate pinning, but the app passed no pin and discarded the observed fingerprint — silently trusting any host. First connect now shows the host's SHA-256 fingerprint (compare with the "clients pin this fingerprint" line in the host log) over the live-but-blurred stream; the stream must pump immediately (the opening IDR is the only guaranteed one), so StreamView gains a capturesCursor switch to keep the cursor free while the prompt needs clicking, and input capture starts only after confirmation. Trusting pins the fingerprint per host; a changed host identity then refuses to connect. - PUNKTFUNK_AUTOCONNECT keeps working (auto-trusts, doesn't touch the saved hosts). Host→client authorization (pairing PIN) remains a punktfunk-core roadmap item — the host still accepts any client that can reach its port. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.8 KiB
punktfunk Apple client (SwiftUI)
The native macOS/iOS client for punktfunk/1 (the post-GameStream protocol). All
networking/protocol work — QUIC control plane, UDP data plane, GF(2¹⁶) FEC, AES-GCM,
input datagrams, Opus audio, cert pinning — lives in the shared Rust core (statically
linked as PunktfunkCore.xcframework); this package is the Swift shell: decode
(VideoToolbox), present (SwiftUI), input capture.
Status — first light achieved (2026-06-10)
Validated live, Mac ↔ Linux box over the LAN: gamescope virtual output → NVENC HEVC →
punktfunk/1 (GF(2¹⁶) FEC + AES-GCM over UDP, QUIC control) → VideoToolbox →
AVSampleBufferDisplayLayer on glass at 1280×720@60, with mouse/keyboard flowing back as
QUIC datagrams into the host's gamescope EIS injector (thousands of events injected during
the session). Headless variant of the same proof: RemoteFirstLightTests decoded 60/60
received AUs spanning 983 ms of host capture clock.
The connector underneath (punktfunk_core::client::NativeClient over the C ABI) carries the
full session: video AUs, Opus audio (nextAudio()), rumble (nextRumble()),
input incl. gamepads, and cert pinning + TOFU (pinSHA256:/hostFingerprint) — see
m3.rs::tests::c_abi_connection_roundtrip (three sequential sessions: TOFU, pinned
reconnect, wrong-pin rejection). The host (punktfunk-host m3-host) is a persistent listener:
reconnect at will during development.
What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
PunktfunkKit(library)PunktfunkConnection.swift— wrapper over the C ABI. AUs/audio are copied intoData(the C pointer is only valid until the next call of the same kind).close()is safe from any thread: per-plane locks enforce the C contract ("never close with anext_au/next_audioin flight") instead of leaving it to callers. Pinning + TOFU viapinSHA256:/hostFingerprint.AnnexB.swift— in-band VPS/SPS/PPS →CMVideoFormatDescription; Annex-B → AVCCCMSampleBufferwithDisplayImmediatelyset.StreamView.swift— SwiftUINSViewRepresentableoverAVSampleBufferDisplayLayer(stage-1 presenter: the layer hardware-decodes compressed HEVC itself). One pump thread per view, token-cancelled so reconnects can't double-pump.InputCapture.swift—GCMouseraw deltas +GCKeyboardHID→VK mapping (the host'svk_to_evdevconsumes Windows VKs), with fractional-delta accumulation so sub-pixel motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2); scroll is WHEEL_DELTA(120)-scaled.
PunktfunkClient(the app): hosts grid (saved in UserDefaults), "+" toolbar sheet to add hosts, stream mode in Settings (⌘,), trust-on-first-use fingerprint prompt over the live-but-blurred stream → pinned reconnects, fps/Mb-s HUD. (Audio playback and gamepad capture are not wired into the app yet — the connector surface is there; see notes 5–6.)- Tests (
swift test): byte-level Annex-B units; a real-codec round trip (VTCompressionSession-encoded HEVC rebuilt as the host's wire shape →AnnexB→ VTDecompressionSession → pixels); loopback integration against a real local host (test-loopback.sh); the remote first-light test above.
Build / run / test (on a Mac)
rustup target add aarch64-apple-darwin x86_64-apple-darwin
bash scripts/build-xcframework.sh # → clients/apple/PunktfunkCore.xcframework
cd clients/apple
swift build && swift test # loopback/remote tests self-skip without a host
swift run PunktfunkClient # the unbundled dev shell (CLI)
open Punktfunk.xcodeproj # the real app: ⌘R builds + runs Punktfunk.app
bash test-loopback.sh # full loopback proof: builds punktfunk-host
# (synthetic source — runs on macOS), streams
# byte-verified frames into the Swift client
# against the real host (Linux box, see CLAUDE.md "Running on this box") — m3-host is a
# persistent listener, reconnect at will:
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
# cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 60
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkClient # on glass
Xcode project (Punktfunk.xcodeproj)
The app target Punktfunk wraps the same sources as the swift run shell
(Sources/PunktfunkClient, a synchronized folder — no duplication) plus App/ (asset
catalog) and links PunktfunkKit from the local package. Generated Info.plist, ad-hoc
signing, bundle id io.unom.punktfunk. Notes:
- App icon:
App/Assets.xcassetsships an emptyAppIconslot. For an Icon Composer.icon: add the file to the project (target Punktfunk), set it as the App Icon in the target's General tab, and delete the placeholderAppIcon.appiconset. Heads-up: CLIactool(Xcode 26.5) crashed compilingpunktfunk_Logo.icon— if Xcode does the same, suspect the icon bundle (it has a duplicate-named layer, "…Layer-3 2.svg"), not the project. - Tests from Xcode: the package tests run with
swift test; to get them on ⌘U, addPunktfunkKitTestsonce via Edit Scheme → Test → + (Xcode persists it into the shared scheme — a hand-written package-test reference doesn't resolve headlessly). xcodebuild -project Punktfunk.xcodeproj -scheme Punktfunk buildworks headlessly.
Notes for whoever picks this up next
- cbindgen import quirk (the predicted "small compile fixes", now fixed): the
C17-compatible header spells
PunktfunkStatus/PunktfunkInputKindas integer typedefs while the enum constants import into Swift as a distinct same-named type — bridge with.rawValue(see the top ofPunktfunkConnection.swift). Don't fight the generated header. - ABI contract: one video pump thread per connection, plus optionally one separate
audio drain thread for
nextAudio()/nextRumble()(the core keeps per-plane borrow slots, so the planes never alias);send()is enqueue-only and safe alongside all of them. The wrapper's per-plane locks makeclose()safe from anywhere (it waits out in-flight polls, ≤ their timeouts). - Decode flow: the host opens every stream with an IDR carrying VPS/SPS/PPS in-band
and recovery keyframes re-send them — "refresh the format description on every IDR"
(what
StreamViewdoes) is sufficient; there is no out-of-band extradata, ever. - Stage 2 (next): explicit
VTDecompressionSession+CAMetalLayerfor frame-pacing control (ProMotion/120 Hz), glass-to-glass measurement viatools/latency-probe(the host stampspts_nswith its capture wall clock; across machines you need a clock offset estimate from the QUIC RTT). - Audio:
nextAudio()yields raw Opus packets (48 kHz stereo, one 5 ms frame each, sequence-numbered). Decode with libopus orAVAudioConverter/kAudioFormatOpusinto anAVAudioEnginesource node; conceal gaps (drop/dup) rather than blocking — the Rust side buffers 320 ms and drops the newest packet when the puller lags. Wall-clockptsNsshares the host clock with video AUs for A/V sync. Wiring this intoPunktfunkClientis the next app-side task. - Gamepads:
GCController→.gamepadButton(...)/.gamepadAxis(...)events (wire contract documented on the constructors; the host accumulates them into a virtual Xbox 360 pad). PollnextRumble()and feedGCDeviceHapticsfor force feedback. Client-side capture isn't inInputCaptureyet. - Trust: connect once with
pinSHA256: nil(TOFU), persisthostFingerprintkeyed by host, pass it on every later connect — a mismatch throws.connectFailed. The host logs its fingerprint at startup ("clients pin this fingerprint") for out-of-band verification UX; a PIN-style pairing ceremony is a later punktfunk-core task.PunktfunkClientimplements exactly this: explicit fingerprint confirmation on first connect (input/cursor capture held back until confirmed), pin stored per host (HostStore), "Forget Identity" in the card's context menu for legitimate host reinstalls. Note the OTHER direction is still open: the host authorizes no one — any client that reaches the port gets a session (fine on a LAN, not on the internet). - Input capture caveats (stage 1): GC handlers only fire while the app has focus —
on focus loss
InputCaptureauto-releases everything still held (keys + buttons) so nothing sticks down host-side. While the stream has focus the LOCAL cursor is hidden and frozen mid-view (CursorCapturein StreamView.swift — the host renders its own cursor; the local one diverges from it and a stray click would focus another app); Cmd+Tab frees it, ⌘D disconnects. Local shortcuts (⌘-anything) still also reach the host; a capture toggle is a small follow-up. One live capture per process (the GC mouse/keyboard singletons have a single handler slot — ownership is tracked so a stale capture's stop() can't clobber a newer one). - iOS: same package (
BUILD_IOS=1for the xcframework slice);StreamViewneeds theUIViewRepresentabletwin and touch→input mapping.
Known limitations of the current host (relevant to client UX)
- One session at a time (the listener is persistent, but a second concurrent client waits in the accept queue until the current session ends — the virtual output and encoder are single-tenant).
- Mid-stream renegotiation (resolution change without reconnect) is designed-for but not implemented (the Welcome is one-shot today).
- Host-side gamepad injection needs
/dev/uinputaccess on the box (udev rule fromdocs/linux-setup.md).