feat: hosts grid + trust-on-first-use UX + settings pane
ci / rust (push) Has been cancelled

The app grows from a dev connect form into a real client shell:

- Home is a grid of saved hosts (UserDefaults-persisted; context menu: Remove / Forget
  Identity), "+" in the toolbar opens the add-host sheet, the stream mode moved into
  Settings (⌘, / gear) — native resolution stays the only mode, no scaling.
- Trust is now explicit: the protocol always supported certificate pinning, but the app
  passed no pin and discarded the observed fingerprint — silently trusting any host.
  First connect now shows the host's SHA-256 fingerprint (compare with the "clients pin
  this fingerprint" line in the host log) over the live-but-blurred stream; the stream
  must pump immediately (the opening IDR is the only guaranteed one), so StreamView gains
  a capturesCursor switch to keep the cursor free while the prompt needs clicking, and
  input capture starts only after confirmation. Trusting pins the fingerprint per host;
  a changed host identity then refuses to connect.
- PUNKTFUNK_AUTOCONNECT keeps working (auto-trusts, doesn't touch the saved hosts).

Host→client authorization (pairing PIN) remains a punktfunk-core roadmap item — the host
still accepts any client that can reach its port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:15:37 +02:00
parent dc42d6a375
commit 5e77731da0
8 changed files with 479 additions and 95 deletions
+11 -5
View File
@@ -39,9 +39,11 @@ 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 `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 motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2); scroll is
WHEEL_DELTA(120)-scaled. WHEEL_DELTA(120)-scaled.
- **`PunktfunkClient`** (development app shell): connect form → stream + input, fps/Mb-s HUD. - **`PunktfunkClient`** (the app): hosts grid (saved in UserDefaults), "+" toolbar
(Audio playback and gamepad capture are not wired into the app yet — the connector sheet to add hosts, stream mode in Settings (⌘,), trust-on-first-use fingerprint prompt
surface is there; see notes 56.) over the live-but-blurred stream → pinned reconnects, 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 - **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip
(VTCompressionSession-encoded HEVC rebuilt as the host's wire shape → `AnnexB` (VTCompressionSession-encoded HEVC rebuilt as the host's wire shape → `AnnexB`
VTDecompressionSession → pixels); loopback integration against a real local host VTDecompressionSession → pixels); loopback integration against a real local host
@@ -118,8 +120,12 @@ signing, bundle id `io.unom.punktfunk`. Notes:
7. **Trust**: connect once with `pinSHA256: nil` (TOFU), persist `hostFingerprint` keyed 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 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 logs its fingerprint at startup ("clients pin this fingerprint") for out-of-band
verification UX; a PIN-style pairing ceremony is a later punktfunk-core task. `PunktfunkClient` verification UX; a PIN-style pairing ceremony is a later punktfunk-core task.
doesn't persist fingerprints yet — add it alongside the "add host" UX. `PunktfunkClient` implements exactly this: explicit fingerprint confirmation on first
connect (input/cursor capture held back until confirmed), pin stored per host
(`HostStore`), "Forget Identity" in the card's context menu for legitimate host
reinstalls. Note the OTHER direction is still open: the host authorizes no one — any
client that reaches the port gets a session (fine on a LAN, not on the internet).
8. **Input capture caveats** (stage 1): GC handlers only fire while the app has focus — 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 on focus loss `InputCapture` auto-releases everything still held (keys + buttons) so
nothing sticks down host-side. While the stream has focus the LOCAL cursor is hidden nothing sticks down host-side. While the stream has focus the LOCAL cursor is hidden
@@ -0,0 +1,42 @@
// "+" sheet: name (optional) + address + port a card in the hosts grid. The first
// actual connection runs the trust-on-first-use fingerprint prompt.
import SwiftUI
struct AddHostSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var address = ""
@State private var port = 9777
let onAdd: (StoredHost) -> Void
var body: some View {
VStack(spacing: 0) {
Form {
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
TextField("Address", text: $address, prompt: Text("IP or hostname"))
TextField("Port", value: $port, format: .number.grouping(.never))
}
.formStyle(.grouped)
HStack {
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Add Host") {
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(clamping: port)))
dismiss()
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(16)
}
.frame(width: 380)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -1,4 +1,9 @@
// Connect form live stream. Stage-1 UX: pick host + mode, see frames, type/aim. // Hosts grid trust prompt live stream.
//
// Home is a grid of saved hosts (click to connect); "+" in the toolbar adds one; the
// stream mode lives in Settings (,). First connect to a host shows its certificate
// fingerprint over the live-but-blurred stream for explicit trust-on-first-use; once
// pinned, reconnects are silent and a changed host identity refuses to connect.
import AppKit import AppKit
import PunktfunkKit import PunktfunkKit
@@ -6,61 +11,227 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject private var model = SessionModel() @StateObject private var model = SessionModel()
@AppStorage("punktfunk.host") private var host = "192.168.1.70" @StateObject private var store = HostStore()
@AppStorage("punktfunk.port") private var port = 9777
@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
@State private var showAddHost = false
var body: some View { var body: some View {
Group { Group {
if let conn = model.connection { switch model.phase {
stream(conn) case .idle, .connecting:
} else { home
connectForm case .awaitingTrust(let fingerprint):
trustPrompt(fingerprint)
case .streaming:
stream(capturesCursor: true)
} }
} }
.onAppear { autoConnectIfAsked() } .onAppear { autoConnectIfAsked() }
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more) .onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
} }
/// Development hook: PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately at the saved // MARK: - Home (hosts grid)
/// (or PUNKTFUNK_MODE=WxHxHz) mode lets scripts drive first-light runs. (IPv4/hostname
/// only; an IPv6 literal would need bracket parsing.) private var home: some View {
private func autoConnectIfAsked() { NavigationStack {
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"], Group {
!target.isEmpty, model.connection == nil, !model.connecting if store.hosts.isEmpty {
else { return } emptyState
let parts = target.split(separator: ":") } else {
host = String(parts[0]) ScrollView {
if parts.count == 2, let p = Int(parts[1]) { port = p } LazyVGrid(
if let mode = ProcessInfo.processInfo.environment["PUNKTFUNK_MODE"] { columns: [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)],
let dims = mode.split(separator: "x").compactMap { Int($0) } spacing: 16
if dims.count == 3 { ) {
width = dims[0] ForEach(store.hosts) { host in
height = dims[1] hostCard(host)
hz = dims[2] }
}
.padding(20)
}
}
}
.navigationTitle("punktfunk")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showAddHost = true
} label: {
Label("Add Host", systemImage: "plus")
}
.help("Add a host")
}
ToolbarItem {
SettingsLink {
Label("Settings", systemImage: "gearshape")
}
.help("Stream mode and settings")
}
} }
} }
.frame(minWidth: 480, minHeight: 360)
.sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
.alert(
"Connection failed",
isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(model.errorMessage ?? "")
}
}
private var emptyState: some View {
ContentUnavailableView {
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
} description: {
Text("Add your punktfunk host with the + button.")
} actions: {
Button("Add Host") { showAddHost = true }
.buttonStyle(.borderedProminent)
}
}
private func hostCard(_ host: StoredHost) -> some View {
let isConnecting = model.phase == .connecting && model.activeHost?.id == host.id
return Button {
connect(host)
} label: {
VStack(spacing: 10) {
ZStack {
Image(systemName: "play.display")
.font(.system(size: 42, weight: .light))
.foregroundStyle(.tint)
.opacity(isConnecting ? 0.3 : 1)
if isConnecting {
ProgressView()
}
}
.frame(height: 56)
VStack(spacing: 2) {
Text(host.displayName)
.font(.headline)
.lineLimit(1)
HStack(spacing: 4) {
if host.pinnedSHA256 != nil {
Image(systemName: "lock.fill")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
Text("\(host.address):\(String(host.port))")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.padding(.horizontal, 12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
.disabled(model.isBusy)
.contextMenu {
if host.pinnedSHA256 != nil {
Button("Forget Identity") { store.forgetIdentity(host) }
}
Button("Remove", role: .destructive) { store.remove(host) }
}
}
private func connect(_ host: StoredHost) {
model.connect( model.connect(
host: host, port: UInt16(clamping: port), 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))
} }
private func stream(_ conn: PunktfunkConnection) -> some View { // MARK: - Trust on first use
StreamView(
connection: conn, private func trustPrompt(_ fingerprint: Data) -> some View {
onFrame: { [meter = model.meter] au in meter.note(byteCount: au.data.count) }, ZStack {
onSessionEnd: { [weak model] in // Keep the stream pumping (the opening IDR must be consumed) but blurred and
Task { @MainActor in model?.sessionEnded() } // cursor-free until the host is verified.
stream(capturesCursor: false)
.blur(radius: 32)
.overlay(.black.opacity(0.45))
VStack(spacing: 14) {
Image(systemName: "lock.shield")
.font(.system(size: 36, weight: .light))
Text("Verify \(model.activeHost?.displayName ?? "host")")
.font(.title3.weight(.semibold))
Text("First connection. Compare this fingerprint with the one "
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
+ "fingerprint\u{201D}):")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Text(Self.format(fingerprint: fingerprint))
.font(.system(.callout, design: .monospaced))
.textSelection(.enabled)
.padding(10)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: 12) {
Button("Cancel", role: .cancel) { model.rejectTrust() }
.keyboardShortcut(.cancelAction)
Button("Trust & Connect") {
if let fp = model.confirmTrust(), let host = model.activeHost {
store.pin(host.id, fingerprint: fp)
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
} }
) .padding(28)
.overlay(alignment: .topTrailing) { hud(conn) } .frame(maxWidth: 440)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
}
.frame(minWidth: 640, minHeight: 360) .frame(minWidth: 640, minHeight: 360)
.background(Color.black) .background(Color.black)
} }
/// 64 hex chars four groups per line, two lines easy to eyeball against the log.
private static func format(fingerprint: Data) -> String {
let hex = fingerprint.map { String(format: "%02x", $0) }.joined()
let groups = stride(from: 0, to: hex.count, by: 8).map { i -> String in
let start = hex.index(hex.startIndex, offsetBy: i)
let end = hex.index(start, offsetBy: min(8, hex.count - i))
return String(hex[start..<end])
}
return groups.chunks(of: 4).map { $0.joined(separator: " ") }.joined(separator: "\n")
}
// MARK: - Stream
private func stream(capturesCursor: Bool) -> some View {
Group {
if let conn = model.connection {
StreamView(
connection: conn,
capturesCursor: capturesCursor,
onFrame: { [meter = model.meter] au in meter.note(byteCount: au.data.count) },
onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() }
}
)
.overlay(alignment: .topTrailing) {
if capturesCursor { hud(conn) }
}
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
}
}
}
private func hud(_ conn: PunktfunkConnection) -> some View { private func hud(_ conn: PunktfunkConnection) -> some View {
VStack(alignment: .trailing, spacing: 4) { VStack(alignment: .trailing, spacing: 4) {
Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s") Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
@@ -77,49 +248,36 @@ struct ContentView: View {
.padding(10) .padding(10)
} }
private var connectForm: some View { // MARK: - Dev hook
VStack(spacing: 14) {
Text("punktfunk").font(.largeTitle.weight(.semibold))
Form {
TextField("Host", text: $host)
TextField("Port", value: $port, format: .number.grouping(.never))
HStack {
TextField("Width", value: $width, format: .number.grouping(.never))
Text("×")
TextField("Height", value: $height, format: .number.grouping(.never))
Text("@")
TextField("Hz", value: $hz, format: .number.grouping(.never))
}
Button("Use this display's mode") { fillFromMainScreen() }
.buttonStyle(.link)
}
.frame(width: 340)
if let error = model.errorMessage { /// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use,
Text(error) /// auto-confirmed dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without
.font(.caption) /// touching the saved host list. (IPv4/hostname only.)
.foregroundStyle(.red) private func autoConnectIfAsked() {
.frame(width: 340) guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
!target.isEmpty, model.phase == .idle
else { return }
let parts = target.split(separator: ":")
var host = StoredHost(name: "", address: String(parts[0]))
if parts.count == 2, let p = UInt16(parts[1]) { host.port = p }
if let mode = ProcessInfo.processInfo.environment["PUNKTFUNK_MODE"] {
let dims = mode.split(separator: "x").compactMap { Int($0) }
if dims.count == 3 {
width = dims[0]
height = dims[1]
hz = dims[2]
} }
Button(model.connecting ? "Connecting…" : "Connect") {
model.connect(
host: host, port: UInt16(clamping: port),
width: UInt32(clamping: width), height: UInt32(clamping: height),
hz: UInt32(clamping: hz))
}
.keyboardShortcut(.defaultAction)
.disabled(model.connecting || host.isEmpty)
} }
.padding(28) model.connect(
.frame(minWidth: 420, minHeight: 320) to: host,
} width: UInt32(clamping: width), height: UInt32(clamping: height),
hz: UInt32(clamping: hz),
private func fillFromMainScreen() { autoTrust: true)
guard let screen = NSScreen.main else { return } }
let scale = screen.backingScaleFactor }
width = Int(screen.frame.width * scale)
height = Int(screen.frame.height * scale) private extension Array {
hz = screen.maximumFramesPerSecond func chunks(of size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
} }
} }
@@ -0,0 +1,66 @@
// Saved hosts + their pinned identities, persisted as JSON in UserDefaults.
//
// Trust model (client side of punktfunk/1): the host serves a persistent certificate and
// logs its SHA-256 fingerprint at startup. First connect is trust-on-first-use the user
// explicitly confirms the observed fingerprint against the host's log, and we pin it here.
// Every later connect passes the pin into punktfunk-core, which refuses a host whose
// identity changed. (Hostclient authorization a pairing PIN is a roadmap item; today
// the host accepts any client that can reach its port.)
import Foundation
import SwiftUI
struct StoredHost: Identifiable, Codable, Hashable {
var id = UUID()
var name: String
var address: String
var port: UInt16 = 9777
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
var pinnedSHA256: Data?
var displayName: String { name.isEmpty ? address : name }
}
@MainActor
final class HostStore: ObservableObject {
private static let key = "punktfunk.hosts"
@Published var hosts: [StoredHost] {
didSet { persist() }
}
init() {
if let data = UserDefaults.standard.data(forKey: Self.key),
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
hosts = decoded
} else {
hosts = []
}
}
func add(_ host: StoredHost) {
hosts.append(host)
}
func remove(_ host: StoredHost) {
hosts.removeAll { $0.id == host.id }
}
func pin(_ hostID: UUID, fingerprint: Data) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].pinnedSHA256 = fingerprint
}
/// Drop the pinned identity (e.g. after a legitimate host reinstall) the next
/// connect goes through the trust prompt again.
func forgetIdentity(_ host: StoredHost) {
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
hosts[i].pinnedSHA256 = nil
}
private func persist() {
if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: Self.key)
}
}
}
@@ -1,5 +1,5 @@
// PunktfunkClient development app shell around PunktfunkKit (swift run PunktfunkClient). // PunktfunkClient the macOS client app (also runs unbundled via swift run).
// Connect form StreamView (AVSampleBufferDisplayLayer HEVC) + InputCapture. // Hosts grid trust-on-first-use StreamView (AVSampleBufferDisplayLayer HEVC) + input.
import AppKit import AppKit
import SwiftUI import SwiftUI
@@ -12,6 +12,9 @@ struct PunktfunkClientApp: App {
WindowGroup("punktfunk") { WindowGroup("punktfunk") {
ContentView() ContentView()
} }
Settings {
SettingsView()
}
} }
} }
@@ -1,5 +1,5 @@
// Session state for the app shell: owns the connection, the input capture, and the // Session state for the app shell: owns the connection, the input capture, the trust
// pump-thread main-actor stats relay. // handshake phase, and the pump-thread main-actor stats relay.
import Foundation import Foundation
import PunktfunkKit import PunktfunkKit
@@ -35,8 +35,20 @@ final class FrameMeter: @unchecked Sendable {
@MainActor @MainActor
final class SessionModel: ObservableObject { final class SessionModel: ObservableObject {
@Published var connection: PunktfunkConnection? enum Phase: Equatable {
@Published var connecting = false case idle
case connecting
/// Connected to an unpinned host: the stream is live (and pumping the opening
/// IDR must not be missed) but input/cursor capture wait for the user to confirm
/// the observed fingerprint.
case awaitingTrust(fingerprint: Data)
case streaming
}
@Published private(set) var phase: Phase = .idle
@Published private(set) var connection: PunktfunkConnection?
/// The host this session is for (a value copy; identity = id).
@Published private(set) var activeHost: StoredHost?
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var fps = 0 @Published var fps = 0
@Published var mbps = 0.0 @Published var mbps = 0.0
@@ -46,30 +58,57 @@ final class SessionModel: ObservableObject {
private var inputCapture: InputCapture? private var inputCapture: InputCapture?
private var statsTimer: Timer? private var statsTimer: Timer?
func connect(host: String, port: UInt16, width: UInt32, height: UInt32, hz: UInt32) { var isBusy: Bool { phase != .idle }
guard !connecting else { return }
connecting = true func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
autoTrust: Bool = false) {
guard phase == .idle else { return }
phase = .connecting
activeHost = host
errorMessage = nil errorMessage = nil
let pin = host.pinnedSHA256
Task.detached(priority: .userInitiated) { Task.detached(priority: .userInitiated) {
// PunktfunkConnection.init blocks on the QUIC handshake keep it off the main actor. // PunktfunkConnection.init blocks on the QUIC handshake keep it off the main actor.
let result = Result { try PunktfunkConnection( let result = Result { try PunktfunkConnection(
host: host, port: port, width: width, height: height, refreshHz: hz) } host: host.address, port: host.port,
width: width, height: height, refreshHz: hz,
pinSHA256: pin) }
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
self.connecting = false
switch result { switch result {
case .success(let conn): case .success(let conn):
self.connection = conn self.connection = conn
self.startInput(conn)
self.startStatsTimer() self.startStatsTimer()
if pin != nil || autoTrust {
self.beginStreaming()
} else {
self.phase = .awaitingTrust(fingerprint: conn.hostFingerprint)
}
case .failure: case .failure:
self.errorMessage = "Connection failed — is the host running? " + self.phase = .idle
"(punktfunk-host m3-host on \(host):\(port))" self.activeHost = nil
self.errorMessage = pin != nil
? "Could not connect to \(host.displayName) — host unreachable, "
+ "not running, or its identity no longer matches the pinned "
+ "fingerprint."
: "Could not connect to \(host.displayName) — is punktfunk-host "
+ "running on \(host.address):\(host.port)?"
} }
} }
} }
} }
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
func confirmTrust() -> Data? {
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
beginStreaming()
return fingerprint
}
func rejectTrust() {
disconnect()
}
func disconnect() { func disconnect() {
inputCapture?.stop() inputCapture?.stop()
inputCapture = nil inputCapture = nil
@@ -81,6 +120,8 @@ final class SessionModel: ObservableObject {
Task.detached { conn.close() } Task.detached { conn.close() }
} }
connection = nil connection = nil
activeHost = nil
phase = .idle
fps = 0 fps = 0
mbps = 0 mbps = 0
} }
@@ -88,11 +129,14 @@ final class SessionModel: ObservableObject {
/// Called (via the main actor) when the pump hits end-of-session. /// Called (via the main actor) when the pump hits end-of-session.
func sessionEnded() { func sessionEnded() {
guard connection != nil else { return } guard connection != nil else { return }
let name = activeHost?.displayName ?? "host"
disconnect() disconnect()
errorMessage = "Session ended by host." errorMessage = "Session ended by \(name)."
} }
private func startInput(_ conn: PunktfunkConnection) { private func beginStreaming() {
guard let conn = connection else { return }
phase = .streaming
let capture = InputCapture(connection: conn) let capture = InputCapture(connection: conn)
capture.start() capture.start()
inputCapture = capture inputCapture = capture
@@ -0,0 +1,46 @@
// 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.
import AppKit
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
var body: some View {
Form {
Section {
HStack {
TextField("Resolution", value: $width, format: .number.grouping(.never))
Text("×")
TextField("", value: $height, format: .number.grouping(.never))
.labelsHidden()
}
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() }
}
} header: {
Text("Stream mode")
} footer: {
Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.frame(width: 380)
.fixedSize()
}
private func fillFromMainScreen() {
guard let screen = NSScreen.main else { return }
let scale = screen.backingScaleFactor
width = Int(screen.frame.width * scale)
height = Int(screen.frame.height * scale)
hz = screen.maximumFramesPerSecond
}
}
@@ -44,27 +44,34 @@ private final class CursorCapture {
public struct StreamView: NSViewRepresentable { public struct StreamView: NSViewRepresentable {
private let connection: PunktfunkConnection private let connection: PunktfunkConnection
private let capturesCursor: Bool
private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)? private let onSessionEnd: (@Sendable () -> Void)?
/// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI. /// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI.
/// `capturesCursor: false` keeps the local cursor usable while UI (e.g. a trust
/// prompt) is layered over the stream; flip it to true to enter capture.
public init( public init(
connection: PunktfunkConnection, connection: PunktfunkConnection,
capturesCursor: Bool = true,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil onSessionEnd: (@Sendable () -> Void)? = nil
) { ) {
self.connection = connection self.connection = connection
self.capturesCursor = capturesCursor
self.onFrame = onFrame self.onFrame = onFrame
self.onSessionEnd = onSessionEnd self.onSessionEnd = onSessionEnd
} }
public func makeNSView(context: Context) -> StreamLayerView { public func makeNSView(context: Context) -> StreamLayerView {
let view = StreamLayerView() let view = StreamLayerView()
view.capturesCursor = capturesCursor
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return view return view
} }
public func updateNSView(_ view: StreamLayerView, context: Context) { public func updateNSView(_ view: StreamLayerView, context: Context) {
view.capturesCursor = capturesCursor
// SwiftUI reuses the NSView across state changes repoint the pump only when the // SwiftUI reuses the NSView across state changes repoint the pump only when the
// connection identity actually changed. // connection identity actually changed.
if view.connection !== connection { if view.connection !== connection {
@@ -101,6 +108,18 @@ public final class StreamLayerView: NSView {
private let cursorCapture = CursorCapture() private let cursorCapture = CursorCapture()
private var appObservers: [NSObjectProtocol] = [] private var appObservers: [NSObjectProtocol] = []
/// Main-thread only. False = leave the local cursor alone (UI layered over the
/// stream); switching back to true re-enters capture immediately.
public var capturesCursor = true {
didSet {
if capturesCursor {
captureCursorIfStreaming()
} else {
cursorCapture.release()
}
}
}
public override init(frame: NSRect) { public override init(frame: NSRect) {
super.init(frame: frame) super.init(frame: frame)
displayLayer.videoGravity = .resizeAspect displayLayer.videoGravity = .resizeAspect
@@ -132,7 +151,7 @@ public final class StreamLayerView: NSView {
} }
private func captureCursorIfStreaming() { private func captureCursorIfStreaming() {
guard token != nil, NSApp.isActive else { return } guard capturesCursor, token != nil, NSApp.isActive else { return }
cursorCapture.capture(in: self) cursorCapture.capture(in: self)
} }