From d8fc4696a8a9514dda1a30b5fac68a84e3aed1ef Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sun, 21 Jun 2026 04:45:18 -0700 Subject: [PATCH] iOS pairing scanner: handle no-camera / denied gracefully The QR scanner sat on a silent black screen when the camera couldn't start: it called AVCaptureDevice.default(for: .video), got nil (e.g. on the Simulator, which has no camera), and bailed via an early guard with no UI feedback. It also never requested camera permission (a denied device stayed black with no recovery) and sized the preview layer only once in viewDidLoad. Now it gates on AVCaptureDevice authorization (requesting access when undetermined, surfacing a Settings link when denied), reports an "unavailable" state when there's no camera so the UI explains the black screen, and updates the preview frame in viewDidLayoutSubviews. Camera runs work on a background queue. Co-Authored-By: Claude Opus 4.8 --- .../Views/PairingScannerView.swift | 114 +++++++++++++++--- 1 file changed, 98 insertions(+), 16 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift index 5ae082f..77aafa4 100644 --- a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift +++ b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift @@ -2,34 +2,39 @@ import SwiftUI import AVFoundation import NucleicProtocol +/// Why the live camera feed isn't showing, so the scanner never sits on a silent black screen. +enum ScannerCameraState: Equatable { + case scanning // feed is live + case denied // permission off — needs Settings + case unavailable // no camera on this device (e.g. the Simulator) +} + /// Camera QR scanner (UX_IOS §7). Decodes the Mac's `nucleic://pair?d=…` QR into a /// `PairingPayload` and hands it back exactly once. struct PairingScannerView: View { let onScanned: (PairingPayload) -> Void @Environment(\.dismiss) private var dismiss @State private var error: String? + @State private var cameraState: ScannerCameraState = .scanning var body: some View { NavigationStack { ZStack { - QRScannerRepresentable { value in handle(value) } + Color.black.ignoresSafeArea() + QRScannerRepresentable(onFound: handle, onState: { cameraState = $0 }) .ignoresSafeArea() VStack { Spacer() - RoundedRectangle(cornerRadius: 16) - .stroke(.white.opacity(0.8), lineWidth: 3) - .frame(width: 240, height: 240) - Spacer() - if let error { - Text(error).foregroundStyle(.white) - .padding().background(.red.opacity(0.8), in: Capsule()) - } else { - Text("Point at the QR code on your Mac") - .foregroundStyle(.white) - .padding().background(.black.opacity(0.5), in: Capsule()) + if cameraState == .scanning { + RoundedRectangle(cornerRadius: 16) + .stroke(.white.opacity(0.8), lineWidth: 3) + .frame(width: 240, height: 240) } + Spacer() + statusBanner Spacer().frame(height: 40) } + .padding(.horizontal, 24) } .navigationTitle("Scan to pair") .navigationBarTitleDisplayMode(.inline) @@ -37,6 +42,41 @@ struct PairingScannerView: View { } } + @ViewBuilder + private var statusBanner: some View { + if let error { + banner(error, tint: .red.opacity(0.85)) + } else { + switch cameraState { + case .scanning: + banner("Point at the QR code on your Mac", tint: .black.opacity(0.5)) + case .unavailable: + VStack(spacing: 12) { + banner("The camera isn't available here — pairing needs a real iPhone, not the Simulator.", + tint: .black.opacity(0.6)) + } + case .denied: + VStack(spacing: 12) { + banner("Camera access is off. Enable it to scan the pairing code.", tint: .black.opacity(0.6)) + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + } + } + } + + private func banner(_ text: String, tint: Color) -> some View { + Text(text) + .multilineTextAlignment(.center) + .foregroundStyle(.white) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(tint, in: RoundedRectangle(cornerRadius: 16)) + } + private func handle(_ value: String) { guard let payload = try? PairingPayload(qrString: value) else { error = "That isn't a Nucleic pairing code"; return @@ -48,10 +88,12 @@ struct PairingScannerView: View { /// UIKit bridge for the AVCaptureSession metadata scan. struct QRScannerRepresentable: UIViewControllerRepresentable { let onFound: (String) -> Void + let onState: (ScannerCameraState) -> Void func makeUIViewController(context: Context) -> QRScannerController { let controller = QRScannerController() controller.onFound = onFound + controller.onState = onState return controller } func updateUIViewController(_ controller: QRScannerController, context: Context) {} @@ -59,18 +101,45 @@ struct QRScannerRepresentable: UIViewControllerRepresentable { final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onFound: ((String) -> Void)? + var onState: ((ScannerCameraState) -> Void)? private let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session") + private var preview: AVCaptureVideoPreviewLayer? private var didFind = false override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .black + // Gate on camera authorization first — without this the prompt may never appear and the + // feed silently fails (and on a denied device it would just stay black). + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + granted ? self?.configureSession() : self?.report(.denied) + } + } + case .denied, .restricted: + report(.denied) + @unknown default: + report(.denied) + } + } + + /// Build the capture graph. Reports `.unavailable` when there's no camera (the Simulator) so + /// the UI can explain the black screen instead of leaving the user guessing. + private func configureSession() { guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } + session.canAddInput(input) else { + report(.unavailable); return + } session.addInput(input) let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { return } + guard session.canAddOutput(output) else { report(.unavailable); return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) output.metadataObjectTypes = [.qr] @@ -79,13 +148,26 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject preview.videoGravity = .resizeAspectFill preview.frame = view.layer.bounds view.layer.addSublayer(preview) + self.preview = preview - Task.detached { [session] in session.startRunning() } + report(.scanning) + sessionQueue.async { [session] in session.startRunning() } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // Keep the preview filling the view across layout/rotation (setting it once in viewDidLoad + // left it mis-sized when bounds weren't final yet). + preview?.frame = view.layer.bounds } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - session.stopRunning() + sessionQueue.async { [session] in session.stopRunning() } + } + + private func report(_ state: ScannerCameraState) { + onState?(state) } func metadataOutput(