iOS: QR Code Scanner (#68181)

* Add initial implementation of QRScannerView

* Wire up QRScannerView to navigate to device enrollment

* Consolidate validateScan and onScan API calls

* Add handling for various camera permission states
This commit is contained in:
Gustavo Medori
2026-07-02 09:43:12 -06:00
committed by GitHub
parent fed6d6ddbc
commit f08b36fbac
8 changed files with 379 additions and 7 deletions
@@ -41,12 +41,12 @@ struct LandingView: View {
// MARK: Navigation
.navigationDestination(
item: $viewModel.destination.deviceEnrollment,
destination: { deviceEnrollmentViewModel in
EnrollDeviceView(viewModel: deviceEnrollmentViewModel)
},
)
.navigationDestination(item: $viewModel.destination.deviceEnrollment) { deviceEnrollmentViewModel in
EnrollDeviceView(viewModel: deviceEnrollmentViewModel)
}
.sheet(item: $viewModel.destination.cameraScanner, id: \.presentationID) { enrollCameraScannerViewModel in
EnrollCameraScannerView(viewModel: enrollCameraScannerViewModel)
}
.alert(
item: $viewModel.destination.deepLinkParsingAlert,
title: { errorMessage in
@@ -56,6 +56,10 @@ struct LandingView: View {
Button("OK") {}
},
)
// MARK: Haptics
.sensoryFeedback(.success, trigger: viewModel.sensoryFeedbackTrigger)
}
}
}
@@ -97,7 +101,7 @@ extension LandingView {
private var scanQRCodeButton: some View {
Button {
print("scanning has not been built yet")
viewModel.userTappedOnScanQRCode()
} label: {
Text("Scan QR Code")
.padding(.vertical, .xsmall)
@@ -23,9 +23,19 @@ final class LandingViewModel {
enum Destination {
case deviceEnrollment(EnrollDeviceViewModel)
case deepLinkParsingAlert(errorMessage: String)
case cameraScanner(EnrollCameraScannerViewModel)
}
var destination: Destination? = nil
var sensoryFeedbackTrigger = false
}
// MARK: - User Actions
extension LandingViewModel {
func userTappedOnScanQRCode() {
destination = .cameraScanner(EnrollCameraScannerViewModel(delegate: self))
}
}
// MARK: - Programmatic Navigation
@@ -47,3 +57,15 @@ extension LandingViewModel: EnrollDeviceViewModel.Delegate {
destination = nil
}
}
// MARK: - EnrollCameraScannerViewModel.Delegate
extension LandingViewModel: EnrollCameraScannerViewModel.Delegate {
func enrollCameraScannerViewModel(
_ viewModel: EnrollCameraScannerViewModel,
didReceiveEnrollMobileDeviceDeepLink deepLink: EnrollMobileDeviceDeepLink,
) {
sensoryFeedbackTrigger.toggle()
destination = .deviceEnrollment(EnrollDeviceViewModel(deepLink: deepLink, delegate: self))
}
}
@@ -60,3 +60,14 @@ enum DeepLinkParseError: LocalizedError, Equatable {
}
}
}
// MARK: - DeepLink + CustomDebugStringConvertible
extension DeepLink: CustomDebugStringConvertible {
var debugDescription: String {
switch self {
case let .enrollMobileDevice(enrollMobileDeviceDeepLink):
"enrollMobileDevice(\(enrollMobileDeviceDeepLink.debugDescription))"
}
}
}
@@ -41,3 +41,12 @@ extension EnrollMobileDeviceDeepLink {
)
}
}
// MARK: - CustomDebugStringConvertible
extension EnrollMobileDeviceDeepLink: CustomDebugStringConvertible {
var debugDescription: String {
let portString = if let port { "\(port)" } else { "(nil)" }
return "\(hostname):\(portString)?enroll_pairing_token=\(enrollPairingToken)"
}
}
@@ -0,0 +1,62 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/
import AVFoundation
import Foundation
import SwiftUI
struct EnrollCameraScannerView: View {
var viewModel: EnrollCameraScannerViewModel
@Environment(\.openURL)
var openURL
var body: some View {
Group {
switch viewModel.cameraAuthorizationStatus {
case .notDetermined:
ProgressView()
case .restricted, .denied:
// TODO: Add a button below this view that opens the iOS settings
ContentUnavailableView(
"QR Scanner Unavailable",
systemImage: "video.slash",
description: unavailableCameraDescriptionText,
)
case .authorized:
QRScannerView(onScan: viewModel.didScan(_:))
.ignoresSafeArea()
@unknown default:
EmptyView()
}
}
.task(viewModel.requestCameraAccess)
}
}
// MARK: - Subviews
extension EnrollCameraScannerView {
var unavailableCameraDescriptionText: Text {
if viewModel.cameraAuthorizationStatus == .restricted {
// If the authorization status is restricted, it usually means by some external mechanism like MDM or
// parental controls. It's often something that the user doesn't have control over.
Text("Your device has prevented Teleport Verify from accessing your camera.")
} else {
Text("Teleport Verify doesn't have permission to show the QR scanner. Grant permission in iOS settings.")
}
}
}
@@ -0,0 +1,102 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/
import AVFoundation
import Observation
import OSLog
@Observable @MainActor
final class EnrollCameraScannerViewModel {
private static let logger = Logger.forType(EnrollCameraScannerViewModel.self)
var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined
weak var delegate: (any Delegate)? = nil
init(delegate: (any Delegate)? = nil) {
self.delegate = delegate
}
}
// MARK: - EnrollCameraScannerViewModel.Delegate
extension EnrollCameraScannerViewModel {
protocol Delegate: AnyObject {
func enrollCameraScannerViewModel(
_ viewModel: EnrollCameraScannerViewModel,
didReceiveEnrollMobileDeviceDeepLink deepLink: EnrollMobileDeviceDeepLink,
)
}
}
// MARK: - Scanner Actions
extension EnrollCameraScannerViewModel {
func didScan(_ payload: String) -> QRScannerDecision {
guard let enrollMobileDeviceDeepLink = validateScannedCode(payload) else {
return .continueScanning
}
Self.logger.info("Scanned deep link: \(enrollMobileDeviceDeepLink.debugDescription)")
delegate?.enrollCameraScannerViewModel(self, didReceiveEnrollMobileDeviceDeepLink: enrollMobileDeviceDeepLink)
return .stopScanning
}
private func validateScannedCode(_ payload: String) -> EnrollMobileDeviceDeepLink? {
Self.logger.debug("Validating scanned QR code: \(payload)")
do {
guard let url = URL(string: payload) else {
return nil
}
let deepLink = try DeepLink(from: url)
guard case let .enrollMobileDevice(enrollMobileDeviceDeepLink) = deepLink else {
return nil
}
return enrollMobileDeviceDeepLink
} catch {
Self.logger.debug("\(payload) did not pass validation")
return nil
}
}
}
// MARK: - Navigation Helpers
extension EnrollCameraScannerViewModel {
/// For the purposes of presentation, there is no distinction between instances of EnrollCameraScannerViewModel,
/// so we vend this constant presentation ID to express that to SwiftUI.
var presentationID: String {
"EnrollCameraScannerViewModel"
}
}
// MARK: - Camera Authorization
extension EnrollCameraScannerViewModel {
func requestCameraAccess() async {
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
switch cameraAuthorizationStatus {
case .notDetermined:
await AVCaptureDevice.requestAccess(for: .video)
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
case .restricted, .denied, .authorized:
break
@unknown default:
Self.logger.warning(
"Encountered unknown camera authorization status: \(self.cameraAuthorizationStatus.rawValue)",
)
}
}
}
@@ -0,0 +1,160 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/
import Foundation
import SwiftUI
import Vision
import VisionKit
struct QRScannerView: UIViewControllerRepresentable {
var onScan: (String) -> QRScannerDecision
var onError: (any Error) -> Void = { _ in }
func makeUIViewController(context: Context) -> DataScannerViewController {
let viewController = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
recognizesMultipleItems: false,
isHighFrameRateTrackingEnabled: false,
isPinchToZoomEnabled: true,
isGuidanceEnabled: true,
isHighlightingEnabled: true,
)
viewController.delegate = context.coordinator
return viewController
}
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {
guard !uiViewController.isScanning else {
return
}
guard DataScannerViewController.isSupported else {
context.coordinator.reportError(QRScannerError.unsupportedDevice)
return
}
guard DataScannerViewController.isAvailable else {
context.coordinator.reportError(QRScannerError.scannerUnavailable)
return
}
do {
try uiViewController.startScanning()
} catch {
context.coordinator.reportError(error)
}
}
static func dismantleUIViewController(_ uiViewController: DataScannerViewController, coordinator: Coordinator) {
uiViewController.stopScanning()
}
func makeCoordinator() -> Coordinator {
Coordinator(onScan: onScan, onError: onError)
}
@MainActor
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
private var didScan = false
private var didReportError = false
private let onScan: (String) -> QRScannerDecision
private let onError: (any Error) -> Void
init(
onScan: @escaping (String) -> QRScannerDecision,
onError: @escaping (any Error) -> Void,
) {
self.onScan = onScan
self.onError = onError
}
func reportError(_ error: any Error) {
guard !didReportError else {
return
}
didReportError = true
onError(error)
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem],
) {
handle(items: addedItems, dataScanner: dataScanner)
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didUpdate updatedItems: [RecognizedItem],
allItems: [RecognizedItem],
) {
handle(items: updatedItems, dataScanner: dataScanner)
}
func dataScanner(
_ dataScanner: DataScannerViewController,
becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable,
) {
reportError(error)
}
private func handle(items: [RecognizedItem], dataScanner: DataScannerViewController) {
guard !didScan else {
return
}
for item in items {
guard
case let .barcode(barcode) = item,
let payload = barcode.payloadStringValue
else {
continue
}
switch onScan(payload) {
case .continueScanning:
break
case .stopScanning:
didScan = true
dataScanner.stopScanning()
return
}
}
}
}
}
enum QRScannerError: LocalizedError {
case unsupportedDevice
case scannerUnavailable
var errorDescription: String? {
switch self {
case .unsupportedDevice:
"QR code scanning is not supported on this device."
case .scannerUnavailable:
"QR code scanning is currently unavailable."
}
}
}
enum QRScannerDecision {
case continueScanning, stopScanning
}
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Teleport Verify uses the built-in QR code scanner to receive enrollment data from the Teleport Web UI</string>
<key>CFBundleURLTypes</key>
<array>
<dict>