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>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// Annex-B HEVC → CoreMedia plumbing.
|
||||
//
|
||||
// The punktfunk host emits Annex-B access units with in-band VPS/SPS/PPS on every IDR
|
||||
// (deliberately — the client needs no out-of-band extradata). VideoToolbox wants the AVCC
|
||||
// flavor instead: a CMVideoFormatDescription built from the parameter sets, and sample
|
||||
// buffers whose NALs are 4-byte-length-prefixed. This file converts between the two.
|
||||
//
|
||||
// SCAFFOLD: written on the Linux host, not yet compiled against Xcode.
|
||||
|
||||
import CoreMedia
|
||||
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)
|
||||
var i = 0
|
||||
var start = -1
|
||||
while i + 2 < bytes.count {
|
||||
if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 {
|
||||
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
|
||||
i += 3
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
if start >= 0, start < bytes.count {
|
||||
nals.append(Data(bytes[start...]))
|
||||
}
|
||||
return nals
|
||||
}
|
||||
|
||||
/// HEVC NAL unit type (bits 1..6 of the first byte).
|
||||
public static func hevcNalType(_ nal: Data) -> UInt8 {
|
||||
guard let first = nal.first else { return 0xFF }
|
||||
return (first >> 1) & 0x3F
|
||||
}
|
||||
|
||||
/// Build a format description from an IDR AU's in-band VPS(32)/SPS(33)/PPS(34).
|
||||
/// Returns nil when the AU carries no parameter sets (non-IDR).
|
||||
public static func formatDescription(fromIDR au: Data) -> CMVideoFormatDescription? {
|
||||
var vps: Data?, sps: Data?, pps: Data?
|
||||
for nal in nalUnits(in: au) {
|
||||
switch hevcNalType(nal) {
|
||||
case 32: vps = nal
|
||||
case 33: sps = nal
|
||||
case 34: pps = nal
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let vps, let sps, let pps else { return nil }
|
||||
|
||||
var format: CMVideoFormatDescription?
|
||||
let sets = [vps, sps, pps]
|
||||
let status: OSStatus = sets[0].withUnsafeBytes { v in
|
||||
sets[1].withUnsafeBytes { s in
|
||||
sets[2].withUnsafeBytes { p in
|
||||
let pointers: [UnsafePointer<UInt8>] = [
|
||||
v.bindMemory(to: UInt8.self).baseAddress!,
|
||||
s.bindMemory(to: UInt8.self).baseAddress!,
|
||||
p.bindMemory(to: UInt8.self).baseAddress!,
|
||||
]
|
||||
let sizes = [vps.count, sps.count, pps.count]
|
||||
return CMVideoFormatDescriptionCreateFromHEVCParameterSets(
|
||||
allocator: kCFAllocatorDefault,
|
||||
parameterSetCount: 3,
|
||||
parameterSetPointers: pointers,
|
||||
parameterSetSizes: sizes,
|
||||
nalUnitHeaderLength: 4,
|
||||
extensions: nil,
|
||||
formatDescriptionOut: &format)
|
||||
}
|
||||
}
|
||||
}
|
||||
return status == noErr ? format : nil
|
||||
}
|
||||
|
||||
/// Re-pack an Annex-B AU as AVCC (4-byte big-endian length before each NAL), dropping
|
||||
/// the parameter-set NALs (they live in the format description).
|
||||
public static func avcc(from au: Data) -> Data {
|
||||
var out = Data(capacity: au.count + 16)
|
||||
for nal in nalUnits(in: au) {
|
||||
let t = hevcNalType(nal)
|
||||
if t == 32 || t == 33 || t == 34 { continue } // VPS/SPS/PPS
|
||||
var len = UInt32(nal.count).bigEndian
|
||||
withUnsafeBytes(of: &len) { out.append(contentsOf: $0) }
|
||||
out.append(nal)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Wrap one AU as a decode-ready CMSampleBuffer.
|
||||
public static func sampleBuffer(
|
||||
au: AccessUnit, format: CMVideoFormatDescription
|
||||
) -> CMSampleBuffer? {
|
||||
let avccData = avcc(from: au.data)
|
||||
var blockBuffer: CMBlockBuffer?
|
||||
guard CMBlockBufferCreateWithMemoryBlock(
|
||||
allocator: kCFAllocatorDefault, memoryBlock: nil,
|
||||
blockLength: avccData.count, blockAllocator: kCFAllocatorDefault,
|
||||
customBlockSource: nil, offsetToData: 0, dataLength: avccData.count,
|
||||
flags: 0, blockBufferOut: &blockBuffer) == noErr,
|
||||
let block = blockBuffer
|
||||
else { return nil }
|
||||
let copied = avccData.withUnsafeBytes { raw in
|
||||
CMBlockBufferReplaceDataBytes(
|
||||
with: raw.baseAddress!, blockBuffer: block,
|
||||
offsetIntoDestination: 0, dataLength: avccData.count)
|
||||
}
|
||||
guard copied == noErr else { return nil }
|
||||
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: .invalid,
|
||||
presentationTimeStamp: CMTime(value: Int64(au.ptsNs), timescale: 1_000_000_000),
|
||||
decodeTimeStamp: .invalid)
|
||||
var sampleSize = avccData.count
|
||||
var sample: CMSampleBuffer?
|
||||
guard CMSampleBufferCreate(
|
||||
allocator: kCFAllocatorDefault, dataBuffer: block, dataReady: true,
|
||||
makeDataReadyCallback: nil, refcon: nil, formatDescription: format,
|
||||
sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing,
|
||||
sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize,
|
||||
sampleBufferOut: &sample) == noErr
|
||||
else { return nil }
|
||||
// Low-latency display: render on arrival, don't wait for a clock.
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample!, createIfNecessary: true) {
|
||||
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||
CFDictionarySetValue(
|
||||
dict,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||
}
|
||||
return sample
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Input capture → punktfunk/1 datagrams, via the GameController framework.
|
||||
//
|
||||
// GCMouse delivers RAW deltas (not the accelerated cursor) — exactly what the host-side
|
||||
// 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, but m3's injector path doesn't route them yet.
|
||||
//
|
||||
// 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 PunktfunkCore
|
||||
|
||||
public final class InputCapture {
|
||||
private static weak var activeCapture: InputCapture?
|
||||
|
||||
private let connection: PunktfunkConnection
|
||||
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: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
}
|
||||
|
||||
/// 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(
|
||||
forName: .GCMouseDidConnect, object: nil, queue: .main
|
||||
) { [weak self] n in
|
||||
if let m = n.object as? GCMouse { self?.attach(mouse: m) }
|
||||
})
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .GCKeyboardDidConnect, object: nil, queue: .main
|
||||
) { [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,
|
||||
!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).
|
||||
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 = { [weak self] _, _, pressed in
|
||||
self?.sendButton(1, pressed: pressed)
|
||||
}
|
||||
input.rightButton?.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
self?.sendButton(3, pressed: pressed)
|
||||
}
|
||||
input.middleButton?.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
self?.sendButton(2, pressed: pressed)
|
||||
}
|
||||
// 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) {
|
||||
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; every VK emitted
|
||||
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together).
|
||||
static let hidToVK: [Int: UInt32] = {
|
||||
var m: [Int: UInt32] = [:]
|
||||
// a–z: HID 0x04..0x1D → VK 'A'..'Z'.
|
||||
for i in 0..<26 { m[0x04 + i] = UInt32(0x41 + i) }
|
||||
// 1–9, 0: HID 0x1E..0x27 → VK '1'..'9','0'.
|
||||
for i in 0..<9 { m[0x1E + i] = UInt32(0x31 + i) }
|
||||
m[0x27] = 0x30
|
||||
m[0x28] = 0x0D // return
|
||||
m[0x29] = 0x1B // escape
|
||||
m[0x2A] = 0x08 // backspace
|
||||
m[0x2B] = 0x09 // tab
|
||||
m[0x2C] = 0x20 // space
|
||||
m[0x2D] = 0xBD; m[0x2E] = 0xBB // - =
|
||||
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
|
||||
}()
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,265 @@
|
||||
// Swift wrapper around the punktfunk-core C ABI's punktfunk/1 connection API.
|
||||
//
|
||||
// Threading contract (mirrors the C header): one PunktfunkConnection 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.
|
||||
//
|
||||
// Trust: pass the host's pinned certificate fingerprint (the host logs it at startup, and
|
||||
// `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).
|
||||
//
|
||||
// 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 PunktfunkCore
|
||||
|
||||
// cbindgen's C17-compatible header spells the typedefs as plain integers
|
||||
// (`typedef int32_t PunktfunkStatus`, `typedef uint8_t PunktfunkInputKind`) while the enum
|
||||
// constants import as a distinct same-named Swift type — bridge by raw value once here.
|
||||
private let statusOK: Int32 = PUNKTFUNK_STATUS_OK.rawValue
|
||||
private let statusNoFrame: Int32 = PUNKTFUNK_STATUS_NO_FRAME.rawValue
|
||||
private let statusClosed: Int32 = PUNKTFUNK_STATUS_CLOSED.rawValue
|
||||
|
||||
/// One reassembled, FEC-recovered, decrypted access unit (Annex-B HEVC from the host).
|
||||
public struct AccessUnit: Sendable {
|
||||
public let data: Data
|
||||
public let ptsNs: UInt64
|
||||
public let frameIndex: UInt32
|
||||
public let flags: UInt32
|
||||
}
|
||||
|
||||
/// One Opus audio packet (48 kHz stereo, 5 ms frames) — decode with AVAudioConverter
|
||||
/// (`kAudioFormatOpus`) or libopus into an AVAudioEngine source node.
|
||||
public struct AudioPacket: Sendable {
|
||||
public let data: Data
|
||||
public let ptsNs: UInt64
|
||||
public let seq: UInt32
|
||||
}
|
||||
|
||||
public enum PunktfunkClientError: Error {
|
||||
/// Connect failed — wrong host/port, timeout, or a certificate-pin mismatch.
|
||||
case connectFailed
|
||||
/// `pinSHA256` was non-nil but not exactly 32 bytes. Failing closed: connecting
|
||||
/// unpinned when the caller asked for verification would be a silent trust downgrade.
|
||||
case invalidPin
|
||||
case closed
|
||||
case status(Int32)
|
||||
}
|
||||
|
||||
public final class PunktfunkConnection {
|
||||
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
|
||||
public private(set) var height: UInt32 = 0
|
||||
public private(set) var refreshHz: UInt32 = 0
|
||||
|
||||
/// SHA-256 fingerprint of the certificate the host presented (32 bytes). After a
|
||||
/// trust-on-first-use connect, persist this and pass it as `pinSHA256` next time.
|
||||
public private(set) var hostFingerprint: Data = Data()
|
||||
|
||||
/// Connect and start a session at the requested mode (the host creates a native virtual
|
||||
/// output at exactly this size/refresh). Blocks up to `timeoutMs`.
|
||||
///
|
||||
/// `pinSHA256`: the host's expected certificate fingerprint (exactly 32 bytes, else
|
||||
/// `invalidPin` is thrown — never silently downgraded); nil = trust on first use
|
||||
/// (check `hostFingerprint` afterwards). A pinned mismatch throws.
|
||||
public init(
|
||||
host: String, port: UInt16 = 9777,
|
||||
width: UInt32, height: UInt32, refreshHz: UInt32,
|
||||
pinSHA256: Data? = nil,
|
||||
timeoutMs: UInt32 = 10_000
|
||||
) throws {
|
||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||
var observed = [UInt8](repeating: 0, count: 32)
|
||||
handle = host.withCString { cs in
|
||||
if let pin = pinSHA256 {
|
||||
return pin.withUnsafeBytes { p in
|
||||
punktfunk_connect(
|
||||
cs, port, width, height, refreshHz,
|
||||
p.bindMemory(to: UInt8.self).baseAddress, &observed, timeoutMs)
|
||||
}
|
||||
}
|
||||
return punktfunk_connect(cs, port, width, height, refreshHz, nil, &observed, timeoutMs)
|
||||
}
|
||||
guard handle != nil else { throw PunktfunkClientError.connectFailed }
|
||||
hostFingerprint = Data(observed)
|
||||
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
|
||||
self.width = w
|
||||
self.height = h
|
||||
self.refreshHz = hz
|
||||
}
|
||||
|
||||
/// 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 PunktfunkClientError.closed }
|
||||
|
||||
var frame = PunktfunkFrame()
|
||||
let rc = punktfunk_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 statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 PunktfunkClientError.closed }
|
||||
|
||||
var pkt = PunktfunkAudioPacket()
|
||||
let rc = punktfunk_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 statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next force-feedback update for the GCController haptics engine:
|
||||
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
|
||||
/// 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 PunktfunkClientError.closed }
|
||||
|
||||
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0
|
||||
let rc = punktfunk_connection_next_rumble(h, &pad, &low, &high, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return (pad, low, high)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
throw PunktfunkClientError.closed
|
||||
default:
|
||||
throw PunktfunkClientError.status(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
|
||||
/// silently dropped after close.
|
||||
public func send(_ event: PunktfunkInputEvent) {
|
||||
var ev = event
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_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() {
|
||||
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 {
|
||||
punktfunk_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
|
||||
// punktfunk_core::input::InputEvent; see punktfunk_core.h).
|
||||
public extension PunktfunkInputEvent {
|
||||
private static func make(
|
||||
_ kind: UInt32, code: UInt32, x: Int32, y: Int32, flags: UInt32 = 0
|
||||
) -> PunktfunkInputEvent {
|
||||
PunktfunkInputEvent(kind: UInt8(kind), _pad: (0, 0, 0), code: code, x: x, y: y, flags: flags)
|
||||
}
|
||||
static func mouseMove(dx: Int32, dy: Int32) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_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) -> PunktfunkInputEvent {
|
||||
make(
|
||||
(down ? PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN : PUNKTFUNK_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) -> PunktfunkInputEvent {
|
||||
make((down ? PUNKTFUNK_INPUT_KIND_KEY_DOWN : PUNKTFUNK_INPUT_KIND_KEY_UP).rawValue, code: vk, x: 0, y: 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) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_MOUSE_SCROLL.rawValue, code: horizontal ? 1 : 0, x: delta, y: 0)
|
||||
}
|
||||
|
||||
// Gamepad (wire contract in punktfunk_core::input::gamepad): one transition per event,
|
||||
// `pad` = controller index, accumulated host-side into a virtual Xbox 360 pad.
|
||||
|
||||
/// `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) -> PunktfunkInputEvent {
|
||||
make(
|
||||
PUNKTFUNK_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) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// SwiftUI presentation: AVSampleBufferDisplayLayer fed straight from the punktfunk/1 connection.
|
||||
//
|
||||
// Stage-1 presenter (see README): the layer accepts *compressed* HEVC sample buffers and
|
||||
// does hardware decode + display itself — fastest path to pixels, IOSurface-backed
|
||||
// zero-copy on Apple silicon. Stage 2 (explicit VTDecompressionSession + CAMetalLayer)
|
||||
// replaces this when we start tuning frame pacing / measuring glass-to-glass.
|
||||
//
|
||||
// macOS-first (NSViewRepresentable); the iOS variant is the same layer under
|
||||
// UIViewRepresentable.
|
||||
|
||||
#if os(macOS)
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
public struct StreamView: NSViewRepresentable {
|
||||
private let connection: PunktfunkConnection
|
||||
private let onFrame: (@Sendable (AccessUnit) -> Void)?
|
||||
private let onSessionEnd: (@Sendable () -> Void)?
|
||||
|
||||
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
||||
public init(
|
||||
connection: PunktfunkConnection,
|
||||
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, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
return view
|
||||
}
|
||||
|
||||
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 token: PumpToken?
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
|
||||
public override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
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: PunktfunkConnection,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
|
||||
onSessionEnd: (@Sendable () -> Void)? = nil
|
||||
) {
|
||||
stop()
|
||||
let token = PumpToken()
|
||||
self.token = token
|
||||
self.connection = connection
|
||||
let layer = displayLayer
|
||||
layer.flush() // drop any frames a previous connection left queued
|
||||
|
||||
let thread = Thread {
|
||||
var format: CMVideoFormatDescription?
|
||||
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)
|
||||
}
|
||||
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 punktfunk/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 = "punktfunk-pump"
|
||||
thread.qualityOfService = .userInteractive
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||
public func stop() {
|
||||
token?.cancel()
|
||||
token = nil
|
||||
connection = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
token?.cancel()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user