From 18d0009c3527bbbafd9763852032c19bed34b1d2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 08:43:32 +0200 Subject: [PATCH] feat(apple): the trust prompt takes controller input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trust-on-first-use had no pad wiring at all, and it appears at the worst possible moment for that: mid-connect, with capture already disabled so ContentView can blur the stream and stop forwarding. A pad-only user pressed A to connect, got this card, and found the controller in their hands doing nothing — the only way past was to reach for the screen. A trusts, B cancels, X runs the PIN ceremony instead. The legend only appears with a pad attached; naming controller buttons to a trackpad user would be describing hardware they don't have. `GamepadManager` is observed rather than read once so the bar arrives if a pad wakes up while the card is already open. Safe to poll unconditionally while the card is up: capture is off for its whole lifetime and the home screens are unmounted behind the session view, so nothing else is reading the pad. The A press that STARTED the connect is typically still held when this mounts — `GamepadMenuInput` adopts the held state without firing on start, so it doesn't auto-trust. tvOS is untouched: the focus engine already drives these buttons. macOS + tvOS typecheck. --- .../PunktfunkClient/Trust/TrustCardView.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/clients/apple/Sources/PunktfunkClient/Trust/TrustCardView.swift b/clients/apple/Sources/PunktfunkClient/Trust/TrustCardView.swift index 2d60e3d6..87fe33f9 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/TrustCardView.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/TrustCardView.swift @@ -1,6 +1,12 @@ // Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an // unpinned host. The user compares the fingerprint with the one the host logged at startup, // or drops this and runs the PIN pairing ceremony instead. +// +// Controller-drivable on iOS/macOS (A trust, B cancel, X pair instead). It had no controller +// wiring at all, which made it a dead end for a pad-only user at the worst possible moment: the +// card appears mid-connect with capture disabled (ContentView blurs the stream and stops +// forwarding), so the pad in their hands genuinely did nothing and the only way past was to reach +// for the screen. tvOS needs none of this — the focus engine drives the buttons natively. import Foundation import PunktfunkKit @@ -13,6 +19,12 @@ struct TrustCardView: View { let onTrust: () -> Void let onPairInstead: () -> Void + #if os(iOS) || os(macOS) + /// Observed so the legend appears the moment a pad wakes up mid-prompt — and so it stays + /// absent for the mouse/touch users this card is otherwise for. + @ObservedObject private var gamepads = GamepadManager.shared + #endif + var body: some View { VStack(spacing: 14) { Image(systemName: "lock.shield") @@ -60,12 +72,35 @@ struct TrustCardView: View { .buttonStyle(.borderless) #endif .font(.geist(16, relativeTo: .callout)) + #if os(iOS) || os(macOS) + // Only with a pad attached: controller glyphs in front of a trackpad user would be + // naming buttons they don't have. + if gamepads.active != nil { + GamepadHintBar(hints: [ + .init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Trust", + action: onTrust), + .init( + glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Pair with PIN", + action: onPairInstead), + .init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel", + action: onCancel), + ]) + .padding(.top, 2) + } + #endif } .padding(28) .frame(maxWidth: 440) // Floating trust card over the blurred stream — Liquid Glass on 26+, .regularMaterial // fallback below. The inner fingerprint box stays .quaternary (content, not glass). .glassBackground(RoundedRectangle(cornerRadius: 18)) + #if os(iOS) || os(macOS) + .background { + TrustControllerInput(onTrust: onTrust, onCancel: onCancel, onPairInstead: onPairInstead) + } + #endif } /// 64 hex chars → four groups per line, two lines — easy to eyeball against the log. @@ -80,6 +115,35 @@ struct TrustCardView: View { } } +#if os(iOS) || os(macOS) +/// Controller binding for the trust prompt: A trusts, B cancels, X runs the PIN ceremony instead. +/// The same zero-size-backing-view shape as `ConnectOverlay`'s `ConnectControllerInput` — mounted +/// for exactly as long as the card is up, and `GamepadMenuInput`'s snapshot-on-start swallows +/// whatever button was still held when it appeared (the A press that started the connect is +/// usually still down). +/// +/// Nothing else is polling the pad here: capture is off for the duration of the prompt, and the +/// home screens are unmounted behind the session view. +private struct TrustControllerInput: View { + let onTrust: () -> Void + let onCancel: () -> Void + let onPairInstead: () -> Void + @State private var input = GamepadMenuInput(manager: .shared) + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onAppear { + input.onConfirm = onTrust + input.onBack = onCancel + input.onTertiary = onPairInstead + input.start() + } + .onDisappear { input.stop() } + } +} +#endif + private extension Array { func chunks(of size: Int) -> [[Element]] { stride(from: 0, to: count, by: size).map { Array(self[$0..