mirror of
https://github.com/gravitational/teleport.git
synced 2026-08-28 21:12:20 +08:00
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:
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user