fix: keep the stream view's identity stable across the trust prompt
ci / rust (push) Has been cancelled

The awaiting-trust and streaming phases rendered StreamView in different switch
branches, so confirming trust dismantled and recreated the NSView — the fresh pump had
already missed the opening IDR (infinite GOP: no other keyframe ever comes) and decoded
nothing. One session branch now hosts a single StreamView; the trust card is an overlay
on the blurred stream and only the capturesCursor flag flips on confirmation.

Verified live against the box (gamescope+vkcube at 720p60, 11.7 Mb/s on glass). Note for
host runs: without PUNKTFUNK_COMPOSITOR=gamescope + PUNKTFUNK_GAMESCOPE_APP, m3-host
auto-picks KWin and streams its (black, empty) session — looks identical to a client
bug but isn't one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:44:25 +02:00
parent 187c173e01
commit 977c792b4b
@@ -19,19 +19,42 @@ struct ContentView: View {
var body: some View { var body: some View {
Group { Group {
switch model.phase { // The stream view's structural identity MUST be stable across the
case .idle, .connecting: // awaiting-trust streaming transition: recreating it restarts the pump,
// which has then already missed the opening IDR (infinite GOP no other
// keyframe ever comes) and decodes nothing. So: one branch per connection,
// trust prompt as an overlay.
if model.connection != nil {
sessionView
} else {
home home
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)
} }
private var sessionView: some View {
let pendingFingerprint: Data? = {
if case .awaitingTrust(let fp) = model.phase { return fp }
return nil
}()
return ZStack {
stream(capturesCursor: pendingFingerprint == nil)
.blur(radius: pendingFingerprint != nil ? 32 : 0)
.overlay {
if pendingFingerprint != nil {
Color.black.opacity(0.45)
}
}
if let fp = pendingFingerprint {
trustCard(fp)
}
}
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
}
// MARK: - Home (hosts grid) // MARK: - Home (hosts grid)
private var home: some View { private var home: some View {
@@ -156,47 +179,38 @@ struct ContentView: View {
// MARK: - Trust on first use // MARK: - Trust on first use
private func trustPrompt(_ fingerprint: Data) -> some View { private func trustCard(_ fingerprint: Data) -> some View {
ZStack { VStack(spacing: 14) {
// Keep the stream pumping (the opening IDR must be consumed) but blurred and Image(systemName: "lock.shield")
// cursor-free until the host is verified. .font(.system(size: 36, weight: .light))
stream(capturesCursor: false) Text("Verify \(model.activeHost?.displayName ?? "host")")
.blur(radius: 32) .font(.title3.weight(.semibold))
.overlay(.black.opacity(0.45)) Text("First connection. Compare this fingerprint with the one "
VStack(spacing: 14) { + "punktfunk-host logged at startup (\u{201C}clients pin this "
Image(systemName: "lock.shield") + "fingerprint\u{201D}):")
.font(.system(size: 36, weight: .light)) .font(.callout)
Text("Verify \(model.activeHost?.displayName ?? "host")") .foregroundStyle(.secondary)
.font(.title3.weight(.semibold)) .multilineTextAlignment(.center)
Text("First connection. Compare this fingerprint with the one " Text(Self.format(fingerprint: fingerprint))
+ "punktfunk-host logged at startup (\u{201C}clients pin this " .font(.system(.callout, design: .monospaced))
+ "fingerprint\u{201D}):") .textSelection(.enabled)
.font(.callout) .padding(10)
.foregroundStyle(.secondary) .background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
.multilineTextAlignment(.center) HStack(spacing: 12) {
Text(Self.format(fingerprint: fingerprint)) Button("Cancel", role: .cancel) { model.rejectTrust() }
.font(.system(.callout, design: .monospaced)) .keyboardShortcut(.cancelAction)
.textSelection(.enabled) Button("Trust & Connect") {
.padding(10) if let fp = model.confirmTrust(), let host = model.activeHost {
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) store.pin(host.id, fingerprint: fp)
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)
} }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
} }
.padding(28)
.frame(maxWidth: 440)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
} }
.frame(minWidth: 640, minHeight: 360) .padding(28)
.background(Color.black) .frame(maxWidth: 440)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
} }
/// 64 hex chars four groups per line, two lines easy to eyeball against the log. /// 64 hex chars four groups per line, two lines easy to eyeball against the log.
@@ -226,8 +240,6 @@ struct ContentView: View {
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
if capturesCursor { hud(conn) } if capturesCursor { hud(conn) }
} }
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
} }
} }
} }