Files
swiftapp/swift/Sources/Features/Auth/QRScannerView.swift
Jürgen Kunz a6939453f8
Some checks failed
CI / test (push) Has been cancelled
Adopt root-level tsswift app layout
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.
2026-04-19 01:21:43 +02:00

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