rename: lumen → punktfunk, everywhere
ci / rust (push) Has been cancelled

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:
2026-06-10 13:11:59 +00:00
parent b8b23c8fb2
commit bfd64ce871
119 changed files with 1245 additions and 1185 deletions
+6 -6
View File
@@ -1,18 +1,18 @@
# lumen Android client (later)
# punktfunk Android client (later)
Kotlin UI + MediaCodec (decode) + a thin JNI layer over the `lumen-core` C ABI.
Kotlin UI + MediaCodec (decode) + a thin JNI layer over the `punktfunk-core` C ABI.
## Wiring
1. Build the core as a shared library per Android ABI:
```sh
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
cargo build -p lumen-core --release --target aarch64-linux-android # liblumen_core.so
cargo build -p punktfunk-core --release --target aarch64-linux-android # libpunktfunk_core.so
```
(Use `cargo-ndk` to handle the NDK toolchain/linker.)
2. JNI shim: small C/Rust glue mapping `lumen_*` to Kotlin `external fun`s, bundling
`liblumen_core.so` into the APK's `jniLibs/`.
3. Kotlin: client `LumenSession` → `lumen_client_poll_frame` on a decode thread → feed
2. JNI shim: small C/Rust glue mapping `punktfunk_*` to Kotlin `external fun`s, bundling
`libpunktfunk_core.so` into the APK's `jniLibs/`.
3. Kotlin: client `PunktfunkSession` → `punktfunk_client_poll_frame` on a decode thread → feed
`MediaCodec` → render to a `SurfaceView` aligned to the display refresh.
## Status
+11 -11
View File
@@ -1,21 +1,21 @@
// swift-tools-version: 5.9
// LumenKit Swift wrapper around the lumen-core C ABI (lumen/1 client connector) plus the
// SwiftUI/VideoToolbox presentation layer. Build LumenCore.xcframework first:
// PunktfunkKit Swift wrapper around the punktfunk-core C ABI (punktfunk/1 client connector) plus the
// SwiftUI/VideoToolbox presentation layer. Build PunktfunkCore.xcframework first:
// bash ../../scripts/build-xcframework.sh (on a Mac; see README.md)
import PackageDescription
let package = Package(
name: "LumenKit",
name: "PunktfunkKit",
platforms: [.macOS(.v14), .iOS(.v17)],
products: [
.library(name: "LumenKit", targets: ["LumenKit"]),
.executable(name: "LumenClient", targets: ["LumenClient"]),
.library(name: "PunktfunkKit", targets: ["PunktfunkKit"]),
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
],
targets: [
.binaryTarget(name: "LumenCore", path: "LumenCore.xcframework"),
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
.target(
name: "LumenKit",
dependencies: ["LumenCore"],
name: "PunktfunkKit",
dependencies: ["PunktfunkCore"],
linkerSettings: [
// Rust staticlib system deps.
.linkedFramework("Security"),
@@ -23,8 +23,8 @@ let package = Package(
.linkedLibrary("resolv"),
]
),
// Development app shell (swift run LumenClient): connect form stream + input.
.executableTarget(name: "LumenClient", dependencies: ["LumenKit"]),
.testTarget(name: "LumenKitTests", dependencies: ["LumenKit"]),
// Development app shell (swift run PunktfunkClient): connect form stream + input.
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]),
]
)
+20 -20
View File
@@ -1,31 +1,31 @@
# lumen Apple client (SwiftUI)
# punktfunk Apple client (SwiftUI)
The native macOS/iOS client for **`lumen/1`** (the post-GameStream protocol). All
The native macOS/iOS client for **`punktfunk/1`** (the post-GameStream protocol). All
networking/protocol work — QUIC control plane, UDP data plane, GF(2¹⁶) FEC, AES-GCM,
input datagrams, Opus audio, cert pinning — lives in the shared Rust core (statically
linked as `LumenCore.xcframework`); this package is the Swift shell: decode
linked as `PunktfunkCore.xcframework`); this package is the Swift shell: decode
(VideoToolbox), present (SwiftUI), input capture.
## Status — first light achieved (2026-06-10)
Validated live, Mac ↔ Linux box over the LAN: gamescope virtual output → NVENC HEVC →
`lumen/1` (GF(2¹⁶) FEC + AES-GCM over UDP, QUIC control) → VideoToolbox →
`punktfunk/1` (GF(2¹⁶) FEC + AES-GCM over UDP, QUIC control) → VideoToolbox →
`AVSampleBufferDisplayLayer` on glass at 1280×720@60, with mouse/keyboard flowing back as
QUIC datagrams into the host's gamescope EIS injector (thousands of events injected during
the session). Headless variant of the same proof: `RemoteFirstLightTests` decoded 60/60
received AUs spanning 983 ms of host capture clock.
The connector underneath (`lumen_core::client::NativeClient` over the C ABI) carries the
The connector underneath (`punktfunk_core::client::NativeClient` over the C ABI) carries the
full session: video AUs, **Opus audio** (`nextAudio()`), **rumble** (`nextRumble()`),
input incl. gamepads, and **cert pinning + TOFU** (`pinSHA256:`/`hostFingerprint`) — see
`m3.rs::tests::c_abi_connection_roundtrip` (three sequential sessions: TOFU, pinned
reconnect, wrong-pin rejection). The host (`lumen-host m3-host`) is a persistent listener:
reconnect, wrong-pin rejection). The host (`punktfunk-host m3-host`) is a persistent listener:
reconnect at will during development.
What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
- **`LumenKit`** (library)
- `LumenConnection.swift` — wrapper over the C ABI. AUs/audio are copied into `Data`
- **`PunktfunkKit`** (library)
- `PunktfunkConnection.swift` — wrapper over the C ABI. AUs/audio are copied into `Data`
(the C pointer is only valid until the next call of the same kind). `close()` is safe
from any thread: per-plane locks enforce the C contract ("never close with a
`next_au`/`next_audio` in flight") instead of leaving it to callers. Pinning + TOFU
@@ -39,7 +39,7 @@ What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
`vk_to_evdev` consumes Windows VKs), with fractional-delta accumulation so sub-pixel
motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2); scroll is
WHEEL_DELTA(120)-scaled.
- **`LumenClient`** (development app shell): connect form → stream + input, fps/Mb-s HUD.
- **`PunktfunkClient`** (development app shell): connect form → stream + input, fps/Mb-s HUD.
(Audio playback and gamepad capture are not wired into the app yet — the connector
surface is there; see notes 56.)
- **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip
@@ -51,29 +51,29 @@ What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3):
```sh
rustup target add aarch64-apple-darwin x86_64-apple-darwin
bash scripts/build-xcframework.sh # → clients/apple/LumenCore.xcframework
bash scripts/build-xcframework.sh # → clients/apple/PunktfunkCore.xcframework
cd clients/apple
swift build && swift test # loopback/remote tests self-skip without a host
swift run LumenClient # the app; or open Package.swift in Xcode
swift run PunktfunkClient # the app; or open Package.swift in Xcode
bash test-loopback.sh # full loopback proof: builds lumen-host
bash test-loopback.sh # full loopback proof: builds punktfunk-host
# (synthetic source — runs on macOS), streams
# byte-verified frames into the Swift client
# against the real host (Linux box, see CLAUDE.md "Running on this box") — m3-host is a
# persistent listener, reconnect at will:
# LUMEN_COMPOSITOR=gamescope LUMEN_GAMESCOPE_APP=vkcube LUMEN_ZEROCOPY=1 \
# cargo run -rp lumen-host -- m3-host --source virtual --seconds 60
LUMEN_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
LUMEN_AUTOCONNECT=<box-ip> LUMEN_MODE=1280x720x60 swift run LumenClient # on glass
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
# cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 60
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkClient # on glass
```
## Notes for whoever picks this up next
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
C17-compatible header spells `LumenStatus`/`LumenInputKind` as integer typedefs while
C17-compatible header spells `PunktfunkStatus`/`PunktfunkInputKind` as integer typedefs while
the enum *constants* import into Swift as a distinct same-named type — bridge with
`.rawValue` (see the top of `LumenConnection.swift`). Don't fight the generated header.
`.rawValue` (see the top of `PunktfunkConnection.swift`). Don't fight the generated header.
2. **ABI contract**: one video pump thread per connection, plus optionally one *separate*
audio drain thread for `nextAudio()`/`nextRumble()` (the core keeps per-plane borrow
slots, so the planes never alias); `send()` is enqueue-only and safe alongside all of
@@ -91,7 +91,7 @@ LUMEN_AUTOCONNECT=<box-ip> LUMEN_MODE=1280x720x60 swift run LumenClient # on gla
`AVAudioEngine` source node; conceal gaps (drop/dup) rather than blocking — the Rust
side buffers 320 ms and drops the newest packet when the puller lags. Wall-clock
`ptsNs` shares the host clock with video AUs for A/V sync. Wiring this into
`LumenClient` is the next app-side task.
`PunktfunkClient` is the next app-side task.
6. **Gamepads**: `GCController``.gamepadButton(...)`/`.gamepadAxis(...)` events (wire
contract documented on the constructors; the host accumulates them into a virtual
Xbox 360 pad). Poll `nextRumble()` and feed `GCDeviceHaptics` for force feedback.
@@ -99,7 +99,7 @@ LUMEN_AUTOCONNECT=<box-ip> LUMEN_MODE=1280x720x60 swift run LumenClient # on gla
7. **Trust**: connect once with `pinSHA256: nil` (TOFU), persist `hostFingerprint` keyed
by host, pass it on every later connect — a mismatch throws `.connectFailed`. The host
logs its fingerprint at startup ("clients pin this fingerprint") for out-of-band
verification UX; a PIN-style pairing ceremony is a later lumen-core task. `LumenClient`
verification UX; a PIN-style pairing ceremony is a later punktfunk-core task. `PunktfunkClient`
doesn't persist fingerprints yet — add it alongside the "add host" UX.
8. **Input capture caveats** (stage 1): GC handlers only fire while the app has focus —
on focus loss `InputCapture` auto-releases everything still held (keys + buttons) so
@@ -1,16 +1,16 @@
// Connect form live stream. Stage-1 UX: pick host + mode, see frames, type/aim.
import AppKit
import LumenKit
import PunktfunkKit
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
@AppStorage("punktfunk.host") private var host = "192.168.1.70"
@AppStorage("punktfunk.port") private var port = 9777
@AppStorage("punktfunk.width") private var width = 1920
@AppStorage("punktfunk.height") private var height = 1080
@AppStorage("punktfunk.hz") private var hz = 60
var body: some View {
Group {
@@ -24,17 +24,17 @@ struct ContentView: View {
.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
/// Development hook: PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately at the saved
/// (or PUNKTFUNK_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"],
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_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"] {
if let mode = ProcessInfo.processInfo.environment["PUNKTFUNK_MODE"] {
let dims = mode.split(separator: "x").compactMap { Int($0) }
if dims.count == 3 {
width = dims[0]
@@ -48,7 +48,7 @@ struct ContentView: View {
hz: UInt32(clamping: hz))
}
private func stream(_ conn: LumenConnection) -> some View {
private func stream(_ conn: PunktfunkConnection) -> some View {
StreamView(
connection: conn,
onFrame: { [meter = model.meter] au in meter.note(byteCount: au.data.count) },
@@ -61,7 +61,7 @@ struct ContentView: View {
.background(Color.black)
}
private func hud(_ conn: LumenConnection) -> some View {
private func hud(_ conn: PunktfunkConnection) -> 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))
@@ -76,7 +76,7 @@ struct ContentView: View {
private var connectForm: some View {
VStack(spacing: 14) {
Text("lumen").font(.largeTitle.weight(.semibold))
Text("punktfunk").font(.largeTitle.weight(.semibold))
Form {
TextField("Host", text: $host)
TextField("Port", value: $port, format: .number.grouping(.never))
@@ -1,15 +1,15 @@
// LumenClient development app shell around LumenKit (swift run LumenClient).
// PunktfunkClient development app shell around PunktfunkKit (swift run PunktfunkClient).
// Connect form StreamView (AVSampleBufferDisplayLayer HEVC) + InputCapture.
import AppKit
import SwiftUI
@main
struct LumenClientApp: App {
struct PunktfunkClientApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup("lumen") {
WindowGroup("punktfunk") {
ContentView()
}
}
@@ -2,7 +2,7 @@
// pump-thread main-actor stats relay.
import Foundation
import LumenKit
import PunktfunkKit
import SwiftUI
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
@@ -35,7 +35,7 @@ final class FrameMeter: @unchecked Sendable {
@MainActor
final class SessionModel: ObservableObject {
@Published var connection: LumenConnection?
@Published var connection: PunktfunkConnection?
@Published var connecting = false
@Published var errorMessage: String?
@Published var fps = 0
@@ -51,8 +51,8 @@ final class SessionModel: ObservableObject {
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(
// PunktfunkConnection.init blocks on the QUIC handshake keep it off the main actor.
let result = Result { try PunktfunkConnection(
host: host, port: port, width: width, height: height, refreshHz: hz) }
await MainActor.run { [weak self] in
guard let self else { return }
@@ -64,7 +64,7 @@ final class SessionModel: ObservableObject {
self.startStatsTimer()
case .failure:
self.errorMessage = "Connection failed — is the host running? " +
"(lumen-host m3-host on \(host):\(port))"
"(punktfunk-host m3-host on \(host):\(port))"
}
}
}
@@ -92,7 +92,7 @@ final class SessionModel: ObservableObject {
errorMessage = "Session ended by host."
}
private func startInput(_ conn: LumenConnection) {
private func startInput(_ conn: PunktfunkConnection) {
let capture = InputCapture(connection: conn)
capture.start()
inputCapture = capture
@@ -1,6 +1,6 @@
// Annex-B HEVC CoreMedia plumbing.
//
// The lumen host emits Annex-B access units with in-band VPS/SPS/PPS on every IDR
// 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.
@@ -1,4 +1,4 @@
// Input capture lumen/1 datagrams, via the GameController framework.
// 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
@@ -22,12 +22,12 @@
import AppKit
import Foundation
import GameController
import LumenCore
import PunktfunkCore
public final class InputCapture {
private static weak var activeCapture: InputCapture?
private let connection: LumenConnection
private let connection: PunktfunkConnection
private var observers: [NSObjectProtocol] = []
private var mice: [GCMouse] = []
private var keyboards: [GCKeyboard] = []
@@ -40,7 +40,7 @@ public final class InputCapture {
private var pressedVKs: Set<UInt32> = []
private var pressedButtons: Set<UInt32> = []
public init(connection: LumenConnection) {
public init(connection: PunktfunkConnection) {
self.connection = connection
}
@@ -183,7 +183,7 @@ public final class InputCapture {
}
/// 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).
/// here exists in punktfunk-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'.
@@ -1,6 +1,6 @@
// Swift wrapper around the lumen-core C ABI's lumen/1 connection API.
// Swift wrapper around the punktfunk-core C ABI's punktfunk/1 connection API.
//
// Threading contract (mirrors the C header): one LumenConnection is pumped from a single
// 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
@@ -18,14 +18,14 @@
// close, the pull methods throw `.closed` and the threads unwind on their own.
import Foundation
import LumenCore
import PunktfunkCore
// cbindgen's C17-compatible header spells the typedefs as plain integers
// (`typedef int32_t LumenStatus`, `typedef uint8_t LumenInputKind`) while the enum
// (`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 = LUMEN_STATUS_OK.rawValue
private let statusNoFrame: Int32 = LUMEN_STATUS_NO_FRAME.rawValue
private let statusClosed: Int32 = LUMEN_STATUS_CLOSED.rawValue
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 {
@@ -43,7 +43,7 @@ public struct AudioPacket: Sendable {
public let seq: UInt32
}
public enum LumenClientError: Error {
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
@@ -53,7 +53,7 @@ public enum LumenClientError: Error {
case status(Int32)
}
public final class LumenConnection {
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
@@ -88,22 +88,22 @@ public final class LumenConnection {
pinSHA256: Data? = nil,
timeoutMs: UInt32 = 10_000
) throws {
if let pin = pinSHA256, pin.count != 32 { throw LumenClientError.invalidPin }
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
lumen_connect(
punktfunk_connect(
cs, port, width, height, refreshHz,
p.bindMemory(to: UInt8.self).baseAddress, &observed, timeoutMs)
}
}
return lumen_connect(cs, port, width, height, refreshHz, nil, &observed, timeoutMs)
return punktfunk_connect(cs, port, width, height, refreshHz, nil, &observed, timeoutMs)
}
guard handle != nil else { throw LumenClientError.connectFailed }
guard handle != nil else { throw PunktfunkClientError.connectFailed }
hostFingerprint = Data(observed)
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
_ = lumen_connection_mode(handle, &w, &h, &hz)
_ = punktfunk_connection_mode(handle, &w, &h, &hz)
self.width = w
self.height = h
self.refreshHz = hz
@@ -114,10 +114,10 @@ public final class LumenConnection {
public func nextAU(timeoutMs: UInt32 = 100) throws -> AccessUnit? {
pumpLock.lock()
defer { pumpLock.unlock() }
guard let h = liveHandle() else { throw LumenClientError.closed }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var frame = LumenFrame()
let rc = lumen_connection_next_au(h, &frame, timeoutMs)
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 }
@@ -128,9 +128,9 @@ public final class LumenConnection {
case statusNoFrame:
return nil
case statusClosed:
throw LumenClientError.closed
throw PunktfunkClientError.closed
default:
throw LumenClientError.status(rc)
throw PunktfunkClientError.status(rc)
}
}
@@ -140,10 +140,10 @@ public final class LumenConnection {
public func nextAudio(timeoutMs: UInt32 = 100) throws -> AudioPacket? {
audioLock.lock()
defer { audioLock.unlock() }
guard let h = liveHandle() else { throw LumenClientError.closed }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pkt = LumenAudioPacket()
let rc = lumen_connection_next_audio(h, &pkt, timeoutMs)
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 }
@@ -152,9 +152,9 @@ public final class LumenConnection {
case statusNoFrame:
return nil
case statusClosed:
throw LumenClientError.closed
throw PunktfunkClientError.closed
default:
throw LumenClientError.status(rc)
throw PunktfunkClientError.status(rc)
}
}
@@ -164,30 +164,30 @@ public final class LumenConnection {
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 }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0
let rc = lumen_connection_next_rumble(h, &pad, &low, &high, timeoutMs)
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 LumenClientError.closed
throw PunktfunkClientError.closed
default:
throw LumenClientError.status(rc)
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: LumenInputEvent) {
public func send(_ event: PunktfunkInputEvent) {
var ev = event
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
_ = lumen_connection_send_input(h, &ev)
_ = punktfunk_connection_send_input(h, &ev)
}
/// Close the connection and free the handle. Safe from any thread, idempotent; waits
@@ -205,7 +205,7 @@ public final class LumenConnection {
audioLock.unlock()
pumpLock.unlock()
if let h {
lumen_connection_close(h) // joins the connection's internal Rust threads
punktfunk_connection_close(h) // joins the connection's internal Rust threads
}
}
@@ -220,46 +220,46 @@ public final class LumenConnection {
}
// Convenience constructors for the wire input events (field semantics match
// lumen_core::input::InputEvent; see lumen_core.h).
public extension LumenInputEvent {
// 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
) -> LumenInputEvent {
LumenInputEvent(kind: UInt8(kind), _pad: (0, 0, 0), code: code, x: x, y: y, flags: flags)
) -> PunktfunkInputEvent {
PunktfunkInputEvent(kind: UInt8(kind), _pad: (0, 0, 0), code: code, x: x, y: y, flags: flags)
}
static func mouseMove(dx: Int32, dy: Int32) -> LumenInputEvent {
make(LUMEN_INPUT_KIND_MOUSE_MOVE.rawValue, code: 0, x: dx, y: dy)
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) -> LumenInputEvent {
static func mouseButton(_ button: UInt32, down: Bool) -> PunktfunkInputEvent {
make(
(down ? LUMEN_INPUT_KIND_MOUSE_BUTTON_DOWN : LUMEN_INPUT_KIND_MOUSE_BUTTON_UP).rawValue,
(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) -> LumenInputEvent {
make((down ? LUMEN_INPUT_KIND_KEY_DOWN : LUMEN_INPUT_KIND_KEY_UP).rawValue, code: vk, x: 0, y: 0)
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) -> LumenInputEvent {
make(LUMEN_INPUT_KIND_MOUSE_SCROLL.rawValue, code: horizontal ? 1 : 0, x: delta, y: 0)
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 lumen_core::input::gamepad): one transition per event,
// 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) -> LumenInputEvent {
static func gamepadButton(_ button: UInt32, down: Bool, pad: UInt32 = 0) -> PunktfunkInputEvent {
make(
LUMEN_INPUT_KIND_GAMEPAD_BUTTON.rawValue,
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) -> LumenInputEvent {
make(LUMEN_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
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)
}
}
@@ -1,4 +1,4 @@
// SwiftUI presentation: AVSampleBufferDisplayLayer fed straight from the lumen/1 connection.
// 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
@@ -13,13 +13,13 @@ import AVFoundation
import SwiftUI
public struct StreamView: NSViewRepresentable {
private let connection: LumenConnection
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: LumenConnection,
connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil
) {
@@ -67,7 +67,7 @@ public final class StreamLayerView: NSView {
private let displayLayer = AVSampleBufferDisplayLayer()
private var token: PumpToken?
public private(set) var connection: LumenConnection?
public private(set) var connection: PunktfunkConnection?
public override init(frame: NSRect) {
super.init(frame: frame)
@@ -81,7 +81,7 @@ public final class StreamLayerView: NSView {
/// 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,
connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil
) {
@@ -104,7 +104,7 @@ public final class StreamLayerView: NSView {
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
// 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()
@@ -123,13 +123,13 @@ public final class StreamLayerView: NSView {
}
}
}
thread.name = "lumen-pump"
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 (LumenConnection.close() is safe alongside a draining pump).
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
public func stop() {
token?.cancel()
token = nil
@@ -2,7 +2,7 @@
// VideoToolboxRoundTripTests covers the real-bitstream path).
import XCTest
@testable import LumenKit
@testable import PunktfunkKit
final class AnnexBTests: XCTestCase {
/// NAL with the given HEVC type in bits 1..6 of the first header byte.
@@ -1,20 +1,20 @@
// Integration: the Swift wrapper against a real lumen/1 host over QUIC + UDP on loopback
// the Swift twin of lumen-host's m3.rs::c_abi_connection_roundtrip, this time through the
// Integration: the Swift wrapper against a real punktfunk/1 host over QUIC + UDP on loopback
// the Swift twin of punktfunk-host's m3.rs::c_abi_connection_roundtrip, this time through the
// statically linked xcframework. Driven by clients/apple/test-loopback.sh, which builds and
// starts `lumen-host m3-host --source synthetic` and sets LUMEN_LOOPBACK_PORT.
// starts `punktfunk-host m3-host --source synthetic` and sets PUNKTFUNK_LOOPBACK_PORT.
import XCTest
@testable import LumenKit
@testable import PunktfunkKit
final class LoopbackIntegrationTests: XCTestCase {
func testSyntheticStreamRoundTrip() throws {
guard let portStr = ProcessInfo.processInfo.environment["LUMEN_LOOPBACK_PORT"],
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
let port = UInt16(portStr)
else {
throw XCTSkip("needs a running m3-host — use clients/apple/test-loopback.sh")
}
let conn = try LumenConnection(
let conn = try PunktfunkConnection(
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60)
XCTAssertEqual(conn.width, 1280)
XCTAssertEqual(conn.height, 720)
@@ -49,7 +49,7 @@ final class LoopbackIntegrationTests: XCTestCase {
conn.close()
XCTAssertThrowsError(try conn.nextAU(timeoutMs: 10)) { error in
guard case LumenClientError.closed = error else {
guard case PunktfunkClientError.closed = error else {
return XCTFail("expected .closed, got \(error)")
}
}
@@ -58,7 +58,7 @@ final class LoopbackIntegrationTests: XCTestCase {
func testConnectFailureThrows() {
// Nothing listens on this port; connect must fail within its timeout, not hang.
XCTAssertThrowsError(
try LumenConnection(
try PunktfunkConnection(
host: "127.0.0.1", port: 9, width: 640, height: 480, refreshHz: 30,
timeoutMs: 2000))
}
@@ -4,25 +4,25 @@
// putting the layer on glass.
//
// Run (host side, on the Linux box):
// LUMEN_COMPOSITOR=gamescope LUMEN_GAMESCOPE_APP=vkcube LUMEN_ZEROCOPY=1 \
// lumen-host m3-host --source virtual --seconds 120
// PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
// punktfunk-host m3-host --source virtual --seconds 120
// Then here:
// LUMEN_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
// PUNKTFUNK_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
import CoreMedia
import VideoToolbox
import XCTest
@testable import LumenKit
@testable import PunktfunkKit
final class RemoteFirstLightTests: XCTestCase {
func testRemoteStreamDecodesToPixels() throws {
guard let host = ProcessInfo.processInfo.environment["LUMEN_REMOTE_HOST"] else {
throw XCTSkip("set LUMEN_REMOTE_HOST (and start m3-host --source virtual there)")
guard let host = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_HOST"] else {
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
}
let width: UInt32 = 1280
let height: UInt32 = 720
let conn = try LumenConnection(
let conn = try PunktfunkConnection(
host: host, width: width, height: height, refreshHz: 60)
defer { conn.close() }
XCTAssertEqual(conn.width, width)
@@ -1,13 +1,13 @@
// Real-bitstream proof of the decode-prep path: VTCompressionSession encodes HEVC, we
// rebuild the host's wire shape (Annex-B AU with in-band VPS/SPS/PPS exactly what
// lumen-host emits on every IDR), run it through AnnexB, and hand the result to a real
// punktfunk-host emits on every IDR), run it through AnnexB, and hand the result to a real
// VTDecompressionSession. Pixels out = the whole client decode path is sound.
import AVFoundation
import CoreMedia
import VideoToolbox
import XCTest
@testable import LumenKit
@testable import PunktfunkKit
final class VideoToolboxRoundTripTests: XCTestCase {
private let width = 320
+5 -5
View File
@@ -1,17 +1,17 @@
#!/usr/bin/env bash
# Loopback integration: a real lumen/1 host (synthetic source — pure protocol, runs fine on
# Loopback integration: a real punktfunk/1 host (synthetic source — pure protocol, runs fine on
# macOS) on 127.0.0.1, then the Swift integration tests against it through the xcframework.
# The m3 host serves exactly one session and exits; the trap is just for failure paths.
set -euo pipefail
cd "$(dirname "$0")/../.."
PORT="${LUMEN_LOOPBACK_PORT:-19778}"
PORT="${PUNKTFUNK_LOOPBACK_PORT:-19778}"
cargo build --release -p lumen-host
target/release/lumen-host m3-host --port "$PORT" --source synthetic --frames 300 &
cargo build --release -p punktfunk-host
target/release/punktfunk-host m3-host --port "$PORT" --source synthetic --frames 300 &
HOST_PID=$!
trap 'kill "$HOST_PID" 2>/dev/null || true' EXIT
sleep 1
cd clients/apple
LUMEN_LOOPBACK_PORT="$PORT" swift test --filter LoopbackIntegrationTests
PUNKTFUNK_LOOPBACK_PORT="$PORT" swift test --filter LoopbackIntegrationTests