Refocus app around identity proof flows
This commit is contained in:
@@ -1,29 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
private let loginAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
||||
private let loginGold = Color(red: 0.90, green: 0.79, blue: 0.60)
|
||||
private let loginAccent = AppTheme.accent
|
||||
|
||||
struct LoginRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: compactLayout ? 18 : 24) {
|
||||
LoginHeroPanel(model: model, compactLayout: compactLayout)
|
||||
PairingConsoleCard(model: model, compactLayout: compactLayout)
|
||||
TrustFootprintCard(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.frame(maxWidth: 1040)
|
||||
.padding(compactLayout ? 18 : 28)
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
LoginHeroPanel(model: model, compactLayout: compactLayout)
|
||||
PairingConsoleCard(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.sheet(isPresented: $model.isScannerPresented) {
|
||||
QRScannerSheet(
|
||||
seededPayload: model.suggestedQRCodePayload,
|
||||
seededPayload: model.suggestedPairingPayload,
|
||||
title: "Scan linking QR",
|
||||
description: "Use the camera to scan the QR code from the web flow that activates this device as your passport.",
|
||||
navigationTitle: "Scan Linking QR",
|
||||
onCodeScanned: { payload in
|
||||
model.manualQRCodePayload = payload
|
||||
model.manualPairingPayload = payload
|
||||
Task {
|
||||
await model.signIn(with: payload)
|
||||
await model.signIn(with: payload, transport: .qr)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -44,51 +41,49 @@ private struct LoginHeroPanel: View {
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
RoundedRectangle(cornerRadius: 36, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.13, green: 0.22, blue: 0.19),
|
||||
Color(red: 0.20, green: 0.41, blue: 0.33),
|
||||
loginGold
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Secure passport setup", tone: loginAccent)
|
||||
|
||||
VStack(alignment: .leading, spacing: compactLayout ? 16 : 18) {
|
||||
Text("Bind this device to your idp.global account")
|
||||
.font(.system(size: compactLayout ? 32 : 44, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
Text("Turn this device into a passport for your idp.global identity")
|
||||
.font(.system(size: compactLayout ? 28 : 36, weight: .bold, design: .rounded))
|
||||
.lineLimit(3)
|
||||
|
||||
Text("Scan the pairing QR from your account to turn this device into your approval and notification app.")
|
||||
.font(compactLayout ? .body : .title3)
|
||||
.foregroundStyle(.white.opacity(0.88))
|
||||
Text("Scan a linking QR code or paste a payload to activate this device as your passport for identity proofs and security alerts.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HeroTag(title: "Account binding")
|
||||
HeroTag(title: "QR pairing")
|
||||
HeroTag(title: "iPhone, iPad, Mac")
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
HeroTag(title: "Account binding")
|
||||
HeroTag(title: "QR pairing")
|
||||
HeroTag(title: "iPhone, iPad, Mac")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview pairing payload…")
|
||||
.tint(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
LoginFeatureRow(icon: "qrcode.viewfinder", title: "Scan a QR code from the web flow")
|
||||
LoginFeatureRow(icon: "doc.text.viewfinder", title: "Paste a payload when you already have one")
|
||||
LoginFeatureRow(icon: "iphone.gen3", title: "Handle identity checks and alerts here")
|
||||
}
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview passport...")
|
||||
.tint(loginAccent)
|
||||
}
|
||||
.padding(compactLayout ? 22 : 32)
|
||||
}
|
||||
.frame(minHeight: compactLayout ? 280 : 320)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginFeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(loginAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,46 +92,41 @@ private struct PairingConsoleCard: View {
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
LoginCard(title: "Bind your account", subtitle: "Scan the QR code from your idp.global account or use the preview payload while backend wiring is still in progress.") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Open your account pairing screen, then scan the QR code here.")
|
||||
.font(.headline)
|
||||
Text("If you are testing the preview build without the live backend yet, the seeded payload below will still bind the mock session.")
|
||||
AppSectionCard(title: "Set up passport", compactLayout: compactLayout) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Link payload")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
AppTextEditorField(
|
||||
text: $model.manualPairingPayload,
|
||||
minHeight: compactLayout ? 132 : 150
|
||||
)
|
||||
}
|
||||
|
||||
if model.isAuthenticating {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Activating this passport...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
TextEditor(text: $model.manualQRCodePayload)
|
||||
.font(.body.monospaced())
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(16)
|
||||
.frame(minHeight: compactLayout ? 130 : 150)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
Text("NFC, QR, and OTP proof methods become available after this passport is active.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if model.isAuthenticating {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Binding this device to your account…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
primaryButtons
|
||||
secondaryButtons
|
||||
}
|
||||
|
||||
Group {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
primaryButtons
|
||||
secondaryButtons
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
primaryButtons
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
secondaryButtons
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
primaryButtons
|
||||
}
|
||||
|
||||
secondaryButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,154 +137,57 @@ private struct PairingConsoleCard: View {
|
||||
Button {
|
||||
model.isScannerPresented = true
|
||||
} label: {
|
||||
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
|
||||
Label("Scan QR", systemImage: "qrcode.viewfinder")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithManualCode()
|
||||
}
|
||||
} label: {
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Bind With Payload", systemImage: "arrow.right.circle.fill")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.isAuthenticating)
|
||||
.controlSize(.large)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var secondaryButtons: some View {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
usePayloadButton
|
||||
previewPayloadButton
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
usePayloadButton
|
||||
previewPayloadButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var usePayloadButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedCode()
|
||||
await model.signInWithManualPayload()
|
||||
}
|
||||
} label: {
|
||||
Label("Use Preview QR", systemImage: "wand.and.stars")
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Link with payload", systemImage: "arrow.right.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Text("This preview keeps the account-binding flow realistic while the live API is still being wired in.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .trailing)
|
||||
.controlSize(.large)
|
||||
.disabled(model.isAuthenticating)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrustFootprintCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
LoginCard(title: "About this build", subtitle: "Keep the first-run screen simple, but still explain the trust context and preview status clearly.") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
trustFacts
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
trustFacts
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Preview Pairing Payload")
|
||||
.font(.headline)
|
||||
Text(model.suggestedQRCodePayload.isEmpty ? "Preparing preview payload…" : model.suggestedQRCodePayload)
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
}
|
||||
private var previewPayloadButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
} label: {
|
||||
Label("Use preview passport", systemImage: "wand.and.stars")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trustFacts: some View {
|
||||
TrustFactCard(
|
||||
icon: "person.badge.key.fill",
|
||||
title: "Account Binding",
|
||||
message: "This device binds to your idp.global account and becomes your place for approvals and alerts."
|
||||
)
|
||||
TrustFactCard(
|
||||
icon: "person.2.badge.gearshape.fill",
|
||||
title: "Built by foss.global",
|
||||
message: "foss.global is the open-source collective behind idp.global and the current preview environment."
|
||||
)
|
||||
TrustFactCard(
|
||||
icon: "bolt.badge.clock",
|
||||
title: "Preview Backend",
|
||||
message: "Login, requests, and notifications are mocked behind a clean service boundary until live integration is ready."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginCard<Content: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let content: () -> Content
|
||||
|
||||
init(title: String, subtitle: String, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
Text(subtitle)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeroTag: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrustFactCard: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(loginAccent)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text(message)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
296
Sources/Features/Auth/NFCPairingView.swift
Normal file
296
Sources/Features/Auth/NFCPairingView.swift
Normal file
@@ -0,0 +1,296 @@
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(CoreLocation) && canImport(CoreNFC) && canImport(CryptoKit) && os(iOS)
|
||||
import CoreLocation
|
||||
import CoreNFC
|
||||
import CryptoKit
|
||||
|
||||
@MainActor
|
||||
final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCNDEFReaderSessionDelegate {
|
||||
@Published private(set) var helperText: String
|
||||
@Published private(set) var isScanning = false
|
||||
@Published private(set) var isSupported: Bool
|
||||
|
||||
var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
private let signedGPSPositionProvider = SignedGPSPositionProvider()
|
||||
private var session: NFCNDEFReaderSession?
|
||||
private var isPreparingLocationProof = false
|
||||
|
||||
override init() {
|
||||
let supported = NFCNDEFReaderSession.readingAvailable
|
||||
_helperText = Published(initialValue: supported ? NFCIdentifyReader.idleHelperText : NFCIdentifyReader.unavailableHelperText)
|
||||
_isSupported = Published(initialValue: supported)
|
||||
super.init()
|
||||
}
|
||||
|
||||
func beginScanning() {
|
||||
refreshAvailability()
|
||||
|
||||
guard isSupported else {
|
||||
onError?(Self.unavailableErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
guard !isScanning else { return }
|
||||
|
||||
isScanning = true
|
||||
isPreparingLocationProof = false
|
||||
helperText = Self.scanningHelperText
|
||||
|
||||
let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
|
||||
session.alertMessage = "Hold your iPhone near the idp.global tag. A signed GPS position will be attached to this NFC identify action."
|
||||
self.session = session
|
||||
session.begin()
|
||||
}
|
||||
|
||||
func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.helperText = Self.scanningHelperText
|
||||
}
|
||||
}
|
||||
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
|
||||
guard let payload = extractPayload(from: messages) else {
|
||||
session.invalidate()
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
self.onError?(Self.invalidTagMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isPreparingLocationProof = true
|
||||
self.helperText = Self.signingLocationHelperText
|
||||
|
||||
Task { @MainActor in
|
||||
await self.completeAuthentication(for: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||
let nsError = error as NSError
|
||||
let ignoredCodes = [200, 204] // User canceled, first tag read.
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.session = nil
|
||||
}
|
||||
|
||||
guard !(nsError.domain == NFCErrorDomain && ignoredCodes.contains(nsError.code)) else {
|
||||
if !isPreparingLocationProof {
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
self.onError?(Self.failureMessage(for: nsError))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func completeAuthentication(for payload: String) async {
|
||||
do {
|
||||
let signedGPSPosition = try await signedGPSPositionProvider.currentSignedGPSPosition(for: payload)
|
||||
let request = PairingAuthenticationRequest(
|
||||
pairingPayload: payload,
|
||||
transport: .nfc,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
finishScanning()
|
||||
onAuthenticationRequestDetected?(request)
|
||||
} catch let error as AppError {
|
||||
finishScanning()
|
||||
onError?(error.errorDescription ?? Self.gpsSigningFailureMessage)
|
||||
} catch {
|
||||
finishScanning()
|
||||
onError?(Self.gpsSigningFailureMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishScanning() {
|
||||
session = nil
|
||||
isPreparingLocationProof = false
|
||||
isScanning = false
|
||||
refreshAvailability()
|
||||
}
|
||||
|
||||
private func refreshAvailability() {
|
||||
let available = NFCNDEFReaderSession.readingAvailable
|
||||
isSupported = available
|
||||
if !isScanning {
|
||||
helperText = available ? Self.idleHelperText : Self.unavailableHelperText
|
||||
}
|
||||
}
|
||||
|
||||
private func extractPayload(from messages: [NFCNDEFMessage]) -> String? {
|
||||
for message in messages {
|
||||
for record in message.records {
|
||||
if let url = record.wellKnownTypeURIPayload() {
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
let (text, _) = record.wellKnownTypeTextPayload()
|
||||
if let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if let fallback = String(data: record.payload, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!fallback.isEmpty {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func failureMessage(for error: NSError) -> String {
|
||||
if error.domain == NFCErrorDomain && error.code == 2 {
|
||||
return "NFC identify is not permitted in this build. Check the NFC entitlement and privacy description."
|
||||
}
|
||||
|
||||
return "NFC identify could not be completed on this device."
|
||||
}
|
||||
|
||||
private static let idleHelperText = "Tap to identify with an NFC tag on supported iPhone hardware. A signed GPS position will be attached automatically."
|
||||
private static let scanningHelperText = "Hold the top of your iPhone near the NFC tag until it is identified."
|
||||
private static let signingLocationHelperText = "Tag detected. Capturing and signing the current GPS position for NFC identify."
|
||||
private static let unavailableHelperText = "NFC identify is unavailable on this device."
|
||||
private static let unavailableErrorMessage = "Tap to identify requires supported iPhone hardware with NFC enabled."
|
||||
private static let invalidTagMessage = "The NFC tag did not contain a usable idp.global payload."
|
||||
private static let gpsSigningFailureMessage = "The NFC tag was read, but the signed GPS position could not be attached."
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SignedGPSPositionProvider: NSObject, @preconcurrency CLLocationManagerDelegate {
|
||||
private var manager: CLLocationManager?
|
||||
private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
|
||||
|
||||
func currentSignedGPSPosition(for pairingPayload: String) async throws -> SignedGPSPosition {
|
||||
let location = try await currentLocation()
|
||||
return try sign(location: location, pairingPayload: pairingPayload)
|
||||
}
|
||||
|
||||
private func currentLocation() async throws -> CLLocation {
|
||||
let manager = CLLocationManager()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
manager.distanceFilter = kCLDistanceFilterNone
|
||||
self.manager = manager
|
||||
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
break
|
||||
case .notDetermined:
|
||||
let status = await requestAuthorization(using: manager)
|
||||
guard status == .authorizedAlways || status == .authorizedWhenInUse else {
|
||||
throw AppError.locationPermissionDenied
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw AppError.locationPermissionDenied
|
||||
@unknown default:
|
||||
throw AppError.locationUnavailable
|
||||
}
|
||||
|
||||
return try await requestLocation(using: manager)
|
||||
}
|
||||
|
||||
private func requestAuthorization(using manager: CLLocationManager) async -> CLAuthorizationStatus {
|
||||
manager.requestWhenInUseAuthorization()
|
||||
return await withCheckedContinuation { continuation in
|
||||
authorizationContinuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation(using manager: CLLocationManager) async throws -> CLLocation {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
locationContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func sign(location: CLLocation, pairingPayload: String) throws -> SignedGPSPosition {
|
||||
let isFresh = abs(location.timestamp.timeIntervalSinceNow) <= 120
|
||||
guard location.horizontalAccuracy >= 0,
|
||||
isFresh else {
|
||||
throw AppError.locationUnavailable
|
||||
}
|
||||
|
||||
let unsignedPosition = SignedGPSPosition(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude,
|
||||
horizontalAccuracyMeters: location.horizontalAccuracy,
|
||||
capturedAt: location.timestamp
|
||||
)
|
||||
|
||||
let privateKey = P256.Signing.PrivateKey()
|
||||
let signature = try privateKey.signature(for: unsignedPosition.signingPayload(for: pairingPayload))
|
||||
return unsignedPosition.signed(
|
||||
signatureData: signature.derRepresentation,
|
||||
publicKeyData: privateKey.publicKey.x963Representation
|
||||
)
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
guard let continuation = authorizationContinuation else { return }
|
||||
|
||||
let status = manager.authorizationStatus
|
||||
guard status != .notDetermined else { return }
|
||||
|
||||
authorizationContinuation = nil
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let continuation = locationContinuation,
|
||||
let location = locations.last else {
|
||||
return
|
||||
}
|
||||
|
||||
authorizationContinuation = nil
|
||||
locationContinuation = nil
|
||||
self.manager = nil
|
||||
continuation.resume(returning: location)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
|
||||
authorizationContinuation = nil
|
||||
locationContinuation = nil
|
||||
self.manager = nil
|
||||
|
||||
if let locationError = error as? CLError,
|
||||
locationError.code == .denied {
|
||||
continuation.resume(throwing: AppError.locationPermissionDenied)
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(throwing: AppError.locationUnavailable)
|
||||
}
|
||||
}
|
||||
#else
|
||||
@MainActor
|
||||
final class NFCIdentifyReader: NSObject, ObservableObject {
|
||||
@Published private(set) var helperText = "NFC identify with a signed GPS position is available on supported iPhone hardware only."
|
||||
@Published private(set) var isScanning = false
|
||||
@Published private(set) var isSupported = false
|
||||
|
||||
var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
func beginScanning() {
|
||||
onError?("Tap to identify requires supported iPhone hardware with NFC and location access enabled.")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -9,56 +9,58 @@ import AppKit
|
||||
|
||||
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 {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Use the camera to scan the QR code shown by the web portal. If you’re on a simulator or desktop without a camera, the seeded payload works as a mock fallback.")
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
AppSectionCard(title: title, compactLayout: compactLayout) {
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LiveQRScannerView(onCodeScanned: onCodeScanned)
|
||||
.frame(minHeight: 340)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Fallback Pairing Payload")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $manualFallback)
|
||||
.font(.body.monospaced())
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(14)
|
||||
.frame(minHeight: 120)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) {
|
||||
AppTextEditorField(text: $manualFallback, minHeight: 120)
|
||||
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Use Fallback Payload", systemImage: "arrow.up.forward.square")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
manualFallback = seededPayload
|
||||
} label: {
|
||||
Label("Use Seeded Mock", systemImage: "wand.and.rays")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationTitle(navigationTitleText)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
@@ -71,6 +73,36 @@ struct QRScannerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user