7b10714b62
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>
290 lines
22 KiB
Markdown
290 lines
22 KiB
Markdown
# 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()`),
|
||
**DualSense feedback** (`nextHidOutput()` — lightbar, player LEDs, adaptive-trigger
|
||
effects), input incl. gamepads + DualSense touchpad/motion (`sendTouchpad`/`sendMotion`),
|
||
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 into `Data`
|
||
(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 a
|
||
`next_au`/`next_audio` in flight") instead of leaving it to callers. Pinning + TOFU
|
||
via `pinSHA256:`/`hostFingerprint`.
|
||
- `AnnexB.swift` — in-band VPS/SPS/PPS → `CMVideoFormatDescription`; Annex-B → AVCC
|
||
`CMSampleBuffer` with `DisplayImmediately` set.
|
||
- `StreamView.swift` — SwiftUI `NSViewRepresentable` over `AVSampleBufferDisplayLayer`
|
||
(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` — `GCMouse` raw deltas + `GCKeyboard` HID→VK mapping (the host's
|
||
`vk_to_evdev` consumes 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's `scrollWheel` override, iPad via
|
||
GCMouse's scroll dpad when pointer-locked and a scroll-only `UIPanGestureRecognizer`
|
||
otherwise (trackpad gestures never reach GC's scroll dpad).
|
||
- `GamepadManager.swift` — app-lifetime controller discovery + selection (`.shared`):
|
||
watches `GCController` connect/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 over
|
||
`GCExtendedGamepad` into incremental `gamepadButton`/`gamepadAxis` events (pad 0),
|
||
plus DualSense touchpad contacts and ~250 Hz motion samples on the rich-input plane
|
||
(the GC→DualSense unit conversions live in `GamepadWire`, 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 for `nextRumble()` (→ `CHHapticEngine` per handle
|
||
locality) and `nextHidOutput()` (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: an `NWBrowser` over `_punktfunk._udp`
|
||
(the host's `crate::discovery` mDNS advert), resolving each service to an IP:port via a
|
||
throwaway `NWConnection` and parsing the TXT (`fp` advisory cert fingerprint, `pair`,
|
||
stable `id`). iOS/tvOS need `NSBonjourServices` (`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;
|
||
`ClientIdentityStore` keeps 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 AU `pts_ns` (host capture clock) to the instant the client
|
||
received it, **skew-corrected** across machines via `PunktfunkConnection.clockOffsetNs` (the
|
||
connect-time wall-clock handshake, `punktfunk_connection_clock_offset_ns`). It excludes the
|
||
layer's decode+present (stage-1 `AVSampleBufferDisplayLayer` has 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 (`SessionModel` owns 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.
|
||
- **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-pairing` gate
|
||
against a second, armed host); the remote first-light test above.
|
||
|
||
## Build / run / test (on a Mac)
|
||
|
||
```sh
|
||
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") — 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_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:
|
||
|
||
- **App icon**: `App/Assets.xcassets` ships an empty `AppIcon` slot. 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 placeholder `AppIcon.appiconset`. Heads-up: CLI
|
||
`actool` (Xcode 26.5) crashed compiling `punktfunk_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, add
|
||
`PunktfunkKitTests` once 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 build` works headlessly;
|
||
same for `-scheme Punktfunk-iOS -destination 'generic/platform=iOS Simulator'` (run it
|
||
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
||
passes the dev autoconnect env through).
|
||
|
||
## Notes for whoever picks this up next
|
||
|
||
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
||
C17-compatible header spells `PunktfunkStatus`/`PunktfunkInputKind` as integer typedefs while
|
||
the enum *constants* import into Swift as a distinct same-named type — bridge with
|
||
`.rawValue` (see the top of `PunktfunkConnection.swift`). Don't fight the generated header.
|
||
2. **ABI contract**: one video pump thread per connection, plus optionally one *separate*
|
||
audio drain thread for `nextAudio()` and one feedback drain thread for
|
||
`nextRumble()`/`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 make `close()` safe from anywhere (it waits out in-flight
|
||
polls, ≤ their timeouts).
|
||
3. **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 `StreamView` does) is sufficient; there is no out-of-band extradata, ever.
|
||
4. **Stage 2 — built, opt-in (`punktfunk.presenter == "stage2"`, default stage 1).** Explicit
|
||
`VTDecompressionSession` decode (`VideoDecoder`) → a `CAMetalLayer` + display-link present
|
||
(`MetalVideoPresenter`/`Stage2Pipeline`), hosted as a sublayer by the same `StreamView`s with
|
||
input capture + HUD unchanged. It adds a **capture→present** (glass-to-glass, modulo the host
|
||
render→capture term) HUD line, skew-corrected via `PunktfunkConnection.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`.
|
||
5. **Audio — wired, both directions.** Playback: `SessionAudio` drains `nextAudio()`
|
||
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 an `AVAudioSourceNode`. Mic: a second engine taps the input
|
||
device, resamples to 48 kHz stereo, Opus-encodes 20 ms chunks and `sendMic()`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 or `AVAudioConverter`/`kAudioFormatOpus` into an
|
||
`AVAudioEngine` source node; conceal gaps (drop/dup) rather than blocking — the Rust
|
||
side buffers 320 ms and drops the newest packet when the puller lags. Wall-clock
|
||
`ptsNs` shares the host clock with video AUs for A/V sync. Wiring this into
|
||
`PunktfunkClient` is the next app-side task.
|
||
6. **Gamepads — wired end to end.** Exactly ONE controller (the `GamepadManager`
|
||
selection) 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 in `resolvedGamepad` — Automatic resolves from the physical
|
||
pad at connect time; host precedence: explicit client choice > host `PUNKTFUNK_GAMEPAD`
|
||
env > 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 in `GamepadCapture.forwardMotion`/`GamepadWire` and `evtest` the
|
||
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).
|
||
7. **Trust — the full ceremony exists now (SPAKE2).** `generateIdentity()` once (persist
|
||
both PEMs in the Keychain), then `pair(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 pass `pinSHA256:` + `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`. `PunktfunkClient` implements 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, `PairSheet` from 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-pairing` the host now
|
||
authorizes clients too (the "other direction" is no longer open, opt-in per host);
|
||
the whole gate is regression-tested in `testPairingCeremonyAndRequirePairingGate`.
|
||
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) and
|
||
`currentMode()` reflects the switch. Wire it to window-resize events.
|
||
8. **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.forwarding` gates 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).
|
||
9. **iOS/iPadOS — ported and first-lit** (iPad simulator ↔ the real host, 60 fps).
|
||
`BUILD_IOS=1 bash scripts/build-xcframework.sh` builds device + universal-simulator
|
||
slices; the Xcode project has a second target, **Punktfunk-iOS**, sharing the same
|
||
synchronized sources. The iOS `StreamView` (StreamViewIOS.swift — same name/signature
|
||
as the macOS one, so the SwiftUI shell is identical) hosts the shared `StreamPump` in
|
||
a view controller for `prefersPointerLocked`: 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 + `.indirectPointer` touches), 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.
|
||
`InputCapture` is cross-platform (GC works the same on iPadOS; ⌘⎋ is detected from
|
||
the HID stream there); audio routes via `AVAudioSession` (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 (`.card` host 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/uinput` access on the box (udev rule from
|
||
`docs/linux-setup.md`).
|