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