Build passport-style identity app shell
This commit is contained in:
238
Sources/App/AppViewModel.swift
Normal file
238
Sources/App/AppViewModel.swift
Normal file
@@ -0,0 +1,238 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppViewModel: ObservableObject {
|
||||
@Published var suggestedQRCodePayload = ""
|
||||
@Published var manualQRCodePayload = ""
|
||||
@Published var session: AuthSession?
|
||||
@Published var profile: MemberProfile?
|
||||
@Published var requests: [ApprovalRequest] = []
|
||||
@Published var notifications: [AppNotification] = []
|
||||
@Published var notificationPermission: NotificationPermissionState = .unknown
|
||||
@Published var selectedSection: AppSection = .overview
|
||||
@Published var isBootstrapping = false
|
||||
@Published var isAuthenticating = false
|
||||
@Published var isRefreshing = false
|
||||
@Published var isNotificationCenterPresented = false
|
||||
@Published var activeRequestID: ApprovalRequest.ID?
|
||||
@Published var isScannerPresented = false
|
||||
@Published var bannerMessage: String?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var hasBootstrapped = false
|
||||
private let service: IDPServicing
|
||||
private let notificationCoordinator: NotificationCoordinating
|
||||
private let launchArguments: [String]
|
||||
|
||||
private var preferredLaunchSection: AppSection? {
|
||||
guard let argument = launchArguments.first(where: { $0.hasPrefix("--mock-section=") }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rawValue = String(argument.dropFirst("--mock-section=".count))
|
||||
if rawValue == "notifications" {
|
||||
return .activity
|
||||
}
|
||||
return AppSection(rawValue: rawValue)
|
||||
}
|
||||
|
||||
init(
|
||||
service: IDPServicing = MockIDPService(),
|
||||
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
||||
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
||||
) {
|
||||
self.service = service
|
||||
self.notificationCoordinator = notificationCoordinator
|
||||
self.launchArguments = launchArguments
|
||||
}
|
||||
|
||||
var pendingRequests: [ApprovalRequest] {
|
||||
requests
|
||||
.filter { $0.status == .pending }
|
||||
.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var handledRequests: [ApprovalRequest] {
|
||||
requests
|
||||
.filter { $0.status != .pending }
|
||||
.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var unreadNotificationCount: Int {
|
||||
notifications.filter(\.isUnread).count
|
||||
}
|
||||
|
||||
var elevatedPendingCount: Int {
|
||||
pendingRequests.filter { $0.risk == .elevated }.count
|
||||
}
|
||||
|
||||
var latestNotification: AppNotification? {
|
||||
notifications.first
|
||||
}
|
||||
|
||||
var pairedDeviceSummary: String {
|
||||
session?.deviceName ?? "No active device"
|
||||
}
|
||||
|
||||
func bootstrap() async {
|
||||
guard !hasBootstrapped else { return }
|
||||
hasBootstrapped = true
|
||||
|
||||
isBootstrapping = true
|
||||
defer { isBootstrapping = false }
|
||||
|
||||
do {
|
||||
let bootstrap = try await service.bootstrap()
|
||||
suggestedQRCodePayload = bootstrap.suggestedQRCodePayload
|
||||
manualQRCodePayload = bootstrap.suggestedQRCodePayload
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
|
||||
if launchArguments.contains("--mock-auto-pair"),
|
||||
session == nil {
|
||||
await signIn(with: bootstrap.suggestedQRCodePayload)
|
||||
|
||||
if let preferredLaunchSection {
|
||||
selectedSection = preferredLaunchSection
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Unable to prepare the app."
|
||||
}
|
||||
}
|
||||
|
||||
func signInWithManualCode() async {
|
||||
await signIn(with: manualQRCodePayload)
|
||||
}
|
||||
|
||||
func signInWithSuggestedCode() async {
|
||||
manualQRCodePayload = suggestedQRCodePayload
|
||||
await signIn(with: suggestedQRCodePayload)
|
||||
}
|
||||
|
||||
func signIn(with payload: String) async {
|
||||
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "Paste or scan a QR payload first."
|
||||
return
|
||||
}
|
||||
|
||||
isAuthenticating = true
|
||||
defer { isAuthenticating = false }
|
||||
|
||||
do {
|
||||
let result = try await service.signIn(withQRCode: trimmed)
|
||||
session = result.session
|
||||
apply(snapshot: result.snapshot)
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
selectedSection = .overview
|
||||
bannerMessage = "Paired with \(result.session.deviceName)."
|
||||
isScannerPresented = false
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
} catch {
|
||||
errorMessage = "Unable to complete sign-in."
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDashboard() async {
|
||||
guard session != nil else { return }
|
||||
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.refreshDashboard()
|
||||
apply(snapshot: snapshot)
|
||||
} catch {
|
||||
errorMessage = "Unable to refresh the dashboard."
|
||||
}
|
||||
}
|
||||
|
||||
func approve(_ request: ApprovalRequest) async {
|
||||
await mutateRequest(request, approve: true)
|
||||
}
|
||||
|
||||
func reject(_ request: ApprovalRequest) async {
|
||||
await mutateRequest(request, approve: false)
|
||||
}
|
||||
|
||||
func simulateIncomingRequest() async {
|
||||
guard session != nil else { return }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.simulateIncomingRequest()
|
||||
apply(snapshot: snapshot)
|
||||
selectedSection = .requests
|
||||
bannerMessage = "A new mock approval request arrived."
|
||||
} catch {
|
||||
errorMessage = "Unable to seed a new request right now."
|
||||
}
|
||||
}
|
||||
|
||||
func requestNotificationAccess() async {
|
||||
do {
|
||||
notificationPermission = try await notificationCoordinator.requestAuthorization()
|
||||
if notificationPermission == .allowed || notificationPermission == .provisional {
|
||||
bannerMessage = "Notifications are ready on this device."
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Unable to update notification permission."
|
||||
}
|
||||
}
|
||||
|
||||
func sendTestNotification() async {
|
||||
do {
|
||||
try await notificationCoordinator.scheduleTestNotification(
|
||||
title: "idp.global approval pending",
|
||||
body: "A mock request is waiting for approval in the app."
|
||||
)
|
||||
bannerMessage = "A local test notification will appear in a few seconds."
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
} catch {
|
||||
errorMessage = "Unable to schedule a test notification."
|
||||
}
|
||||
}
|
||||
|
||||
func markNotificationRead(_ notification: AppNotification) async {
|
||||
do {
|
||||
let snapshot = try await service.markNotificationRead(id: notification.id)
|
||||
apply(snapshot: snapshot)
|
||||
} catch {
|
||||
errorMessage = "Unable to update the notification."
|
||||
}
|
||||
}
|
||||
|
||||
func signOut() {
|
||||
session = nil
|
||||
profile = nil
|
||||
requests = []
|
||||
notifications = []
|
||||
selectedSection = .overview
|
||||
bannerMessage = nil
|
||||
manualQRCodePayload = suggestedQRCodePayload
|
||||
}
|
||||
|
||||
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
|
||||
guard session != nil else { return }
|
||||
|
||||
activeRequestID = request.id
|
||||
defer { activeRequestID = nil }
|
||||
|
||||
do {
|
||||
let snapshot = approve
|
||||
? try await service.approveRequest(id: request.id)
|
||||
: try await service.rejectRequest(id: request.id)
|
||||
apply(snapshot: snapshot)
|
||||
bannerMessage = approve ? "Request approved for \(request.source)." : "Request rejected for \(request.source)."
|
||||
} catch {
|
||||
errorMessage = "Unable to update the request."
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(snapshot: DashboardSnapshot) {
|
||||
profile = snapshot.profile
|
||||
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||
}
|
||||
}
|
||||
63
Sources/App/IDPGlobalApp.swift
Normal file
63
Sources/App/IDPGlobalApp.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IDPGlobalApp: App {
|
||||
@StateObject private var model = AppViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.tint(Color(red: 0.12, green: 0.40, blue: 0.31))
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
.alert("Something went wrong", isPresented: errorPresented) {
|
||||
Button("OK") {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.defaultSize(width: 1380, height: 920)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var errorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.session == nil {
|
||||
LoginRootView(model: model)
|
||||
} else {
|
||||
HomeRootView(model: model)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.96, green: 0.97, blue: 0.94),
|
||||
Color(red: 0.89, green: 0.94, blue: 0.92),
|
||||
Color(red: 0.94, green: 0.91, blue: 0.84)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
}
|
||||
}
|
||||
346
Sources/Core/Models/AppModels.swift
Normal file
346
Sources/Core/Models/AppModels.swift
Normal file
@@ -0,0 +1,346 @@
|
||||
import Foundation
|
||||
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
||||
case overview
|
||||
case requests
|
||||
case activity
|
||||
case account
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .overview: "Passport"
|
||||
case .requests: "Requests"
|
||||
case .activity: "Activity"
|
||||
case .account: "Account"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .overview: "person.crop.square.fill"
|
||||
case .requests: "checklist.checked"
|
||||
case .activity: "clock.arrow.trianglehead.counterclockwise.rotate.90"
|
||||
case .account: "person.crop.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationPermissionState: String, CaseIterable, Identifiable {
|
||||
case unknown
|
||||
case allowed
|
||||
case provisional
|
||||
case denied
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .unknown: "Not Asked Yet"
|
||||
case .allowed: "Enabled"
|
||||
case .provisional: "Delivered Quietly"
|
||||
case .denied: "Disabled"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .unknown: "bell"
|
||||
case .allowed: "bell.badge.fill"
|
||||
case .provisional: "bell.badge"
|
||||
case .denied: "bell.slash.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .unknown:
|
||||
"The app has not asked for notification delivery yet."
|
||||
case .allowed:
|
||||
"Alerts can break through immediately when a request arrives."
|
||||
case .provisional:
|
||||
"Notifications can be delivered quietly until the user promotes them."
|
||||
case .denied:
|
||||
"Approval events stay in-app until the user re-enables notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BootstrapContext {
|
||||
let suggestedQRCodePayload: String
|
||||
}
|
||||
|
||||
struct DashboardSnapshot {
|
||||
let profile: MemberProfile
|
||||
let requests: [ApprovalRequest]
|
||||
let notifications: [AppNotification]
|
||||
}
|
||||
|
||||
struct SignInResult {
|
||||
let session: AuthSession
|
||||
let snapshot: DashboardSnapshot
|
||||
}
|
||||
|
||||
struct MemberProfile: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let handle: String
|
||||
let organization: String
|
||||
let deviceCount: Int
|
||||
let recoverySummary: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
handle: String,
|
||||
organization: String,
|
||||
deviceCount: Int,
|
||||
recoverySummary: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.handle = handle
|
||||
self.organization = organization
|
||||
self.deviceCount = deviceCount
|
||||
self.recoverySummary = recoverySummary
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthSession: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let pairedAt: Date
|
||||
let tokenPreview: String
|
||||
let pairingCode: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
deviceName: String,
|
||||
originHost: String,
|
||||
pairedAt: Date,
|
||||
tokenPreview: String,
|
||||
pairingCode: String
|
||||
) {
|
||||
self.id = id
|
||||
self.deviceName = deviceName
|
||||
self.originHost = originHost
|
||||
self.pairedAt = pairedAt
|
||||
self.tokenPreview = tokenPreview
|
||||
self.pairingCode = pairingCode
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
||||
case signIn
|
||||
case accessGrant
|
||||
case elevatedAction
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .signIn: "Sign-In"
|
||||
case .accessGrant: "Access Grant"
|
||||
case .elevatedAction: "Elevated Action"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .signIn: "qrcode.viewfinder"
|
||||
case .accessGrant: "key.fill"
|
||||
case .elevatedAction: "shield.lefthalf.filled"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRisk: String, Hashable {
|
||||
case routine
|
||||
case elevated
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .routine: "Routine"
|
||||
case .elevated: "Elevated"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"Routine access to profile or sign-in scopes."
|
||||
case .elevated:
|
||||
"Sensitive access that can sign, publish, or unlock privileged actions."
|
||||
}
|
||||
}
|
||||
|
||||
var guidance: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"Review the origin and scope list, then approve if the session matches the device you expect."
|
||||
case .elevated:
|
||||
"Treat this like a privileged operation. Verify the origin, the requested scopes, and whether the action is time-bound before approving."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalStatus: String, Hashable {
|
||||
case pending
|
||||
case approved
|
||||
case rejected
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pending: "Pending"
|
||||
case .approved: "Approved"
|
||||
case .rejected: "Rejected"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .pending: "clock.badge"
|
||||
case .approved: "checkmark.circle.fill"
|
||||
case .rejected: "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalRequest: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let source: String
|
||||
let createdAt: Date
|
||||
let kind: ApprovalRequestKind
|
||||
let risk: ApprovalRisk
|
||||
let scopes: [String]
|
||||
var status: ApprovalStatus
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
subtitle: String,
|
||||
source: String,
|
||||
createdAt: Date,
|
||||
kind: ApprovalRequestKind,
|
||||
risk: ApprovalRisk,
|
||||
scopes: [String],
|
||||
status: ApprovalStatus
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.source = source
|
||||
self.createdAt = createdAt
|
||||
self.kind = kind
|
||||
self.risk = risk
|
||||
self.scopes = scopes
|
||||
self.status = status
|
||||
}
|
||||
|
||||
var scopeSummary: String {
|
||||
if scopes.isEmpty {
|
||||
return "No scopes listed"
|
||||
}
|
||||
|
||||
let suffix = scopes.count == 1 ? "" : "s"
|
||||
return "\(scopes.count) requested scope\(suffix)"
|
||||
}
|
||||
|
||||
var trustHeadline: String {
|
||||
switch (kind, risk) {
|
||||
case (.signIn, .routine):
|
||||
"Low-friction sign-in request"
|
||||
case (.signIn, .elevated):
|
||||
"Privileged sign-in request"
|
||||
case (.accessGrant, _):
|
||||
"Token grant request"
|
||||
case (.elevatedAction, _):
|
||||
"Sensitive action request"
|
||||
}
|
||||
}
|
||||
|
||||
var trustDetail: String {
|
||||
switch kind {
|
||||
case .signIn:
|
||||
"This request usually creates or refreshes a session token for a browser, CLI, or device."
|
||||
case .accessGrant:
|
||||
"This request issues scoped access for a service or automation that wants to act on your behalf."
|
||||
case .elevatedAction:
|
||||
"This request performs a privileged action such as signing, publishing, or creating short-lived credentials."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppNotificationKind: String, Hashable {
|
||||
case approval
|
||||
case security
|
||||
case system
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .approval: "Approval"
|
||||
case .security: "Security"
|
||||
case .system: "System"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .approval: "checkmark.seal.fill"
|
||||
case .security: "shield.fill"
|
||||
case .system: "sparkles"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .approval:
|
||||
"Decision and approval activity"
|
||||
case .security:
|
||||
"Pairing and security posture updates"
|
||||
case .system:
|
||||
"Product and environment status messages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNotification: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let message: String
|
||||
let sentAt: Date
|
||||
let kind: AppNotificationKind
|
||||
var isUnread: Bool
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
message: String,
|
||||
sentAt: Date,
|
||||
kind: AppNotificationKind,
|
||||
isUnread: Bool
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.sentAt = sentAt
|
||||
self.kind = kind
|
||||
self.isUnread = isUnread
|
||||
}
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
case invalidQRCode
|
||||
case requestNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidQRCode:
|
||||
"That QR payload is not valid for idp.global sign-in."
|
||||
case .requestNotFound:
|
||||
"The selected request could not be found."
|
||||
}
|
||||
}
|
||||
}
|
||||
246
Sources/Core/Services/MockIDPService.swift
Normal file
246
Sources/Core/Services/MockIDPService.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
import Foundation
|
||||
|
||||
protocol IDPServicing {
|
||||
func bootstrap() async throws -> BootstrapContext
|
||||
func signIn(withQRCode payload: String) async throws -> SignInResult
|
||||
func refreshDashboard() async throws -> DashboardSnapshot
|
||||
func approveRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
func rejectRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
func simulateIncomingRequest() async throws -> DashboardSnapshot
|
||||
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
|
||||
}
|
||||
|
||||
actor MockIDPService: IDPServicing {
|
||||
private let profile = MemberProfile(
|
||||
name: "Phil Kunz",
|
||||
handle: "phil@idp.global",
|
||||
organization: "idp.global",
|
||||
deviceCount: 4,
|
||||
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
|
||||
)
|
||||
|
||||
private var requests: [ApprovalRequest] = []
|
||||
private var notifications: [AppNotification] = []
|
||||
|
||||
init() {
|
||||
requests = Self.seedRequests()
|
||||
notifications = Self.seedNotifications()
|
||||
}
|
||||
|
||||
func bootstrap() async throws -> BootstrapContext {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
return BootstrapContext(
|
||||
suggestedQRCodePayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(withQRCode payload: String) async throws -> SignInResult {
|
||||
try await Task.sleep(for: .milliseconds(260))
|
||||
|
||||
let session = try parseSession(from: payload)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "New device paired",
|
||||
message: "\(session.deviceName) completed a QR pairing against \(session.originHost).",
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return SignInResult(
|
||||
session: session,
|
||||
snapshot: snapshot()
|
||||
)
|
||||
}
|
||||
|
||||
func refreshDashboard() async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
|
||||
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
||||
throw AppError.requestNotFound
|
||||
}
|
||||
|
||||
requests[index].status = .approved
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Request approved",
|
||||
message: "\(requests[index].title) was approved for \(requests[index].source).",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
|
||||
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
||||
throw AppError.requestNotFound
|
||||
}
|
||||
|
||||
requests[index].status = .rejected
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Request rejected",
|
||||
message: "\(requests[index].title) was rejected before token issuance.",
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func simulateIncomingRequest() async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
|
||||
let syntheticRequest = ApprovalRequest(
|
||||
title: "Approve SSH certificate issue",
|
||||
subtitle: "CI runner wants a short-lived signing certificate for a deployment pipeline.",
|
||||
source: "deploy.idp.global",
|
||||
createdAt: .now,
|
||||
kind: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["sign:ssh", "ttl:10m", "environment:staging"],
|
||||
status: .pending
|
||||
)
|
||||
|
||||
requests.insert(syntheticRequest, at: 0)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Fresh approval request",
|
||||
message: "A staging deployment is waiting for your approval.",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(80))
|
||||
|
||||
guard let index = notifications.firstIndex(where: { $0.id == id }) else {
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
notifications[index].isUnread = false
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
private func snapshot() -> DashboardSnapshot {
|
||||
DashboardSnapshot(
|
||||
profile: profile,
|
||||
requests: requests,
|
||||
notifications: notifications
|
||||
)
|
||||
}
|
||||
|
||||
private func parseSession(from payload: String) throws -> AuthSession {
|
||||
if let components = URLComponents(string: payload),
|
||||
components.scheme == "idp.global",
|
||||
components.host == "pair" {
|
||||
let queryItems = components.queryItems ?? []
|
||||
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
|
||||
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||
|
||||
return AuthSession(
|
||||
deviceName: device,
|
||||
originHost: origin,
|
||||
pairedAt: .now,
|
||||
tokenPreview: String(token.suffix(6)),
|
||||
pairingCode: payload
|
||||
)
|
||||
}
|
||||
|
||||
if payload.contains("token") || payload.contains("pair") {
|
||||
return AuthSession(
|
||||
deviceName: "Manual Pairing",
|
||||
originHost: "code.foss.global",
|
||||
pairedAt: .now,
|
||||
tokenPreview: String(payload.suffix(6)),
|
||||
pairingCode: payload
|
||||
)
|
||||
}
|
||||
|
||||
throw AppError.invalidQRCode
|
||||
}
|
||||
|
||||
private static func seedRequests() -> [ApprovalRequest] {
|
||||
[
|
||||
ApprovalRequest(
|
||||
title: "Approve Safari sign-in",
|
||||
subtitle: "A browser session from Berlin wants an SSO token for the portal.",
|
||||
source: "code.foss.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 12),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["openid", "profile", "groups:read"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Grant package publish access",
|
||||
subtitle: "The release bot is asking for a scoped publish token.",
|
||||
source: "registry.foss.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 42),
|
||||
kind: .accessGrant,
|
||||
risk: .elevated,
|
||||
scopes: ["packages:write", "ttl:30m"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Approve CLI login",
|
||||
subtitle: "A terminal session completed QR pairing earlier today.",
|
||||
source: "cli.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 180),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["openid", "profile"],
|
||||
status: .approved
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private static func seedNotifications() -> [AppNotification] {
|
||||
[
|
||||
AppNotification(
|
||||
title: "Two requests are waiting",
|
||||
message: "The queue includes one routine sign-in and one elevated access grant.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 8),
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
),
|
||||
AppNotification(
|
||||
title: "Recovery health check passed",
|
||||
message: "Backup recovery channels were verified in the last 24 hours.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 95),
|
||||
kind: .system,
|
||||
isUnread: false
|
||||
),
|
||||
AppNotification(
|
||||
title: "Quiet hours active on mobile",
|
||||
message: "Routine notifications will be delivered silently until the morning.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 220),
|
||||
kind: .security,
|
||||
isUnread: false
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
54
Sources/Core/Services/NotificationCoordinator.swift
Normal file
54
Sources/Core/Services/NotificationCoordinator.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
protocol NotificationCoordinating {
|
||||
func authorizationStatus() async -> NotificationPermissionState
|
||||
func requestAuthorization() async throws -> NotificationPermissionState
|
||||
func scheduleTestNotification(title: String, body: String) async throws
|
||||
}
|
||||
|
||||
final class NotificationCoordinator: NotificationCoordinating {
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
func authorizationStatus() async -> NotificationPermissionState {
|
||||
let settings = await center.notificationSettings()
|
||||
return NotificationPermissionState(settings.authorizationStatus)
|
||||
}
|
||||
|
||||
func requestAuthorization() async throws -> NotificationPermissionState {
|
||||
_ = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||
return await authorizationStatus()
|
||||
}
|
||||
|
||||
func scheduleTestNotification(title: String, body: String) async throws {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: content,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false)
|
||||
)
|
||||
|
||||
try await center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NotificationPermissionState {
|
||||
init(_ status: UNAuthorizationStatus) {
|
||||
switch status {
|
||||
case .authorized:
|
||||
self = .allowed
|
||||
case .provisional, .ephemeral:
|
||||
self = .provisional
|
||||
case .denied:
|
||||
self = .denied
|
||||
case .notDetermined:
|
||||
self = .unknown
|
||||
@unknown default:
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
300
Sources/Features/Auth/LoginRootView.swift
Normal file
300
Sources/Features/Auth/LoginRootView.swift
Normal file
@@ -0,0 +1,300 @@
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
.sheet(isPresented: $model.isScannerPresented) {
|
||||
QRScannerSheet(
|
||||
seededPayload: model.suggestedQRCodePayload,
|
||||
onCodeScanned: { payload in
|
||||
model.manualQRCodePayload = payload
|
||||
Task {
|
||||
await model.signIn(with: payload)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginHeroPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
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("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))
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview pairing payload…")
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
.padding(compactLayout ? 22 : 32)
|
||||
}
|
||||
.frame(minHeight: compactLayout ? 280 : 320)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingConsoleCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
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.")
|
||||
.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))
|
||||
|
||||
if model.isAuthenticating {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Binding this device to your account…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
primaryButtons
|
||||
secondaryButtons
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
primaryButtons
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
secondaryButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryButtons: some View {
|
||||
Button {
|
||||
model.isScannerPresented = true
|
||||
} label: {
|
||||
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var secondaryButtons: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedCode()
|
||||
}
|
||||
} label: {
|
||||
Label("Use Preview QR", systemImage: "wand.and.stars")
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
359
Sources/Features/Auth/QRScannerView.swift
Normal file
359
Sources/Features/Auth/QRScannerView.swift
Normal file
@@ -0,0 +1,359 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct QRScannerSheet: View {
|
||||
let seededPayload: String
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var manualFallback = ""
|
||||
|
||||
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.")
|
||||
.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))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("Scan QR Code")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
manualFallback = seededPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveQRScannerView: View {
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@StateObject private var scanner = QRScannerViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
Group {
|
||||
if scanner.isPreviewAvailable {
|
||||
ScannerPreview(session: scanner.captureSession)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(Color.black.opacity(0.86))
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Image(systemName: "video.slash.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Live camera preview unavailable")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.22), lineWidth: 1.5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Camera Scanner")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
}
|
||||
.padding(22)
|
||||
|
||||
ScanFrameOverlay()
|
||||
.padding(40)
|
||||
}
|
||||
.task {
|
||||
scanner.onCodeScanned = { payload in
|
||||
onCodeScanned(payload)
|
||||
}
|
||||
await scanner.start()
|
||||
}
|
||||
.onDisappear {
|
||||
scanner.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScanFrameOverlay: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = min(geometry.size.width, geometry.size.height) * 0.5
|
||||
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||
.frame(width: size, height: size)
|
||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
@Published var isPreviewAvailable = false
|
||||
@Published var statusMessage = "Point the camera at the QR code from the idp.global web portal."
|
||||
|
||||
let captureSession = AVCaptureSession()
|
||||
|
||||
var onCodeScanned: ((String) -> Void)?
|
||||
|
||||
private let queue = DispatchQueue(label: "global.idp.qrscanner")
|
||||
private var isConfigured = false
|
||||
private var hasDeliveredCode = false
|
||||
|
||||
func start() async {
|
||||
#if os(iOS) && targetEnvironment(simulator)
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "The iOS simulator has no live camera feed. Use the seeded payload below."
|
||||
}
|
||||
#else
|
||||
#endif
|
||||
|
||||
#if !(os(iOS) && targetEnvironment(simulator))
|
||||
let authorization = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
switch authorization {
|
||||
case .authorized:
|
||||
await configureIfNeeded()
|
||||
startRunning()
|
||||
case .notDetermined:
|
||||
let granted = await requestCameraAccess()
|
||||
await MainActor.run {
|
||||
self.statusMessage = granted
|
||||
? "Point the camera at the QR code from the idp.global web portal."
|
||||
: "Camera access was denied. Use the fallback payload below."
|
||||
}
|
||||
guard granted else { return }
|
||||
await configureIfNeeded()
|
||||
startRunning()
|
||||
case .denied, .restricted:
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "Camera access is unavailable. Use the fallback payload below."
|
||||
}
|
||||
@unknown default:
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "Camera access could not be initialized on this device."
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async {
|
||||
if self.captureSession.isRunning {
|
||||
self.captureSession.stopRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard !hasDeliveredCode,
|
||||
let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
readable.type == .qr,
|
||||
let payload = readable.stringValue else {
|
||||
return
|
||||
}
|
||||
|
||||
hasDeliveredCode = true
|
||||
stop()
|
||||
|
||||
#if os(iOS)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [onCodeScanned] in
|
||||
onCodeScanned?(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestCameraAccess() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureIfNeeded() async {
|
||||
guard !isConfigured else {
|
||||
await MainActor.run {
|
||||
self.isPreviewAvailable = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
queue.async {
|
||||
self.captureSession.beginConfiguration()
|
||||
defer {
|
||||
self.captureSession.commitConfiguration()
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
self.captureSession.canAddInput(input) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.captureSession.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard self.captureSession.canAddOutput(output) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "Unable to configure QR metadata scanning on this device."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.captureSession.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
self.isConfigured = true
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = true
|
||||
self.statusMessage = "Point the camera at the QR code from the idp.global web portal."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startRunning() {
|
||||
queue.async {
|
||||
guard !self.captureSession.isRunning else { return }
|
||||
self.hasDeliveredCode = false
|
||||
self.captureSession.startRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QRScannerViewModel: @unchecked Sendable {}
|
||||
|
||||
#if os(iOS)
|
||||
private struct ScannerPreview: UIViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeUIView(context: Context) -> ScannerPreviewUIView {
|
||||
let view = ScannerPreviewUIView()
|
||||
view.previewLayer.session = session
|
||||
view.previewLayer.videoGravity = .resizeAspectFill
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ScannerPreviewUIView, context: Context) {
|
||||
uiView.previewLayer.session = session
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScannerPreviewUIView: UIView {
|
||||
override class var layerClass: AnyClass {
|
||||
AVCaptureVideoPreviewLayer.self
|
||||
}
|
||||
|
||||
var previewLayer: AVCaptureVideoPreviewLayer {
|
||||
layer as! AVCaptureVideoPreviewLayer
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
private struct ScannerPreview: NSViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeNSView(context: Context) -> ScannerPreviewNSView {
|
||||
let view = ScannerPreviewNSView()
|
||||
view.attach(session: session)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: ScannerPreviewNSView, context: Context) {
|
||||
nsView.attach(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScannerPreviewNSView: NSView {
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
func attach(session: AVCaptureSession) {
|
||||
let layer = previewLayer ?? AVCaptureVideoPreviewLayer(session: session)
|
||||
layer.session = session
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
self.layer = layer
|
||||
previewLayer = layer
|
||||
}
|
||||
}
|
||||
#endif
|
||||
2918
Sources/Features/Home/HomeRootView.swift
Normal file
2918
Sources/Features/Home/HomeRootView.swift
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user