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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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;
|
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
|
`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
|
gamepad capture are not wired into the app yet — the connector surface is there; see
|
||||||
notes 5–6.)
|
notes 5–6.)
|
||||||
- **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip
|
- **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 \
|
# PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
|
||||||
# cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 60
|
# cargo run -rp punktfunk-host -- m3-host --source virtual --seconds 60
|
||||||
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
|
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests # headless
|
||||||
|
# (+ PUNKTFUNK_REMOTE_PORT / PUNKTFUNK_REMOTE_COMPOSITOR=gamescope|kwin|… /
|
||||||
|
# PUNKTFUNK_REMOTE_PIN=<arming-pin> for the remote pairing test)
|
||||||
PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkClient # on glass
|
PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkClient # on glass
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct ContentView: View {
|
|||||||
@AppStorage("punktfunk.width") private var width = 1920
|
@AppStorage("punktfunk.width") private var width = 1920
|
||||||
@AppStorage("punktfunk.height") private var height = 1080
|
@AppStorage("punktfunk.height") private var height = 1080
|
||||||
@AppStorage("punktfunk.hz") private var hz = 60
|
@AppStorage("punktfunk.hz") private var hz = 60
|
||||||
|
@AppStorage("punktfunk.compositor") private var compositor = 0
|
||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
|
|
||||||
@@ -196,7 +197,9 @@ struct ContentView: View {
|
|||||||
model.connect(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
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
|
// MARK: - Trust on first use
|
||||||
@@ -303,7 +306,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
/// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use,
|
/// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use,
|
||||||
/// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without
|
/// 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() {
|
private func autoConnectIfAsked() {
|
||||||
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
|
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
|
||||||
!target.isEmpty, model.phase == .idle
|
!target.isEmpty, model.phase == .idle
|
||||||
@@ -319,10 +323,17 @@ struct ContentView: View {
|
|||||||
hz = dims[2]
|
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(
|
model.connect(
|
||||||
to: host,
|
to: host,
|
||||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
hz: UInt32(clamping: hz),
|
hz: UInt32(clamping: hz),
|
||||||
|
compositor: pref,
|
||||||
autoTrust: true)
|
autoTrust: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ final class SessionModel: ObservableObject {
|
|||||||
var isBusy: Bool { phase != .idle }
|
var isBusy: Bool { phase != .idle }
|
||||||
|
|
||||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||||
|
compositor: PunktfunkConnection.Compositor = .auto,
|
||||||
autoTrust: Bool = false) {
|
autoTrust: Bool = false) {
|
||||||
guard phase == .idle else { return }
|
guard phase == .idle else { return }
|
||||||
phase = .connecting
|
phase = .connecting
|
||||||
@@ -78,7 +79,7 @@ final class SessionModel: ObservableObject {
|
|||||||
let result = Result { try PunktfunkConnection(
|
let result = Result { try PunktfunkConnection(
|
||||||
host: host.address, port: host.port,
|
host: host.address, port: host.port,
|
||||||
width: width, height: height, refreshHz: hz,
|
width: width, height: height, refreshHz: hz,
|
||||||
pinSHA256: pin, identity: identity) }
|
pinSHA256: pin, identity: identity, compositor: compositor) }
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch result {
|
switch result {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
// App settings (⌘,): the stream mode. The host creates a native virtual output at
|
// App settings (⌘,): the stream mode + the host compositor. The host creates a native
|
||||||
// exactly this size/refresh — there is no scaling anywhere in the pipeline.
|
// virtual output at exactly this size/refresh — there is no scaling anywhere in the
|
||||||
|
// pipeline.
|
||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@AppStorage("punktfunk.width") private var width = 1920
|
@AppStorage("punktfunk.width") private var width = 1920
|
||||||
@AppStorage("punktfunk.height") private var height = 1080
|
@AppStorage("punktfunk.height") private var height = 1080
|
||||||
@AppStorage("punktfunk.hz") private var hz = 60
|
@AppStorage("punktfunk.hz") private var hz = 60
|
||||||
|
@AppStorage("punktfunk.compositor") private var compositor = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -30,6 +33,23 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.formStyle(.grouped)
|
||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
|
|||||||
@@ -138,6 +138,31 @@ public final class PunktfunkConnection {
|
|||||||
/// trust-on-first-use connect, persist this and pass it as `pinSHA256` next time.
|
/// trust-on-first-use connect, persist this and pass it as `pinSHA256` next time.
|
||||||
public private(set) var hostFingerprint: Data = Data()
|
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
|
/// 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`.
|
/// 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
|
/// `identity`: this client's persistent identity (from `generateIdentity()`, stored in
|
||||||
/// the Keychain) — presented so a host recognizes a paired client. nil = anonymous;
|
/// the Keychain) — presented so a host recognizes a paired client. nil = anonymous;
|
||||||
/// hosts running `--require-pairing` reject anonymous sessions.
|
/// hosts running `--require-pairing` reject anonymous sessions.
|
||||||
|
///
|
||||||
|
/// `compositor`: which backend should drive the virtual output host-side (see
|
||||||
|
/// `Compositor`; `.auto` = host decides).
|
||||||
public init(
|
public init(
|
||||||
host: String, port: UInt16 = 9777,
|
host: String, port: UInt16 = 9777,
|
||||||
width: UInt32, height: UInt32, refreshHz: UInt32,
|
width: UInt32, height: UInt32, refreshHz: UInt32,
|
||||||
pinSHA256: Data? = nil,
|
pinSHA256: Data? = nil,
|
||||||
identity: ClientIdentity? = nil,
|
identity: ClientIdentity? = nil,
|
||||||
|
compositor: Compositor = .auto,
|
||||||
timeoutMs: UInt32 = 10_000
|
timeoutMs: UInt32 = 10_000
|
||||||
) throws {
|
) throws {
|
||||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||||
@@ -162,14 +191,15 @@ public final class PunktfunkConnection {
|
|||||||
withOptionalCString(identity?.keyPEM) { key in
|
withOptionalCString(identity?.keyPEM) { key in
|
||||||
if let pin = pinSHA256 {
|
if let pin = pinSHA256 {
|
||||||
return pin.withUnsafeBytes { p in
|
return pin.withUnsafeBytes { p in
|
||||||
punktfunk_connect(
|
punktfunk_connect_ex(
|
||||||
cs, port, width, height, refreshHz,
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||||
cert, key, timeoutMs)
|
cert, key, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return punktfunk_connect(
|
return punktfunk_connect_ex(
|
||||||
cs, port, width, height, refreshHz, nil, &observed, cert, key, timeoutMs)
|
cs, port, width, height, refreshHz, compositor.rawValue,
|
||||||
|
nil, &observed, cert, key, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,11 +53,16 @@ final class RemoteFirstLightTests: XCTestCase {
|
|||||||
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
|
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)")
|
||||||
}
|
}
|
||||||
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
|
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 width: UInt32 = 1280
|
||||||
let height: UInt32 = 720
|
let height: UInt32 = 720
|
||||||
|
|
||||||
let conn = try PunktfunkConnection(
|
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() }
|
defer { conn.close() }
|
||||||
XCTAssertEqual(conn.width, width)
|
XCTAssertEqual(conn.width, width)
|
||||||
XCTAssertEqual(conn.height, height)
|
XCTAssertEqual(conn.height, height)
|
||||||
|
|||||||
Reference in New Issue
Block a user