From 5e77731da08437f275ea51e20263fb8a0580e7b9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 10 Jun 2026 16:15:37 +0200 Subject: [PATCH] feat: hosts grid + trust-on-first-use UX + settings pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- clients/apple/README.md | 16 +- .../PunktfunkClient/AddHostSheet.swift | 42 +++ .../Sources/PunktfunkClient/ContentView.swift | 304 +++++++++++++----- .../Sources/PunktfunkClient/HostStore.swift | 66 ++++ .../PunktfunkClient/PunktfunkClientApp.swift | 7 +- .../PunktfunkClient/SessionModel.swift | 72 ++++- .../PunktfunkClient/SettingsView.swift | 46 +++ .../Sources/PunktfunkKit/StreamView.swift | 21 +- 8 files changed, 479 insertions(+), 95 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/AddHostSheet.swift create mode 100644 clients/apple/Sources/PunktfunkClient/HostStore.swift create mode 100644 clients/apple/Sources/PunktfunkClient/SettingsView.swift diff --git a/clients/apple/README.md b/clients/apple/README.md index e9e375d..e706338 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -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 motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2); scroll is WHEEL_DELTA(120)-scaled. -- **`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 5–6.) +- **`PunktfunkClient`** (the app): hosts grid (saved in UserDefaults), "+" toolbar + sheet to add hosts, stream mode in Settings (⌘,), trust-on-first-use fingerprint prompt + 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 5–6.) - **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip (VTCompressionSession-encoded HEVC rebuilt as the host's wire shape → `AnnexB` → 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 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 punktfunk-core task. `PunktfunkClient` - doesn't persist fingerprints yet — add it alongside the "add host" UX. + verification UX; a PIN-style pairing ceremony is a later punktfunk-core task. + `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 — 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 diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift new file mode 100644 index 0000000..1593c86 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift @@ -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) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 7e0b34d..54e9746 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 PunktfunkKit @@ -6,61 +11,227 @@ import SwiftUI struct ContentView: View { @StateObject private var model = SessionModel() - @AppStorage("punktfunk.host") private var host = "192.168.1.70" - @AppStorage("punktfunk.port") private var port = 9777 + @StateObject private var store = HostStore() @AppStorage("punktfunk.width") private var width = 1920 @AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.hz") private var hz = 60 + @State private var showAddHost = false var body: some View { Group { - if let conn = model.connection { - stream(conn) - } else { - connectForm + switch model.phase { + case .idle, .connecting: + home + case .awaitingTrust(let fingerprint): + trustPrompt(fingerprint) + case .streaming: + stream(capturesCursor: true) } } .onAppear { autoConnectIfAsked() } .onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more) } - /// 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["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["PUNKTFUNK_MODE"] { - let dims = mode.split(separator: "x").compactMap { Int($0) } - if dims.count == 3 { - width = dims[0] - height = dims[1] - hz = dims[2] + // MARK: - Home (hosts grid) + + private var home: some View { + NavigationStack { + Group { + if store.hosts.isEmpty { + emptyState + } else { + ScrollView { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)], + spacing: 16 + ) { + ForEach(store.hosts) { host in + hostCard(host) + } + } + .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( - host: host, port: UInt16(clamping: port), + to: host, width: UInt32(clamping: width), height: UInt32(clamping: height), hz: UInt32(clamping: hz)) } - private func stream(_ conn: PunktfunkConnection) -> some View { - StreamView( - connection: conn, - onFrame: { [meter = model.meter] au in meter.note(byteCount: au.data.count) }, - onSessionEnd: { [weak model] in - Task { @MainActor in model?.sessionEnded() } + // MARK: - Trust on first use + + private func trustPrompt(_ fingerprint: Data) -> some View { + ZStack { + // Keep the stream pumping (the opening IDR must be consumed) but blurred and + // 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) + } } - ) - .overlay(alignment: .topTrailing) { hud(conn) } + .padding(28) + .frame(maxWidth: 440) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) + } .frame(minWidth: 640, minHeight: 360) .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.. 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 { VStack(alignment: .trailing, spacing: 4) { 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) } - private var connectForm: some View { - 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) + // MARK: - Dev hook - if let error = model.errorMessage { - Text(error) - .font(.caption) - .foregroundStyle(.red) - .frame(width: 340) + /// 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.) + private func autoConnectIfAsked() { + 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) - .frame(minWidth: 420, minHeight: 320) - } - - 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 + model.connect( + to: host, + width: UInt32(clamping: width), height: UInt32(clamping: height), + hz: UInt32(clamping: hz), + autoTrust: true) + } +} + +private extension Array { + func chunks(of size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { Array(self[$0.. Data? { + guard case .awaitingTrust(let fingerprint) = phase else { return nil } + beginStreaming() + return fingerprint + } + + func rejectTrust() { + disconnect() + } + func disconnect() { inputCapture?.stop() inputCapture = nil @@ -81,6 +120,8 @@ final class SessionModel: ObservableObject { Task.detached { conn.close() } } connection = nil + activeHost = nil + phase = .idle fps = 0 mbps = 0 } @@ -88,11 +129,14 @@ final class SessionModel: ObservableObject { /// Called (via the main actor) when the pump hits end-of-session. func sessionEnded() { guard connection != nil else { return } + let name = activeHost?.displayName ?? "host" 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) capture.start() inputCapture = capture diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift new file mode 100644 index 0000000..2fb785d --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -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 + } +} diff --git a/clients/apple/Sources/PunktfunkKit/StreamView.swift b/clients/apple/Sources/PunktfunkKit/StreamView.swift index ece30aa..4cf5e2e 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamView.swift @@ -44,27 +44,34 @@ private final class CursorCapture { public struct StreamView: NSViewRepresentable { private let connection: PunktfunkConnection + private let capturesCursor: Bool 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. + /// `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( connection: PunktfunkConnection, + capturesCursor: Bool = true, onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil ) { self.connection = connection + self.capturesCursor = capturesCursor self.onFrame = onFrame self.onSessionEnd = onSessionEnd } public func makeNSView(context: Context) -> StreamLayerView { let view = StreamLayerView() + view.capturesCursor = capturesCursor view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) return view } public func updateNSView(_ view: StreamLayerView, context: Context) { + view.capturesCursor = capturesCursor // SwiftUI reuses the NSView across state changes — repoint the pump only when the // connection identity actually changed. if view.connection !== connection { @@ -101,6 +108,18 @@ public final class StreamLayerView: NSView { private let cursorCapture = CursorCapture() 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) { super.init(frame: frame) displayLayer.videoGravity = .resizeAspect @@ -132,7 +151,7 @@ public final class StreamLayerView: NSView { } private func captureCursorIfStreaming() { - guard token != nil, NSApp.isActive else { return } + guard capturesCursor, token != nil, NSApp.isActive else { return } cursorCapture.capture(in: self) }