Files
openclaw/apps/ios/Sources/Onboarding/QRScannerView.swift
Colin Johnson 632efa2d73 fix ios qr scanner lifecycle (#101235)
* fix ios qr scanner lifecycle

* fix(ios): tighten QR scanner lifecycle change

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-07-06 21:34:44 -05:00

260 lines
8.9 KiB
Swift

import OpenClawKit
import SwiftUI
import VisionKit
enum QRScannerResult: Equatable {
case gatewayLink(GatewayConnectDeepLink)
case setupCode(String)
}
@MainActor
struct GatewayPendingTargetSuppression {
enum Owner: Equatable {
case qrScanner
case setupLink
}
private var value: (owner: Owner, lease: GatewayConnectionController.AutoConnectSuppressionLease)?
mutating func replace(
owner: Owner,
lease: GatewayConnectionController.AutoConnectSuppressionLease)
{
self.value = (owner, lease)
}
mutating func take(ifOwnedBy owner: Owner? = nil) -> GatewayConnectionController.AutoConnectSuppressionLease? {
guard let value = self.value else { return nil }
if let owner, value.owner != owner { return nil }
self.value = nil
return value.lease
}
mutating func resumeAutoConnect(_ owner: Owner? = nil, controller: GatewayConnectionController) {
guard let lease = self.take(ifOwnedBy: owner) else { return }
controller.resumeAutoConnect(after: lease)
}
mutating func releaseAutoConnect(_ owner: Owner? = nil, controller: GatewayConnectionController) {
guard let lease = self.take(ifOwnedBy: owner) else { return }
controller.releaseAutoConnectSuppression(after: lease)
}
}
@MainActor
final class QRScannerResultHandoff {
/// SwiftUI's onDismiss can precede VisionKit's AV capture teardown. Delay
/// pairing UI briefly so it cannot race the scanner's camera shutdown.
static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000
private let settlingNanoseconds: UInt64
private var pendingResult: QRScannerResult?
private var deliveryTask: Task<Void, Never>?
private var activeScanID: UInt64 = 0
init(settlingNanoseconds: UInt64 = QRScannerResultHandoff.defaultSettlingNanoseconds) {
self.settlingNanoseconds = settlingNanoseconds
}
@discardableResult
func beginScan() -> UInt64 {
self.cancel()
return self.activeScanID
}
func isActive(scanID: UInt64) -> Bool {
scanID == self.activeScanID && self.pendingResult == nil
}
@discardableResult
func queue(_ result: QRScannerResult, scanID: UInt64) -> Bool {
// Camera and Photos can finish together; the first valid result owns this scan.
guard self.isActive(scanID: scanID) else { return false }
self.pendingResult = result
return true
}
@discardableResult
func processAfterDismissal(
_ process: @escaping @MainActor (QRScannerResult) -> Void) -> Task<Void, Never>?
{
guard let result = self.pendingResult else {
self.cancel()
return nil
}
self.pendingResult = nil
self.activeScanID &+= 1
self.deliveryTask?.cancel()
let settlingNanoseconds = self.settlingNanoseconds
let task = Task { @MainActor in
do {
try await Task.sleep(nanoseconds: settlingNanoseconds)
} catch {
return
}
process(result)
}
self.deliveryTask = task
return task
}
func cancel() {
self.activeScanID &+= 1
self.deliveryTask?.cancel()
self.deliveryTask = nil
self.pendingResult = nil
}
}
struct QRScannerView: UIViewControllerRepresentable {
let onResult: (QRScannerResult) -> Void
let onError: (String) -> Void
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> UIViewController {
guard DataScannerViewController.isSupported else {
context.coordinator.reportError("QR scanning is not supported on this device.")
return UIViewController()
}
guard DataScannerViewController.isAvailable else {
context.coordinator.reportError("Camera scanning is currently unavailable.")
return UIViewController()
}
return QRScannerContainerViewController(coordinator: context.coordinator)
}
func updateUIViewController(_: UIViewController, context _: Context) {}
static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
if let scanner = uiViewController as? QRScannerContainerViewController {
scanner.stopScannerCapture()
}
coordinator.parent.onDismiss()
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
final class QRScannerContainerViewController: UIViewController {
private let coordinator: Coordinator
private let scanner: DataScannerViewController
private var didStartScanning = false
init(coordinator: Coordinator) {
self.coordinator = coordinator
self.scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
isHighlightingEnabled: true)
super.init(nibName: nil, bundle: nil)
self.scanner.delegate = coordinator
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .systemBackground
self.addChild(self.scanner)
self.scanner.view.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(self.scanner.view)
NSLayoutConstraint.activate([
self.scanner.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
self.scanner.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
self.scanner.view.topAnchor.constraint(equalTo: self.view.topAnchor),
self.scanner.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
])
self.scanner.didMove(toParent: self)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// VisionKit owns the camera session; start it only after UIKit has
// presented the scanner so sheet teardown cannot race construction.
self.startScanningIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
self.stopScannerCapture()
super.viewWillDisappear(animated)
}
func stopScannerCapture() {
self.scanner.stopScanning()
self.didStartScanning = false
}
private func startScanningIfNeeded() {
guard !self.didStartScanning else { return }
do {
try self.scanner.startScanning()
self.didStartScanning = true
} catch {
self.coordinator.reportError("Could not start QR scanner.")
}
}
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let parent: QRScannerView
private var handled = false
private var reportedError = false
init(parent: QRScannerView) {
self.parent = parent
}
func reportError(_ message: String) {
guard !self.reportedError else { return }
self.reportedError = true
Task { @MainActor in
self.parent.onError(message)
}
}
func dataScanner(
_ scanner: DataScannerViewController,
didAdd items: [RecognizedItem],
allItems _: [RecognizedItem])
{
guard !self.handled else { return }
for item in items {
guard case let .barcode(barcode) = item,
let payload = barcode.payloadStringValue
else { continue }
if let link = GatewayConnectDeepLink.fromSetupInput(payload) {
self.deliver(.gatewayLink(link), scanner: scanner)
return
}
if AppleReviewDemoMode.isSetupCode(payload) {
self.deliver(.setupCode(payload), scanner: scanner)
return
}
}
}
private func deliver(_ result: QRScannerResult, scanner: DataScannerViewController) {
self.handled = true
// DataScannerViewController has no teardown-completion callback. Stop capture
// before owners dismiss the sheet and later present pairing UI.
scanner.stopScanning()
Task { @MainActor in
self.parent.onResult(result)
}
}
func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
func dataScanner(
_: DataScannerViewController,
becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable)
{
self.reportError("Camera is not available on this device.")
}
}
}