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>
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>
The Apple client now consumes the connector's clock offset. PunktfunkConnection
reads punktfunk_connection_clock_offset_ns into clockOffsetNs at connect; a new
LatencyMeter (PunktfunkKit, NSLock + percentiles, mirrors FrameMeter) records each
AU's capture->client-receipt latency = now(CLOCK_REALTIME) + offset - pts_ns, and
SessionModel drains p50/p95 into the macOS HUD ("capture->client N/N ms p50/p95",
"(same-host)" when the host didn't answer the skew handshake). Wired at the
existing onFrame hook in ContentView — additive, no change to the decode/present
path. Unit test for the meter (percentiles, skew flag, absurd-value guard).
This is the first cross-machine latency the real Apple client reports. SCOPE:
stage-1 AVSampleBufferDisplayLayer decodes+presents compressed samples internally
with no per-frame callback, so this excludes decode+present; true decode->present
needs the stage-2 presenter (VTDecompressionSession + CAMetalLayer). Rebuild
PunktfunkCore.xcframework (for the new C getter) before swift build/test on a Mac.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
macOS GCKeyboard delivery is flaky — the same GameController quirk that
killed GCMouse motion (e414ec0). Keyboard input intermittently failed to
reach the host (e.g. typing in a gamescope game). Switch the macOS key
source to NSEvent, mirroring the mouse fix:
- StreamLayerView.keyDown/keyUp map NSEvent.keyCode (Carbon virtual
keycode) → Windows VK via the new InputCapture.keyCodeToVK table and
forward through InputCapture.sendKey, then consume the event (no beep).
- flagsChanged drives InputCapture.handleFlagsChanged, which diffs the raw
modifier flags to recover each L/R modifier down/up (modifiers never fire
keyDown/keyUp on macOS) and emits the same L/R VKs hidToVK already does.
- The macOS GCKeyboard keyChangedHandler is disabled (#if !os(macOS)) so it
can't double-send; iOS keeps the GCKeyboard path unchanged.
sendKey honors the ⌘⎋ capture-toggle suppressedVK latch and tracks into
pressedVKs so releaseAll()/blur flushes anything still held. The emitted
VKs are identical to the existing HID path, so the host (vk_to_evdev)
needs no change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Apple client grows full gamepad support and punktfunk/1 learns to negotiate
the virtual pad type:
- Protocol: Hello carries a GamepadPref byte (offset 21, the same trailing-byte
back-compat pattern as the compositor; echoed resolved in Welcome at 54).
Host precedence: explicit client choice > PUNKTFUNK_GAMEPAD env > Xbox 360,
DualSense (UHID) only where available. ABI: punktfunk_connect_ex2 +
punktfunk_connection_gamepad (connect_ex delegates; ABI_VERSION stays 2 — the
trailing byte IS the compat mechanism). punktfunk-client-rs gets --gamepad.
- Swift client: GamepadManager (app-lifetime discovery + selection — Settings
lists every controller with capabilities/battery/"In use"; exactly ONE pad
forwards as pad 0, auto = most recently connected, or pinned), GamepadCapture
(snapshot-diff button/axis events, DualSense touchpad + ~250 Hz motion on the
rich-input plane, held state released on switch/deactivate/stop),
GamepadFeedback (rumble → CoreHaptics per-handle engines; lightbar →
GCDeviceLight; player LEDs → playerIndex; adaptive-trigger blocks → the
table-driven DualSenseTriggerEffect parser → GCDualSenseAdaptiveTrigger,
exact for the 10-zone positional modes). The pad type auto-resolves from the
physical controller at connect time, user-overridable in Settings.
- Host DualSense fixes surfaced by adversarial review against hid-playstation /
SDL / Nielk1 ground truth: input-report sensor/touch offsets were off by one
(the kernel read garbage motion + phantom touches), the L2/R2 trigger blocks
were swapped (the report is right-trigger-first), feedback now gates on the
report's valid-flags (a plain rumble write no longer blanks lightbar/
triggers), and the touchpad rescale clamps to the advertised ABS_MT extents.
- Tests: Hello/Welcome trailing-byte back-compat, pick_gamepad precedence,
byte-exact input-report layout, valid-flag gating, per-mode trigger-parser
table (incl. packed 3-bit zones), wire conversions, and a scripted loopback
feedback burst (PUNKTFUNK_TEST_FEEDBACK=1) asserted through the xcframework
on the rumble + HID-output planes.
Validated: cargo test/clippy/fmt green on macOS + Linux (61 host tests), swift
build/test green, test-loopback.sh green, tvOS/iOS targets compile. DualSense
motion sign/scale is derived from the calibration blob, not yet live-verified
(constants isolated in GamepadWire).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PIN now surfaces in the host's web admin UI (port 3000 → Pairing), which is where
users will actually read it — the pairing sheet's footer, field prompts, the tvOS
keyboard title, and the wrong-PIN/failure errors all reference the console instead of
the host log / --allow-pairing flag (the log mention stays in the README as the
secondary path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Host-side logs proved the macOS client sent keyboard + scroll but ZERO relative
mouse-motion and ZERO button events for an entire session — the user was moving
the mouse the whole time. Root cause is client-side: GCMouse's
mouseMovedHandler/pressedChangedHandler silently never fired on the live Mac
(a documented GameController quirk) while GCKeyboard worked and scroll already
rode NSEvent. So motion/buttons were the only input on a GCMouse-only path, and
that path was dead.
macOS: stop relying on GCMouse for motion/buttons (compiled out with
#if !os(macOS)); drive them from a local NSEvent monitor installed only while
captured — the same channel scrollWheel already uses successfully. Under
CGAssociateMouseAndMouseCursorPosition(false) the mouseMoved/dragged deltaX/deltaY
ARE the relative motion (OS-acceleration-applied, exactly what Moonlight's macOS
client ships). All four motion event types are covered so motion keeps flowing
during a button-held drag; buttons map left/right/middle/X1/X2 through the
existing engage-click-suppression + release-on-blur logic. NSEvent deltaY is
already screen-space (+y down) so, unlike the GCMouse path, it is NOT negated.
iPad: the input failure there was a different cause — GCMouse only delivers
relative deltas while the scene holds a true pointer LOCK, which the system grants
only to a full-screen, frontmost iPad scene and which UIHostingController doesn't
consult for children. Gate prefersPointerLocked to iPad + captured, add
childViewControllerForPointerLock so a reparenting container forwards the lock
decision to this VC, and log the resolved lock state. Touch remains the
unconditional fallback.
Adds a PUNKTFUNK_INPUT_DEBUG=1 switch (os.Logger, throttled) so motion/buttons
being SENT is verifiable on-device without host-side logs. iOS GCMouse path
otherwise unchanged; GCKeyboard unchanged on both. Researched + adversarially
reviewed; Swift builds only on a Mac, so this is unverified-compiled here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The slide now runs on UISpringTimingParameters (stiffness 300, damping 30 — a ~0.87
damping ratio: settles quickly with a hint of life, no overshoot ping-pong) via the
transition library's .interpolatingSpring animation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The navigationLink Picker's INTERNAL destination list renders its rows in the focused
(dark-text) style while the push animates — black text over the dark backdrop until
focus settles (present under the old fade too; a SwiftUI-on-tvOS quirk we don't
control). Settings now uses its own primitives instead:
- TVSelectionRow: label + current value, pushes…
- TVSelectionList: a Settings-app-style option list (plain button rows + checkmark,
selecting pops back) — ordinary button chrome, no focused-style pre-rendering.
The stream-mode and compositor pickers are gone on tvOS; the Settings screen itself is
a plain scroll of rows + footer (no Form), matching the rest of the tv UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SwiftUI's NavigationStack on tvOS animates pushes as a bare crossfade with no public
customization — the system Settings app slides. The home stack now applies
.customNavigationTransition(.slide) on tvOS via davdroman/swiftui-navigation-transitions
(MIT, tvOS 13+), covering the top-level routes AND the settings pickers' drill-ins.
The dependency is referenced by the Xcode PROJECT only and linked solely by the
Punktfunk-tvOS target: its manifest (no macOS platform declared vs 10.15 deps) breaks
SwiftPM whole-graph validation for plain `swift build`, and the #if os(tvOS) import
never compiles in the macOS-only SwiftPM dev shell anyway. Headless builds need
xcodebuild -skipMacroValidation (the lib pulls Swift macro packages; in the Xcode UI
it's a one-time Trust & Enable prompt).
iOS/macOS keep their untouched system navigation animations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add Host, Settings and PIN pairing were fullScreenCover overlays, which is why
navigating felt unlike the system Settings app (no push animation, no Menu-pops-a-level
semantics). They are now navigationDestination ROUTES pushed inside the home
NavigationStack:
- the system push/pop animation and Menu-button back navigation come for free;
- the Settings pickers' navigationLink pushes reuse the same stack (its inner
NavigationStack wrapper is gone, as is the tvOS Done row — Menu pops, like Settings);
- Add Host is a real full-screen page (system navigation title, Settings-style rows on
the standard backdrop) instead of a floating dialog, same for the pairing page;
- the thickMaterial cover backdrops became unnecessary and are gone. The system
keyboard entries stay as covers — that presentation is system-owned either way.
iOS/macOS keep their sheets. Verified by screenshot: Add Host renders as a pushed
full-screen route with the title top-center.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SwiftUI's inline TextField on tvOS is structurally wrong for television: it grows when
activated, shows a full-width editing surface behind the pill, and floats labels
off-center — none of it stylable into the Settings-app look. Per Apple's tvOS text
input guidance, real tvOS apps never edit inline: a field is a value ROW, and pressing
it raises the SYSTEM fullscreen keyboard.
- TVTextEntry (UIViewControllerRepresentable): a UITextField that becomesFirstResponder
on appear, presenting the standard tvOS fullscreen keyboard with the field's prompt;
done/dismiss commits the text. TVFieldRow is the Settings-style label+value lozenge.
- Add Host and PIN pairing on tvOS now use rows + keyboard covers exclusively (the
port row also fixes the off-center value text for good — it's a Text, not a field);
the port input validates 1...65535.
- No SwiftUI TextField remains in any tvOS code path.
Verified by screenshot: the dialog rows render exactly like the Settings app, and the
address row raises the system linear keyboard with prompt + done.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three more tvOS-isms, all the same lesson — let the focus engine own the chrome:
- Host cards drew their own material platter + accent ring INSIDE the .card button
style, muting the native grow/tilt focus motion. On tvOS the card style now owns the
platter outright (material/ring stay on the pointer platforms), and the grid gets
48 pt spacing so the focused card swells without overlapping siblings.
- Add Host and Settings no longer sit in the hosts row: they're a compact button row
below the grid (and the empty state gains a Settings button, since tvOS has no
toolbar).
- The Add Host and pairing dialogs drop Form entirely on tvOS — list rows added a
full-width focus fill plus a row platter behind every field's own pill (the
"second outer pill"). As standalone fields in a centered dialog over the dimmed
home, each input is exactly one pill with vertically centered text.
Verified by screenshot in the Apple TV simulator (home grid + Add Host dialog).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inline iOS form widgets fought the tvOS focus system at every turn: focused fields
showed nested pills, rows darkened oddly and grew on activation, the Compositor picker
was not even focusable, and prefilled fields (port, client name) floated their label
inside the pill, shoving the value off-center.
- Settings is now a fully tv-native screen: NO inline text entry — the stream mode is
a preset picker (This TV native / 720p / 1080p / 4K, plus a Custom entry preserving
a mode set on another platform) and both pickers use .navigationLink style (pushed
selection lists, exactly like the system Settings app — and properly focusable; the
cover wraps in a NavigationStack for the pushes).
- Where text entry is unavoidable (Add Host, PIN pairing), the fields keep their stock
single-pill chrome (the grouped form style stays off tvOS — its row platters were
one of the nested pills) and prefilled fields hide their floating label so values
center vertically.
- All earlier row-clearing experiments reverted.
Verified by screenshot in the Apple TV simulator: Settings rows render as single
focus lozenges with chevrons; the Add Host pills are uniform with centered text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tvOS forms/lists have CLEAR backgrounds and a fullScreenCover only shows what the
presented view paints, so Settings/Add Host/pairing rendered transparently over the
hosts grid. All three covers now sit on .thickMaterial edge to edge — the standard
tvOS blur-over-content panel look (verified in the Apple TV simulator).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The iOS chrome half-worked on tvOS: toolbar items rendered tiny with clipped labels
and could not even be focused (which is why "+" never opened the add-host form), and
sheet presentations are not a tvOS idiom (the Settings form looked broken).
- The toolbar is gone on tvOS. Add Host and Settings live IN the hosts grid as
full-size, focus-native tiles (.card style, same geometry as the host cards) — the
natural way actions work on television.
- Every modal (Add Host, Settings, PIN pairing) presents as a fullScreenCover on tvOS;
Settings gains a tvOS-only Done button (covers don't dismiss themselves).
- iOS/macOS keep their existing toolbar + sheets untouched.
Verified in the Apple TV simulator: title, host card and both action tiles render
full-size and focusable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The same app now runs on tvOS (target Punktfunk-tvOS, bundle io.unom.punktfunk.tvos),
validated live against the box: vkcube at 1280x720@60, 60 fps in the Apple TV 4K
simulator, glass HUD with a focusable Disconnect button.
- PunktfunkCore.xcframework grows tvOS device + universal-simulator slices. These are
TIER-3 Rust targets (no prebuilt std): BUILD_TVOS=1 builds them with nightly and
-Zbuild-std from rust-src — the full quic stack (quinn/rustls-ring/tokio) compiles
for tvOS unchanged.
- The UIKit stream view covers iOS AND tvOS, with pointer interaction, pointer lock,
touch forwarding and InputCapture gated to iOS — tvOS is view-only until gamepad
capture lands (the natural tvOS input).
- SessionAudio on tvOS: .playback session, no mic (no app-accessible microphone).
- App chrome gates: keyboardShortcut/textSelection/controlSize/statusBarHidden are
iOS/macOS-only; host cards use the focus-native .card button style on tvOS; the
Audio settings section hides (system-routed); mode seeding works from the TV screen
(1920x1080@60).
- Package platforms += .tvOS(.v17); new Xcode target + shared scheme
(TARGETED_DEVICE_FAMILY 3, local-network usage description included).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Working through the brand-color follow-ups:
- AccentColor gains a dark-appearance variant (#8678F5 — the brand violet lifted one
step toward the icon's light periwinkle) so tinted controls keep contrast on dark.
- Host cards remember sessions: StoredHost.lastConnected (set when a session reaches
streaming) renders as a "Connected … ago" relative-time line, and the most recent
host's card carries a subtle accent ring — the grid finally has hierarchy.
- The HUD swaps the pre-glass black-50% rectangle for .regularMaterial with an accent
live-dot; hint lines use semantic .secondary instead of opacity.
- Security moments: the trust card's lock.shield and the pairing sheet's header take
the brand tint; the PIN field is larger monospaced and uses the number pad on iOS.
Icon ↔ accent decision: the accent stays the exact brand #6656F2; the Icon Composer
layers keep their adjacent palette (#6C5BF3 family) — close enough to read as one
brand, and the icon remains the design-tool source of truth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 160 pt grid minimum packed five small cards per iPad row. iOS columns now use a
280 pt minimum (one full-width card on iPhone portrait, 3–4 generous cards on iPad)
and the card content scales with it: 56 pt icon, title3 name, taller padding. macOS
keeps its compact 180–240 pt cards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matches the bundle display name; was the lowercase project name "punktfunk" in the
home navigation title (iOS large title / macOS titlebar) and the WindowGroup title.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Streaming on iPad left the status bar up and the video boxed inside the safe areas, on
top of a 16:9 default mode letterboxing on the 4:3 screen, with the iPadOS cursor
hovering over the video. The session view is now immersive on iOS:
- .ignoresSafeArea + .statusBarHidden + .persistentSystemOverlays(.hidden) for the
session only (home gets its chrome back on disconnect).
- First run seeds the stream mode from the device's native screen
(UIScreen.nativeBounds + maximumFramesPerSecond) instead of 1920×1080 — verified
live: a fresh install negotiated the iPad's 2752×2064 with the host. macOS keeps the
1080p default (a desktop window is not the screen).
- The iPadOS cursor hides while over the video (UIPointerInteraction .hidden(),
re-resolved on capture toggles) — the host renders its own cursor from our deltas;
true pointer lock through UIHostingController remains the documented gap.
Found along the way (host-side, not fixed here): at very high modes a keyframe burst
can fill the UDP send buffer and m3 treats the sendmmsg WouldBlock as fatal
("session ended with error: submit_frame: WouldBlock") instead of backpressuring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "title looks off" report traced to the GRID, not the title: the Mac-tuned
adaptive(180–240) columns yielded a single max-width card, centered, so nothing aligned
with the leading large title. The header is now entirely stock primitives — default
.navigationTitle large-title behavior (the inlineLarge experiment is gone), default
.padding() so content sits on the system 16 pt margins — and the grid columns are
platform-tuned: iOS drops the max so columns FILL the width and the cards stay
edge-aligned with the title; macOS keeps the 180–240 cap (huge windows shouldn't grow
huge cards).
Verified in the iPhone 17 simulator with seeded hosts: pill top-right, large title at
system metrics, two full-width-filling cards flush with the title's leading edge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ToolbarSpacer split into separate circles was the wrong read — with the
inline-large title row in place, the expected header is the single grouped pill
(the system default for adjacent trailing items). Dropped the spacer and the
availability fork; the two trailing items now share one pill next to the title.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The home screen stacked the toolbar row above the large title; the modern (iOS 26
Liquid Glass) header puts the large title leading and the glass action circles trailing
on the SAME row. That's exactly .toolbarTitleDisplayMode(.inlineLarge) — applied on iOS
only, macOS keeps its window chrome untouched.
Verified in the iPhone 17 simulator: "punktfunk" large title left, gear/+ circles
right, one row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The iOS chrome inherited macOS dialog sizing and read as undersized on a phone:
- Toolbar: the two trailing actions shared one compact glass pill; on iOS 26+ each now
gets its own full-size circle (explicit .topBarTrailing placements split by a fixed
ToolbarSpacer — the system-app look, e.g. Files), with the grouped-pill fallback on
iOS 17–18. The buttons are extracted so macOS keeps SettingsLink + .help untouched.
- Sheets and CTAs (AddHostSheet, PairSheet, trust card, empty-state Add Host) get
.controlSize(.large) on iOS — proper touch targets instead of macOS dialog buttons.
Verified in the iPhone 17 simulator: two ~44 pt glass circles matching the Files app's
toolbar sizing; macOS suite and app build unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The whole client now runs on iPadOS/iOS from the same sources, first-lit live in the
iPad simulator against the real host at 1280x720@60 (60 fps on the HUD, capture state
machine active, mic permission flow shown).
- PunktfunkCore.xcframework grows iOS device + universal-simulator slices
(BUILD_IOS=1; rustup targets aarch64-apple-ios{,-sim} + x86_64-apple-ios).
- The decode pump is extracted into a shared StreamPump (identical IDR re-gate logic on
both platforms); the iOS StreamView (StreamViewIOS.swift) has the same name/signature
as the macOS one, so ContentView & co. are byte-identical across platforms — hosted
in a UIViewController for prefersPointerLocked (the iPadOS cursor capture; see README
note 9 for the UIHostingController forwarding caveat).
- Touch is always forwarded: per-finger wire ids, coordinates mapped through the
aspect-fit letterbox into LIVE host-mode pixels (surface == host mode, identity
rescale host-side; follows mid-stream requestMode switches).
- InputCapture is cross-platform: GC works the same on iPadOS, ⌘⎋ is detected from the
HID stream there; stale-⌘ tracking after focus loss fixed on both platforms
(releaseAll now drops the modifier/latch state — a ⌘ released in another app
otherwise hijacked Esc forever).
- SessionAudio: AVAudioSession on iOS (.playAndRecord + .defaultToSpeaker — without it
iPhones route host audio to the EARPIECE; deactivated with
notifyOthersOnDeactivation on stop so interrupted background audio resumes); HAL
device pinning + the Settings pickers stay macOS-only.
- New Punktfunk-iOS app target (shared synchronized sources, generated Info.plist with
mic + local-network usage descriptions — QUIC to a LAN host trips local network
privacy on real devices — scene manifest + indirect input events for Stage Manager /
external displays), shared scheme, macOS min-window frames gated off iOS.
For the iPad-on-an-external-screen idea: with multiple scenes + indirect input enabled,
Stage Manager iPads can drag the punktfunk window onto the external display and drive
the PC with keyboard/mouse/touch. Known gaps (README note 9): the pointer-lock
preference isn't consulted through UIHostingController (relative mouse works, the local
cursor just stays visible) and AVAudioSession interruptions don't auto-restart audio.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both directions of the audio plane, on CoreAudio's built-in Opus codec
(kAudioFormatOpus — no bundled libopus; OpusCodec.swift, round trip unit-tested):
- Playback: a drain thread pulls nextAudio() packets, decodes, and writes a priming
jitter ring feeding an AVAudioSourceNode (~20 ms prefill, adaptive to the device's
render quantum so large-buffer devices don't oscillate prime/dropout; a high-water
clamp sheds stall backlog so one network hiccup can't permanently lag audio behind
video; underrun re-primes — one dip, not sustained crackle).
- Mic: a second engine taps the input device, resamples to 48 kHz stereo, Opus-encodes
20 ms chunks and sendMic()s them into the host's virtual PipeWire source. Permission
via AVCaptureDevice (NSMicrophoneUsageDescription added to the Xcode target).
- Settings: Speaker + Microphone pickers (CoreAudio HAL enumeration, persisted by
device UID — "System default" leaves the engine unpinned so it follows macOS device
changes) and a "Send microphone" toggle (default on). Applies from the next session.
- Audio starts with streaming, never during the trust prompt (no host sound — and no
mic uplink — before the user trusted the host); teardown stops audio before close().
Adversarial-review fixes baked in: stop() and the dangling mic-permission callback
share one lock+flag protocol (no hot mic with no owner), the connect-success handler
bails when the attempt was abandoned mid-handshake (no session/mic for a dead window),
SessionAudio gets a deinit backstop (a dropped instance can't pin the connection via
its drain thread), and the render scratch buffer is block-owned (was leaked per
session).
Verified live against the box: remote test decodes 100 host Opus packets to PCM and
the host opens its virtual mic on the first uplinked frame ("punktfunk/1 virtual mic
ready"); on-glass session runs with both engines up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts the new ABI surface (still v2, additive):
- PunktfunkConnection.sendMic(_:seq:ptsNs:) — Opus mic frames (48 kHz) to the host's
virtual PipeWire source; enqueue-only, empty data = DTX silence. Wiring the actual
Mac microphone (AVAudioEngine input → Opus) into the app is the follow-up, alongside
audio playback (README note 5).
- PunktfunkInputEvent.touchDown/touchMove/touchUp — absolute pixels + surface size in
flags, host injects via libei ei_touchscreen. Built for the iOS variant; nothing on
macOS emits them yet.
- Loopback round trip now also sends touch events and mic frames (incl. a DTX frame)
through the wrapper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scroll was wired to GCMouse's scroll dpad, which only fires for plain HID wheel
deltas — trackpad and Magic Mouse scrolling are gesture events that never reach
GameController, so scrolling was dead on the default Mac setups. The stream view now
overrides scrollWheel (while captured the cursor is parked mid-view, so it receives
every scroll event) and feeds InputCapture.sendScroll: precise gesture deltas are
pixels (~0.1 notch/px, SDL's factor → ×12 for WHEEL_DELTA(120)), classic wheels are
lines (×120), fractional remainders accumulate, and the GC scroll handler is gone so
wheel mice can't double-deliver. Signs pass through as-is, preserving the local
(natural-)scrolling preference.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts punktfunk_connect_ex from the compositor-selection batch: a Compositor enum on
PunktfunkConnection (auto/kwin/wlroots/mutter/gamescope, with the host's name aliases
for env parsing), a "Host compositor" picker in Settings (default Automatic — a
concrete choice is honored only if that backend is available host-side), and
PUNKTFUNK_COMPOSITOR / PUNKTFUNK_REMOTE_COMPOSITOR pass-throughs for the autoconnect
dev hook and the remote first-light test. The wire change is backward-compatible
(optional trailing byte), so no behavior changes at the default.
Validated live against the box: host with no compositor env (auto-detect = KWin)
logged "honoring client compositor request compositor=gamescope" and streamed 60/60
decoded frames from the spawned gamescope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Capture used to engage whenever the app became active, so the click that activates the
window — on the title bar (a drag) or a resize edge — got the cursor warped away
mid-gesture, and raw deltas kept streaming to the host while the user fought the window.
Reworked Moonlight-style, with capture as a deliberate, reversible state owned by
StreamLayerView:
- Engage: automatically once when the stream starts / trust is confirmed (one-shot, can
never fire surprisingly later), or by clicking into the video (that click's
press/release are suppressed toward the host; acceptsFirstMouse makes it one click
from another app). NEVER on app re-activation.
- Release: ⌘⎋ (toggles, key-window-scoped), focus loss — now including same-app window
switches (⌘, / ⌘N / ⌘M resign key without resigning the app; previously the new
window inherited a hidden frozen cursor and its typing was double-delivered to the
host) — and disconnect.
- While released: nothing is forwarded (InputCapture.forwarding gates the GC handlers;
held keys/buttons are flushed host-side so nothing sticks), the cursor is free, and
the HUD (now showing the capture state) is clickable.
- The no-beep behavior moved from the NSEvent monitor to first-responder key
consumption — swallowing at the monitor risked starving GC's own delivery (the
"input broken altogether" report). The monitor now only intercepts ⌘⎋.
- Adversarial-review fixes: a second session preempts the previous one cleanly instead
of leaving it captured with dead GC handlers (onPreempted); the engage click's
suppression latch can't outlive the click (mouseUp backstop); ⌘⎋'s physical Esc can't
type into the host in either toggle direction (suppressedVK latch + Esc-while-⌘
guard); capture callbacks defer out of the SwiftUI update pass.
Validated live against the box: 16185 input datagrams injected during a captured
session (gamescope EIS), title-bar drag/resize free while released, and visible
cursor + typing on a streamed KWin desktop, all user-confirmed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GCKeyboard reads the HID state directly, so the key NSEvents kept traveling the
responder chain unhandled — and an unhandled keyDown makes NSWindow play the
"invalid input" sound on every keystroke. InputCapture now installs a local event
monitor for its lifetime that swallows key events, except ⌘-combos, which still
reach the local app (the HUD's ⌘D disconnect, ⌘Q) in addition to the host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pairing/renegotiation batch bumped the punktfunk/1 ABI to v2 and the host now
hard-rejects v1 Hellos (m3.rs), so streaming from the Mac was dead until the bundled
PunktfunkCore.xcframework is rebuilt — it is gitignored, so that is a per-checkout step:
bash scripts/build-xcframework.sh. The Swift wrapper itself was already adapted upstream;
this lands the app on top of it.
- ClientIdentityStore: persistent client identity in the login Keychain, presented on
every connect so paired hosts recognize this Mac. Keychain access failure throws
instead of regenerating (a fresh identity would silently un-pair this Mac from every
--require-pairing host); a lost first-run race resolves toward the stored identity;
pairing uses the strict loadForPairing() so a memory-only identity can't strand a
ceremony.
- PairSheet: the SPAKE2 PIN ceremony, reachable from a host card's context menu and from
the trust prompt's "Pair with PIN instead…" (which drops the live session first — the
host's accept loop is sequential). Success pins the verified fingerprint and connects;
an in-flight ceremony self-discards when the sheet is dismissed, so a late success
can't pin + auto-connect behind the user's back. Wrong PIN and Keychain failures get
distinct, actionable error text.
- Tests: identity unit tests; the full pairing ceremony + --require-pairing gate on
loopback (test-loopback.sh arms a second host, parses its PIN from the log, and gives
both hosts throwaway config homes — no more writes to the real ~/.config/punktfunk);
remote pairing + pinned stream over the LAN (PUNKTFUNK_REMOTE_PIN, _PORT).
Validated live against the box: SPAKE2 ceremony with the host's arming PIN → verified
fingerprint → pinned + identified 720p60 session (host persisted the client identity);
first light 60/60 AUs decoded to pixels; vkcube on glass through the app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Triaged the multi-agent review of the renegotiation + pairing + Sway + AV1/surround batch
(1 critical, 11 major/minor confirmed). Fixes:
CRITICAL — PIN pairing was offline-brute-forceable. The HMAC-of-PIN proof let an active
MITM who terminates the TOFU ceremony recover the 4-digit PIN by offline dictionary search
(all other inputs observable) and forge a correctly-bound proof. Replaced with **SPAKE2**
(balanced PAKE, `spake2` crate) + key-confirmation MACs, binding both cert fingerprints as
the SPAKE2 identities: an attacker gets exactly ONE online guess, no offline search, and
mismatched cert views (a real MITM) never reach a shared key. Also reworked the UX to an
"arming PIN" — one PIN per arming window shown at host startup (the SPAKE2 client needs the
PIN to build its first message, so it can't be minted per-connection). Validated live:
wrong PIN rejected in 0.1s, right PIN pairs + persists + the paired identity streams.
Pairing hardening: `--allow-pairing`/`--require-pairing` must arm pairing (default rejects
unsolicited ceremonies); per-host cooldown bounds online guessing; the client flushes its
CONNECTION_CLOSE so a refused ceremony can't wedge the sequential host for the full timeout;
atomic (temp+rename) paired-store writes.
Protocol: control/pairing messages use a distinct CTL_MAGIC (PKFc) — fully disjoint from
the positional Hello namespace (a future abi_version can't be misparsed as a control
message); all typed decodes are length-exact. ABI_VERSION → 2 (punktfunk_connect signature
gained the identity params; header regenerated).
Renegotiation: drain the reconfig channel to the NEWEST mode (one rebuild, not one per
stale step); validate refresh_hz; build the new pipeline BEFORE dropping the old so a
rebuild failure keeps the session on its current mode instead of killing it.
GameStream: packetDuration snaps to {5,10} (an in-between value isn't a legal Opus frame
size and would kill audio). Sway: chooser file moved to $XDG_RUNTIME_DIR (was a fixed
world-writable /tmp path — DoS / capture-misdirection by another local user).
Swift: fixed two compile breakers in the new pairing/identity APIs (Int32 status .rawValue,
UInt cap cast). New SPAKE2 + namespace-disjointness + pairing-roundtrip unit tests; the
in-process pairing test now also exercises the arming PIN + cooldown. 114 tests green,
clippy -D warnings clean (both feature sets), fmt, C-ABI harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renegotiation (no reconnect on resize): the handshake bi-stream stays open; the client
sends Reconfigure{mode} (typed post-handshake message), the host validates + acks
Reconfigured and rebuilds capture/encoder/virtual output at the new mode while the data
plane (keys, ports, FEC) runs untouched — the first new-mode AU is an IDR with in-band
parameter sets. NativeClient::request_mode / punktfunk_connection_request_mode; mode()
reflects the active mode. Validated live on KWin: one continuous stream, 225 frames
@1280x720 then 395 @1920x1080, ~90 ms pipeline rebuild (ffprobe shows both resolutions).
PIN pairing (mutual trust, kills TOFU MITM): clients get persistent self-signed
identities presented via QUIC client auth (generate_identity / client auth offered but
optional server-side — legacy clients still connect). Ceremony on the control stream:
PairRequest{name} → host shows a 4-digit PIN (log) + PairChallenge{salt} → client proves
with HMAC-SHA256(PIN‖salt, client_fp‖host_fp) — binding both certs means a MITM can't
forward a proof, single attempt per PIN, constant-time compare → PairResult; host
persists the fingerprint (~/.config/punktfunk/punktfunk1-paired.json), client pins the
host's. m3-host --require-pairing gates sessions on the paired set.
NativeClient::pair + punktfunk_pair/punktfunk_generate_identity in the ABI; reference
client: --pair PIN --name LABEL + auto-generated persistent identity, --remode for live
renegotiation testing. Swift wrapper: ClientIdentity/generateIdentity()/pair(),
requestMode()/currentMode(); README handoff updated.
Tested: reconfigure/pairing wire roundtrips, C-ABI mode switch ack, full in-process
ceremony (wrong PIN → Crypto, anonymous-vs-gate rejection, success → pinned session);
live wrong-PIN ceremony against the serving host (PIN logged, proof rejected).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The awaiting-trust and streaming phases rendered StreamView in different switch
branches, so confirming trust dismantled and recreated the NSView — the fresh pump had
already missed the opening IDR (infinite GOP: no other keyframe ever comes) and decoded
nothing. One session branch now hosts a single StreamView; the trust card is an overlay
on the blurred stream and only the capturesCursor flag flips on confirmation.
Verified live against the box (gamescope+vkcube at 720p60, 11.7 Mb/s on glass). Note for
host runs: without PUNKTFUNK_COMPOSITOR=gamescope + PUNKTFUNK_GAMESCOPE_APP, m3-host
auto-picks KWin and streams its (black, empty) session — looks identical to a client
bug but isn't one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The host renders its own cursor from our raw deltas, so the local macOS cursor both
stays visible and drifts away from the remote one — and it can wander out of the window,
where a click focuses another app. While the stream has focus, do what Moonlight does:
warp the cursor mid-view, disconnect it from mouse movement
(CGAssociateMouseAndMouseCursorPosition(false) — GCMouse still delivers raw HID deltas),
and hide it. Released on app deactivation (Cmd+Tab is the escape hatch), view teardown,
and disconnect; re-captured when the stream regains focus. The HUD's Disconnect gains ⌘D
since a hidden, frozen cursor can't click it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full project rename, decided 2026-06-10:
- Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs.
- C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h,
PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl.
PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants).
- Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1.
WIRE BREAK: clients must be rebuilt from this revision.
- Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / ….
- Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the
persistent identity is unchanged, pinned fingerprints stay valid).
- Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection
(Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated.
- scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated.
Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of
"desktop but no apps/settings" over the stream: plasmashell launched without
XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and
rendered an empty menu. The script sets the complete KDE session env (menu prefix,
KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell.
Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS,
zero lumen references left outside .git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clients/apple scaffold is now a working macOS client, validated live against this
repo's host across the LAN: gamescope virtual output → NVENC HEVC → lumen/1 (GF(2¹⁶) FEC +
AES-GCM over UDP, QUIC control) → VideoToolbox → AVSampleBufferDisplayLayer at 720p60,
mouse/keyboard flowing back as QUIC datagrams into the host's gamescope EIS injector
(~3.7k events injected in one session).
LumenKit:
- LumenConnection: the predicted cbindgen compile fixes (C17 header spells the typedefs as
integers while the enum constants import as a distinct Swift type — bridge by rawValue);
close() is now safe from any thread (a close flag + pumpLock held across the blocking
poll enforce the C contract "never close with a next_au in flight"; flag prevents
lock-starvation by back-to-back polls).
- StreamView: per-pump cancellation token (reconnects can't double-pump), flush + re-gate
on the next in-band parameter sets when the layer fails, no stale enqueue after restart.
- InputCapture: fractional-delta accumulation (sub-pixel motion isn't truncated away),
pressed-state tracking with release-all on focus loss and stop() (nothing sticks down
host-side), global-singleton ownership guard (GC has one handler slot per process),
X1/X2 buttons, horizontal scroll, full keypad/CapsLock/ISO-102nd/PrintScreen/Menu VKs.
- LumenClient app shell (swift run LumenClient): connect form, fps/Mb-s HUD,
LUMEN_AUTOCONNECT/LUMEN_MODE for scripted first-light runs.
- Tests: Annex-B byte-level units; real-codec round trip (VTCompressionSession-encoded
HEVC rebuilt as the host's wire shape → AnnexB → VTDecompressionSession → pixels);
test-loopback.sh (Swift client vs a real local m3-host over loopback — the Swift twin of
c_abi_connection_roundtrip); RemoteFirstLightTests (full pipeline over the LAN).
Host/build fixes that fell out:
- The workspace builds on non-Linux again: gamestream audio (opus) and sendmmsg batching
are now platform-gated with stubs/fallback, per the crate's "compiles everywhere" rule.
- Horizontal scroll was inverted end-to-end: the injectors negated BOTH axes onto the
ei/wl axes, but GameStream's horizontal convention is positive = right
(moonlight-qt/Sunshine pass it through unnegated) — only vertical flips now. This also
un-inverts real Moonlight clients.
- AnnexB drops all zeros preceding a start code (trailing_zero_8bits padding), ffmpeg's
policy, instead of leaking them into the preceding NAL.
- build-xcframework.sh: deployment targets pinned to the package floor + an otool guard —
cargo does not fingerprint MACOSX_DEPLOYMENT_TARGET, so warm caches can silently ship
too-new minos objects.
Adversarially reviewed (5-dimension multi-agent pass, every finding refutation-verified):
14 confirmed findings, all fixed above; the send-while-polling core-contract gap flagged
here is closed by the lumen/1 session-planes work (&self pulls + per-plane borrow slots).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
m3-host is now a real host, not a one-shot demo. Everything validated live on this box
(two back-to-back sessions, pinned + TOFU, ~200 audio pkts/s, p50 0.84 ms at 720p60).
lumen-core:
- quic.rs: QUIC-datagram side planes demuxed by first byte — Opus audio 0xC9
([magic][u32 seq][u64 pts_ns][opus], host→client) and rumble 0xCA ([magic][pad][low][high]).
- Trust: endpoint::server_with_identity (persistent PEM identity) and
endpoint::client_pinned — SHA-256 cert-fingerprint pinning with TOFU (observed
fingerprint reported back for persisting). The verifier checks the TLS 1.3
CertificateVerify signature for real (an MITM replaying the host's public cert without
its key is rejected; cert pinning alone would not prove key possession).
- client.rs: NativeClient gains pin + host_fingerprint, audio/rumble receivers
(next_audio / next_rumble); pull methods take &self so the C ABI's per-plane threads
never alias a &mut (per-plane mutexed borrow slots in abi.rs).
- abi.rs: lumen_connect(pin_sha256, observed_sha256_out) + lumen_connection_next_audio /
next_rumble. input.rs: documented gamepad wire contract (GameStream buttonFlags bits,
XInput axis conventions, +y = up) — exported as LUMEN_BTN_*/LUMEN_AXIS_* (bare BTN_*
collides with <linux/input-event-codes.h> at different values).
lumen-host (m3):
- Persistent accept loop: sessions back to back on one endpoint (--max-sessions, 0 =
forever); per-session failures log and the loop keeps serving; 10 s handshake deadline
so a silent client can't wedge the sequential accept queue; teardown on every exit path
(stop flag → conn.close → join audio+input threads).
- Audio plane: desktop PipeWire capture → Opus 48 kHz stereo 5 ms CBR → datagrams; ONE
capturer reused across sessions via an AudioCapSlot (PipeWire streams have no cheap
teardown — per-session opens would leak a thread + core connection + live node each).
- Gamepad routing: incremental GamepadButton/GamepadAxis datagrams accumulate into
per-pad state feeding the uinput xpad manager; force feedback returns as rumble
datagrams, with current state re-sent every 500 ms (idempotent-state healing for the
lossy channel). QUIC endpoint serves the persistent ~/.config/lumen identity and logs
the pinnable fingerprint.
lumen-client-rs: --pin (malformed values abort — never silently downgrade to TOFU),
TOFU fingerprint logging, audio/rumble datagram counters, gamepad events in --input-test.
clients/apple: scaffold synced — pinSHA256/hostFingerprint (wrong-size pin throws,
fail-closed), nextAudio/nextRumble, gamepad event constructors; README handoff updated
(persistent listener, audio decode notes, trust UX).
Adversarially reviewed (5-dimension multi-agent pass over the diff, 2-skeptic
verification): fixed the MITM signature-check gap, a Y-axis contract inversion, header
macro collisions, ABI aliasing UB, the PipeWire per-session leak, the missing handshake
deadline, fail-open pin parsing, and teardown-on-error paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared-core architecture pays off: platform clients now link ONE Rust library that
does the entire lumen/1 protocol, and only add decode/present/input on top.
lumen-core:
- client.rs (quic feature): NativeClient — QUIC handshake + UDP data plane + input
datagrams on internal threads; embedder surface = connect / next_frame / send_input.
- abi.rs: lumen_connect / lumen_connection_next_au (borrow-until-next-call, matching
lumen_client_poll_frame semantics) / lumen_connection_send_input / lumen_connection_mode /
lumen_connection_close. Guarded in the generated header by LUMEN_FEATURE_QUIC (cbindgen
[defines] mapping), so the checked-in header is stable across feature sets.
- error.rs: append-only LumenStatus additions Timeout (-9) and Closed (-10).
- TESTED end-to-end through the C ABI: in-process lumen/1 host, lumen_connect pulls 25
byte-verified frames, sends input, closes (m3.rs::c_abi_connection_roundtrip).
Apple client (clients/apple — SCAFFOLD, written on Linux, first Xcode build pending):
- scripts/build-xcframework.sh: cargo per Apple target → universal staticlib + header
(LUMEN_FEATURE_QUIC pre-defined) + modulemap → LumenCore.xcframework.
- Package.swift (LumenKit) + Swift sources: LumenConnection (ABI wrapper), AnnexB
(in-band VPS/SPS/PPS → CMVideoFormatDescription, Annex-B → AVCC CMSampleBuffers with
DisplayImmediately), StreamView (SwiftUI over AVSampleBufferDisplayLayer — stage-1
presenter that hardware-decodes compressed HEVC itself), InputCapture (GCMouse raw
deltas + GCKeyboard HID→VK).
- README.md is the full handoff for the next (Mac-side) agent: build steps, ABI contract,
first-light test recipe against the Linux host, stage-2 (VT+Metal pacing) plan, and the
known host-side gaps (single-session m3-host, no lumen/1 audio yet, gamepad kinds not
yet routed in m3's injector, seed-stage trust).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>