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 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()
|
||||||
|
if cameraState == .scanning {
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.stroke(.white.opacity(0.8), lineWidth: 3)
|
.stroke(.white.opacity(0.8), lineWidth: 3)
|
||||||
.frame(width: 240, height: 240)
|
.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())
|
|
||||||
}
|
}
|
||||||
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user