import SwiftUI import AVFoundation struct QRCodeScannerView: View { let onScan: (Data) -> Void @Environment(\.dismiss) private var dismiss @State private var cameraPermission: CameraPermission = .unknown enum CameraPermission { case unknown, granted, denied } var body: some View { NavigationStack { ZStack { switch cameraPermission { case .unknown: ProgressView("Requesting camera access...") case .denied: VStack(spacing: 16) { Image(systemName: "camera.fill") .font(.system(size: 48)) .foregroundStyle(.secondary) Text("Camera access is required to scan QR codes.") .multilineTextAlignment(.center) Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .buttonStyle(.borderedProminent) } .padding() case .granted: ScannerRepresentable(onScan: { data in onScan(data) dismiss() }) .ignoresSafeArea() } } .navigationTitle("Scan QR Code") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } } } .task { await checkCameraPermission() } } private func checkCameraPermission() async { let status = AVCaptureDevice.authorizationStatus(for: .video) switch status { case .authorized: cameraPermission = .granted case .notDetermined: let granted = await AVCaptureDevice.requestAccess(for: .video) cameraPermission = granted ? .granted : .denied default: cameraPermission = .denied } } } // MARK: - Scanner UIKit wrapper private struct ScannerRepresentable: UIViewControllerRepresentable { let onScan: (Data) -> Void func makeUIViewController(context: Context) -> ScannerViewController { let vc = ScannerViewController() vc.onScan = onScan return vc } func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {} } final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onScan: ((Data) -> Void)? private var captureSession: AVCaptureSession? private var previewLayer: AVCaptureVideoPreviewLayer? private var hasScanned = false override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black setupCamera() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.bounds } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) captureSession?.stopRunning() } private func setupCamera() { let session = AVCaptureSession() captureSession = session guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { return } session.addInput(input) let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) output.metadataObjectTypes = [.qr] let layer = AVCaptureVideoPreviewLayer(session: session) layer.videoGravity = .resizeAspectFill layer.frame = view.bounds view.layer.addSublayer(layer) previewLayer = layer DispatchQueue.global(qos: .userInitiated).async { session.startRunning() } } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard !hasScanned, let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, object.type == .qr else { return } hasScanned = true captureSession?.stopRunning() // QR codes contain base64-encoded binary data (matching Python client) if let stringValue = object.stringValue, let data = Data(base64Encoded: stringValue) { onScan?(data) } } }