mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 18:06:07 +00:00
* fix(ios): defer QR pairing after scanner dismissal * fix(ios): process QR pairing after scanner dismissal * fix(ios): harden QR scanner handoff * fix(ios): give QR scanner dismissal more time * fix(ios): keep onboarding open for QR trust prompt * fix(ios): keep QR trust prompt owned by onboarding * fix(ios): recover operator pairing after QR bootstrap * fix(ios): cancel stale QR scanner handoffs Co-authored-by: PollyBot13 <pollybot13@gmail.com> * fix(ios): defer QR setup until onboarding closes * fix(ios): keep QR setup links with visible settings * fix(ios): consume setup links during onboarding * fix(ios): handle setup links during onboarding launch * fix(ios): route setup links through active onboarding * fix(ios): harden QR gateway handoff * fix(ios): cancel superseded gateway attempts * fix(ios): serialize scanner result delivery * fix(ios): prevent stale gateway reconnects * fix(ios): serialize gateway target handoff * fix(ios): disable stale gateway relaunch route * fix(ios): await staged bootstrap reset * test(ios): bound gateway reset handoff * fix(ios): preserve explicit gateway handoff * fix(ios): harden gateway lifecycle ownership * chore(ios): sync native i18n inventory * test(ios): align gateway ownership assertions * refactor(ios): remove superseded gateway helpers * fix(ios): keep gateway auth route scoped * fix(ios): restore gateway target review state * fix(protocol): refresh Swift plugin approval model * test(ios): isolate state directory overrides * fix(ios): preserve watch alerts across gateway switches * fix(ios): bind deferred work to gateway ownership * docs(changelog): credit iOS gateway handoff fix * chore(i18n): sync native app inventory * test(ios): remove unused Watch approval hooks --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
207 lines
6.8 KiB
Swift
207 lines
6.8 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()
|
|
}
|
|
let scanner = DataScannerViewController(
|
|
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
|
isHighlightingEnabled: true)
|
|
scanner.delegate = context.coordinator
|
|
do {
|
|
try scanner.startScanning()
|
|
} catch {
|
|
context.coordinator.reportError("Could not start QR scanner.")
|
|
}
|
|
return scanner
|
|
}
|
|
|
|
func updateUIViewController(_: UIViewController, context _: Context) {}
|
|
|
|
static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
|
|
if let scanner = uiViewController as? DataScannerViewController {
|
|
scanner.stopScanning()
|
|
}
|
|
coordinator.parent.onDismiss()
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(parent: self)
|
|
}
|
|
|
|
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.")
|
|
}
|
|
}
|
|
}
|