feat: M4 stage 1 — the SwiftUI client is real: compiles, tested, first light on glass
ci / rust (push) Has been cancelled

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>
This commit is contained in:
2026-06-10 14:38:01 +02:00
parent 520d7342dd
commit bf8a974e8b
23 changed files with 1212 additions and 180 deletions
@@ -0,0 +1,122 @@
// Connect form live stream. Stage-1 UX: pick host + mode, see frames, type/aim.
import AppKit
import LumenKit
import SwiftUI
struct ContentView: View {
@StateObject private var model = SessionModel()
@AppStorage("lumen.host") private var host = "192.168.1.70"
@AppStorage("lumen.port") private var port = 9777
@AppStorage("lumen.width") private var width = 1920
@AppStorage("lumen.height") private var height = 1080
@AppStorage("lumen.hz") private var hz = 60
var body: some View {
Group {
if let conn = model.connection {
stream(conn)
} else {
connectForm
}
}
.onAppear { autoConnectIfAsked() }
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
}
/// Development hook: LUMEN_AUTOCONNECT=host[:port] connects immediately at the saved
/// (or LUMEN_MODE=WxHxHz) mode lets scripts drive first-light runs. (IPv4/hostname
/// only; an IPv6 literal would need bracket parsing.)
private func autoConnectIfAsked() {
guard let target = ProcessInfo.processInfo.environment["LUMEN_AUTOCONNECT"],
!target.isEmpty, model.connection == nil, !model.connecting
else { return }
let parts = target.split(separator: ":")
host = String(parts[0])
if parts.count == 2, let p = Int(parts[1]) { port = p }
if let mode = ProcessInfo.processInfo.environment["LUMEN_MODE"] {
let dims = mode.split(separator: "x").compactMap { Int($0) }
if dims.count == 3 {
width = dims[0]
height = dims[1]
hz = dims[2]
}
}
model.connect(
host: host, port: UInt16(clamping: port),
width: UInt32(clamping: width), height: UInt32(clamping: height),
hz: UInt32(clamping: hz))
}
private func stream(_ conn: LumenConnection) -> some View {
StreamView(
connection: conn,
onFrame: { [meter = model.meter] au in meter.note(byteCount: au.data.count) },
onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() }
}
)
.overlay(alignment: .topTrailing) { hud(conn) }
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
}
private func hud(_ conn: LumenConnection) -> some View {
VStack(alignment: .trailing, spacing: 4) {
Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
.font(.system(.caption, design: .monospaced))
Button("Disconnect") { model.disconnect() }
.font(.caption)
}
.padding(8)
.background(.black.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
.foregroundStyle(.white)
.padding(10)
}
private var connectForm: some View {
VStack(spacing: 14) {
Text("lumen").font(.largeTitle.weight(.semibold))
Form {
TextField("Host", text: $host)
TextField("Port", value: $port, format: .number.grouping(.never))
HStack {
TextField("Width", value: $width, format: .number.grouping(.never))
Text("×")
TextField("Height", value: $height, format: .number.grouping(.never))
Text("@")
TextField("Hz", value: $hz, format: .number.grouping(.never))
}
Button("Use this display's mode") { fillFromMainScreen() }
.buttonStyle(.link)
}
.frame(width: 340)
if let error = model.errorMessage {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.frame(width: 340)
}
Button(model.connecting ? "Connecting…" : "Connect") {
model.connect(
host: host, port: UInt16(clamping: port),
width: UInt32(clamping: width), height: UInt32(clamping: height),
hz: UInt32(clamping: hz))
}
.keyboardShortcut(.defaultAction)
.disabled(model.connecting || host.isEmpty)
}
.padding(28)
.frame(minWidth: 420, minHeight: 320)
}
private func fillFromMainScreen() {
guard let screen = NSScreen.main else { return }
let scale = screen.backingScaleFactor
width = Int(screen.frame.width * scale)
height = Int(screen.frame.height * scale)
hz = screen.maximumFramesPerSecond
}
}
@@ -0,0 +1,29 @@
// LumenClient development app shell around LumenKit (swift run LumenClient).
// Connect form StreamView (AVSampleBufferDisplayLayer HEVC) + InputCapture.
import AppKit
import SwiftUI
@main
struct LumenClientApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup("lumen") {
ContentView()
}
}
}
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
// `swift run` launches an unbundled binary; promote it to a regular app so the
// window fronts and receives keyboard/mouse focus (GameController needs focus).
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
}
@@ -0,0 +1,115 @@
// Session state for the app shell: owns the connection, the input capture, and the
// pump-thread main-actor stats relay.
import Foundation
import LumenKit
import SwiftUI
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
/// values. NSLock instead of an actor the writer is the (non-async) pump thread.
final class FrameMeter: @unchecked Sendable {
private let lock = NSLock()
private var frames = 0
private var bytes = 0
private var totalFrames = 0
func note(byteCount: Int) {
lock.lock()
frames += 1
bytes += byteCount
totalFrames += 1
lock.unlock()
}
/// Returns and resets the per-interval counters (the running total stays).
func drain() -> (frames: Int, bytes: Int, total: Int) {
lock.lock()
defer {
frames = 0
bytes = 0
lock.unlock()
}
return (frames, bytes, totalFrames)
}
}
@MainActor
final class SessionModel: ObservableObject {
@Published var connection: LumenConnection?
@Published var connecting = false
@Published var errorMessage: String?
@Published var fps = 0
@Published var mbps = 0.0
@Published var totalFrames = 0
let meter = FrameMeter()
private var inputCapture: InputCapture?
private var statsTimer: Timer?
func connect(host: String, port: UInt16, width: UInt32, height: UInt32, hz: UInt32) {
guard !connecting else { return }
connecting = true
errorMessage = nil
Task.detached(priority: .userInitiated) {
// LumenConnection.init blocks on the QUIC handshake keep it off the main actor.
let result = Result { try LumenConnection(
host: host, port: port, width: width, height: height, refreshHz: hz) }
await MainActor.run { [weak self] in
guard let self else { return }
self.connecting = false
switch result {
case .success(let conn):
self.connection = conn
self.startInput(conn)
self.startStatsTimer()
case .failure:
self.errorMessage = "Connection failed — is the host running? " +
"(lumen-host m3-host on \(host):\(port))"
}
}
}
}
func disconnect() {
inputCapture?.stop()
inputCapture = nil
statsTimer?.invalidate()
statsTimer = nil
if let conn = connection {
// close() waits out an in-flight poll (100 ms) and joins the Rust worker
// threads keep that off the main actor.
Task.detached { conn.close() }
}
connection = nil
fps = 0
mbps = 0
}
/// Called (via the main actor) when the pump hits end-of-session.
func sessionEnded() {
guard connection != nil else { return }
disconnect()
errorMessage = "Session ended by host."
}
private func startInput(_ conn: LumenConnection) {
let capture = InputCapture(connection: conn)
capture.start()
inputCapture = capture
}
private func startStatsTimer() {
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
let (frames, bytes, total) = self.meter.drain()
self.fps = frames
self.mbps = Double(bytes) * 8 / 1_000_000
self.totalFrames = total
}
}
// .common so the HUD keeps updating during window drags / menu tracking.
RunLoop.main.add(timer, forMode: .common)
statsTimer = timer
}
}
+8 -2
View File
@@ -12,6 +12,9 @@ import Foundation
public enum AnnexB {
/// Split an Annex-B stream into NAL units (start codes 00 00 01 / 00 00 00 01 stripped).
/// All zeros immediately preceding a start code are dropped: they're either the
/// 4-byte-code prefix or `trailing_zero_8bits` padding, never NAL payload (emulation
/// prevention keeps 00 00 0x out of conforming NAL bytes) same policy as ffmpeg.
public static func nalUnits(in data: Data) -> [Data] {
var nals: [Data] = []
let bytes = [UInt8](data)
@@ -19,8 +22,11 @@ public enum AnnexB {
var start = -1
while i + 2 < bytes.count {
if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 {
let codeStart = (i > 0 && bytes[i - 1] == 0) ? i - 1 : i
if start >= 0 {
var codeStart = i
while codeStart > 0, bytes[codeStart - 1] == 0 {
codeStart -= 1
}
if start >= 0, start < codeStart {
nals.append(Data(bytes[start..<codeStart]))
}
start = i + 3
+143 -21
View File
@@ -4,26 +4,50 @@
// injector expects for relative motion. GCKeyboard gives HID keycodes which we map to the
// Windows VK space the host's vk_to_evdev table consumes (same space Moonlight uses).
// Gamepads (GCController) come later the host's uinput pads already speak the
// GamepadButton/GamepadAxis event kinds.
// GamepadButton/GamepadAxis event kinds, but m3's injector path doesn't route them yet.
//
// SCAFFOLD: written on the Linux host, not yet compiled against Xcode. The VK map covers
// the common keys; extend alongside lumen-host/src/inject.rs::vk_to_evdev.
// The wire carries integer deltas; GC hands us Floats. We accumulate the fractional
// remainder per axis so slow, sub-pixel motion isn't truncated away.
//
// GC only delivers while the app is active, so anything held when focus leaves would
// stick down on the host forever we track pressed keys/buttons and release them all on
// didResignActive and on stop(). All GC handlers and notifications fire on the main
// queue (the framework default), so the mutable state here needs no locking.
//
// GCMouse.current/GCKeyboard.coalesced are process-global singletons with one handler
// slot each: only one InputCapture can be live per process. `activeCapture` tracks
// ownership so a stale capture's stop() can't clobber a newer one's handlers.
#if os(macOS)
import AppKit
import Foundation
import GameController
import LumenCore
public final class InputCapture {
private static weak var activeCapture: InputCapture?
private let connection: LumenConnection
private var observers: [NSObjectProtocol] = []
private var mice: [GCMouse] = []
private var keyboards: [GCKeyboard] = []
// Main-queue-only state (see header comment).
private var residualX: Float = 0
private var residualY: Float = 0
private var residualScrollX: Float = 0
private var residualScrollY: Float = 0
private var pressedVKs: Set<UInt32> = []
private var pressedButtons: Set<UInt32> = []
public init(connection: LumenConnection) {
self.connection = connection
}
/// Begin forwarding the current (and future) mouse/keyboard to the host.
/// Begin forwarding the current (and future) mouse/keyboard to the host. Steals the
/// global GC handler slots from any previous capture (one live capture per process).
public func start() {
Self.activeCapture = self
if let mouse = GCMouse.current { attach(mouse: mouse) }
if let keyboard = GCKeyboard.coalesced { attach(keyboard: keyboard) }
observers.append(NotificationCenter.default.addObserver(
@@ -36,44 +60,130 @@ public final class InputCapture {
) { [weak self] n in
if let k = n.object as? GCKeyboard { self?.attach(keyboard: k) }
})
// Focus loss: GC stops delivering, so release everything still held host-side.
observers.append(NotificationCenter.default.addObserver(
forName: NSApplication.didResignActiveNotification, object: nil, queue: .main
) { [weak self] _ in
self?.releaseAll()
})
}
public func stop() {
releaseAll()
observers.forEach(NotificationCenter.default.removeObserver(_:))
observers.removeAll()
// Don't clobber the handlers if a newer capture has taken the global devices.
if Self.activeCapture === self || Self.activeCapture == nil {
for mouse in mice {
guard let input = mouse.mouseInput else { continue }
input.mouseMovedHandler = nil
input.leftButton.pressedChangedHandler = nil
input.rightButton?.pressedChangedHandler = nil
input.middleButton?.pressedChangedHandler = nil
input.auxiliaryButtons?.forEach { $0.pressedChangedHandler = nil }
input.scroll.valueChangedHandler = nil
}
for keyboard in keyboards {
keyboard.keyboardInput?.keyChangedHandler = nil
}
Self.activeCapture = nil
}
mice.removeAll()
keyboards.removeAll()
}
deinit { stop() }
/// Send release events for everything currently held, and drop the motion residuals.
private func releaseAll() {
for vk in pressedVKs {
connection.send(.key(vk, down: false))
}
for button in pressedButtons {
connection.send(.mouseButton(button, down: false))
}
pressedVKs.removeAll()
pressedButtons.removeAll()
residualX = 0
residualY = 0
residualScrollX = 0
residualScrollY = 0
}
private func sendButton(_ button: UInt32, pressed: Bool) {
if pressed {
pressedButtons.insert(button)
} else {
pressedButtons.remove(button)
}
connection.send(.mouseButton(button, down: pressed))
}
private func attach(mouse: GCMouse) {
guard let input = mouse.mouseInput else { return }
let conn = connection
input.mouseMovedHandler = { _, dx, dy in
guard let input = mouse.mouseInput,
!mice.contains(where: { $0 === mouse }) // re-delivered on wake attach once
else { return }
mice.append(mouse)
input.mouseMovedHandler = { [weak self] _, dx, dy in
guard let self else { return }
// GC gives +y up; the host expects screen-space (+y down).
conn.send(.mouseMove(dx: Int32(dx), dy: Int32(-dy)))
let fx = dx + self.residualX
let fy = -dy + self.residualY
let ix = fx.rounded(.towardZero)
let iy = fy.rounded(.towardZero)
self.residualX = fx - ix
self.residualY = fy - iy
if ix != 0 || iy != 0 {
self.connection.send(.mouseMove(dx: Int32(ix), dy: Int32(iy)))
}
}
input.leftButton.pressedChangedHandler = { _, _, pressed in
conn.send(.mouseButton(1, down: pressed))
input.leftButton.pressedChangedHandler = { [weak self] _, _, pressed in
self?.sendButton(1, pressed: pressed)
}
input.rightButton?.pressedChangedHandler = { _, _, pressed in
conn.send(.mouseButton(3, down: pressed))
input.rightButton?.pressedChangedHandler = { [weak self] _, _, pressed in
self?.sendButton(3, pressed: pressed)
}
input.middleButton?.pressedChangedHandler = { _, _, pressed in
conn.send(.mouseButton(2, down: pressed))
input.middleButton?.pressedChangedHandler = { [weak self] _, _, pressed in
self?.sendButton(2, pressed: pressed)
}
input.scroll.valueChangedHandler = { _, _, dy in
if dy != 0 { conn.send(.scroll(Int32(dy * 120))) }
// First two side buttons GameStream X1/X2.
if let aux = input.auxiliaryButtons {
for (i, button) in aux.prefix(2).enumerated() {
button.pressedChangedHandler = { [weak self] _, _, pressed in
self?.sendButton(UInt32(4 + i), pressed: pressed)
}
}
}
input.scroll.valueChangedHandler = { [weak self] _, x, y in
guard let self else { return }
// WHEEL_DELTA(120) per notch; positive = up / right (Moonlight's convention).
let fy = y * 120 + self.residualScrollY
let fx = x * 120 + self.residualScrollX
let iy = fy.rounded(.towardZero)
let ix = fx.rounded(.towardZero)
self.residualScrollY = fy - iy
self.residualScrollX = fx - ix
if iy != 0 { self.connection.send(.scroll(Int32(iy))) }
if ix != 0 { self.connection.send(.scroll(Int32(ix), horizontal: true)) }
}
}
private func attach(keyboard: GCKeyboard) {
let conn = connection
keyboard.keyboardInput?.keyChangedHandler = { _, _, keyCode, pressed in
if let vk = Self.hidToVK[keyCode.rawValue] {
conn.send(.key(vk, down: pressed))
guard !keyboards.contains(where: { $0 === keyboard }) else { return }
keyboards.append(keyboard)
keyboard.keyboardInput?.keyChangedHandler = { [weak self] _, _, keyCode, pressed in
guard let self, let vk = Self.hidToVK[keyCode.rawValue] else { return }
if pressed {
self.pressedVKs.insert(vk)
} else {
self.pressedVKs.remove(vk)
}
self.connection.send(.key(vk, down: pressed))
}
}
/// HID usage (GCKeyCode raw) Windows VK (the host maps VK evdev).
/// HID usage (GCKeyCode raw) Windows VK (the host maps VK evdev; every VK emitted
/// here exists in lumen-host/src/inject.rs::vk_to_evdev extend the two together).
static let hidToVK: [Int: UInt32] = {
var m: [Int: UInt32] = [:]
// az: HID 0x04..0x1D VK 'A'..'Z'.
@@ -90,11 +200,23 @@ public final class InputCapture {
m[0x2F] = 0xDB; m[0x30] = 0xDD; m[0x31] = 0xDC // [ ] backslash
m[0x33] = 0xBA; m[0x34] = 0xDE; m[0x35] = 0xC0 // ; ' `
m[0x36] = 0xBC; m[0x37] = 0xBE; m[0x38] = 0xBF // , . /
m[0x39] = 0x14 // caps lock
// F1..F12: HID 0x3A..0x45 VK 0x70..0x7B.
for i in 0..<12 { m[0x3A + i] = UInt32(0x70 + i) }
m[0x46] = 0x2C; m[0x47] = 0x91; m[0x48] = 0x13 // printscreen scrolllock pause
m[0x4F] = 0x27; m[0x50] = 0x25; m[0x51] = 0x28; m[0x52] = 0x26 // arrows R L D U
m[0x49] = 0x2D; m[0x4A] = 0x24; m[0x4B] = 0x21 // insert home pageup
m[0x4C] = 0x2E; m[0x4D] = 0x23; m[0x4E] = 0x22 // delete end pagedown
// Keypad: NumLock, / * - +, Enter, 1..9, 0, decimal. KP Enter goes as
// VK_SEPARATOR (0x6C) this host maps it to KEY_KPENTER (Windows itself would
// send VK_RETURN+extended, which vk_to_evdev can't distinguish).
m[0x53] = 0x90
m[0x54] = 0x6F; m[0x55] = 0x6A; m[0x56] = 0x6D; m[0x57] = 0x6B
m[0x58] = 0x6C
for i in 0..<9 { m[0x59 + i] = UInt32(0x61 + i) }
m[0x62] = 0x60; m[0x63] = 0x6E
m[0x64] = 0xE2 // ISO 102nd key (<> next to left shift on ISO layouts)
m[0x65] = 0x5D // menu/application
m[0xE0] = 0xA2; m[0xE1] = 0xA0; m[0xE2] = 0xA4; m[0xE3] = 0x5B // Lctrl Lshift Lalt Lcmd
m[0xE4] = 0xA3; m[0xE5] = 0xA1; m[0xE6] = 0xA5; m[0xE7] = 0x5C // Rctrl Rshift Ralt Rcmd
return m
@@ -1,8 +1,9 @@
// Swift wrapper around the lumen-core C ABI's lumen/1 connection API.
//
// Threading contract (mirrors the C header): one LumenConnection is used from a single
// pump thread for nextAU(); nextAudio() may run on its own (single) audio thread;
// sendInput() is enqueue-only and safe alongside both. The pointers inside an AU/audio
// Threading contract (mirrors the C header): one LumenConnection is pumped from a single
// video thread via nextAU(); nextAudio()/nextRumble() may each run on their own (single)
// drain thread the core keeps per-plane borrow slots, so the planes never alias;
// send() is enqueue-only and safe alongside all of them. The pointers inside an AU/audio
// packet are only valid until the next call of the same kind, so we copy into Data here
// the copies are small and keep the Swift side memory-safe.
//
@@ -10,12 +11,22 @@
// `hostFingerprint` reports what a trust-on-first-use connect observed persist it, e.g.
// in UserDefaults keyed by host, and pin it from then on).
//
// SCAFFOLD: written on the Linux host, not yet compiled against Xcode expect to fix
// trivial issues on first build (see README.md "Handoff").
// close() is safe from any thread: it flags the pullers to exit at their next poll
// boundary, then takes the per-plane locks (each held across its blocking C poll), so the
// handle is never freed under an in-flight call the C contract ("never close with a
// next_au/next_audio call in flight") is enforced here rather than left to callers. After
// close, the pull methods throw `.closed` and the threads unwind on their own.
import Foundation
import LumenCore
// cbindgen's C17-compatible header spells the typedefs as plain integers
// (`typedef int32_t LumenStatus`, `typedef uint8_t LumenInputKind`) while the enum
// constants import as a distinct same-named Swift type bridge by raw value once here.
private let statusOK: Int32 = LUMEN_STATUS_OK.rawValue
private let statusNoFrame: Int32 = LUMEN_STATUS_NO_FRAME.rawValue
private let statusClosed: Int32 = LUMEN_STATUS_CLOSED.rawValue
/// One reassembled, FEC-recovered, decrypted access unit (Annex-B HEVC from the host).
public struct AccessUnit: Sendable {
public let data: Data
@@ -39,10 +50,22 @@ public enum LumenClientError: Error {
/// unpinned when the caller asked for verification would be a silent trust downgrade.
case invalidPin
case closed
case status(Int32)
}
public final class LumenConnection {
private var handle: OpaquePointer?
/// Set by close() before it contends for the plane locks: the pullers see it at their
/// next poll boundary and exit, so close() can't be starved by back-to-back polls
/// (NSLock is not fair).
private var closeRequested = false
/// Serializes send()/close() against each other and guards `handle`/`closeRequested`.
private let abiLock = NSLock()
/// Held across the blocking next_au call; close() takes it (same plane-lock abiLock
/// order as the pullers) so it can never free the handle under an in-flight poll.
private let pumpLock = NSLock()
/// Same role for the audio/rumble drain thread (its own plane in the core).
private let audioLock = NSLock()
/// Negotiated session mode (host-confirmed).
public private(set) var width: UInt32 = 0
@@ -86,87 +109,141 @@ public final class LumenConnection {
self.refreshHz = hz
}
/// Pull the next access unit; nil on timeout, throws once the session is closed.
/// Pull the next access unit; nil on timeout, throws `.closed` once the session ended.
/// Call from a single pump thread.
public func nextAU(timeoutMs: UInt32 = 100) throws -> AccessUnit? {
pumpLock.lock()
defer { pumpLock.unlock() }
guard let h = liveHandle() else { throw LumenClientError.closed }
var frame = LumenFrame()
switch lumen_connection_next_au(handle, &frame, timeoutMs) {
case LUMEN_STATUS_OK:
let data = Data(bytes: frame.data, count: frame.len) // copy: ptr valid only until next call
let rc = lumen_connection_next_au(h, &frame, timeoutMs)
switch rc {
case statusOK:
guard let base = frame.data, frame.len > 0 else { return nil }
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
return AccessUnit(
data: data, ptsNs: frame.pts_ns,
frameIndex: frame.frame_index, flags: frame.flags)
case LUMEN_STATUS_NO_FRAME:
case statusNoFrame:
return nil
case LUMEN_STATUS_CLOSED:
case statusClosed:
throw LumenClientError.closed
default:
throw LumenClientError.closed
throw LumenClientError.status(rc)
}
}
/// Pull the next Opus audio packet; nil on timeout, throws once the session is closed.
/// Drain from a dedicated audio thread packets arrive every 5 ms (320 ms buffered).
/// Pull the next Opus audio packet; nil on timeout, throws `.closed` once the session
/// ended. Drain from a dedicated audio thread packets arrive every 5 ms (the core
/// buffers 320 ms and drops the newest when the puller lags).
public func nextAudio(timeoutMs: UInt32 = 100) throws -> AudioPacket? {
audioLock.lock()
defer { audioLock.unlock() }
guard let h = liveHandle() else { throw LumenClientError.closed }
var pkt = LumenAudioPacket()
switch lumen_connection_next_audio(handle, &pkt, timeoutMs) {
case LUMEN_STATUS_OK:
let data = Data(bytes: pkt.data, count: pkt.len) // copy: ptr valid only until next call
let rc = lumen_connection_next_audio(h, &pkt, timeoutMs)
switch rc {
case statusOK:
guard let base = pkt.data, pkt.len > 0 else { return nil }
let data = Data(bytes: base, count: Int(pkt.len)) // copy: ptr valid only until next call
return AudioPacket(data: data, ptsNs: pkt.pts_ns, seq: pkt.seq)
case LUMEN_STATUS_NO_FRAME:
case statusNoFrame:
return nil
default:
case statusClosed:
throw LumenClientError.closed
default:
throw LumenClientError.status(rc)
}
}
/// Pull the next force-feedback update for the GCController haptics engine:
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
public func nextRumble(timeoutMs: UInt32 = 100) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
/// Shares the audio drain thread's plane (call from that thread).
public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
audioLock.lock()
defer { audioLock.unlock() }
guard let h = liveHandle() else { throw LumenClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0
switch lumen_connection_next_rumble(handle, &pad, &low, &high, timeoutMs) {
case LUMEN_STATUS_OK:
let rc = lumen_connection_next_rumble(h, &pad, &low, &high, timeoutMs)
switch rc {
case statusOK:
return (pad, low, high)
case LUMEN_STATUS_NO_FRAME:
case statusNoFrame:
return nil
default:
case statusClosed:
throw LumenClientError.closed
default:
throw LumenClientError.status(rc)
}
}
/// Send one input event (delivered to the host as a QUIC datagram).
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
/// silently dropped after close.
public func send(_ event: LumenInputEvent) {
var ev = event
_ = lumen_connection_send_input(handle, &ev)
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
_ = lumen_connection_send_input(h, &ev)
}
/// Close the connection and free the handle. Safe from any thread, idempotent; waits
/// for in-flight pulls ( their timeouts) before tearing down.
public func close() {
if let h = handle {
lumen_connection_close(h)
handle = nil
abiLock.lock()
closeRequested = true
abiLock.unlock()
pumpLock.lock() // pullers exit at their next poll boundary, releasing these
audioLock.lock()
abiLock.lock()
let h = handle
handle = nil
abiLock.unlock()
audioLock.unlock()
pumpLock.unlock()
if let h {
lumen_connection_close(h) // joins the connection's internal Rust threads
}
}
deinit { close() }
/// Snapshot the handle unless close is pending (callers hold their plane lock).
private func liveHandle() -> OpaquePointer? {
abiLock.lock()
defer { abiLock.unlock() }
return closeRequested ? nil : handle
}
}
// Convenience constructors for the wire input events (field semantics match
// lumen_core::input::InputEvent; see lumen_core.h).
public extension LumenInputEvent {
private static func make(
_ kind: UInt32, code: UInt32, x: Int32, y: Int32, flags: UInt32 = 0
) -> LumenInputEvent {
LumenInputEvent(kind: UInt8(kind), _pad: (0, 0, 0), code: code, x: x, y: y, flags: flags)
}
static func mouseMove(dx: Int32, dy: Int32) -> LumenInputEvent {
LumenInputEvent(kind: LUMEN_INPUT_KIND_MOUSE_MOVE, _pad: (0, 0, 0), code: 0, x: dx, y: dy, flags: 0)
make(LUMEN_INPUT_KIND_MOUSE_MOVE.rawValue, code: 0, x: dx, y: dy)
}
/// GameStream button ids: 1=left 2=middle 3=right 4=X1 5=X2 (host maps to evdev BTN_*).
static func mouseButton(_ button: UInt32, down: Bool) -> LumenInputEvent {
LumenInputEvent(
kind: down ? LUMEN_INPUT_KIND_MOUSE_BUTTON_DOWN : LUMEN_INPUT_KIND_MOUSE_BUTTON_UP,
_pad: (0, 0, 0), code: button, x: 0, y: 0, flags: 0)
make(
(down ? LUMEN_INPUT_KIND_MOUSE_BUTTON_DOWN : LUMEN_INPUT_KIND_MOUSE_BUTTON_UP).rawValue,
code: button, x: 0, y: 0)
}
/// `vk` is a Windows virtual-key code (the host's vk_to_evdev table consumes these).
static func key(_ vk: UInt32, down: Bool) -> LumenInputEvent {
LumenInputEvent(
kind: down ? LUMEN_INPUT_KIND_KEY_DOWN : LUMEN_INPUT_KIND_KEY_UP,
_pad: (0, 0, 0), code: vk, x: 0, y: 0, flags: 0)
make((down ? LUMEN_INPUT_KIND_KEY_DOWN : LUMEN_INPUT_KIND_KEY_UP).rawValue, code: vk, x: 0, y: 0)
}
static func scroll(_ delta: Int32) -> LumenInputEvent {
LumenInputEvent(kind: LUMEN_INPUT_KIND_MOUSE_SCROLL, _pad: (0, 0, 0), code: 0, x: delta, y: 0, flags: 0)
/// WHEEL_DELTA(120)-scaled; positive = up (vertical) / right (horizontal) the
/// convention Moonlight/SDL use; the host maps onto the ei/wl axes.
static func scroll(_ delta: Int32, horizontal: Bool = false) -> LumenInputEvent {
make(LUMEN_INPUT_KIND_MOUSE_SCROLL.rawValue, code: horizontal ? 1 : 0, x: delta, y: 0)
}
// Gamepad (wire contract in lumen_core::input::gamepad): one transition per event,
@@ -175,16 +252,14 @@ public extension LumenInputEvent {
/// `button` is a GameStream buttonFlags bit (A=0x1000 B=0x2000 X=0x4000 Y=0x8000,
/// dpad=0x1/2/4/8, start=0x10 back=0x20 LS=0x40 RS=0x80 LB=0x100 RB=0x200 guide=0x400).
static func gamepadButton(_ button: UInt32, down: Bool, pad: UInt32 = 0) -> LumenInputEvent {
LumenInputEvent(
kind: LUMEN_INPUT_KIND_GAMEPAD_BUTTON,
_pad: (0, 0, 0), code: button, x: down ? 1 : 0, y: 0, flags: pad)
make(
LUMEN_INPUT_KIND_GAMEPAD_BUTTON.rawValue,
code: button, x: down ? 1 : 0, y: 0, flags: pad)
}
/// Axis ids: 0=LSX 1=LSY 2=RSX 3=RSY (32768...32767, XInput convention: +y = UP
/// `GCControllerDirectionPad.yAxis` already matches, no flip), 4=LT 5=RT (0...255).
static func gamepadAxis(_ axis: UInt32, value: Int32, pad: UInt32 = 0) -> LumenInputEvent {
LumenInputEvent(
kind: LUMEN_INPUT_KIND_GAMEPAD_AXIS,
_pad: (0, 0, 0), code: axis, x: value, y: 0, flags: pad)
make(LUMEN_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
}
}
+80 -20
View File
@@ -5,8 +5,8 @@
// zero-copy on Apple silicon. Stage 2 (explicit VTDecompressionSession + CAMetalLayer)
// replaces this when we start tuning frame pacing / measuring glass-to-glass.
//
// SCAFFOLD: written on the Linux host, not yet compiled against Xcode. macOS-first
// (NSViewRepresentable); the iOS variant is the same layer under UIViewRepresentable.
// macOS-first (NSViewRepresentable); the iOS variant is the same layer under
// UIViewRepresentable.
#if os(macOS)
import AVFoundation
@@ -14,70 +14,130 @@ import SwiftUI
public struct StreamView: NSViewRepresentable {
private let connection: LumenConnection
private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)?
public init(connection: LumenConnection) {
/// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI.
public init(
connection: LumenConnection,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil
) {
self.connection = connection
self.onFrame = onFrame
self.onSessionEnd = onSessionEnd
}
public func makeNSView(context: Context) -> StreamLayerView {
let view = StreamLayerView()
view.start(connection: connection)
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return view
}
public func updateNSView(_ view: StreamLayerView, context: Context) {}
public func updateNSView(_ view: StreamLayerView, context: Context) {
// SwiftUI reuses the NSView across state changes repoint the pump only when the
// connection identity actually changed.
if view.connection !== connection {
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
}
}
public static func dismantleNSView(_ view: StreamLayerView, coordinator: ()) {
view.stop()
}
}
public final class StreamLayerView: NSView {
/// Cancellation handle owned by exactly one pump thread a restart hands the old pump
/// its own token, so it can never be revived by a newer start().
private final class PumpToken: @unchecked Sendable {
private let lock = NSLock()
private var live = true
var isLive: Bool {
lock.lock()
defer { lock.unlock() }
return live
}
func cancel() {
lock.lock()
live = false
lock.unlock()
}
}
private let displayLayer = AVSampleBufferDisplayLayer()
private var pump: Thread?
private var running = false
private var token: PumpToken?
public private(set) var connection: LumenConnection?
public override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
displayLayer.videoGravity = .resizeAspect
layer = displayLayer
layer = displayLayer // layer-hosting: assign before wantsLayer
wantsLayer = true
}
public required init?(coder: NSCoder) { fatalError("not used") }
/// Pump thread: pull AUs from the connection, wrap, enqueue. The first IDR yields the
/// format description; non-IDR AUs before it are dropped (the host opens with an IDR).
public func start(connection: LumenConnection) {
guard !running else { return }
running = true
public func start(
connection: LumenConnection,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil
) {
stop()
let token = PumpToken()
self.token = token
self.connection = connection
let layer = displayLayer
let thread = Thread { [weak self] in
layer.flush() // drop any frames a previous connection left queued
let thread = Thread {
var format: CMVideoFormatDescription?
while self?.running == true {
while token.isLive {
do {
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
onFrame?(au)
if let f = AnnexB.formatDescription(fromIDR: au.data) {
format = f // refreshed on every IDR (mode changes included)
}
guard let f = format,
let sample = AnnexB.sampleBuffer(au: au, format: f)
else { continue }
if layer.status == .failed {
// Decode wedged: flush and re-gate on the next in-band parameter
// sets resuming with a delta frame can't recover. (A
// request-IDR channel on lumen/1 is a host-side TODO; with the
// host's infinite GOP this may otherwise stay black until the
// next recovery keyframe.)
layer.flush()
format = AnnexB.formatDescription(fromIDR: au.data)
}
guard let f = format,
let sample = AnnexB.sampleBuffer(au: au, format: f),
token.isLive // don't enqueue a stale frame after a restart
else { continue }
layer.enqueue(sample)
} catch {
if token.isLive {
onSessionEnd?()
}
break // session closed
}
}
}
thread.name = "lumen-pump"
thread.qualityOfService = .userInteractive
pump = thread
thread.start()
}
/// Stop pumping ( one poll timeout). Does not close the connection that stays with
/// whoever owns it (LumenConnection.close() is safe alongside a draining pump).
public func stop() {
running = false
token?.cancel()
token = nil
connection = nil
}
deinit { running = false }
deinit {
token?.cancel()
}
}
#endif