import AVFoundation import Combine import SwiftUI #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif struct QRScannerSheet: View { let seededPayload: String let title: String let description: String let navigationTitleText: String let onCodeScanned: (String) -> Void @Environment(\.dismiss) private var dismiss @State private var manualFallback = "" init( seededPayload: String, title: String = "Scan QR", description: String = "Use the camera to scan an idp.global QR challenge.", navigationTitle: String = "Scan QR", onCodeScanned: @escaping (String) -> Void ) { self.seededPayload = seededPayload self.title = title self.description = description self.navigationTitleText = navigationTitle self.onCodeScanned = onCodeScanned } var body: some View { NavigationStack { ZStack(alignment: .top) { LiveQRScannerView { payload in onCodeScanned(payload) dismiss() } .ignoresSafeArea() VStack(spacing: 12) { IdPGlassCapsule { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.headline) Text(description) .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } Spacer() VStack(alignment: .leading, spacing: 12) { Text("Manual fallback") .font(.headline) TextEditor(text: $manualFallback) .font(.footnote.monospaced()) .scrollContentBackground(.hidden) .frame(minHeight: 110) .padding(10) .background(Color.idpTertiaryFill, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) VStack(spacing: 10) { Button("Use payload") { let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines) onCodeScanned(chosen.isEmpty ? seededPayload : chosen) dismiss() } .buttonStyle(PrimaryActionStyle()) Button("Use demo payload") { manualFallback = seededPayload } .buttonStyle(SecondaryActionStyle()) } } .padding(18) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) } .padding(16) } .navigationTitle(navigationTitleText) .applyInlineNavigationTitleDisplayMode() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } } .onAppear { manualFallback = seededPayload } } } } private extension View { @ViewBuilder func applyInlineNavigationTitleDisplayMode() -> some View { #if os(macOS) self #else navigationBarTitleDisplayMode(.inline) #endif } } struct LiveQRScannerView: View { let onCodeScanned: (String) -> Void @StateObject private var scanner = QRScannerViewModel() @State private var didDetectCode = false var body: some View { ZStack(alignment: .bottomLeading) { Group { if scanner.isPreviewAvailable { ScannerPreview(session: scanner.captureSession) } else { Color.black VStack(alignment: .leading, spacing: 10) { Image(systemName: "video.slash.fill") .font(.system(size: 24, weight: .semibold)) .foregroundStyle(.white) Text("Camera preview unavailable") .font(.title3.weight(.semibold)) .foregroundStyle(.white) Text(scanner.statusMessage) .foregroundStyle(.white.opacity(0.78)) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } Color.black.opacity(0.18) .ignoresSafeArea() ScanFrameOverlay(detected: didDetectCode) .padding(40) VStack(alignment: .leading, spacing: 6) { Text("Point the camera at the pairing QR") .font(.headline.weight(.semibold)) .foregroundStyle(.white) Text(scanner.statusMessage) .font(.subheadline) .foregroundStyle(.white.opacity(0.84)) } .padding(22) } .task { scanner.onCodeScanned = { payload in withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) { didDetectCode = true } Task { try? await Task.sleep(for: .milliseconds(180)) onCodeScanned(payload) } } await scanner.start() } .onDisappear { scanner.stop() } } } private struct ScanFrameOverlay: View { let detected: Bool var body: some View { GeometryReader { geometry in let size = min(geometry.size.width, geometry.size.height) * 0.5 let inset = detected ? 18.0 : 0 ZStack { CornerTick(rotation: .degrees(0)) .frame(width: size, height: size) CornerTick(rotation: .degrees(90)) .frame(width: size, height: size) CornerTick(rotation: .degrees(180)) .frame(width: size, height: size) CornerTick(rotation: .degrees(270)) .frame(width: size, height: size) } .frame(width: size - inset, height: size - inset) .position(x: geometry.size.width / 2, y: geometry.size.height / 2) .animation(.spring(response: 0.3, dampingFraction: 0.82), value: detected) } .allowsHitTesting(false) } } private struct CornerTick: View { let rotation: Angle var body: some View { Path { path in let length: CGFloat = 34 path.move(to: CGPoint(x: 0, y: length)) path.addLine(to: CGPoint(x: 0, y: 0)) path.addLine(to: CGPoint(x: length, y: 0)) } .stroke(.white, style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)) .rotationEffect(rotation) } } private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { @Published var isPreviewAvailable = false @Published var statusMessage = "Point the camera at the QR code from the idp.global web portal." let captureSession = AVCaptureSession() var onCodeScanned: ((String) -> Void)? private let queue = DispatchQueue(label: "global.idp.qrscanner") private var isConfigured = false private var hasDeliveredCode = false func start() async { #if os(iOS) && targetEnvironment(simulator) await MainActor.run { isPreviewAvailable = false statusMessage = "The iOS simulator has no live camera feed. Use the demo payload below." } #endif #if !(os(iOS) && targetEnvironment(simulator)) let authorization = AVCaptureDevice.authorizationStatus(for: .video) switch authorization { case .authorized: await configureIfNeeded() startRunning() case .notDetermined: let granted = await requestCameraAccess() await MainActor.run { self.statusMessage = granted ? "Point the camera at the QR code from the idp.global web portal." : "Camera access was denied. Use the manual fallback instead." } guard granted else { return } await configureIfNeeded() startRunning() case .denied, .restricted: await MainActor.run { isPreviewAvailable = false statusMessage = "Camera access is unavailable. Use the manual fallback instead." } @unknown default: await MainActor.run { isPreviewAvailable = false statusMessage = "Camera access could not be initialized on this device." } } #endif } func stop() { queue.async { if self.captureSession.isRunning { self.captureSession.stopRunning() } } } func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection ) { guard !hasDeliveredCode, let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject, readable.type == .qr, let payload = readable.stringValue else { return } hasDeliveredCode = true stop() #if os(iOS) UINotificationFeedbackGenerator().notificationOccurred(.success) #endif DispatchQueue.main.async { [onCodeScanned] in onCodeScanned?(payload) } } private func requestCameraAccess() async -> Bool { await withCheckedContinuation { continuation in AVCaptureDevice.requestAccess(for: .video) { granted in continuation.resume(returning: granted) } } } private func configureIfNeeded() async { guard !isConfigured else { await MainActor.run { self.isPreviewAvailable = true } return } await withCheckedContinuation { (continuation: CheckedContinuation) in queue.async { self.captureSession.beginConfiguration() defer { self.captureSession.commitConfiguration() continuation.resume() } guard let device = AVCaptureDevice.default(for: .video) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } guard let input = try? AVCaptureDeviceInput(device: device) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } guard self.captureSession.canAddInput(input) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } self.captureSession.addInput(input) let output = AVCaptureMetadataOutput() guard self.captureSession.canAddOutput(output) else { self.captureSession.removeInput(input) DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "Unable to configure QR scanning on this device." } return } self.captureSession.addOutput(output) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) let supportedTypes = output.availableMetadataObjectTypes guard supportedTypes.contains(.qr) else { self.captureSession.removeOutput(output) self.captureSession.removeInput(input) DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "This camera does not support QR scanning. Use the manual fallback instead." } return } output.metadataObjectTypes = [.qr] self.isConfigured = true DispatchQueue.main.async { self.isPreviewAvailable = true self.statusMessage = "Point the camera at the QR code from the idp.global web portal." } } } } private func startRunning() { queue.async { guard !self.captureSession.isRunning else { return } self.hasDeliveredCode = false self.captureSession.startRunning() } } } extension QRScannerViewModel: @unchecked Sendable {} #if os(iOS) private struct ScannerPreview: UIViewRepresentable { let session: AVCaptureSession func makeUIView(context: Context) -> ScannerPreviewUIView { let view = ScannerPreviewUIView() view.previewLayer.session = session view.previewLayer.videoGravity = .resizeAspectFill return view } func updateUIView(_ uiView: ScannerPreviewUIView, context: Context) { uiView.previewLayer.session = session } } private final class ScannerPreviewUIView: UIView { override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } } #elseif os(macOS) private struct ScannerPreview: NSViewRepresentable { let session: AVCaptureSession func makeNSView(context: Context) -> ScannerPreviewNSView { let view = ScannerPreviewNSView() view.attach(session: session) return view } func updateNSView(_ nsView: ScannerPreviewNSView, context: Context) { nsView.attach(session: session) } } private final class ScannerPreviewNSView: NSView { private var previewLayer: AVCaptureVideoPreviewLayer? override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true } required init?(coder: NSCoder) { super.init(coder: coder) wantsLayer = true } func attach(session: AVCaptureSession) { let layer = previewLayer ?? AVCaptureVideoPreviewLayer(session: session) layer.session = session layer.videoGravity = .resizeAspectFill self.layer = layer previewLayer = layer } } #endif