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 <[email protected]>
This commit is contained in:
Nucleic
2026-06-21 04:45:18 -07:00
co-authored by Claude Opus 4.8
parent e6ef59e830
commit d8fc4696a8
@@ -2,34 +2,39 @@ import SwiftUI
import AVFoundation import AVFoundation
import NucleicProtocol 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 /// Camera QR scanner (UX_IOS §7). Decodes the Mac's `nucleic://pair?d=…` QR into a
/// `PairingPayload` and hands it back exactly once. /// `PairingPayload` and hands it back exactly once.
struct PairingScannerView: View { struct PairingScannerView: View {
let onScanned: (PairingPayload) -> Void let onScanned: (PairingPayload) -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var error: String? @State private var error: String?
@State private var cameraState: ScannerCameraState = .scanning
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
QRScannerRepresentable { value in handle(value) } Color.black.ignoresSafeArea()
QRScannerRepresentable(onFound: handle, onState: { cameraState = $0 })
.ignoresSafeArea() .ignoresSafeArea()
VStack { VStack {
Spacer() Spacer()
RoundedRectangle(cornerRadius: 16) if cameraState == .scanning {
.stroke(.white.opacity(0.8), lineWidth: 3) RoundedRectangle(cornerRadius: 16)
.frame(width: 240, height: 240) .stroke(.white.opacity(0.8), lineWidth: 3)
Spacer() .frame(width: 240, height: 240)
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())
} }
Spacer()
statusBanner
Spacer().frame(height: 40) Spacer().frame(height: 40)
} }
.padding(.horizontal, 24)
} }
.navigationTitle("Scan to pair") .navigationTitle("Scan to pair")
.navigationBarTitleDisplayMode(.inline) .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) { private func handle(_ value: String) {
guard let payload = try? PairingPayload(qrString: value) else { guard let payload = try? PairingPayload(qrString: value) else {
error = "That isn't a Nucleic pairing code"; return error = "That isn't a Nucleic pairing code"; return
@@ -48,10 +88,12 @@ struct PairingScannerView: View {
/// UIKit bridge for the AVCaptureSession metadata scan. /// UIKit bridge for the AVCaptureSession metadata scan.
struct QRScannerRepresentable: UIViewControllerRepresentable { struct QRScannerRepresentable: UIViewControllerRepresentable {
let onFound: (String) -> Void let onFound: (String) -> Void
let onState: (ScannerCameraState) -> Void
func makeUIViewController(context: Context) -> QRScannerController { func makeUIViewController(context: Context) -> QRScannerController {
let controller = QRScannerController() let controller = QRScannerController()
controller.onFound = onFound controller.onFound = onFound
controller.onState = onState
return controller return controller
} }
func updateUIViewController(_ controller: QRScannerController, context: Context) {} func updateUIViewController(_ controller: QRScannerController, context: Context) {}
@@ -59,18 +101,45 @@ struct QRScannerRepresentable: UIViewControllerRepresentable {
final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onFound: ((String) -> Void)? var onFound: ((String) -> Void)?
var onState: ((ScannerCameraState) -> Void)?
private let session = AVCaptureSession() private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session")
private var preview: AVCaptureVideoPreviewLayer?
private var didFind = false private var didFind = false
override func viewDidLoad() { override func viewDidLoad() {
super.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), guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device), let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return } session.canAddInput(input) else {
report(.unavailable); return
}
session.addInput(input) session.addInput(input)
let output = AVCaptureMetadataOutput() let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return } guard session.canAddOutput(output) else { report(.unavailable); return }
session.addOutput(output) session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main) output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr] output.metadataObjectTypes = [.qr]
@@ -79,13 +148,26 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
preview.videoGravity = .resizeAspectFill preview.videoGravity = .resizeAspectFill
preview.frame = view.layer.bounds preview.frame = view.layer.bounds
view.layer.addSublayer(preview) 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) { override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
session.stopRunning() sessionQueue.async { [session] in session.stopRunning() }
}
private func report(_ state: ScannerCameraState) {
onState?(state)
} }
func metadataOutput( func metadataOutput(