From a9d1c1606742f8fc7f21785ce64fb53a62259c6f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 10 Jun 2026 22:51:42 +0200 Subject: [PATCH] feat(apple): client-selectable compositor in the macOS client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts punktfunk_connect_ex from the compositor-selection batch: a Compositor enum on PunktfunkConnection (auto/kwin/wlroots/mutter/gamescope, with the host's name aliases for env parsing), a "Host compositor" picker in Settings (default Automatic — a concrete choice is honored only if that backend is available host-side), and PUNKTFUNK_COMPOSITOR / PUNKTFUNK_REMOTE_COMPOSITOR pass-throughs for the autoconnect dev hook and the remote first-light test. The wire change is backward-compatible (optional trailing byte), so no behavior changes at the default. Validated live against the box: host with no compositor env (auto-detect = KWin) logged "honoring client compositor request compositor=gamescope" and streamed 60/60 decoded frames from the spawned gamescope. Co-Authored-By: Claude Fable 5 --- clients/apple/README.md | 6 ++- .../Sources/PunktfunkClient/ContentView.swift | 15 +++++++- .../PunktfunkClient/SessionModel.swift | 3 +- .../PunktfunkClient/SettingsView.swift | 24 +++++++++++- .../PunktfunkKit/PunktfunkConnection.swift | 38 +++++++++++++++++-- .../RemoteFirstLightTests.swift | 7 +++- 6 files changed, 82 insertions(+), 11 deletions(-) diff --git a/clients/apple/README.md b/clients/apple/README.md index 345a626..e0a8bc3 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -44,7 +44,9 @@ What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3): trust-on-first-use fingerprint prompt over the live-but-blurred stream, and SPAKE2 PIN pairing (`PairSheet`, from a host card's context menu or the trust prompt; `ClientIdentityStore` keeps the client identity in the Keychain and presents it on - every connect) — then pinned reconnects, fps/Mb-s HUD. (Audio playback and + every connect) — then pinned reconnects, fps/Mb-s HUD. Settings also picks the HOST + compositor (KWin/wlroots/Mutter/gamescope, default automatic — the host honors it + only if that backend is available there). (Audio playback and gamepad capture are not wired into the app yet — the connector surface is there; see notes 5–6.) - **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip @@ -73,6 +75,8 @@ bash test-loopback.sh # full loopback proof: builds punktfunk # PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \ # cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 60 PUNKTFUNK_REMOTE_HOST= swift test --filter RemoteFirstLightTests # headless +# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… / +# PUNKTFUNK_REMOTE_PIN= for the remote pairing test) PUNKTFUNK_AUTOCONNECT= PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkClient # on glass ``` diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 44b6a06..932a50b 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -18,6 +18,7 @@ struct ContentView: View { @AppStorage("punktfunk.width") private var width = 1920 @AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.hz") private var hz = 60 + @AppStorage("punktfunk.compositor") private var compositor = 0 @State private var showAddHost = false @State private var pairingTarget: StoredHost? @@ -196,7 +197,9 @@ struct ContentView: View { model.connect( to: host, width: UInt32(clamping: width), height: UInt32(clamping: height), - hz: UInt32(clamping: hz)) + hz: UInt32(clamping: hz), + compositor: PunktfunkConnection.Compositor( + rawValue: UInt32(clamping: compositor)) ?? .auto) } // MARK: - Trust on first use @@ -303,7 +306,8 @@ struct ContentView: View { /// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use, /// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without - /// touching the saved host list. (IPv4/hostname only.) + /// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope|… overrides the + /// compositor preference (same names as the host env knob). (IPv4/hostname only.) private func autoConnectIfAsked() { guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"], !target.isEmpty, model.phase == .idle @@ -319,10 +323,17 @@ struct ContentView: View { hz = dims[2] } } + var pref = PunktfunkConnection.Compositor( + rawValue: UInt32(clamping: compositor)) ?? .auto + if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"], + let c = PunktfunkConnection.Compositor(name: name) { + pref = c + } model.connect( to: host, width: UInt32(clamping: width), height: UInt32(clamping: height), hz: UInt32(clamping: hz), + compositor: pref, autoTrust: true) } } diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index a05bac6..d5ac397 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -63,6 +63,7 @@ final class SessionModel: ObservableObject { var isBusy: Bool { phase != .idle } func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32, + compositor: PunktfunkConnection.Compositor = .auto, autoTrust: Bool = false) { guard phase == .idle else { return } phase = .connecting @@ -78,7 +79,7 @@ final class SessionModel: ObservableObject { let result = Result { try PunktfunkConnection( host: host.address, port: host.port, width: width, height: height, refreshHz: hz, - pinSHA256: pin, identity: identity) } + pinSHA256: pin, identity: identity, compositor: compositor) } await MainActor.run { [weak self] in guard let self else { return } switch result { diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 2fb785d..f689fd8 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -1,13 +1,16 @@ -// App settings (⌘,): the stream mode. The host creates a native virtual output at -// exactly this size/refresh — there is no scaling anywhere in the pipeline. +// App settings (⌘,): the stream mode + the host compositor. The host creates a native +// virtual output at exactly this size/refresh — there is no scaling anywhere in the +// pipeline. import AppKit +import PunktfunkKit import SwiftUI struct SettingsView: View { @AppStorage("punktfunk.width") private var width = 1920 @AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.hz") private var hz = 60 + @AppStorage("punktfunk.compositor") private var compositor = 0 var body: some View { Form { @@ -30,6 +33,23 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + Section { + Picker("Compositor", selection: $compositor) { + Text("Automatic").tag(0) + Text("KWin (KDE Plasma)").tag(1) + Text("wlroots (Sway / Hyprland)").tag(2) + Text("Mutter (GNOME)").tag(3) + Text("gamescope").tag(4) + } + } header: { + Text("Host compositor") + } footer: { + Text("Which compositor drives the virtual output on the host. A specific " + + "choice is honored only if that backend is available there — " + + "otherwise the host falls back to auto-detection.") + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) .frame(width: 380) diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift index c9db306..77dfae9 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift @@ -138,6 +138,31 @@ public final class PunktfunkConnection { /// trust-on-first-use connect, persist this and pass it as `pinSHA256` next time. public private(set) var hostFingerprint: Data = Data() + /// Compositor preference for the host's per-session virtual output (the + /// `PUNKTFUNK_COMPOSITOR_*` ABI values). `.auto` lets the host auto-detect from its + /// running desktop; a concrete backend is honored only if available on the host right + /// now — else the host falls back to auto-detect and logs the real choice. + public enum Compositor: UInt32, CaseIterable, Sendable { + case auto = 0 + case kwin = 1 + case wlroots = 2 + case mutter = 3 + case gamescope = 4 + + /// Loose name parsing for env/dev hooks ("kde" and "sway" are accepted aliases, + /// mirroring the host's `CompositorPref::from_name`). + public init?(name: String) { + switch name.lowercased() { + case "auto": self = .auto + case "kwin", "kde": self = .kwin + case "wlroots", "sway", "hyprland": self = .wlroots + case "mutter", "gnome": self = .mutter + case "gamescope": self = .gamescope + default: return nil + } + } + } + /// 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`. /// @@ -148,11 +173,15 @@ public final class PunktfunkConnection { /// `identity`: this client's persistent identity (from `generateIdentity()`, stored in /// the Keychain) — presented so a host recognizes a paired client. nil = anonymous; /// hosts running `--require-pairing` reject anonymous sessions. + /// + /// `compositor`: which backend should drive the virtual output host-side (see + /// `Compositor`; `.auto` = host decides). public init( host: String, port: UInt16 = 9777, width: UInt32, height: UInt32, refreshHz: UInt32, pinSHA256: Data? = nil, identity: ClientIdentity? = nil, + compositor: Compositor = .auto, timeoutMs: UInt32 = 10_000 ) throws { if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin } @@ -162,14 +191,15 @@ public final class PunktfunkConnection { withOptionalCString(identity?.keyPEM) { key in if let pin = pinSHA256 { return pin.withUnsafeBytes { p in - punktfunk_connect( - cs, port, width, height, refreshHz, + punktfunk_connect_ex( + cs, port, width, height, refreshHz, compositor.rawValue, p.bindMemory(to: UInt8.self).baseAddress, &observed, cert, key, timeoutMs) } } - return punktfunk_connect( - cs, port, width, height, refreshHz, nil, &observed, cert, key, timeoutMs) + return punktfunk_connect_ex( + cs, port, width, height, refreshHz, compositor.rawValue, + nil, &observed, cert, key, timeoutMs) } } } diff --git a/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift b/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift index 8a75dee..13ad618 100644 --- a/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift @@ -53,11 +53,16 @@ final class RemoteFirstLightTests: XCTestCase { throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)") } let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777 + // PUNKTFUNK_REMOTE_COMPOSITOR=kwin|gamescope|… asks the host for a specific + // backend (verify in its log: "punktfunk/1 virtual display compositor=…"). + let compositor = env["PUNKTFUNK_REMOTE_COMPOSITOR"] + .flatMap(PunktfunkConnection.Compositor.init(name:)) ?? .auto let width: UInt32 = 1280 let height: UInt32 = 720 let conn = try PunktfunkConnection( - host: host, port: port, width: width, height: height, refreshHz: 60) + host: host, port: port, width: width, height: height, refreshHz: 60, + compositor: compositor) defer { conn.close() } XCTAssertEqual(conn.width, width) XCTAssertEqual(conn.height, height)