Some checks failed
CI / test (push) Has been cancelled
Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
419 lines
14 KiB
Swift
419 lines
14 KiB
Swift
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
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@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 {
|
|
AppScrollScreen(compactLayout: compactLayout) {
|
|
AppSectionCard(title: title, compactLayout: compactLayout) {
|
|
Text(description)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
LiveQRScannerView(onCodeScanned: onCodeScanned)
|
|
.frame(minHeight: 340)
|
|
}
|
|
|
|
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) {
|
|
AppTextEditorField(text: $manualFallback, minHeight: 120)
|
|
|
|
if compactLayout {
|
|
VStack(spacing: 12) {
|
|
useFallbackButton
|
|
useSeededButton
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
useFallbackButton
|
|
useSeededButton
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(navigationTitleText)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
manualFallback = seededPayload
|
|
}
|
|
}
|
|
}
|
|
|
|
private var compactLayout: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
|
|
private var useFallbackButton: some View {
|
|
Button {
|
|
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
|
dismiss()
|
|
} label: {
|
|
Label("Use payload", systemImage: "arrow.up.forward.square")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
|
|
private var useSeededButton: some View {
|
|
Button {
|
|
manualFallback = seededPayload
|
|
} label: {
|
|
Label("Reset sample", systemImage: "wand.and.rays")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
|
|
private struct LiveQRScannerView: View {
|
|
let onCodeScanned: (String) -> Void
|
|
|
|
@StateObject private var scanner = QRScannerViewModel()
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomLeading) {
|
|
Group {
|
|
if scanner.isPreviewAvailable {
|
|
ScannerPreview(session: scanner.captureSession)
|
|
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
|
.fill(Color.black.opacity(0.86))
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Image(systemName: "video.slash.fill")
|
|
.font(.system(size: 28, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
Text("Live camera preview unavailable")
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
Text(scanner.statusMessage)
|
|
.foregroundStyle(.white.opacity(0.78))
|
|
}
|
|
.padding(24)
|
|
}
|
|
}
|
|
|
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.22), lineWidth: 1.5)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Camera Scanner")
|
|
.font(.headline.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
Text(scanner.statusMessage)
|
|
.foregroundStyle(.white.opacity(0.84))
|
|
}
|
|
.padding(22)
|
|
|
|
ScanFrameOverlay()
|
|
.padding(40)
|
|
}
|
|
.task {
|
|
scanner.onCodeScanned = { payload in
|
|
onCodeScanned(payload)
|
|
}
|
|
await scanner.start()
|
|
}
|
|
.onDisappear {
|
|
scanner.stop()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ScanFrameOverlay: View {
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let size = min(geometry.size.width, geometry.size.height) * 0.5
|
|
|
|
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
|
.frame(width: size, height: size)
|
|
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
|
}
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
|
|
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 seeded payload below."
|
|
}
|
|
#else
|
|
#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 fallback payload below."
|
|
}
|
|
guard granted else { return }
|
|
await configureIfNeeded()
|
|
startRunning()
|
|
case .denied, .restricted:
|
|
await MainActor.run {
|
|
isPreviewAvailable = false
|
|
statusMessage = "Camera access is unavailable. Use the fallback payload below."
|
|
}
|
|
@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<Void, Never>) 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 fallback payload below."
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let input = try? AVCaptureDeviceInput(device: device) else {
|
|
DispatchQueue.main.async {
|
|
self.isPreviewAvailable = false
|
|
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
|
}
|
|
return
|
|
}
|
|
|
|
guard self.captureSession.canAddInput(input) else {
|
|
DispatchQueue.main.async {
|
|
self.isPreviewAvailable = false
|
|
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
|
}
|
|
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 metadata 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 metadata scanning. Use the fallback payload below."
|
|
}
|
|
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
|