The file moves (docs/ → design/, docs/api/openapi.json → api/openapi.json) landed
in d01a8fd, but the matching reference updates did not — so mgmt.rs's drift-test
`include_str!("../../../docs/api/openapi.json")` pointed at a path that no longer
exists and the host failed to build. This restores it and updates every reference:
- mgmt.rs include_str! → ../../../api/openapi.json (fixes the build)
- web/orval.config.ts codegen target, web/Dockerfile, .dockerignore
- deb/rpm/Arch packaging install paths
- CLAUDE.md, the .gitea CI workflows, code doc-comments, design-doc cross-links
docs-site route URLs (/docs/...) untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
27 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 — working client (macOS, with iOS / tvOS in the shared build)
A full streaming client: VideoToolbox HEVC decode, controllers incl. DualSense feedback, host
discovery, PIN pairing, and a network speed test. The lower-latency stage-2 presenter
(VTDecompressionSession → CAMetalLayer) is built and opt-in (Settings → Presenter); see below.
First light was achieved 2026-06-10 — validated live, Mac ↔ a Linux host 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()),
DualSense feedback (nextHidOutput() — lightbar, player LEDs, adaptive-trigger
effects), input incl. gamepads + DualSense touchpad/motion (sendTouchpad/sendMotion),
and cert pinning + TOFU (pinSHA256:/hostFingerprint) — see
punktfunk1.rs::tests::c_abi_connection_roundtrip (three sequential sessions: TOFU, pinned
reconnect, wrong-pin rejection). The host (punktfunk-host punktfunk1-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: macOS via the stream view'sscrollWheeloverride, iPad via GCMouse's scroll dpad when pointer-locked and a scroll-onlyUIPanGestureRecognizerotherwise (trackpad gestures never reach GC's scroll dpad).GamepadManager.swift— app-lifetime controller discovery + selection (.shared): watchesGCControllerconnect/disconnect, fingerprints each pad for the Settings UI (name, capabilities, battery), and selects the ONE controller forwarded to the host (user pin via "Use controller", else most recently connected extended gamepad).GamepadCapture.swift— the active controller → wire: snapshot-diff overGCExtendedGamepadinto incrementalgamepadButton/gamepadAxisevents (pad 0), plus DualSense touchpad contacts and ~250 Hz motion samples on the rich-input plane (the GC→DualSense unit conversions live inGamepadWire, one place). Held state is released on the wire on controller switch / app deactivation / stop.GamepadFeedback.swift+DualSenseTriggerEffect.swift— host feedback → the real controller: one drain thread fornextRumble()(→CHHapticEngineper handle locality) andnextHidOutput()(lightbar →GCDeviceLight, player LEDs →playerIndex, adaptive-trigger effect blocks → a total, table-driven parser →GCDualSenseAdaptiveTrigger, exact for the 10-zone positional modes).HostDiscovery.swift— LAN auto-discovery: anNWBrowserover_punktfunk._udp(the host'scrate::discoverymDNS advert), resolving each service to an IP:port via a throwawayNWConnectionand parsing the TXT (fpadvisory cert fingerprint,pair, stableid). iOS/tvOS needNSBonjourServices(Config/Info.plist) or the system blocks the browse.
PunktfunkClient(the app): hosts grid (saved in UserDefaults) with an On this network section listing mDNS-discovered hosts (tap to save + connect, or pair if the host requires it), "+" toolbar sheet to add hosts manually, stream mode in Settings (⌘,), two trust flows — the trust-on-first-use fingerprint prompt over the live-but-blurred stream, and SPAKE2 PIN pairing (PairSheet, from a host card's context menu or the trust prompt;ClientIdentityStorekeeps the client identity in the Keychain and presents it on every connect) — then pinned reconnects, fps/Mb-s HUD + a capture→client-receipt latency line (LatencyMeter, p50/p95): the AUpts_ns(host capture clock) to the instant the client received it, skew-corrected across machines viaPunktfunkConnection.clockOffsetNs(the connect-time wall-clock handshake,punktfunk_connection_clock_offset_ns). It excludes the layer's decode+present (stage-1AVSampleBufferDisplayLayerhas no per-frame present callback); the opt-in stage-2 presenter (Settings → Presenter) adds a capture→present (glass-to-glass) line via explicit decode + a Metal/display-link present. Settings also picks the HOST compositor (KWin/wlroots/Mutter/gamescope, default automatic — the host honors it only if that backend is available there) and has a Controllers section: every detected controller (capability glyphs, battery, "In use" badge), which one to forward ("Use controller", default automatic), and the virtual pad type the host creates ("Controller type": Automatic / Xbox 360 / DualSense — Automatic matches the physical pad; resolved at connect time, the host pad is fixed per session). Gamepad capture + feedback run with streaming (SessionModelowns them, same trust gate as audio). Settings also sets the Bitrate (Automatic toggle = host default; manual is a log-scale slider, 2 Mbps – 3 Gbps, snapped to two significant figures — above 1 Gbps an inline warning says to run a speed test first; tvOS uses a preset picker instead, Slider doesn't exist there; negotiated via the Hello on every connect), and a host card's context menu offers "Test Network Speed…" (SpeedTestSheet): connects, has the host burst probe filler over the real data plane (up to the host's 3 Gbps probe ceiling for 2 s, roadmap §9), shows measured goodput · loss · a recommended bitrate (≈70% of measured), and applies it in one tap. The streaming statistics overlay can be turned off and moved to any corner (Settings → Display → Statistics,DefaultsKey.hudEnabled/hudPlacement), and toggled live with ⌘⇧S — a Scene-level "Stream" menu (StreamCommands) that also carries Disconnect ⌘D, so disconnect survives the HUD being hidden (on iOS a small exit chip appears instead; on tvOS the Siri-Remote Menu button still disconnects). The macOS Settings window is a tabbed preferences pane (General / Display / Audio / Controllers / Advanced) — the sections are shared with the iOS single-Form layout and the tvOS pushed-picker layout, defined once each.- 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); table-driven DualSense trigger-effect parsing (DualSenseTriggerEffectTests) and the gamepad wire conversions (GamepadWireTests); loopback integration against real local hosts (test-loopback.sh— stream round trip incl. gamepad/touchpad/motion sends, a host-scripted feedback burst asserted on the rumble + HID-output planes (PUNKTFUNK_TEST_FEEDBACK=1), the bitrate-negotiation echo and a real 20 Mbps bandwidth probe, plus the PIN pairing ceremony and the--require-pairinggate against a second, armed host); 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
# + BUILD_IOS=1 for the iOS slices (rustup target add aarch64-apple-ios{,-sim} x86_64-apple-ios)
# + BUILD_TVOS=1 for tvOS — TIER-3 Rust targets, built from source:
# rustup toolchain install nightly && rustup component add rust-src --toolchain nightly
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") — punktfunk1-host is a
# persistent listener, reconnect at will:
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
# cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 60
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… /
# PUNKTFUNK_REMOTE_PIN=<arming-pin> for the remote pairing test)
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:
- Entitlements (sandbox): the macOS target uses
Config/Punktfunk-macOS.entitlements; iOS/tvOS use the sharedConfig/Punktfunk.entitlements. The macOS app is App-Sandboxed (mandatory for the Mac App Store/TestFlight, and used for the Developer ID DMG too so the local build matches what ships):com.apple.security.app-sandbox,network.client+network.server(the sandbox gatesbind(); quinn + the raw-UDP plane both bind, so receive breaks without it),device.audio-input(mic),device.bluetooth+device.usb(GameController over BT/USB), and the existingkeychain-access-groups.app-sandboxis macOS-only — keep it OUT of the shared iOS/tvOS file (it fails upload validation there). Verify a build is sandboxed withcodesign -d --entitlements :- <built .app>. Heads-up:device.usbdraws some App Review scrutiny — justify it in the review notes ("reads input from USB game controllers"). - 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; same for-scheme Punktfunk-iOS -destination 'generic/platform=iOS Simulator'(run it in a simulator viaxcrun simctl install/launch—SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…passes the dev autoconnect env through).
App Store screenshots
Automated, faithful screenshots of the real UI for App Store Connect — one set per platform at
exactly the accepted pixel sizes. Driver: tools/screenshots.sh.
tools/screenshots.sh all # macOS + (if full Xcode) iOS, iPadOS, tvOS → ./screenshots
tools/screenshots.sh macos # just macOS
OUT=~/Desktop/shots tools/screenshots.sh ios ipad tvos
PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame behind the hero
How it works: the app has a DEBUG-only shot mode (Sources/PunktfunkClient/Screenshots/).
Launched with PUNKTFUNK_SHOT_SCENE=<name> it renders one mock-populated screen full-bleed
(ScreenshotHostView) instead of ContentView, then the OS screenshots the real, fully-rendered
window — screencapture on macOS, xcrun simctl io booted screenshot on the Simulators. The five
scenes (ShotScenes.all): 01-stream (the stream hero — a synthetic frame + the glass HUD, since
StreamView needs a live connection), 02-hosts, 03-pair, 04-trust, 05-settings. Mock data
is in ShotMock; nothing touches a host.
Output pixels are App Store Connect's required/largest sizes (Apple auto-derives the smaller ones):
mac 2880×1800 · iphone-6.9 1320×2868 (hero 2868×1320) · ipad-13 2064×2752 (hero 2752×2064) ·
appletv 1920×1080.
Why not ImageRenderer (the obvious offscreen route)? It can't rasterize this app's chrome —
NavigationStack, Form/TabView, and Liquid-Glass/NSVisualEffect materials all render black or
SwiftUI's "can't render" placeholder. Capturing the live window/Simulator avoids that entirely.
Requirements / gotchas:
- macOS: only the Swift toolchain is needed, plus a one-time Screen Recording grant for
your terminal (System Settings → Privacy & Security → Screen Recording) — without it
screencapture -lfails with "could not create image from window". (A no-permission fallback,PUNKTFUNK_SHOT_SELFCAPTURE=<dir>, usescacheDisplay— but it omits material blur and can't readScrollViewcontent, so it's for quick checks, not submission.) - iOS/iPadOS/tvOS: needs full Xcode (xcodebuild + Simulators), not just Command Line Tools,
and the matching device Simulators installed (iPhone 16 Pro Max, iPad Pro 13", Apple TV). Run it
on a full-Xcode Mac (e.g. the
macos-arm64CI mini). - The hero defaults to a synthetic synthwave frame — set
PUNKTFUNK_SHOT_HEROto a real captured frame for a production-quality lead screenshot.
CI: the apple workflow's screenshots job runs on the macos-arm64 runner on every main
push + manual dispatch (skipped on PRs), and attaches the result as a single zip artifact,
punktfunk-appstore-screenshots (download it from the run's Artifacts; upload-artifact@v3 —
Gitea's backend rejects v4). It captures the two required iOS sizes — iPhone 6.9" + iPad 13" —
on the Simulator (auto-creating the device if the runner lacks it), and is isolated from the
build/test job so a capture hiccup never reds the build.
macOS and tvOS are NOT in CI, by design: the self-hosted runner is headless (no
window-server session), so the macOS window capture can't run there, and tvOS needs the Tier-3
build-std slice. Generate those on a GUI Mac: tools/screenshots.sh macos tvos. (If the runner is
ever switched to a logged-in GUI session, re-adding macOS to the job's capture step is one line.)
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()and one feedback drain thread fornextRumble()/nextHidOutput()(the core keeps per-plane borrow slots, so the planes never alias; rumble + HID-output are two planes drained sequentially by the one feedback thread);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 — built, opt-in (
punktfunk.presenter == "stage2", default stage 1). ExplicitVTDecompressionSessiondecode (VideoDecoder) → aCAMetalLayer+ display-link present (MetalVideoPresenter/Stage2Pipeline), hosted as a sublayer by the sameStreamViews with input capture + HUD unchanged. It adds a capture→present (glass-to-glass, modulo the host render→capture term) HUD line, skew-corrected viaPunktfunkConnection.clockOffsetNs. The decode half is unit-tested (testVideoDecoderAsyncCallbackDeliversPixels); the Metal present is display-bound — validate live (flip the Settings "Presenter" picker, watch the HUD number and that the image looks right) before making it the default. 10-bit/HDR + a smoothing pacer are later. Plan:docs-site/content/docs/apple-stage2-presenter.md. - Audio — wired, both directions. Playback:
SessionAudiodrainsnextAudio()on its own thread, decodes through CoreAudio's built-in Opus codec (OpusCodec.swift— kAudioFormatOpus, no bundled libopus; round-trip unit-tested) into a priming jitter ring feeding anAVAudioSourceNode. Mic: a second engine taps the input device, resamples to 48 kHz stereo, Opus-encodes 20 ms chunks andsendMic()s them (the host's virtual PipeWire source accepts any frame size ≤ 120 ms). Speaker/mic are chosen in Settings (AudioDevices.swift— persisted by UID; "System default" leaves the engines unpinned so they follow macOS device changes), mic on/off toggle included; the app asks for mic permission on first use (NSMicrophoneUsageDescription is in the Xcode target). A/V sync and packet-loss concealment beyond silence-fill are still open (AudioPacket.seq/ptsNs carry what's needed). 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 — wired end to end. Exactly ONE controller (the
GamepadManagerselection) forwards as pad 0; the host accumulates the incremental events into a virtual pad whose TYPE the client negotiates in the Hello (gamepad:connect parameter, echoed resolved inresolvedGamepad— Automatic resolves from the physical pad at connect time; host precedence: explicit client choice > hostPUNKTFUNK_GAMEPADenv > Xbox 360). A DualSense session carries the full feel: adaptive-trigger blocks (DualSenseTriggerEffect.parse— mode bytes per the community convention (Nielk1/ds5w/inputtino), total, unknown →.off), lightbar, player LEDs, touchpad, motion. Motion scale constants (GamepadWire.gyroLSBPerRadS= 20 LSB per deg/s,accelLSBPerG= 10000) are derived from hid-playstation's math over the host's fixed calibration blob, not yet live-verified — if gyro/accel feel wrong in a real game, correct sign/scale inGamepadCapture.forwardMotion/GamepadWireandevtestthe host's virtual pad. Twin identical controllers share a fingerprint base, so a manual pin can swap between them across reconnects (documented in the Settings footer). - Trust — the full ceremony exists now (SPAKE2).
generateIdentity()once (persist both PEMs in the Keychain), thenpair(host:identity:pin:name:)with the 4-digit PIN the host prints when it ARMS pairing (--allow-pairing/--require-pairing; one PIN per arming window, surfaced in the host's web console — port 3000 → Pairing — and printed at startup; the user reads it before pairing). Returns the host's VERIFIED fingerprint; persist it and passpinSHA256:+identity:to every connect. Pairing is a real PAKE: a wrong PIN gets ONE online guess (no offline dictionary attack), throwing.wrongPIN; a wrong-size pin throws.invalidPin.PunktfunkClientimplements both flows: the TOFU fingerprint sheet keeps working against hosts not running--require-pairing, and the PIN ceremony is wired in —ClientIdentityStore(Keychain) on every connect,PairSheetfrom a host card's context menu or the trust prompt's "Pair with PIN instead…" (the host's accept loop is sequential, so that path drops the live session before pairing). With--require-pairingthe host now authorizes clients too (the "other direction" is no longer open, opt-in per host); the whole gate is regression-tested intestPairingCeremonyAndRequirePairingGate. 7b. Resize without reconnect:requestMode(width:height:refreshHz:)mid-stream — the host rebuilds at the new mode in ~90 ms; the first new-mode AU is an IDR with fresh parameter sets (the refresh-on-IDR decode flow handles it untouched) andcurrentMode()reflects the switch. Wire it to window-resize events. - Input capture (stage 1): capture is a deliberate, reversible STATE owned by
StreamLayerView, Moonlight-style. Engaged when the stream starts / trust is confirmed and when the user clicks into the video (that click is suppressed toward the host); released by ⌘⎋ (toggles) or focus loss; NEVER engaged by mere app activation — activating clicks may be title-bar drags or resizes, which used to get their cursor warped away mid-drag. While captured: the local cursor is hidden + frozen mid-view (the host renders its own), all input is forwarded, and the view consumes key events as first responder so unhandled keyDowns don't beep — ⌘-combos still work locally (⌘D disconnect, ⌘Q) and reach the host via GC. While released: nothing is forwarded (InputCapture.forwardinggates the GC handlers; held keys/buttons are flushed host-side on release so nothing sticks down), the cursor is free, and the HUD shows "Click the stream to capture input". GC handlers only fire while the app has focus, and focus loss also auto-releases everything held. 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/iPadOS — ported and first-lit (iPad simulator ↔ the real host, 60 fps).
BUILD_IOS=1 bash scripts/build-xcframework.shbuilds device + universal-simulator slices; the Xcode project has a second target, Punktfunk-iOS, sharing the same synchronized sources. The iOSStreamView(StreamViewIOS.swift — same name/signature as the macOS one, so the SwiftUI shell is identical) hosts the sharedStreamPumpin a view controller forprefersPointerLocked: with a hardware mouse/trackpad that is the iPadOS cursor capture (system honors it fullscreen-and-frontmost; in Stage Manager it degrades to absolute-mouse forwarding). Input is routed by kind: DIRECT fingers / Pencil are touches (each gets a wire touch id, coordinates mapped through the aspect-fit letterbox into host-mode pixels — surface == host mode, so the host rescale is the identity), while a mouse/trackpad is a MOUSE — pointer-LOCKED it is GCMouse relative deltas; unlocked it is absolute moves + buttons + scroll over the UIKit pointer path (hover +.indirectPointertouches), the local cursor staying visible so you can aim. An indirect pointer is never sent as a touch. Touch is gated on trust (not forwarded under the TOFU prompt), and returning to the foreground restores the capture you had on leaving.InputCaptureis cross-platform (GC works the same on iPadOS; ⌘⎋ is detected from the HID stream there); audio routes viaAVAudioSession(the Settings device pickers are macOS-only). For the iPad-with-external-display setup: the target enables multiple scenes + indirect input events — on Stage Manager iPads, drag the punktfunk window onto the external screen and the stream runs there with full keyboard/mouse/touch. While streaming the session is immersive (edge-to-edge, status bar + home indicator hidden) and the iPadOS cursor is hidden over the video only while the scene is actually pointer-LOCKED (UIPointerInteraction.hidden()); when the lock isn't held it stays visible and the mouse forwards as an absolute cursor instead; on iOS first run the stream mode defaults to the device's native screen so the video fills the display. tvOS runs the same app (target Punktfunk-tvOS, first-lit in the Apple TV simulator at 720p60): playback-only audio (no mic on tvOS), focus-driven UI (.cardhost tiles), no kb/mouse capture yet — input lands with gamepad support, the natural tvOS input anyway. While streaming there is NO focusable control (a focusable Disconnect button would let the focus engine eat the controller's A before the host sees it); the Siri Remote's Menu button disconnects (.onExitCommand). Core slices are tier-3 Rust targets (see Build above). Known gaps: true pointer LOCK (prefersPointerLocked) isn't consulted through UIHostingController, so the hidden cursor can still drift onto a second screen (fixing it means putting the controller into the UIKit presentation chain); and AVAudioSession interruptions (calls, Siri) don't auto-restart the audio engines yet (reconnect recovers).
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 fromdesign/linux-setup.md).