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:
co-authored by
Claude Opus 4.8
parent
e6ef59e830
commit
d8fc4696a8
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user