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
@@ -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