apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

commit af6047761cd445e8861e11b7df15b1fc52c1fbbf
parent 265015fc537bfe507f12d801b9765be9764fbc95
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 03:16:15 +0000

Package privacy manifest with RadrootsKit

- Declare app-container and user-selected file metadata reasons.
- Declare app-scoped defaults access without tracking or collection.
- Normalize all Swift sources and tests to the release formatter.
- Verify the complete 187-test standalone package lane.

Diffstat:
MPackage.swift | 15+++++++++------
ASources/RadrootsKit/PrivacyInfo.xcprivacy | 32++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsAppLocalStateReset.swift | 2+-
MSources/RadrootsKit/RadrootsAppleBackgroundTasks.swift | 190++++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift | 219++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
MSources/RadrootsKit/RadrootsAppleDocumentScanner.swift | 324++++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKit/RadrootsAppleExternalActions.swift | 96++++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKit/RadrootsAppleFileError.swift | 10+++++-----
MSources/RadrootsKit/RadrootsAppleIdentityMetadataStore.swift | 90++++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift | 22+++++++++++-----------
MSources/RadrootsKit/RadrootsAppleLocationServices.swift | 352++++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKit/RadrootsAppleLoggerTelemetry.swift | 6+++---
MSources/RadrootsKit/RadrootsAppleMediaPicker.swift | 809++++++++++++++++++++++++++++++++++++++++---------------------------------------
MSources/RadrootsKit/RadrootsAppleMediaPreparation.swift | 54++++++++++++++++++++++++++++++++----------------------
MSources/RadrootsKit/RadrootsApplePermissionStatus.swift | 160++++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKit/RadrootsAppleSecurityError.swift | 16++++++++--------
MSources/RadrootsKit/RadrootsAppleUserPresence.swift | 219+++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKit/RadrootsBackgroundTasks.swift | 12++++++------
MSources/RadrootsKit/RadrootsBackgroundTransfer.swift | 147+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
MSources/RadrootsKit/RadrootsCaptureIntake.swift | 18+++++++++---------
MSources/RadrootsKit/RadrootsDocumentInterchange.swift | 46+++++++++++++++++++++++-----------------------
MSources/RadrootsKit/RadrootsDocumentPresentation.swift | 61++++++++++++++++++++++++++++++-------------------------------
MSources/RadrootsKit/RadrootsExternalActions.swift | 18++++++++++--------
MSources/RadrootsKit/RadrootsFileAccess.swift | 35+++++++++++++++++------------------
MSources/RadrootsKit/RadrootsIdentityCryptography.swift | 577++++++++++++++++++++++++++++++++++++++++---------------------------------------
MSources/RadrootsKit/RadrootsIdentityCustody.swift | 1732++++++++++++++++++++++++++++++++++++++++---------------------------------------
MSources/RadrootsKit/RadrootsIdentityCustodyTypes.swift | 670++++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKit/RadrootsPermissionLocation.swift | 26+++++++++++++-------------
MSources/RadrootsKit/RadrootsSecureStore.swift | 12++++++------
MSources/RadrootsKit/RadrootsTelemetry.swift | 34+++++++++++++++++-----------------
MSources/RadrootsKit/RadrootsUserPresence.swift | 14+++++++-------
MSources/RadrootsKitTesting/RadrootsBackgroundTaskTesting.swift | 10+++++-----
MSources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift | 10+++++-----
MSources/RadrootsKitTesting/RadrootsCaptureIntakeTesting.swift | 34+++++++++++++++++-----------------
MSources/RadrootsKitTesting/RadrootsExternalActionsTesting.swift | 10+++++-----
MSources/RadrootsKitTesting/RadrootsIdentityMetadataTesting.swift | 110++++++++++++++++++++++++++++++++++++++++----------------------------------------
MSources/RadrootsKitTesting/RadrootsInMemorySecureStore.swift | 2+-
MSources/RadrootsKitTesting/RadrootsPermissionLocationTesting.swift | 12++++++------
MSources/RadrootsKitTesting/RadrootsTelemetryTesting.swift | 2+-
MSources/RadrootsKitTesting/RadrootsUITestLaunchConfiguration.swift | 2+-
MSources/RadrootsKitTesting/RadrootsUserPresenceTesting.swift | 10+++++-----
MTests/RadrootsKitTestingTests/RadrootsBackgroundTaskTestingTests.swift | 2+-
MTests/RadrootsKitTestingTests/RadrootsBackgroundTransferTestingTests.swift | 4++--
MTests/RadrootsKitTestingTests/RadrootsCaptureIntakeTestingTests.swift | 22+++++++++++-----------
MTests/RadrootsKitTestingTests/RadrootsExternalActionsTestingTests.swift | 4++--
MTests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift | 10+++++-----
MTests/RadrootsKitTestingTests/RadrootsTelemetryTestingTests.swift | 4++--
MTests/RadrootsKitTestingTests/RadrootsUserPresenceTestingTests.swift | 2+-
MTests/RadrootsKitTests/RadrootsAppleBackgroundTaskSchedulerTests.swift | 16++++++++--------
MTests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift | 138+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
MTests/RadrootsKitTests/RadrootsAppleDocumentScannerTests.swift | 20++++++++++----------
MTests/RadrootsKitTests/RadrootsAppleExternalActionsTests.swift | 8++++----
MTests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift | 22+++++++++++-----------
MTests/RadrootsKitTests/RadrootsAppleFileRootsTests.swift | 4++--
MTests/RadrootsKitTests/RadrootsAppleLocationServicesTests.swift | 60++++++++++++++++++++++++++++++------------------------------
MTests/RadrootsKitTests/RadrootsAppleLoggerTelemetryTests.swift | 12++++++------
MTests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift | 28++++++++++++++--------------
MTests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift | 25+++++++++++++++----------
MTests/RadrootsKitTests/RadrootsApplePermissionStatusTests.swift | 68++++++++++++++++++++++++++++++++++----------------------------------
MTests/RadrootsKitTests/RadrootsAppleUserPresenceTests.swift | 82++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
MTests/RadrootsKitTests/RadrootsBackgroundTaskTests.swift | 2+-
MTests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift | 91++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
MTests/RadrootsKitTests/RadrootsCaptureIntakeTests.swift | 2+-
MTests/RadrootsKitTests/RadrootsDocumentInterchangeTests.swift | 10+++++-----
MTests/RadrootsKitTests/RadrootsDocumentPresentationTests.swift | 12++++++------
MTests/RadrootsKitTests/RadrootsExternalActionsTests.swift | 2+-
MTests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift | 785++++++++++++++++++++++++++++++++++++++++---------------------------------------
MTests/RadrootsKitTests/RadrootsPermissionLocationTests.swift | 4++--
MTests/RadrootsKitTests/RadrootsSecureStoreTests.swift | 2+-
MTests/RadrootsKitTests/RadrootsTelemetryTests.swift | 24++++++++++++------------
MTests/RadrootsKitTests/RadrootsUserPresenceTests.swift | 2+-
71 files changed, 3953 insertions(+), 3734 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -5,7 +5,7 @@ let package = Package( name: "RadrootsKit", platforms: [ .iOS(.v18), - .macOS(.v15) + .macOS(.v15), ], products: [ .library( @@ -15,19 +15,22 @@ let package = Package( .library( name: "RadrootsKitTesting", targets: ["RadrootsKitTesting"] - ) + ), ], dependencies: [ .package( url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", revision: "e70a10e036a55fffea31568f0af92d69b6d449cd" - ) + ), ], targets: [ .target( name: "RadrootsKit", dependencies: [ - .product(name: "P256K", package: "swift-secp256k1") + .product(name: "P256K", package: "swift-secp256k1"), + ], + resources: [ + .process("PrivacyInfo.xcprivacy"), ], linkerSettings: [ .linkedFramework("Security"), @@ -39,7 +42,7 @@ let package = Package( .linkedFramework("ImageIO"), .linkedFramework("UniformTypeIdentifiers"), .linkedFramework("CoreLocation"), - .linkedFramework("BackgroundTasks", .when(platforms: [.iOS])) + .linkedFramework("BackgroundTasks", .when(platforms: [.iOS])), ] ), .target( @@ -53,6 +56,6 @@ let package = Package( .testTarget( name: "RadrootsKitTestingTests", dependencies: ["RadrootsKitTesting"] - ) + ), ] ) diff --git a/Sources/RadrootsKit/PrivacyInfo.xcprivacy b/Sources/RadrootsKit/PrivacyInfo.xcprivacy @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>NSPrivacyTracking</key> + <false/> + <key>NSPrivacyTrackingDomains</key> + <array/> + <key>NSPrivacyCollectedDataTypes</key> + <array/> + <key>NSPrivacyAccessedAPITypes</key> + <array> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategoryFileTimestamp</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>C617.1</string> + <string>3B52.1</string> + </array> + </dict> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategoryUserDefaults</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>CA92.1</string> + </array> + </dict> + </array> +</dict> +</plist> diff --git a/Sources/RadrootsKit/RadrootsAppLocalStateReset.swift b/Sources/RadrootsKit/RadrootsAppLocalStateReset.swift @@ -45,7 +45,7 @@ public enum RadrootsAppLocalStateReset { } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: trimmed + kSecAttrService as String: trimmed, ] let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTasks.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTasks.swift @@ -1,7 +1,7 @@ import Foundation #if canImport(BackgroundTasks) && os(iOS) -import BackgroundTasks + import BackgroundTasks #endif public struct RadrootsAppleBackgroundTaskRegistration: Sendable { @@ -46,41 +46,41 @@ public struct RadrootsAppleBackgroundTaskSchedulerAdapters: Sendable { public static var live: Self { #if canImport(BackgroundTasks) && os(iOS) - Self( - register: { registration in - BGTaskScheduler.shared.register( - forTaskWithIdentifier: registration.identifier.rawValue, - using: nil - ) { task in - let completion = RadrootsAppleBackgroundTaskCompletion(task: task) - let handlerTask = Task { - await registration.handler() - } - task.expirationHandler = { - handlerTask.cancel() - completion.complete(success: false) - } - Task { - let success = await handlerTask.value - completion.complete(success: success) + Self( + register: { registration in + BGTaskScheduler.shared.register( + forTaskWithIdentifier: registration.identifier.rawValue, + using: nil + ) { task in + let completion = RadrootsAppleBackgroundTaskCompletion(task: task) + let handlerTask = Task { + await registration.handler() + } + task.expirationHandler = { + handlerTask.cancel() + completion.complete(success: false) + } + Task { + let success = await handlerTask.value + completion.complete(success: success) + } } + }, + submit: { request in + try BGTaskScheduler.shared.submit(Self.platformRequest(for: request)) + }, + cancel: { identifier in + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier.rawValue) + }, + cancelAll: { + BGTaskScheduler.shared.cancelAllTaskRequests() + }, + pendingTasks: { + try await Self.pendingPlatformTaskSnapshots() } - }, - submit: { request in - try BGTaskScheduler.shared.submit(Self.platformRequest(for: request)) - }, - cancel: { identifier in - BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier.rawValue) - }, - cancelAll: { - BGTaskScheduler.shared.cancelAllTaskRequests() - }, - pendingTasks: { - try await Self.pendingPlatformTaskSnapshots() - } - ) + ) #else - Self.unavailable + unavailable #endif } @@ -139,82 +139,82 @@ public final class RadrootsAppleBackgroundTaskScheduler: RadrootsBackgroundTaskS } #if canImport(BackgroundTasks) && os(iOS) -private extension RadrootsAppleBackgroundTaskSchedulerAdapters { - static func platformRequest(for request: RadrootsBackgroundTaskRequest) -> BGTaskRequest { - let platformRequest: BGTaskRequest - switch request.kind { - case .appRefresh: - platformRequest = BGAppRefreshTaskRequest(identifier: request.identifier.rawValue) - case .processing: - let processingRequest = BGProcessingTaskRequest(identifier: request.identifier.rawValue) - processingRequest.requiresNetworkConnectivity = request.requiresNetworkConnectivity - processingRequest.requiresExternalPower = request.requiresExternalPower - platformRequest = processingRequest + private extension RadrootsAppleBackgroundTaskSchedulerAdapters { + static func platformRequest(for request: RadrootsBackgroundTaskRequest) -> BGTaskRequest { + let platformRequest: BGTaskRequest + switch request.kind { + case .appRefresh: + platformRequest = BGAppRefreshTaskRequest(identifier: request.identifier.rawValue) + case .processing: + let processingRequest = BGProcessingTaskRequest(identifier: request.identifier.rawValue) + processingRequest.requiresNetworkConnectivity = request.requiresNetworkConnectivity + processingRequest.requiresExternalPower = request.requiresExternalPower + platformRequest = processingRequest + } + platformRequest.earliestBeginDate = request.earliestBeginDate + return platformRequest } - platformRequest.earliestBeginDate = request.earliestBeginDate - return platformRequest - } - static func pendingPlatformTaskSnapshots() async throws -> [RadrootsBackgroundTaskSnapshot] { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[RadrootsBackgroundTaskSnapshot], Error>) in - BGTaskScheduler.shared.getPendingTaskRequests { requests in - do { - let snapshots: [RadrootsBackgroundTaskSnapshot] = try requests.compactMap { request -> RadrootsBackgroundTaskSnapshot? in - guard let identifier = try? RadrootsBackgroundTaskIdentifier(request.identifier) else { - return nil + static func pendingPlatformTaskSnapshots() async throws -> [RadrootsBackgroundTaskSnapshot] { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[RadrootsBackgroundTaskSnapshot], Error>) in + BGTaskScheduler.shared.getPendingTaskRequests { requests in + do { + let snapshots: [RadrootsBackgroundTaskSnapshot] = try requests.compactMap { request -> RadrootsBackgroundTaskSnapshot? in + guard let identifier = try? RadrootsBackgroundTaskIdentifier(request.identifier) else { + return nil + } + let kind: RadrootsBackgroundTaskKind + let requiresNetworkConnectivity: Bool + let requiresExternalPower: Bool + if let processingRequest = request as? BGProcessingTaskRequest { + kind = .processing + requiresNetworkConnectivity = processingRequest.requiresNetworkConnectivity + requiresExternalPower = processingRequest.requiresExternalPower + } else { + kind = .appRefresh + requiresNetworkConnectivity = false + requiresExternalPower = false + } + return try RadrootsBackgroundTaskSnapshot( + identifier: identifier, + kind: kind, + earliestBeginDate: request.earliestBeginDate, + submittedAt: Date(), + requiresNetworkConnectivity: requiresNetworkConnectivity, + requiresExternalPower: requiresExternalPower + ) } - let kind: RadrootsBackgroundTaskKind - let requiresNetworkConnectivity: Bool - let requiresExternalPower: Bool - if let processingRequest = request as? BGProcessingTaskRequest { - kind = .processing - requiresNetworkConnectivity = processingRequest.requiresNetworkConnectivity - requiresExternalPower = processingRequest.requiresExternalPower - } else { - kind = .appRefresh - requiresNetworkConnectivity = false - requiresExternalPower = false + .sorted { left, right in + left.identifier.rawValue < right.identifier.rawValue } - return try RadrootsBackgroundTaskSnapshot( - identifier: identifier, - kind: kind, - earliestBeginDate: request.earliestBeginDate, - submittedAt: Date(), - requiresNetworkConnectivity: requiresNetworkConnectivity, - requiresExternalPower: requiresExternalPower - ) + continuation.resume(returning: snapshots) + } catch { + continuation.resume(throwing: error) } - .sorted { left, right in - left.identifier.rawValue < right.identifier.rawValue - } - continuation.resume(returning: snapshots) - } catch { - continuation.resume(throwing: error) } } } } -} #endif #if canImport(BackgroundTasks) && os(iOS) -private final class RadrootsAppleBackgroundTaskCompletion: @unchecked Sendable { - private let task: BGTask - private let lock = NSLock() - private var completed = false + private final class RadrootsAppleBackgroundTaskCompletion: @unchecked Sendable { + private let task: BGTask + private let lock = NSLock() + private var completed = false - init(task: BGTask) { - self.task = task - } + init(task: BGTask) { + self.task = task + } - func complete(success: Bool) { - lock.lock() - defer { lock.unlock() } - guard !completed else { - return + func complete(success: Bool) { + lock.lock() + defer { lock.unlock() } + guard !completed else { + return + } + completed = true + task.setTaskCompleted(success: success) } - completed = true - task.setTaskCompleted(success: success) } -} #endif diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift @@ -26,7 +26,8 @@ public struct RadrootsAppleBackgroundTransferAdapters: Sendable { cancel: { _ in throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform") }, activeTransferIdentifiers: { throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform") - }, handleBackgroundEvents: { _, completionHandler in completionHandler() }) + }, handleBackgroundEvents: { _, completionHandler in completionHandler() } + ) public static func live( sessionIdentifier: String, store: any RadrootsBackgroundTransferStore, fileResolver: any RadrootsBackgroundTransferFileResolver, @@ -36,14 +37,16 @@ public struct RadrootsAppleBackgroundTransferAdapters: Sendable { let normalizedSessionIdentifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier) let session = RadrootsAppleBackgroundURLSession( identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver, downloadStagingRoot: downloadStagingRoot, - now: now) + now: now + ) return Self( now: now, enqueue: { request in try await session.enqueue(request) }, cancel: { identifier in await session.cancel(identifier) }, activeTransferIdentifiers: { await session.activeTransferIdentifiers() }, handleBackgroundEvents: { identifier, completionHandler in await session.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler) - }) + } + ) #else return .unavailable #endif @@ -66,23 +69,28 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { for: RadrootsFileReference( scope: .temporary, relativePath: - "background_transfers/\(try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier))/downloads"), - allowRootDirectory: true) + "background_transfers/\(RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier))/downloads" + ), + allowRootDirectory: true + ) self.store = store - self.adapters = try .live( - sessionIdentifier: sessionIdentifier, store: store, fileResolver: resolver, downloadStagingRoot: downloadStagingRoot) + adapters = try .live( + sessionIdentifier: sessionIdentifier, store: store, fileResolver: resolver, downloadStagingRoot: downloadStagingRoot + ) } public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { guard try await store.loadSnapshots().contains(where: { $0.identifier == request.identifier }) == false else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier already exists") } - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: request, state: .queued, updatedAt: adapters.now())) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .queued, updatedAt: adapters.now())) do { try await adapters.enqueue(request) } catch { do { try await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot( - request: request, state: .failed, errorMessage: "background_transfer_enqueue_failed", updatedAt: adapters.now())) + RadrootsBackgroundTransferSnapshot( + request: request, state: .failed, errorMessage: "background_transfer_enqueue_failed", updatedAt: adapters.now() + ) + ) } catch { throw RadrootsBackgroundTransferError.persistenceFailure("background transfer enqueue failure could not be persisted") } @@ -91,7 +99,8 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { let current = try await store.loadSnapshots().first { $0.identifier == request.identifier } if current?.state == .queued { try await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: adapters.now())) + RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: adapters.now()) + ) } return RadrootsBackgroundTransferHandle(request: request) } @@ -102,8 +111,10 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { } if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) { try await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .cancelled, progress: existing.progress, updatedAt: adapters.now())) + RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .cancelled, progress: existing.progress, updatedAt: adapters.now() + ) + ) } } @@ -122,14 +133,16 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { if activeIdentifiers.contains(snapshot.identifier), snapshot.state == .queued { let runningSnapshot = try RadrootsBackgroundTransferSnapshot( request: snapshot.request, state: .running, progress: snapshot.progress, errorMessage: snapshot.errorMessage, - updatedAt: adapters.now()) + updatedAt: adapters.now() + ) try await store.saveSnapshot(runningSnapshot) reconciled.append(runningSnapshot) } else if !activeIdentifiers.contains(snapshot.identifier), snapshot.state == .queued || snapshot.state == .running { let interruptedSnapshot = try RadrootsBackgroundTransferSnapshot( request: snapshot.request, state: .interrupted, progress: snapshot.progress, errorMessage: "background_transfer_interrupted", - possibleRemoteOrphan: snapshot.request.isUpload && snapshot.state == .running, updatedAt: adapters.now()) + possibleRemoteOrphan: snapshot.request.isUpload && snapshot.state == .running, updatedAt: adapters.now() + ) try await store.saveSnapshot(interruptedSnapshot) reconciled.append(interruptedSnapshot) } else { @@ -174,20 +187,23 @@ actor RadrootsAppleBackgroundTransferCoordinator { self.fileResolver = fileResolver self.now = now self.fileManager = fileManager - self.completionHandlers = [] - self.unclaimedFinishedEventCount = 0 + completionHandlers = [] + unclaimedFinishedEventCount = 0 } func updateProgress(identifier: RadrootsBackgroundTransferIdentifier, bytesTransferred: Int64, totalBytesExpected: Int64?) async { guard let existing = try? await snapshot(for: identifier), existing.state == .running || existing.state == .queued else { return } guard let progress = Self.progress( - bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, fallback: existing.progress) + bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, fallback: existing.progress + ) else { return } try? await store.saveSnapshot( try RadrootsBackgroundTransferSnapshot( request: existing.request, state: .running, progress: progress, errorMessage: existing.errorMessage, - response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan, updatedAt: now())) + response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan, updatedAt: now() + ) + ) } func complete( @@ -196,7 +212,7 @@ actor RadrootsAppleBackgroundTransferCoordinator { totalBytesExpected: Int64? ) async { guard let existing = try? await snapshot(for: identifier), - existing.state == .queued || existing.state == .running || existing.state == .interrupted + existing.state == .queued || existing.state == .running || existing.state == .interrupted else { return } if httpResult.bodyExceeded { Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) @@ -207,17 +223,19 @@ actor RadrootsAppleBackgroundTransferCoordinator { Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) await fail( existing: existing, code: "background_transfer_platform_failure", - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0) + possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 + ) return } guard let statusCode = httpResult.statusCode else { Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) await fail( existing: existing, code: "background_transfer_response_missing", - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0) + possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 + ) return } - guard (200...299).contains(statusCode) else { + guard (200 ... 299).contains(statusCode) else { Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) await fail(existing: existing, code: "background_transfer_http_status_\(statusCode)") return @@ -235,13 +253,15 @@ actor RadrootsAppleBackgroundTransferCoordinator { return } switch existing.request.operation { - case .download(let destination): + case let .download(destination): await completeDownload( existing: existing, destination: destination, stagedDownloadResult: stagedDownloadResult, response: response, - bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected) + bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected + ) case .upload: await completeUpload( - existing: existing, response: response, bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected) + existing: existing, response: response, bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected + ) } } @@ -270,7 +290,9 @@ actor RadrootsAppleBackgroundTransferCoordinator { } let handlers = completionHandlers completionHandlers.removeAll() - for handler in handlers { handler() } + for handler in handlers { + handler() + } } private func completeUpload( @@ -279,10 +301,12 @@ actor RadrootsAppleBackgroundTransferCoordinator { ) async { let progress = Self.progress(bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, fallback: existing.progress) - ?? existing.progress + ?? existing.progress try? await store.saveSnapshot( try RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .completed, progress: progress, response: response, updatedAt: now())) + request: existing.request, state: .completed, progress: progress, response: response, updatedAt: now() + ) + ) } private func completeDownload( @@ -290,7 +314,7 @@ actor RadrootsAppleBackgroundTransferCoordinator { stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?, response: RadrootsBackgroundTransferResponse, bytesTransferred: Int64, totalBytesExpected: Int64? ) async { - guard case .file(let stagedFileURL) = stagedDownloadResult else { + guard case let .file(stagedFileURL) = stagedDownloadResult else { await fail(existing: existing, code: "background_transfer_download_staging_failure") return } @@ -300,16 +324,20 @@ actor RadrootsAppleBackgroundTransferCoordinator { try Self.moveReplacingItem(from: stagedFileURL, to: destinationURL, fileManager: fileManager) #if os(iOS) try fileManager.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: destinationURL.path) + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: destinationURL.path + ) #endif let fileSize = try Self.fileSize(at: destinationURL, fileManager: fileManager) let progress = Self.progress( - bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected, fallback: existing.progress) + bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected, fallback: existing.progress + ) ?? existing.progress try await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .completed, progress: progress, response: response, updatedAt: now())) + RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .completed, progress: progress, response: response, updatedAt: now() + ) + ) } catch { Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager) await fail(existing: existing, code: "background_transfer_destination_failure") @@ -320,7 +348,9 @@ actor RadrootsAppleBackgroundTransferCoordinator { try? await store.saveSnapshot( try RadrootsBackgroundTransferSnapshot( request: existing.request, state: .failed, progress: existing.progress, errorMessage: code, - possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: now())) + possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: now() + ) + ) } private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { @@ -333,11 +363,11 @@ actor RadrootsAppleBackgroundTransferCoordinator { let safeBytesTransferred = max(bytesTransferred, fallback.bytesTransferred) let safeTotalBytesExpected = totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil } - ?? fallback.totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil } + ?? fallback.totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil } return try? RadrootsBackgroundTransferProgress(bytesTransferred: safeBytesTransferred, totalBytesExpected: safeTotalBytesExpected) } - private static func fileSize(at url: URL, fileManager: FileManager) throws -> Int64 { + private static func fileSize(at url: URL, fileManager _: FileManager) throws -> Int64 { let values = try url.resourceValues(forKeys: [.fileSizeKey]) return Int64(values.fileSize ?? 0) } @@ -348,14 +378,19 @@ actor RadrootsAppleBackgroundTransferCoordinator { return } let backup = destination.deletingLastPathComponent().appendingPathComponent( - ".radroots-transfer-backup-\(UUID().uuidString.lowercased())") + ".radroots-transfer-backup-\(UUID().uuidString.lowercased())" + ) try fileManager.moveItem(at: destination, to: backup) do { try fileManager.moveItem(at: source, to: destination) try fileManager.removeItem(at: backup) } catch { - if fileManager.fileExists(atPath: destination.path) { try? fileManager.removeItem(at: destination) } - if fileManager.fileExists(atPath: backup.path) { try? fileManager.moveItem(at: backup, to: destination) } + if fileManager.fileExists(atPath: destination.path) { + try? fileManager.removeItem(at: destination) + } + if fileManager.fileExists(atPath: backup.path) { + try? fileManager.moveItem(at: backup, to: destination) + } throw error } } @@ -382,7 +417,7 @@ actor RadrootsAppleBackgroundTransferCoordinator { } private static func removeStagedDownload(_ result: RadrootsStagedBackgroundDownloadResult?, fileManager: FileManager) { - guard case .file(let url) = result, fileManager.fileExists(atPath: url.path) else { return } + guard case let .file(url) = result, fileManager.fileExists(atPath: url.path) else { return } try? fileManager.removeItem(at: url) } } @@ -405,26 +440,29 @@ actor RadrootsAppleBackgroundTransferCoordinator { self.identifier = identifier self.fileResolver = fileResolver self.downloadStagingRoot = downloadStagingRoot - self.fileManager = .default - self.coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now) + fileManager = .default + coordinator = RadrootsAppleBackgroundTransferCoordinator( + sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now + ) } func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws { let session = backgroundSession() var urlRequest = URLRequest(url: request.remoteURL) urlRequest.httpMethod = request.method.rawValue - for (key, value) in request.headers { urlRequest.setValue(value, forHTTPHeaderField: key) } + for (key, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: key) + } let task: URLSessionTask switch request.operation { case .download: task = session.downloadTask(with: urlRequest) - case .upload(let source): + case let .upload(source): let sourceURL = try fileResolver.resolve(source) let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) guard values.isRegularFile == true, values.isSymbolicLink != true else { throw RadrootsBackgroundTransferError.invalidRequest("background upload source must be a regular file") } - if case .stagedBlob(let blob) = source, values.fileSize != blob.sizeBytes { + if case let .stagedBlob(blob) = source, values.fileSize != blob.sizeBytes { throw RadrootsBackgroundTransferError.invalidRequest("background upload source size does not match its handle") } if let expectedDigest = request.expectedSourceSHA256, try RadrootsAppleFileDigest.sha256(at: sourceURL) != expectedDigest { @@ -439,7 +477,9 @@ actor RadrootsAppleBackgroundTransferCoordinator { func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async { let tasks = await allTasks() - for task in tasks where task.taskDescription == identifier.rawValue { task.cancel() } + for task in tasks where task.taskDescription == identifier.rawValue { + task.cancel() + } } func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> { @@ -463,7 +503,9 @@ actor RadrootsAppleBackgroundTransferCoordinator { } private func backgroundSession() -> URLSession { - if let session { return session } + if let session { + return session + } removeOrphanedDownloadStagingFiles() let configuration = URLSessionConfiguration.background(withIdentifier: identifier) configuration.sessionSendsLaunchEvents = true @@ -472,24 +514,27 @@ actor RadrootsAppleBackgroundTransferCoordinator { delegateQueue.name = "org.radroots.background-transfer.\(identifier)" delegateQueue.maxConcurrentOperationCount = 1 let delegate = RadrootsAppleBackgroundURLSessionDelegate( - coordinator: coordinator, downloadStagingRoot: downloadStagingRoot, fileManager: fileManager) + coordinator: coordinator, downloadStagingRoot: downloadStagingRoot, fileManager: fileManager + ) let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) self.session = session - self.sessionDelegate = delegate - self.sessionDelegateQueue = delegateQueue + sessionDelegate = delegate + sessionDelegateQueue = delegateQueue return session } private func removeOrphanedDownloadStagingFiles() { guard let urls = try? fileManager.contentsOfDirectory(at: downloadStagingRoot, includingPropertiesForKeys: nil) else { return } - for url in urls where url.pathExtension == "download" { try? fileManager.removeItem(at: url) } + for url in urls where url.pathExtension == "download" { + try? fileManager.removeItem(at: url) + } } } private final class RadrootsAppleBackgroundURLSessionDelegate: NSObject, URLSessionDownloadDelegate, URLSessionDataDelegate, URLSessionTaskDelegate, @unchecked Sendable { - private static let absoluteMaximumResponseBodyBytes = 65_536 + private static let absoluteMaximumResponseBodyBytes = 65536 private let coordinator: RadrootsAppleBackgroundTransferCoordinator private let downloadStagingRoot: URL private let fileManager: FileManager @@ -503,13 +548,13 @@ actor RadrootsAppleBackgroundTransferCoordinator { self.coordinator = coordinator self.downloadStagingRoot = downloadStagingRoot self.fileManager = fileManager - self.stagedDownloadResultsByTaskIdentifier = [:] - self.responseBodyLimitsByTaskIdentifier = [:] - self.responseBodiesByTaskIdentifier = [:] - self.exceededResponseBodyTaskIdentifiers = [] + stagedDownloadResultsByTaskIdentifier = [:] + responseBodyLimitsByTaskIdentifier = [:] + responseBodiesByTaskIdentifier = [:] + exceededResponseBodyTaskIdentifiers = [] } - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { guard let identifier = transferIdentifier(from: downloadTask) else { return } let result: RadrootsStagedBackgroundDownloadResult do { @@ -517,57 +562,67 @@ actor RadrootsAppleBackgroundTransferCoordinator { let destination = downloadStagingRoot.appendingPathComponent( "\(identifier.rawValue)-\(downloadTask.taskIdentifier).download" ).standardizedFileURL - if fileManager.fileExists(atPath: destination.path) { try fileManager.removeItem(at: destination) } + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } try fileManager.moveItem(at: location, to: destination) try fileManager.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: destination.path) + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: destination.path + ) result = .file(destination) } catch { result = .failure } recordDownloadResult(result, taskIdentifier: downloadTask.taskIdentifier) } func urlSession( - _ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, + _: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { guard let identifier = transferIdentifier(from: downloadTask) else { return } Task { await coordinator.updateProgress( identifier: identifier, bytesTransferred: totalBytesWritten, - totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite)) + totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite) + ) } } - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { let shouldCancel = appendResponseBody(data, taskIdentifier: dataTask.taskIdentifier) - if shouldCancel { dataTask.cancel() } + if shouldCancel { + dataTask.cancel() + } } func urlSession( - _ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, + _: URLSession, task: URLSessionTask, didSendBodyData _: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64 ) { guard let identifier = transferIdentifier(from: task) else { return } Task { await coordinator.updateProgress( identifier: identifier, bytesTransferred: totalBytesSent, - totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend)) + totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend) + ) } } - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { let bytesTransferred = max(max(task.countOfBytesReceived, task.countOfBytesSent), 0) let expected = Self.expectedByteCount(max(task.countOfBytesExpectedToReceive, task.countOfBytesExpectedToSend)) let stagedDownloadResult = takeDownloadResult(taskIdentifier: task.taskIdentifier) let httpResult = takeHTTPResult(for: task) guard let identifier = transferIdentifier(from: task) else { - if case .file(let url) = stagedDownloadResult { try? fileManager.removeItem(at: url) } + if case let .file(url) = stagedDownloadResult { + try? fileManager.removeItem(at: url) + } return } Task { await coordinator.complete( identifier: identifier, platformError: error, stagedDownloadResult: stagedDownloadResult, httpResult: httpResult, - bytesTransferred: bytesTransferred, totalBytesExpected: expected) + bytesTransferred: bytesTransferred, totalBytesExpected: expected + ) } } @@ -630,24 +685,26 @@ actor RadrootsAppleBackgroundTransferCoordinator { return try? RadrootsBackgroundTransferIdentifier(taskDescription) } - private static func expectedByteCount(_ value: Int64) -> Int64? { value >= 0 ? value : nil } - + private static func expectedByteCount(_ value: Int64) -> Int64? { + value >= 0 ? value : nil + } } #endif -extension RadrootsBackgroundTransferRequest { - fileprivate var isUpload: Bool { - if case .upload = operation { return true } +private extension RadrootsBackgroundTransferRequest { + var isUpload: Bool { + if case .upload = operation { + return true + } return false } } -extension RadrootsBackgroundTransferError { - fileprivate var stableCode: String { - let value: String - switch self { - case .invalidRequest(let message), .unavailable(let message), .transferFailure(let message), .persistenceFailure(let message): - value = message +private extension RadrootsBackgroundTransferError { + var stableCode: String { + let value: String = switch self { + case let .invalidRequest(message), let .unavailable(message), let .transferFailure(message), let .persistenceFailure(message): + message } guard value.range(of: "^background_transfer_[a-z0-9_]+$", options: .regularExpression) != nil else { return "background_transfer_response_invalid" diff --git a/Sources/RadrootsKit/RadrootsAppleDocumentScanner.swift b/Sources/RadrootsKit/RadrootsAppleDocumentScanner.swift @@ -1,11 +1,11 @@ import Foundation #if canImport(UIKit) -@preconcurrency import UIKit + @preconcurrency import UIKit #endif #if canImport(VisionKit) -@preconcurrency import VisionKit + @preconcurrency import VisionKit #endif public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unchecked Sendable { @@ -13,86 +13,86 @@ public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unche private let callbackTimeout: TimeInterval #if canImport(UIKit) - private let viewControllerProvider: RadrootsAppleViewControllerProvider + private let viewControllerProvider: RadrootsAppleViewControllerProvider #endif #if canImport(UIKit) - public init( - fileAccess: RadrootsAppleFileAccess, - callbackTimeout: TimeInterval = 120 - ) { - self.fileAccess = fileAccess - self.callbackTimeout = callbackTimeout - self.viewControllerProvider = { - try RadrootsAppleUIKitPresentation.activeViewController(service: "document scanner") + public init( + fileAccess: RadrootsAppleFileAccess, + callbackTimeout: TimeInterval = 120 + ) { + self.fileAccess = fileAccess + self.callbackTimeout = callbackTimeout + viewControllerProvider = { + try RadrootsAppleUIKitPresentation.activeViewController(service: "document scanner") + } } - } - public init( - fileAccess: RadrootsAppleFileAccess, - callbackTimeout: TimeInterval = 120, - viewControllerProvider: @escaping RadrootsAppleViewControllerProvider - ) { - self.fileAccess = fileAccess - self.callbackTimeout = callbackTimeout - self.viewControllerProvider = viewControllerProvider - } + public init( + fileAccess: RadrootsAppleFileAccess, + callbackTimeout: TimeInterval = 120, + viewControllerProvider: @escaping RadrootsAppleViewControllerProvider + ) { + self.fileAccess = fileAccess + self.callbackTimeout = callbackTimeout + self.viewControllerProvider = viewControllerProvider + } #else - public init( - fileAccess: RadrootsAppleFileAccess, - callbackTimeout: TimeInterval = 120 - ) { - self.fileAccess = fileAccess - self.callbackTimeout = callbackTimeout - } + public init( + fileAccess: RadrootsAppleFileAccess, + callbackTimeout: TimeInterval = 120 + ) { + self.fileAccess = fileAccess + self.callbackTimeout = callbackTimeout + } #endif public func currentSupport() async throws -> RadrootsDocumentScannerSupport { #if canImport(UIKit) && canImport(VisionKit) - let available = await MainActor.run { - VNDocumentCameraViewController.isSupported - } - return try RadrootsDocumentScannerSupport( - interactiveScanAvailable: available, - multiPageSupported: available, - supportedOutputKinds: available ? [.pdf] : [] - ) + let available = await MainActor.run { + VNDocumentCameraViewController.isSupported + } + return try RadrootsDocumentScannerSupport( + interactiveScanAvailable: available, + multiPageSupported: available, + supportedOutputKinds: available ? [.pdf] : [] + ) #else - return try Self.unavailableSupport() + return try Self.unavailableSupport() #endif } public func scanDocument(_ request: RadrootsDocumentScanRequest) async throws -> RadrootsScannedDocument { #if canImport(UIKit) && canImport(VisionKit) - let support = try await currentSupport() - guard support.interactiveScanAvailable else { - throw RadrootsCaptureIntakeError.unavailable("document scanner is unavailable") - } - let presenter = try await MainActor.run { - try viewControllerProvider() - } - let writer = RadrootsAppleDocumentScanWriter(fileAccess: fileAccess) - let coordinatorID = UUID() - return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( - timeout: callbackTimeout, - timeoutMessage: "timed out while presenting document scanner" - ) { completion, setCleanup in - let controller = VNDocumentCameraViewController() - let coordinator = RadrootsAppleDocumentScannerCoordinator( - writer: writer, - request: request, - coordinatorID: coordinatorID - ) - coordinator.completion = completion - controller.delegate = coordinator - setCleanup { - coordinator.cancelPresentation(controller) + let support = try await currentSupport() + guard support.interactiveScanAvailable else { + throw RadrootsCaptureIntakeError.unavailable("document scanner is unavailable") + } + let presenter = try await MainActor.run { + try viewControllerProvider() + } + let writer = RadrootsAppleDocumentScanWriter(fileAccess: fileAccess) + let coordinatorID = UUID() + return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: callbackTimeout, + timeoutMessage: "timed out while presenting document scanner" + ) { completion, setCleanup in + let controller = VNDocumentCameraViewController() + let coordinator = RadrootsAppleDocumentScannerCoordinator( + writer: writer, + request: request, + coordinatorID: coordinatorID + ) + coordinator.completion = completion + controller.delegate = coordinator + setCleanup { + coordinator.cancelPresentation(controller) + } + RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) + presenter.present(controller, animated: true) } - RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) - presenter.present(controller, animated: true) - } #else - throw RadrootsCaptureIntakeError.unavailable("document scanner is unavailable") + throw RadrootsCaptureIntakeError.unavailable("document scanner is unavailable") #endif } @@ -106,118 +106,118 @@ public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unche } #if canImport(UIKit) && canImport(VisionKit) -@MainActor -private final class RadrootsAppleDocumentScannerCoordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate { - var completion: (@Sendable (Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) -> Void)? - - private let writer: RadrootsAppleDocumentScanWriter - private let request: RadrootsDocumentScanRequest - private let coordinatorID: UUID - private var didResolve: Bool - - init( - writer: RadrootsAppleDocumentScanWriter, - request: RadrootsDocumentScanRequest, - coordinatorID: UUID - ) { - self.writer = writer - self.request = request - self.coordinatorID = coordinatorID - self.didResolve = false - } - - func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) { - controller.dismiss(animated: true) - finish(.failure(.userCancelled("document scan was cancelled"))) - } + @MainActor + private final class RadrootsAppleDocumentScannerCoordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate { + var completion: (@Sendable (Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) -> Void)? + + private let writer: RadrootsAppleDocumentScanWriter + private let request: RadrootsDocumentScanRequest + private let coordinatorID: UUID + private var didResolve: Bool + + init( + writer: RadrootsAppleDocumentScanWriter, + request: RadrootsDocumentScanRequest, + coordinatorID: UUID + ) { + self.writer = writer + self.request = request + self.coordinatorID = coordinatorID + didResolve = false + } - func documentCameraViewController( - _ controller: VNDocumentCameraViewController, - didFailWithError error: Error - ) { - controller.dismiss(animated: true) - finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) - } + func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) { + controller.dismiss(animated: true) + finish(.failure(.userCancelled("document scan was cancelled"))) + } - func documentCameraViewController( - _ controller: VNDocumentCameraViewController, - didFinishWith scan: VNDocumentCameraScan - ) { - controller.dismiss(animated: true) - do { - let rendered = try Self.renderPDF(scan) - finish(.success(try writer.persistPDF( - data: rendered.data, - pageCount: rendered.pageCount, - destinationScope: request.destinationScope - ))) - } catch { + func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + didFailWithError error: Error + ) { + controller.dismiss(animated: true) finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) } - } - private static func renderPDF(_ scan: VNDocumentCameraScan) throws -> (data: Data, pageCount: UInt16) { - let pageCount = scan.pageCount - guard pageCount > 0 else { - throw RadrootsCaptureIntakeError.invalidRequest("document scanner requires at least one page") - } - guard pageCount <= Int(UInt16.max) else { - throw RadrootsCaptureIntakeError.invalidRequest("document scanner page count exceeds supported range") - } - let images = (0..<pageCount).map { scan.imageOfPage(at: $0) } - let bounds = pageBounds(images: images) - let renderer = UIGraphicsPDFRenderer(bounds: bounds) - let data = renderer.pdfData { context in - for image in images { - context.beginPage() - image.draw(in: aspectFitRect(imageSize: image.size, bounds: bounds)) + func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + didFinishWith scan: VNDocumentCameraScan + ) { + controller.dismiss(animated: true) + do { + let rendered = try Self.renderPDF(scan) + try finish(.success(writer.persistPDF( + data: rendered.data, + pageCount: rendered.pageCount, + destinationScope: request.destinationScope + ))) + } catch { + finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) } } - guard !data.isEmpty else { - throw RadrootsCaptureIntakeError.transientFailure("document scanner failed to render a pdf") + + private static func renderPDF(_ scan: VNDocumentCameraScan) throws -> (data: Data, pageCount: UInt16) { + let pageCount = scan.pageCount + guard pageCount > 0 else { + throw RadrootsCaptureIntakeError.invalidRequest("document scanner requires at least one page") + } + guard pageCount <= Int(UInt16.max) else { + throw RadrootsCaptureIntakeError.invalidRequest("document scanner page count exceeds supported range") + } + let images = (0 ..< pageCount).map { scan.imageOfPage(at: $0) } + let bounds = pageBounds(images: images) + let renderer = UIGraphicsPDFRenderer(bounds: bounds) + let data = renderer.pdfData { context in + for image in images { + context.beginPage() + image.draw(in: aspectFitRect(imageSize: image.size, bounds: bounds)) + } + } + guard !data.isEmpty else { + throw RadrootsCaptureIntakeError.transientFailure("document scanner failed to render a pdf") + } + return (data, UInt16(pageCount)) } - return (data, UInt16(pageCount)) - } - private static func pageBounds(images: [UIImage]) -> CGRect { - let fallback = CGSize(width: 612, height: 792) - let width = images.map(\.size.width).filter { $0 > 0 }.max() ?? fallback.width - let height = images.map(\.size.height).filter { $0 > 0 }.max() ?? fallback.height - return CGRect(origin: .zero, size: CGSize(width: width, height: height)) - } + private static func pageBounds(images: [UIImage]) -> CGRect { + let fallback = CGSize(width: 612, height: 792) + let width = images.map(\.size.width).filter { $0 > 0 }.max() ?? fallback.width + let height = images.map(\.size.height).filter { $0 > 0 }.max() ?? fallback.height + return CGRect(origin: .zero, size: CGSize(width: width, height: height)) + } - private static func aspectFitRect(imageSize: CGSize, bounds: CGRect) -> CGRect { - guard imageSize.width > 0, imageSize.height > 0 else { - return bounds + private static func aspectFitRect(imageSize: CGSize, bounds: CGRect) -> CGRect { + guard imageSize.width > 0, imageSize.height > 0 else { + return bounds + } + let widthScale = bounds.width / imageSize.width + let heightScale = bounds.height / imageSize.height + let scale = min(widthScale, heightScale) + let scaledSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) + return CGRect( + origin: CGPoint( + x: bounds.origin.x + ((bounds.width - scaledSize.width) / 2), + y: bounds.origin.y + ((bounds.height - scaledSize.height) / 2) + ), + size: scaledSize + ) } - let widthScale = bounds.width / imageSize.width - let heightScale = bounds.height / imageSize.height - let scale = min(widthScale, heightScale) - let scaledSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) - return CGRect( - origin: CGPoint( - x: bounds.origin.x + ((bounds.width - scaledSize.width) / 2), - y: bounds.origin.y + ((bounds.height - scaledSize.height) / 2) - ), - size: scaledSize - ) - } - func cancelPresentation(_ controller: VNDocumentCameraViewController) { - guard !didResolve else { return } - controller.dismiss(animated: true) - finish(.failure(.transientFailure("document scanner presentation was cancelled"))) - } + func cancelPresentation(_ controller: VNDocumentCameraViewController) { + guard !didResolve else { return } + controller.dismiss(animated: true) + finish(.failure(.transientFailure("document scanner presentation was cancelled"))) + } - private func finish(_ result: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) { - guard !didResolve else { return } - didResolve = true - let completion = completion - self.completion = nil - RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) - completion?(result) + private func finish(_ result: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) { + guard !didResolve else { return } + didResolve = true + let completion = completion + self.completion = nil + RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) + completion?(result) + } } -} #endif private final class RadrootsAppleDocumentScanWriter: @unchecked Sendable { diff --git a/Sources/RadrootsKit/RadrootsAppleExternalActions.swift b/Sources/RadrootsKit/RadrootsAppleExternalActions.swift @@ -1,11 +1,11 @@ import Foundation #if canImport(AppKit) -@preconcurrency import AppKit + @preconcurrency import AppKit #endif #if canImport(UIKit) -@preconcurrency import UIKit + @preconcurrency import UIKit #endif public struct RadrootsAppleExternalActionsAdapters: Sendable { @@ -25,49 +25,49 @@ public struct RadrootsAppleExternalActionsAdapters: Sendable { public static var live: Self { #if canImport(UIKit) - Self( - appSettingsURL: { - await MainActor.run { - URL(string: UIApplication.openSettingsURLString) + Self( + appSettingsURL: { + await MainActor.run { + URL(string: UIApplication.openSettingsURLString) + } + }, + canOpenURL: { url in + await MainActor.run { + UIApplication.shared.canOpenURL(url) + } + }, + openURL: { url in + await Self.openUIKitURL(url) } - }, - canOpenURL: { url in - await MainActor.run { - UIApplication.shared.canOpenURL(url) - } - }, - openURL: { url in - await Self.openUIKitURL(url) - } - ) + ) #elseif canImport(AppKit) - Self( - appSettingsURL: { - nil - }, - canOpenURL: { url in - await MainActor.run { - NSWorkspace.shared.urlForApplication(toOpen: url) != nil + Self( + appSettingsURL: { + nil + }, + canOpenURL: { url in + await MainActor.run { + NSWorkspace.shared.urlForApplication(toOpen: url) != nil + } + }, + openURL: { url in + await MainActor.run { + NSWorkspace.shared.open(url) + } } - }, - openURL: { url in - await MainActor.run { - NSWorkspace.shared.open(url) - } - } - ) + ) #else - Self( - appSettingsURL: { - nil - }, - canOpenURL: { _ in - false - }, - openURL: { _ in - false - } - ) + Self( + appSettingsURL: { + nil + }, + canOpenURL: { _ in + false + }, + openURL: { _ in + false + } + ) #endif } } @@ -116,14 +116,14 @@ public final class RadrootsAppleExternalActions: RadrootsExternalActions, Sendab } #if canImport(UIKit) -private extension RadrootsAppleExternalActionsAdapters { - @MainActor - static func openUIKitURL(_ url: URL) async -> Bool { - await withCheckedContinuation { continuation in - UIApplication.shared.open(url, options: [:]) { success in - continuation.resume(returning: success) + private extension RadrootsAppleExternalActionsAdapters { + @MainActor + static func openUIKitURL(_ url: URL) async -> Bool { + await withCheckedContinuation { continuation in + UIApplication.shared.open(url, options: [:]) { success in + continuation.resume(returning: success) + } } } } -} #endif diff --git a/Sources/RadrootsKit/RadrootsAppleFileError.swift b/Sources/RadrootsKit/RadrootsAppleFileError.swift @@ -11,15 +11,15 @@ public enum RadrootsAppleFileError: Error, Equatable, Sendable { extension RadrootsAppleFileError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message - case .notFound(let message): + case let .notFound(message): message - case .permissionDenied(let message): + case let .permissionDenied(message): message - case .transientFailure(let message): + case let .transientFailure(message): message - case .permanentFailure(let message): + case let .permanentFailure(message): message } } diff --git a/Sources/RadrootsKit/RadrootsAppleIdentityMetadataStore.swift b/Sources/RadrootsKit/RadrootsAppleIdentityMetadataStore.swift @@ -1,58 +1,58 @@ import Foundation public final class RadrootsAppleIdentityMetadataStore: RadrootsIdentityMetadataStore, - @unchecked Sendable + @unchecked Sendable { - private let lock = NSLock() - private let userDefaults: UserDefaults - private let keyPrefix: String + private let lock = NSLock() + private let userDefaults: UserDefaults + private let keyPrefix: String - public init( - namespace: String, - userDefaults: UserDefaults = .standard, - keyPrefix: String = "org.radroots.kit.identity" - ) throws { - self.userDefaults = userDefaults - self.keyPrefix = try Self.normalizedPrefix(keyPrefix, namespace: namespace) - } + public init( + namespace: String, + userDefaults: UserDefaults = .standard, + keyPrefix: String = "org.radroots.kit.identity" + ) throws { + self.userDefaults = userDefaults + self.keyPrefix = try Self.normalizedPrefix(keyPrefix, namespace: namespace) + } - public func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? { - lock.lock() - defer { lock.unlock() } - return userDefaults.data(forKey: key(for: slot)) - } + public func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? { + lock.lock() + defer { lock.unlock() } + return userDefaults.data(forKey: key(for: slot)) + } - public func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws { - guard !data.isEmpty, data.count <= 64 * 1_024 else { - throw RadrootsIdentityCustodyError.invalidMetadata + public func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws { + guard !data.isEmpty, data.count <= 64 * 1024 else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + lock.lock() + userDefaults.set(data, forKey: key(for: slot)) + lock.unlock() } - lock.lock() - userDefaults.set(data, forKey: key(for: slot)) - lock.unlock() - } - public func delete(_ slot: RadrootsIdentityMetadataSlot) throws { - lock.lock() - userDefaults.removeObject(forKey: key(for: slot)) - lock.unlock() - } + public func delete(_ slot: RadrootsIdentityMetadataSlot) throws { + lock.lock() + userDefaults.removeObject(forKey: key(for: slot)) + lock.unlock() + } - func key(for slot: RadrootsIdentityMetadataSlot) -> String { - "\(keyPrefix).\(slot.rawValue)" - } + func key(for slot: RadrootsIdentityMetadataSlot) -> String { + "\(keyPrefix).\(slot.rawValue)" + } - private static func normalizedPrefix(_ value: String, namespace: String) throws -> String { - let prefix = value.trimmingCharacters(in: .whitespacesAndNewlines) - let namespace = namespace.trimmingCharacters(in: .whitespacesAndNewlines) - guard !prefix.isEmpty, - prefix.utf8.count <= 128, - !namespace.isEmpty, - namespace.utf8.count <= 128, - !prefix.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains), - !namespace.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) - else { - throw RadrootsIdentityCustodyError.invalidConfiguration + private static func normalizedPrefix(_ value: String, namespace: String) throws -> String { + let prefix = value.trimmingCharacters(in: .whitespacesAndNewlines) + let namespace = namespace.trimmingCharacters(in: .whitespacesAndNewlines) + guard !prefix.isEmpty, + prefix.utf8.count <= 128, + !namespace.isEmpty, + namespace.utf8.count <= 128, + !prefix.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains), + !namespace.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { + throw RadrootsIdentityCustodyError.invalidConfiguration + } + return "\(prefix).\(namespace)" } - return "\(prefix).\(namespace)" - } } diff --git a/Sources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift b/Sources/RadrootsKit/RadrootsAppleKeychainSecureStore.swift @@ -7,7 +7,7 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche public init(servicePrefix: String = "org.radroots.kit.secure-store") { self.servicePrefix = servicePrefix - self.accessControlFactory = Self.makeAccessControl(for:) + accessControlFactory = Self.makeAccessControl(for:) } init( @@ -37,7 +37,7 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche throw Self.mapStatus(addStatus, defaultMessage: "keychain write failed") } - let updateStatus = SecItemUpdate(try baseQuery(for: key) as CFDictionary, attributes as CFDictionary) + let updateStatus = try SecItemUpdate(baseQuery(for: key) as CFDictionary, attributes as CFDictionary) guard updateStatus == errSecSuccess else { throw Self.mapStatus(updateStatus, defaultMessage: "keychain update failed") } @@ -77,14 +77,14 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche } public func delete(_ key: RadrootsSecureStoreKey) throws { - let status = SecItemDelete(try baseQuery(for: key) as CFDictionary) + let status = try SecItemDelete(baseQuery(for: key) as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw Self.mapStatus(status, defaultMessage: "keychain delete failed") } } public func deleteNamespace(_ namespace: String) throws { - let status = SecItemDelete(try namespaceQuery(namespace) as CFDictionary) + let status = try SecItemDelete(namespaceQuery(namespace) as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw Self.mapStatus(status, defaultMessage: "keychain namespace delete failed") } @@ -92,20 +92,20 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche func baseQuery(for key: RadrootsSecureStoreKey) throws -> [String: Any] { let normalizedKey = try key.normalized() - return [ + return try [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: try normalizedKey.serviceName(servicePrefix: servicePrefix), - kSecAttrAccount as String: normalizedKey.name + kSecAttrService as String: normalizedKey.serviceName(servicePrefix: servicePrefix), + kSecAttrAccount as String: normalizedKey.name, ] } func namespaceQuery(_ namespace: String) throws -> [String: Any] { - return [ + try [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: try RadrootsSecureStoreKey.serviceName( + kSecAttrService as String: RadrootsSecureStoreKey.serviceName( servicePrefix: servicePrefix, namespace: namespace - ) + ), ] } @@ -144,7 +144,7 @@ public final class RadrootsAppleKeychainSecureStore: RadrootsSecureStore, @unche ) throws -> [String: Any] { let mapping = keychainPolicyMapping(for: policy) var attributes: [String: Any] = [ - kSecValueData as String: value + kSecValueData as String: value, ] if mapping.usesAccessControl { attributes[kSecAttrAccessControl as String] = try accessControl(for: mapping) diff --git a/Sources/RadrootsKit/RadrootsAppleLocationServices.swift b/Sources/RadrootsKit/RadrootsAppleLocationServices.swift @@ -1,7 +1,7 @@ import Foundation #if canImport(CoreLocation) -@preconcurrency import CoreLocation + @preconcurrency import CoreLocation #endif public struct RadrootsAppleLocationServicesAdapters: Sendable { @@ -27,62 +27,62 @@ public struct RadrootsAppleLocationServicesAdapters: Sendable { public static var live: Self { #if canImport(CoreLocation) - Self( - locationServicesEnabled: { - CLLocationManager.locationServicesEnabled() - }, - authorizationStatus: { - authorization( - for: CLLocationManager().authorizationStatus, - locationServicesEnabled: CLLocationManager.locationServicesEnabled() - ) - }, - requestWhenInUseAuthorization: { timeoutSeconds in - try await RadrootsCoreLocationAuthorizationSession().start(timeoutSeconds: timeoutSeconds) - }, - requestCurrentLocation: { request in - try await RadrootsCoreLocationReadingSession().start(request: request) - } - ) + Self( + locationServicesEnabled: { + CLLocationManager.locationServicesEnabled() + }, + authorizationStatus: { + authorization( + for: CLLocationManager().authorizationStatus, + locationServicesEnabled: CLLocationManager.locationServicesEnabled() + ) + }, + requestWhenInUseAuthorization: { timeoutSeconds in + try await RadrootsCoreLocationAuthorizationSession().start(timeoutSeconds: timeoutSeconds) + }, + requestCurrentLocation: { request in + try await RadrootsCoreLocationReadingSession().start(request: request) + } + ) #else - Self( - locationServicesEnabled: { false }, - authorizationStatus: { .unsupported }, - requestWhenInUseAuthorization: { _ in - throw RadrootsLocationServicesError.unavailable("CoreLocation is not available") - }, - requestCurrentLocation: { _ in - throw RadrootsLocationServicesError.unavailable("CoreLocation is not available") - } - ) + Self( + locationServicesEnabled: { false }, + authorizationStatus: { .unsupported }, + requestWhenInUseAuthorization: { _ in + throw RadrootsLocationServicesError.unavailable("CoreLocation is not available") + }, + requestCurrentLocation: { _ in + throw RadrootsLocationServicesError.unavailable("CoreLocation is not available") + } + ) #endif } #if canImport(CoreLocation) - public static func authorization( - for status: CLAuthorizationStatus, - locationServicesEnabled: Bool - ) -> RadrootsLocationAuthorization { - guard locationServicesEnabled else { - return .unavailable - } - switch status { - case .notDetermined: - return .notDetermined - case .restricted: - return .restricted - case .denied: - return .denied - case .authorizedAlways: - return .authorizedAlways - #if os(iOS) - case .authorizedWhenInUse: - return .authorizedWhenInUse - #endif - @unknown default: - return .unavailable + public static func authorization( + for status: CLAuthorizationStatus, + locationServicesEnabled: Bool + ) -> RadrootsLocationAuthorization { + guard locationServicesEnabled else { + return .unavailable + } + switch status { + case .notDetermined: + return .notDetermined + case .restricted: + return .restricted + case .denied: + return .denied + case .authorizedAlways: + return .authorizedAlways + #if os(iOS) + case .authorizedWhenInUse: + return .authorizedWhenInUse + #endif + @unknown default: + return .unavailable + } } - } #endif } @@ -160,153 +160,153 @@ public final class RadrootsAppleLocationServices: RadrootsLocationServices, Send } #if canImport(CoreLocation) -@MainActor -private final class RadrootsCoreLocationAuthorizationSession: NSObject, @preconcurrency CLLocationManagerDelegate { - private var continuation: CheckedContinuation<RadrootsLocationAuthorization, any Error>? - private var manager: CLLocationManager? - private var timeoutTask: Task<Void, Never>? + @MainActor + private final class RadrootsCoreLocationAuthorizationSession: NSObject, @preconcurrency CLLocationManagerDelegate { + private var continuation: CheckedContinuation<RadrootsLocationAuthorization, any Error>? + private var manager: CLLocationManager? + private var timeoutTask: Task<Void, Never>? - func start(timeoutSeconds: TimeInterval) async throws -> RadrootsLocationAuthorization { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation - let manager = CLLocationManager() - self.manager = manager - manager.delegate = self - timeoutTask = Task { [weak self] in - let nanoseconds = UInt64(timeoutSeconds * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - self?.finish(.failure(.timeout("location authorization timed out"))) + func start(timeoutSeconds: TimeInterval) async throws -> RadrootsLocationAuthorization { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + let manager = CLLocationManager() + self.manager = manager + manager.delegate = self + timeoutTask = Task { [weak self] in + let nanoseconds = UInt64(timeoutSeconds * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + self?.finish(.failure(.timeout("location authorization timed out"))) + } + manager.requestWhenInUseAuthorization() + } + } onCancel: { + Task { @MainActor [weak self] in + self?.finish(.failure(.cancelled("location authorization was cancelled"))) } - manager.requestWhenInUseAuthorization() - } - } onCancel: { - Task { @MainActor [weak self] in - self?.finish(.failure(.cancelled("location authorization was cancelled"))) } } - } - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let authorization = RadrootsAppleLocationServicesAdapters.authorization( - for: manager.authorizationStatus, - locationServicesEnabled: CLLocationManager.locationServicesEnabled() - ) - guard authorization != .notDetermined else { - return + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let authorization = RadrootsAppleLocationServicesAdapters.authorization( + for: manager.authorizationStatus, + locationServicesEnabled: CLLocationManager.locationServicesEnabled() + ) + guard authorization != .notDetermined else { + return + } + finish(.success(authorization)) } - finish(.success(authorization)) - } - private func finish(_ result: Result<RadrootsLocationAuthorization, RadrootsLocationServicesError>) { - guard let continuation else { - return - } - self.continuation = nil - timeoutTask?.cancel() - timeoutTask = nil - manager?.delegate = nil - manager = nil - switch result { - case .success(let authorization): - continuation.resume(returning: authorization) - case .failure(let error): - continuation.resume(throwing: error) + private func finish(_ result: Result<RadrootsLocationAuthorization, RadrootsLocationServicesError>) { + guard let continuation else { + return + } + self.continuation = nil + timeoutTask?.cancel() + timeoutTask = nil + manager?.delegate = nil + manager = nil + switch result { + case let .success(authorization): + continuation.resume(returning: authorization) + case let .failure(error): + continuation.resume(throwing: error) + } } } -} -@MainActor -private final class RadrootsCoreLocationReadingSession: NSObject, @preconcurrency CLLocationManagerDelegate { - private var continuation: CheckedContinuation<RadrootsLocationReading, any Error>? - private var manager: CLLocationManager? - private var timeoutTask: Task<Void, Never>? + @MainActor + private final class RadrootsCoreLocationReadingSession: NSObject, @preconcurrency CLLocationManagerDelegate { + private var continuation: CheckedContinuation<RadrootsLocationReading, any Error>? + private var manager: CLLocationManager? + private var timeoutTask: Task<Void, Never>? - func start(request: RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation - let manager = CLLocationManager() - self.manager = manager - manager.delegate = self - if let desiredAccuracyMeters = request.desiredAccuracyMeters { - manager.desiredAccuracy = desiredAccuracyMeters + func start(request: RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + let manager = CLLocationManager() + self.manager = manager + manager.delegate = self + if let desiredAccuracyMeters = request.desiredAccuracyMeters { + manager.desiredAccuracy = desiredAccuracyMeters + } + timeoutTask = Task { [weak self] in + let nanoseconds = UInt64(request.timeoutSeconds * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + self?.finish(.failure(.timeout("current location request timed out"))) + } + manager.requestLocation() } - timeoutTask = Task { [weak self] in - let nanoseconds = UInt64(request.timeoutSeconds * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - self?.finish(.failure(.timeout("current location request timed out"))) + } onCancel: { + Task { @MainActor [weak self] in + self?.finish(.failure(.cancelled("current location request was cancelled"))) } - manager.requestLocation() - } - } onCancel: { - Task { @MainActor [weak self] in - self?.finish(.failure(.cancelled("current location request was cancelled"))) } } - } - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.sorted(by: { $0.timestamp < $1.timestamp }).last else { - finish(.failure(.transientFailure("CoreLocation returned no locations"))) - return - } - do { - finish(.success(try Self.reading(from: location))) - } catch let error as RadrootsLocationServicesError { - finish(.failure(error)) - } catch { - finish(.failure(.permanentFailure(error.localizedDescription))) + func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.sorted(by: { $0.timestamp < $1.timestamp }).last else { + finish(.failure(.transientFailure("CoreLocation returned no locations"))) + return + } + do { + try finish(.success(Self.reading(from: location))) + } catch let error as RadrootsLocationServicesError { + finish(.failure(error)) + } catch { + finish(.failure(.permanentFailure(error.localizedDescription))) + } } - } - func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { - if let coreLocationError = error as? CLError { - switch coreLocationError.code { - case .denied: - finish(.failure(.permissionDenied("location permission is denied"))) - case .locationUnknown: - finish(.failure(.transientFailure("current location is temporarily unknown"))) - case .network: - finish(.failure(.transientFailure("location network lookup failed"))) - default: - finish(.failure(.permanentFailure(coreLocationError.localizedDescription))) + func locationManager(_: CLLocationManager, didFailWithError error: any Error) { + if let coreLocationError = error as? CLError { + switch coreLocationError.code { + case .denied: + finish(.failure(.permissionDenied("location permission is denied"))) + case .locationUnknown: + finish(.failure(.transientFailure("current location is temporarily unknown"))) + case .network: + finish(.failure(.transientFailure("location network lookup failed"))) + default: + finish(.failure(.permanentFailure(coreLocationError.localizedDescription))) + } + } else { + finish(.failure(.permanentFailure(error.localizedDescription))) } - } else { - finish(.failure(.permanentFailure(error.localizedDescription))) } - } - private func finish(_ result: Result<RadrootsLocationReading, RadrootsLocationServicesError>) { - guard let continuation else { - return - } - self.continuation = nil - timeoutTask?.cancel() - timeoutTask = nil - manager?.delegate = nil - manager = nil - switch result { - case .success(let reading): - continuation.resume(returning: reading) - case .failure(let error): - continuation.resume(throwing: error) + private func finish(_ result: Result<RadrootsLocationReading, RadrootsLocationServicesError>) { + guard let continuation else { + return + } + self.continuation = nil + timeoutTask?.cancel() + timeoutTask = nil + manager?.delegate = nil + manager = nil + switch result { + case let .success(reading): + continuation.resume(returning: reading) + case let .failure(error): + continuation.resume(throwing: error) + } } - } - private static func reading(from location: CLLocation) throws -> RadrootsLocationReading { - try RadrootsLocationReading( - coordinate: RadrootsLocationCoordinate( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ), - horizontalAccuracyMeters: location.horizontalAccuracy, - altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil, - verticalAccuracyMeters: location.verticalAccuracy >= 0 ? location.verticalAccuracy : nil, - speedMetersPerSecond: location.speed >= 0 ? location.speed : nil, - courseDegrees: location.course >= 0 ? location.course : nil, - capturedAt: location.timestamp - ) + private static func reading(from location: CLLocation) throws -> RadrootsLocationReading { + try RadrootsLocationReading( + coordinate: RadrootsLocationCoordinate( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ), + horizontalAccuracyMeters: location.horizontalAccuracy, + altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil, + verticalAccuracyMeters: location.verticalAccuracy >= 0 ? location.verticalAccuracy : nil, + speedMetersPerSecond: location.speed >= 0 ? location.speed : nil, + courseDegrees: location.course >= 0 ? location.course : nil, + capturedAt: location.timestamp + ) + } } -} #endif diff --git a/Sources/RadrootsKit/RadrootsAppleLoggerTelemetry.swift b/Sources/RadrootsKit/RadrootsAppleLoggerTelemetry.swift @@ -43,7 +43,7 @@ public final class RadrootsAppleLoggerTelemetry: RadrootsTelemetry, Sendable { subsystem: String, adapters: RadrootsAppleLoggerTelemetryAdapters = .live, redactionPolicy: RadrootsTelemetryRedactionPolicy = .default, - maximumRenderedMessageLength: Int = 1_000 + maximumRenderedMessageLength: Int = 1000 ) { self.subsystem = Self.normalizedSubsystem(subsystem) self.adapters = adapters @@ -85,7 +85,7 @@ public final class RadrootsAppleLoggerTelemetry: RadrootsTelemetry, Sendable { public static func renderedMessage( for event: RadrootsTelemetryEvent, - maximumLength: Int = 1_000 + maximumLength: Int = 1000 ) -> String { let payload = RadrootsAppleTelemetryPayload( category: event.category, @@ -95,7 +95,7 @@ public final class RadrootsAppleLoggerTelemetry: RadrootsTelemetry, Sendable { }), level: event.level.rawValue, message: event.message, - occurredAtUnixMilliseconds: Int64(event.occurredAt.timeIntervalSince1970 * 1_000) + occurredAtUnixMilliseconds: Int64(event.occurredAt.timeIntervalSince1970 * 1000) ) let rendered: String do { diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift b/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift @@ -1,27 +1,27 @@ import Foundation #if canImport(AVFoundation) -@preconcurrency import AVFoundation + @preconcurrency import AVFoundation #endif #if canImport(ImageIO) -import ImageIO + import ImageIO #endif #if canImport(PhotosUI) -@preconcurrency import PhotosUI + @preconcurrency import PhotosUI #endif #if canImport(UIKit) -@preconcurrency import UIKit + @preconcurrency import UIKit #endif #if canImport(UniformTypeIdentifiers) -import UniformTypeIdentifiers + import UniformTypeIdentifiers #endif #if canImport(UIKit) -public typealias RadrootsAppleViewControllerProvider = @MainActor @Sendable () throws -> UIViewController + public typealias RadrootsAppleViewControllerProvider = @MainActor @Sendable () throws -> UIViewController #endif public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sendable { @@ -30,128 +30,128 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen private let callbackTimeout: TimeInterval #if canImport(UIKit) - private let viewControllerProvider: RadrootsAppleViewControllerProvider + private let viewControllerProvider: RadrootsAppleViewControllerProvider #endif #if canImport(UIKit) - public init( - fileAccess: RadrootsAppleFileAccess, - fileManager: FileManager = .default, - callbackTimeout: TimeInterval = 120 - ) { - self.fileAccess = fileAccess - self.fileManager = fileManager - self.callbackTimeout = callbackTimeout - self.viewControllerProvider = { - try RadrootsAppleUIKitPresentation.activeViewController(service: "media picker") + public init( + fileAccess: RadrootsAppleFileAccess, + fileManager: FileManager = .default, + callbackTimeout: TimeInterval = 120 + ) { + self.fileAccess = fileAccess + self.fileManager = fileManager + self.callbackTimeout = callbackTimeout + viewControllerProvider = { + try RadrootsAppleUIKitPresentation.activeViewController(service: "media picker") + } } - } - public init( - fileAccess: RadrootsAppleFileAccess, - fileManager: FileManager = .default, - callbackTimeout: TimeInterval = 120, - viewControllerProvider: @escaping RadrootsAppleViewControllerProvider - ) { - self.fileAccess = fileAccess - self.fileManager = fileManager - self.callbackTimeout = callbackTimeout - self.viewControllerProvider = viewControllerProvider - } + public init( + fileAccess: RadrootsAppleFileAccess, + fileManager: FileManager = .default, + callbackTimeout: TimeInterval = 120, + viewControllerProvider: @escaping RadrootsAppleViewControllerProvider + ) { + self.fileAccess = fileAccess + self.fileManager = fileManager + self.callbackTimeout = callbackTimeout + self.viewControllerProvider = viewControllerProvider + } #else - public init( - fileAccess: RadrootsAppleFileAccess, - fileManager: FileManager = .default, - callbackTimeout: TimeInterval = 120 - ) { - self.fileAccess = fileAccess - self.fileManager = fileManager - self.callbackTimeout = callbackTimeout - } + public init( + fileAccess: RadrootsAppleFileAccess, + fileManager: FileManager = .default, + callbackTimeout: TimeInterval = 120 + ) { + self.fileAccess = fileAccess + self.fileManager = fileManager + self.callbackTimeout = callbackTimeout + } #endif public func currentSupport() async throws -> RadrootsMediaPickerSupport { #if canImport(UIKit) && canImport(PhotosUI) - try await MainActor.run { - try Self.liveSupport() - } + try await MainActor.run { + try Self.liveSupport() + } #else - try Self.unavailableSupport() + try Self.unavailableSupport() #endif } public func importMedia(_ request: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult { #if canImport(UIKit) && canImport(PhotosUI) - let support = try await currentSupport() - guard support.importAvailable else { - throw RadrootsCaptureIntakeError.unavailable("media import is unavailable") - } - let writer = RadrootsAppleMediaAssetWriter(fileAccess: fileAccess, fileManager: fileManager) - let presenter = try await MainActor.run { - try viewControllerProvider() - } - let coordinatorID = UUID() - return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( - timeout: callbackTimeout, - timeoutMessage: "timed out while presenting media import" - ) { completion, setCleanup in - var configuration = PHPickerConfiguration(photoLibrary: .shared()) - configuration.selectionLimit = request.selectionLimit - configuration.filter = .images - let picker = PHPickerViewController(configuration: configuration) - let coordinator = RadrootsApplePhotoPickerCoordinator( - writer: writer, - request: request, - coordinatorID: coordinatorID - ) - coordinator.completion = completion - picker.delegate = coordinator - setCleanup { - coordinator.cancelPresentation(picker) + let support = try await currentSupport() + guard support.importAvailable else { + throw RadrootsCaptureIntakeError.unavailable("media import is unavailable") + } + let writer = RadrootsAppleMediaAssetWriter(fileAccess: fileAccess, fileManager: fileManager) + let presenter = try await MainActor.run { + try viewControllerProvider() + } + let coordinatorID = UUID() + return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: callbackTimeout, + timeoutMessage: "timed out while presenting media import" + ) { completion, setCleanup in + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.selectionLimit = request.selectionLimit + configuration.filter = .images + let picker = PHPickerViewController(configuration: configuration) + let coordinator = RadrootsApplePhotoPickerCoordinator( + writer: writer, + request: request, + coordinatorID: coordinatorID + ) + coordinator.completion = completion + picker.delegate = coordinator + setCleanup { + coordinator.cancelPresentation(picker) + } + RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) + presenter.present(picker, animated: true) } - RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) - presenter.present(picker, animated: true) - } #else - throw RadrootsCaptureIntakeError.unavailable("media import is unavailable") + throw RadrootsCaptureIntakeError.unavailable("media import is unavailable") #endif } public func captureMedia(_ request: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult { #if canImport(UIKit) - let support = try await currentSupport() - guard support.cameraCaptureAvailable else { - throw RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable") - } - try await Self.requestCameraAccessIfNeeded() - let writer = RadrootsAppleMediaAssetWriter(fileAccess: fileAccess, fileManager: fileManager) - let presenter = try await MainActor.run { - try viewControllerProvider() - } - let coordinatorID = UUID() - return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( - timeout: callbackTimeout, - timeoutMessage: "timed out while presenting camera photo capture" - ) { completion, setCleanup in - let picker = UIImagePickerController() - picker.sourceType = .camera - picker.mediaTypes = [Self.imageTypeIdentifier()] - picker.cameraCaptureMode = .photo - let coordinator = RadrootsAppleCameraCaptureCoordinator( - writer: writer, - request: request, - coordinatorID: coordinatorID - ) - coordinator.completion = completion - picker.delegate = coordinator - setCleanup { - coordinator.cancelPresentation(picker) + let support = try await currentSupport() + guard support.cameraCaptureAvailable else { + throw RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable") + } + try await Self.requestCameraAccessIfNeeded() + let writer = RadrootsAppleMediaAssetWriter(fileAccess: fileAccess, fileManager: fileManager) + let presenter = try await MainActor.run { + try viewControllerProvider() + } + let coordinatorID = UUID() + return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: callbackTimeout, + timeoutMessage: "timed out while presenting camera photo capture" + ) { completion, setCleanup in + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.mediaTypes = [Self.imageTypeIdentifier()] + picker.cameraCaptureMode = .photo + let coordinator = RadrootsAppleCameraCaptureCoordinator( + writer: writer, + request: request, + coordinatorID: coordinatorID + ) + coordinator.completion = completion + picker.delegate = coordinator + setCleanup { + coordinator.cancelPresentation(picker) + } + RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) + presenter.present(picker, animated: true) } - RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) - presenter.present(picker, animated: true) - } #else - throw RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable") + throw RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable") #endif } @@ -177,290 +177,293 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen static func adapt(fileError: RadrootsAppleFileError) -> RadrootsCaptureIntakeError { switch fileError { - case .invalidRequest(let message): - return .invalidRequest(message) - case .notFound(let message): - return .transientFailure(message) - case .permissionDenied(let message): - return .permissionDenied(message) - case .transientFailure(let message): - return .transientFailure(message) - case .permanentFailure(let message): - return .permanentFailure(message) + case let .invalidRequest(message): + .invalidRequest(message) + case let .notFound(message): + .transientFailure(message) + case let .permissionDenied(message): + .permissionDenied(message) + case let .transientFailure(message): + .transientFailure(message) + case let .permanentFailure(message): + .permanentFailure(message) } } } #if canImport(UIKit) -private extension RadrootsAppleMediaPicker { - @MainActor - static func liveSupport() throws -> RadrootsMediaPickerSupport { - let cameraAvailable = UIImagePickerController.isSourceTypeAvailable(.camera) && - UIImagePickerController.availableMediaTypes(for: .camera)?.contains(imageTypeIdentifier()) == true - return try RadrootsMediaPickerSupport( - importAvailable: true, - cameraCaptureAvailable: cameraAvailable, - supportedImportKinds: [.image], - supportedCaptureKinds: cameraAvailable ? [.image] : [], - multipleSelectionSupported: true - ) - } - - static func imageTypeIdentifier() -> String { - #if canImport(UniformTypeIdentifiers) - UTType.image.identifier - #else - "public.image" - #endif - } - - static func requestCameraAccessIfNeeded() async throws { - #if canImport(AVFoundation) - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - return - case .notDetermined: - let granted = await AVCaptureDevice.requestAccess(for: .video) - guard granted else { - throw RadrootsCaptureIntakeError.permissionDenied("camera access was not granted") - } - case .denied: - throw RadrootsCaptureIntakeError.permissionDenied("camera access is denied") - case .restricted: - throw RadrootsCaptureIntakeError.permissionDenied("camera access is restricted") - @unknown default: - throw RadrootsCaptureIntakeError.unavailable("camera authorization is unavailable") + private extension RadrootsAppleMediaPicker { + @MainActor + static func liveSupport() throws -> RadrootsMediaPickerSupport { + let cameraAvailable = UIImagePickerController.isSourceTypeAvailable(.camera) && + UIImagePickerController.availableMediaTypes(for: .camera)?.contains(imageTypeIdentifier()) == true + return try RadrootsMediaPickerSupport( + importAvailable: true, + cameraCaptureAvailable: cameraAvailable, + supportedImportKinds: [.image], + supportedCaptureKinds: cameraAvailable ? [.image] : [], + multipleSelectionSupported: true + ) } - #else - throw RadrootsCaptureIntakeError.unavailable("camera authorization is unavailable") - #endif - } -} -@MainActor -enum RadrootsAppleUIKitPresentation { - static func activeViewController(service: String) throws -> UIViewController { - let scenes = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .filter { scene in - scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive - } - let windows = scenes.flatMap(\.windows) - guard let window = windows.first(where: \.isKeyWindow) ?? windows.first(where: { !$0.isHidden }) else { - throw RadrootsCaptureIntakeError.unavailable("\(service) requires an active foreground window") + static func imageTypeIdentifier() -> String { + #if canImport(UniformTypeIdentifiers) + UTType.image.identifier + #else + "public.image" + #endif } - guard let rootViewController = window.rootViewController else { - throw RadrootsCaptureIntakeError.unavailable("\(service) requires an active foreground view controller") - } - return topViewController(rootViewController) - } - private static func topViewController(_ viewController: UIViewController) -> UIViewController { - if let presentedViewController = viewController.presentedViewController { - return topViewController(presentedViewController) - } - if let navigationController = viewController as? UINavigationController, - let visibleViewController = navigationController.visibleViewController { - return topViewController(visibleViewController) - } - if let tabBarController = viewController as? UITabBarController, - let selectedViewController = tabBarController.selectedViewController { - return topViewController(selectedViewController) + static func requestCameraAccessIfNeeded() async throws { + #if canImport(AVFoundation) + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + return + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + guard granted else { + throw RadrootsCaptureIntakeError.permissionDenied("camera access was not granted") + } + case .denied: + throw RadrootsCaptureIntakeError.permissionDenied("camera access is denied") + case .restricted: + throw RadrootsCaptureIntakeError.permissionDenied("camera access is restricted") + @unknown default: + throw RadrootsCaptureIntakeError.unavailable("camera authorization is unavailable") + } + #else + throw RadrootsCaptureIntakeError.unavailable("camera authorization is unavailable") + #endif } - return viewController } -} -@MainActor -private final class RadrootsApplePhotoPickerCoordinator: NSObject, PHPickerViewControllerDelegate { - var completion: (@Sendable (Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) -> Void)? - - private let writer: RadrootsAppleMediaAssetWriter - private let request: RadrootsMediaImportRequest - private let coordinatorID: UUID - private var selectedResults: [PHPickerResult] - private var didResolve: Bool - - init( - writer: RadrootsAppleMediaAssetWriter, - request: RadrootsMediaImportRequest, - coordinatorID: UUID - ) { - self.writer = writer - self.request = request - self.coordinatorID = coordinatorID - self.selectedResults = [] - self.didResolve = false - } + @MainActor + enum RadrootsAppleUIKitPresentation { + static func activeViewController(service: String) throws -> UIViewController { + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { scene in + scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive + } + let windows = scenes.flatMap(\.windows) + guard let window = windows.first(where: \.isKeyWindow) ?? windows.first(where: { !$0.isHidden }) else { + throw RadrootsCaptureIntakeError.unavailable("\(service) requires an active foreground window") + } + guard let rootViewController = window.rootViewController else { + throw RadrootsCaptureIntakeError.unavailable("\(service) requires an active foreground view controller") + } + return topViewController(rootViewController) + } - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - picker.dismiss(animated: true) - selectedResults = Array(results.prefix(request.selectionLimit)) - guard !selectedResults.isEmpty else { - finish(.failure(.userCancelled("media import was cancelled"))) - return + private static func topViewController(_ viewController: UIViewController) -> UIViewController { + if let presentedViewController = viewController.presentedViewController { + return topViewController(presentedViewController) + } + if let navigationController = viewController as? UINavigationController, + let visibleViewController = navigationController.visibleViewController + { + return topViewController(visibleViewController) + } + if let tabBarController = viewController as? UITabBarController, + let selectedViewController = tabBarController.selectedViewController + { + return topViewController(selectedViewController) + } + return viewController } - loadResult(at: 0, collected: []) } - private func loadResult(at index: Int, collected: [RadrootsMediaAsset]) { - guard index < selectedResults.count else { - do { - finish(.success(try RadrootsMediaImportResult(items: collected))) - } catch { - finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + @MainActor + private final class RadrootsApplePhotoPickerCoordinator: NSObject, PHPickerViewControllerDelegate { + var completion: (@Sendable (Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) -> Void)? + + private let writer: RadrootsAppleMediaAssetWriter + private let request: RadrootsMediaImportRequest + private let coordinatorID: UUID + private var selectedResults: [PHPickerResult] + private var didResolve: Bool + + init( + writer: RadrootsAppleMediaAssetWriter, + request: RadrootsMediaImportRequest, + coordinatorID: UUID + ) { + self.writer = writer + self.request = request + self.coordinatorID = coordinatorID + selectedResults = [] + didResolve = false + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + selectedResults = Array(results.prefix(request.selectionLimit)) + guard !selectedResults.isEmpty else { + finish(.failure(.userCancelled("media import was cancelled"))) + return } - return + loadResult(at: 0, collected: []) } - let provider = selectedResults[index].itemProvider - let suggestedName = provider.suggestedName ?? "photo" - guard provider.hasItemConformingToTypeIdentifier(RadrootsAppleMediaPicker.imageTypeIdentifier()) else { - finish(.failure(.transientFailure("media import could not resolve an image file representation"))) - return - } - let writer = writer - let destinationScope = request.destinationScope - let mediaTypeHint = mediaTypeHint(from: provider) - provider.loadFileRepresentation(forTypeIdentifier: RadrootsAppleMediaPicker.imageTypeIdentifier()) { url, error in - if let error { - Task { @MainActor in - self.finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + + private func loadResult(at index: Int, collected: [RadrootsMediaAsset]) { + guard index < selectedResults.count else { + do { + try finish(.success(RadrootsMediaImportResult(items: collected))) + } catch { + finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) } return } - guard let url else { - Task { @MainActor in - self.finish(.failure(.transientFailure("media import finished without an image file representation"))) - } + let provider = selectedResults[index].itemProvider + let suggestedName = provider.suggestedName ?? "photo" + guard provider.hasItemConformingToTypeIdentifier(RadrootsAppleMediaPicker.imageTypeIdentifier()) else { + finish(.failure(.transientFailure("media import could not resolve an image file representation"))) return } - let result: Result<RadrootsMediaAsset, RadrootsCaptureIntakeError> - do { - result = .success( - try writer.persistExternalImage( - sourceURL: url, - source: .libraryImport, - destinationScope: destinationScope, - suggestedFilename: suggestedName, - mediaTypeHint: mediaTypeHint + let writer = writer + let destinationScope = request.destinationScope + let mediaTypeHint = mediaTypeHint(from: provider) + provider.loadFileRepresentation(forTypeIdentifier: RadrootsAppleMediaPicker.imageTypeIdentifier()) { url, error in + if let error { + Task { @MainActor in + self.finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + } + return + } + guard let url else { + Task { @MainActor in + self.finish(.failure(.transientFailure("media import finished without an image file representation"))) + } + return + } + let result: Result<RadrootsMediaAsset, RadrootsCaptureIntakeError> + do { + result = try .success( + writer.persistExternalImage( + sourceURL: url, + source: .libraryImport, + destinationScope: destinationScope, + suggestedFilename: suggestedName, + mediaTypeHint: mediaTypeHint + ) ) - ) - } catch { - result = .failure(RadrootsAppleMediaPicker.adapt(error: error)) - } - Task { @MainActor in - switch result { - case .success(let asset): - var nextCollected = collected - nextCollected.append(asset) - self.loadResult(at: index + 1, collected: nextCollected) - case .failure(let error): - self.finish(.failure(error)) + } catch { + result = .failure(RadrootsAppleMediaPicker.adapt(error: error)) + } + Task { @MainActor in + switch result { + case let .success(asset): + var nextCollected = collected + nextCollected.append(asset) + self.loadResult(at: index + 1, collected: nextCollected) + case let .failure(error): + self.finish(.failure(error)) + } } } } - } - - private func mediaTypeHint(from provider: NSItemProvider) -> String? { - #if canImport(UniformTypeIdentifiers) - provider.registeredTypeIdentifiers - .compactMap(UTType.init) - .first(where: { $0.conforms(to: .image) })? - .preferredMIMEType - #else - nil - #endif - } - - func cancelPresentation(_ picker: PHPickerViewController) { - guard !didResolve else { return } - picker.dismiss(animated: true) - finish(.failure(.transientFailure("media import presentation was cancelled"))) - } - - private func finish(_ result: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) { - guard !didResolve else { return } - didResolve = true - let completion = completion - self.completion = nil - RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) - completion?(result) - } -} -@MainActor -private final class RadrootsAppleCameraCaptureCoordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - var completion: (@Sendable (Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) -> Void)? - - private let writer: RadrootsAppleMediaAssetWriter - private let request: RadrootsMediaCaptureRequest - private let coordinatorID: UUID - private var didResolve: Bool + private func mediaTypeHint(from provider: NSItemProvider) -> String? { + #if canImport(UniformTypeIdentifiers) + provider.registeredTypeIdentifiers + .compactMap(UTType.init) + .first(where: { $0.conforms(to: .image) })? + .preferredMIMEType + #else + nil + #endif + } - init( - writer: RadrootsAppleMediaAssetWriter, - request: RadrootsMediaCaptureRequest, - coordinatorID: UUID - ) { - self.writer = writer - self.request = request - self.coordinatorID = coordinatorID - self.didResolve = false - } + func cancelPresentation(_ picker: PHPickerViewController) { + guard !didResolve else { return } + picker.dismiss(animated: true) + finish(.failure(.transientFailure("media import presentation was cancelled"))) + } - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - picker.dismiss(animated: true) - finish(.failure(.userCancelled("camera photo capture was cancelled"))) + private func finish(_ result: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) { + guard !didResolve else { return } + didResolve = true + let completion = completion + self.completion = nil + RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) + completion?(result) + } } - func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] - ) { - picker.dismiss(animated: true) - do { - finish(.success(try RadrootsMediaCaptureResult(item: buildAsset(info: info)))) - } catch { - finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + @MainActor + private final class RadrootsAppleCameraCaptureCoordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + var completion: (@Sendable (Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) -> Void)? + + private let writer: RadrootsAppleMediaAssetWriter + private let request: RadrootsMediaCaptureRequest + private let coordinatorID: UUID + private var didResolve: Bool + + init( + writer: RadrootsAppleMediaAssetWriter, + request: RadrootsMediaCaptureRequest, + coordinatorID: UUID + ) { + self.writer = writer + self.request = request + self.coordinatorID = coordinatorID + didResolve = false + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + finish(.failure(.userCancelled("camera photo capture was cancelled"))) + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.dismiss(animated: true) + do { + try finish(.success(RadrootsMediaCaptureResult(item: buildAsset(info: info)))) + } catch { + finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + } } - } - private func buildAsset(info: [UIImagePickerController.InfoKey: Any]) throws -> RadrootsMediaAsset { - if let imageURL = info[.imageURL] as? URL { - return try writer.persistExternalImage( - sourceURL: imageURL, - source: .cameraCapture, - destinationScope: request.destinationScope, - suggestedFilename: imageURL.lastPathComponent, - mediaTypeHint: nil + private func buildAsset(info: [UIImagePickerController.InfoKey: Any]) throws -> RadrootsMediaAsset { + if let imageURL = info[.imageURL] as? URL { + return try writer.persistExternalImage( + sourceURL: imageURL, + source: .cameraCapture, + destinationScope: request.destinationScope, + suggestedFilename: imageURL.lastPathComponent, + mediaTypeHint: nil + ) + } + guard let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage), + let jpegData = image.jpegData(compressionQuality: 0.92) + else { + throw RadrootsCaptureIntakeError.transientFailure("camera photo capture finished without a usable image") + } + return try writer.persistCapturedJPEG( + data: jpegData, + image: image, + destinationScope: request.destinationScope ) } - guard let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage), - let jpegData = image.jpegData(compressionQuality: 0.92) else { - throw RadrootsCaptureIntakeError.transientFailure("camera photo capture finished without a usable image") - } - return try writer.persistCapturedJPEG( - data: jpegData, - image: image, - destinationScope: request.destinationScope - ) - } - func cancelPresentation(_ picker: UIImagePickerController) { - guard !didResolve else { return } - picker.dismiss(animated: true) - finish(.failure(.transientFailure("camera photo capture presentation was cancelled"))) - } + func cancelPresentation(_ picker: UIImagePickerController) { + guard !didResolve else { return } + picker.dismiss(animated: true) + finish(.failure(.transientFailure("camera photo capture presentation was cancelled"))) + } - private func finish(_ result: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) { - guard !didResolve else { return } - didResolve = true - let completion = completion - self.completion = nil - RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) - completion?(result) + private func finish(_ result: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) { + guard !didResolve else { return } + didResolve = true + let completion = completion + self.completion = nil + RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) + completion?(result) + } } -} #endif @MainActor @@ -469,7 +472,7 @@ final class RadrootsApplePresentationRetainer { private var retainers: [UUID: AnyObject] private init() { - self.retainers = [:] + retainers = [:] } func store(_ retainer: AnyObject, id: UUID) { @@ -526,30 +529,30 @@ private final class RadrootsAppleMediaAssetWriter: @unchecked Sendable { } #if canImport(UIKit) - func persistCapturedJPEG( - data: Data, - image: UIImage, - destinationScope: RadrootsFileScope - ) throws -> RadrootsMediaAsset { - let filename = try sanitizedFilename( - "captured_photo.jpg", - fallbackBasename: "captured_photo", - fallbackExtension: "jpg" - ) - let file = try destinationFile(source: .cameraCapture, scope: destinationScope, filename: filename) - try fileAccess.write(.inline(data), to: file) - return try RadrootsMediaAsset( - source: .cameraCapture, - kind: .image, - file: file, - mediaType: "image/jpeg", - suggestedFilename: filename, - sizeBytes: UInt64(data.count), - pixelWidth: image.cgImage.map { UInt32($0.width) } ?? positiveRoundedUInt32(image.size.width), - pixelHeight: image.cgImage.map { UInt32($0.height) } ?? positiveRoundedUInt32(image.size.height), - capturedAt: Date() - ) - } + func persistCapturedJPEG( + data: Data, + image: UIImage, + destinationScope: RadrootsFileScope + ) throws -> RadrootsMediaAsset { + let filename = try sanitizedFilename( + "captured_photo.jpg", + fallbackBasename: "captured_photo", + fallbackExtension: "jpg" + ) + let file = try destinationFile(source: .cameraCapture, scope: destinationScope, filename: filename) + try fileAccess.write(.inline(data), to: file) + return try RadrootsMediaAsset( + source: .cameraCapture, + kind: .image, + file: file, + mediaType: "image/jpeg", + suggestedFilename: filename, + sizeBytes: UInt64(data.count), + pixelWidth: image.cgImage.map { UInt32($0.width) } ?? positiveRoundedUInt32(image.size.width), + pixelHeight: image.cgImage.map { UInt32($0.height) } ?? positiveRoundedUInt32(image.size.height), + capturedAt: Date() + ) + } #endif private func destinationFile( @@ -557,12 +560,11 @@ private final class RadrootsAppleMediaAssetWriter: @unchecked Sendable { scope: RadrootsFileScope, filename: String ) throws -> RadrootsFileReference { - let namespace: String - switch source { + let namespace = switch source { case .libraryImport: - namespace = "library_import" + "library_import" case .cameraCapture: - namespace = "camera_capture" + "camera_capture" } let validatedFilename = try RadrootsCaptureIntakeValidation.normalizedFilename(filename) return RadrootsFileReference( @@ -585,7 +587,8 @@ private final class RadrootsAppleMediaAssetWriter: @unchecked Sendable { scalar == "/" || scalar == "\\" || scalar == "\0" || - scalar == ":" { + scalar == ":" + { return "_" } return Character(scalar) @@ -605,10 +608,11 @@ private final class RadrootsAppleMediaAssetWriter: @unchecked Sendable { return try RadrootsCaptureIntakeValidation.normalizedMediaType(mediaType) } #if canImport(UniformTypeIdentifiers) - if let type = UTType(filenameExtension: URL(fileURLWithPath: filename).pathExtension), - let preferredMIMEType = type.preferredMIMEType { - return try RadrootsCaptureIntakeValidation.normalizedMediaType(preferredMIMEType) - } + if let type = UTType(filenameExtension: URL(fileURLWithPath: filename).pathExtension), + let preferredMIMEType = type.preferredMIMEType + { + return try RadrootsCaptureIntakeValidation.normalizedMediaType(preferredMIMEType) + } #endif return "image/jpeg" } @@ -628,17 +632,18 @@ private final class RadrootsAppleMediaAssetWriter: @unchecked Sendable { private func imageDimensions(fileURL: URL) -> (width: UInt32, height: UInt32)? { #if canImport(ImageIO) - guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), - let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], - let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, - let height = properties[kCGImagePropertyPixelHeight] as? NSNumber, - width.uint32Value > 0, - height.uint32Value > 0 else { - return nil - } - return (width.uint32Value, height.uint32Value) + guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let height = properties[kCGImagePropertyPixelHeight] as? NSNumber, + width.uint32Value > 0, + height.uint32Value > 0 + else { + return nil + } + return (width.uint32Value, height.uint32Value) #else - return nil + return nil #endif } @@ -698,11 +703,11 @@ private final class RadrootsAppleCaptureAsyncCallbackState<Value: Sendable>: @un private var didResolve: Bool init() { - self.lock = NSLock() - self.continuation = nil - self.cleanup = nil - self.resolvedResult = nil - self.didResolve = false + lock = NSLock() + continuation = nil + cleanup = nil + resolvedResult = nil + didResolve = false } func start(continuation: CheckedContinuation<Value, any Error>) { @@ -737,12 +742,12 @@ private final class RadrootsAppleCaptureAsyncCallbackState<Value: Sendable>: @un return } didResolve = true - let continuation = self.continuation + let continuation = continuation self.continuation = nil if continuation == nil { - self.resolvedResult = result + resolvedResult = result } - let cleanup = self.cleanup + let cleanup = cleanup self.cleanup = nil lock.unlock() @@ -762,9 +767,9 @@ private final class RadrootsAppleCaptureAsyncCallbackState<Value: Sendable>: @un with result: Result<Value, RadrootsCaptureIntakeError> ) { switch result { - case .success(let value): + case let .success(value): continuation.resume(returning: value) - case .failure(let error): + case let .failure(error): continuation.resume(throwing: error) } } diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPreparation.swift b/Sources/RadrootsKit/RadrootsAppleMediaPreparation.swift @@ -12,7 +12,7 @@ public enum RadrootsAppleMediaPreparationError: Error, Equatable, Sendable { extension RadrootsAppleMediaPreparationError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message), .unavailable(let message), .preparationFailure(let message): message + case let .invalidRequest(message), let .unavailable(message), let .preparationFailure(message): message } } } @@ -25,11 +25,11 @@ public struct RadrootsAppleImagePreparationRequest: Sendable, Equatable, Hashabl public let maximumDimension: Int public init( - source: RadrootsBackgroundTransferLocalFile, maximumInputBytes: Int = 40 * 1_024 * 1_024, - maximumOutputBytes: Int = 10 * 1_024 * 1_024, maximumPixelCount: Int = 40_000_000, maximumDimension: Int = 4_096 + source: RadrootsBackgroundTransferLocalFile, maximumInputBytes: Int = 40 * 1024 * 1024, + maximumOutputBytes: Int = 10 * 1024 * 1024, maximumPixelCount: Int = 40_000_000, maximumDimension: Int = 4096 ) throws { - guard (1...(40 * 1_024 * 1_024)).contains(maximumInputBytes), (1...(10 * 1_024 * 1_024)).contains(maximumOutputBytes), - (1...40_000_000).contains(maximumPixelCount), (1...8_192).contains(maximumDimension) + guard (1 ... (40 * 1024 * 1024)).contains(maximumInputBytes), (1 ... (10 * 1024 * 1024)).contains(maximumOutputBytes), + (1 ... 40_000_000).contains(maximumPixelCount), (1 ... 8192).contains(maximumDimension) else { throw RadrootsAppleMediaPreparationError.invalidRequest("image preparation limits are invalid") } do { try RadrootsBackgroundTransferValidation.validateLocalFile(source) } catch { throw RadrootsAppleMediaPreparationError.invalidRequest("image source handle is invalid") @@ -50,7 +50,7 @@ public struct RadrootsApplePreparedImage: Sendable, Equatable, Hashable, CustomD public init(file: RadrootsStagedBlobReference, sha256: String, width: UInt32, height: UInt32) throws { guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, width > 0, height > 0, file.sizeBytes > 0, - file.mediaType == "image/png" + file.mediaType == "image/png" else { throw RadrootsAppleMediaPreparationError.invalidRequest("prepared image commitment is invalid") } self.file = file self.sha256 = sha256 @@ -73,7 +73,7 @@ public actor RadrootsAppleMediaPreparer { roots: RadrootsAppleFileRoots, fileManager: FileManager = .default, protectedData: RadrootsProtectedDataProvider = .available ) { self.roots = roots - self.resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) self.fileManager = fileManager self.protectedData = protectedData } @@ -90,10 +90,10 @@ public actor RadrootsAppleMediaPreparer { let sourceURL = try resolver.resolve(request.source) let sourceValues = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) guard sourceValues.isRegularFile == true, sourceValues.isSymbolicLink != true, let inputBytes = sourceValues.fileSize, - inputBytes > 0, inputBytes <= request.maximumInputBytes + inputBytes > 0, inputBytes <= request.maximumInputBytes else { throw RadrootsAppleMediaPreparationError.invalidRequest("image source is unavailable or exceeds its byte limit") } guard let source = CGImageSourceCreateWithURL(sourceURL as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary), - CGImageSourceGetCount(source) == 1 + CGImageSourceGetCount(source) == 1 else { throw RadrootsAppleMediaPreparationError.invalidRequest("image source must contain exactly one decodable image") } let dimensions = try Self.sourceDimensions(source) guard dimensions.pixelCount <= request.maximumPixelCount else { @@ -101,7 +101,7 @@ public actor RadrootsAppleMediaPreparer { } let thumbnailOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: request.maximumDimension + kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: request.maximumDimension, ] guard let normalizedImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else { throw RadrootsAppleMediaPreparationError.preparationFailure("image normalization failed") @@ -111,11 +111,16 @@ public actor RadrootsAppleMediaPreparer { let temporaryURL = roots.temporaryRoot.appendingPathComponent("media_preparation", isDirectory: true).appendingPathComponent( "\(UUID().uuidString.lowercased()).png" ).standardizedFileURL - defer { if fileManager.fileExists(atPath: temporaryURL.path) { try? fileManager.removeItem(at: temporaryURL) } } + defer { + if fileManager.fileExists(atPath: temporaryURL.path) { + try? fileManager.removeItem(at: temporaryURL) + } + } try fileManager.createDirectory(at: temporaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) #if os(iOS) try fileManager.setAttributes( - [.protectionKey: FileProtectionType.complete], ofItemAtPath: temporaryURL.deletingLastPathComponent().path) + [.protectionKey: FileProtectionType.complete], ofItemAtPath: temporaryURL.deletingLastPathComponent().path + ) #endif guard let destination = CGImageDestinationCreateWithURL(temporaryURL as CFURL, UTType.png.identifier as CFString, 1, nil) else { throw RadrootsAppleMediaPreparationError.preparationFailure("image destination could not be created") @@ -131,7 +136,8 @@ public actor RadrootsAppleMediaPreparer { } let digest = try RadrootsAppleFileDigest.sha256(at: temporaryURL) let staged = try RadrootsStagedBlobReference( - blobID: digest, sizeBytes: outputSize, mediaType: "image/png", filenameHint: "\(digest).png") + blobID: digest, sizeBytes: outputSize, mediaType: "image/png", filenameHint: "\(digest).png" + ) let stagedURL = try roots.stagedBlobURL(for: staged) try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) if fileManager.fileExists(atPath: stagedURL.path) { @@ -146,10 +152,12 @@ public actor RadrootsAppleMediaPreparer { } #if os(iOS) try fileManager.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: stagedURL.path) + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: stagedURL.path + ) #endif return try RadrootsApplePreparedImage( - file: staged, sha256: digest, width: UInt32(normalizedImage.width), height: UInt32(normalizedImage.height)) + file: staged, sha256: digest, width: UInt32(normalizedImage.width), height: UInt32(normalizedImage.height) + ) } public func blossomUploadRequest( @@ -160,13 +168,14 @@ public actor RadrootsAppleMediaPreparer { do { let fileURL = try roots.stagedBlobURL(for: preparedImage.file) guard try Self.fileSize(at: fileURL) == preparedImage.file.sizeBytes, - try RadrootsAppleFileDigest.sha256(at: fileURL) == preparedImage.sha256 + try RadrootsAppleFileDigest.sha256(at: fileURL) == preparedImage.sha256 else { throw RadrootsAppleMediaPreparationError.invalidRequest("prepared image no longer matches its commitment") } return try RadrootsBackgroundTransferRequest( identifier: identifier, remoteURL: remoteURL, method: .put, operation: .upload(source: .stagedBlob(preparedImage.file)), headers: ["Authorization": authorization, "Content-Type": "image/png"], metadata: ["purpose": "blossom_upload", "sha256": preparedImage.sha256], networkPolicy: networkPolicy, - responsePolicy: .boundedJSON(), expectedSourceSHA256: preparedImage.sha256) + responsePolicy: .boundedJSON(), expectedSourceSHA256: preparedImage.sha256 + ) } catch let error as RadrootsAppleMediaPreparationError { throw error } catch let error as RadrootsBackgroundTransferError { throw error } catch { throw RadrootsAppleMediaPreparationError.preparationFailure("prepared image commitment could not be verified") } @@ -180,8 +189,8 @@ public actor RadrootsAppleMediaPreparer { private static func sourceDimensions(_ source: CGImageSource) throws -> (width: Int, height: Int, pixelCount: Int) { guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], - let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, - let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, width > 0, height > 0, width <= Int.max / height + let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, + let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, width > 0, height > 0, width <= Int.max / height else { throw RadrootsAppleMediaPreparationError.invalidRequest("image dimensions are invalid") } return (width, height, width * height) } @@ -192,7 +201,6 @@ public actor RadrootsAppleMediaPreparer { } return size } - } enum RadrootsAppleFileDigest { @@ -201,8 +209,10 @@ enum RadrootsAppleFileDigest { defer { try? handle.close() } var hasher = CryptoKit.SHA256() while true { - let chunk = try handle.read(upToCount: 64 * 1_024) ?? Data() - if chunk.isEmpty { break } + let chunk = try handle.read(upToCount: 64 * 1024) ?? Data() + if chunk.isEmpty { + break + } hasher.update(data: chunk) } return hasher.finalize().map { String(format: "%02x", $0) }.joined() diff --git a/Sources/RadrootsKit/RadrootsApplePermissionStatus.swift b/Sources/RadrootsKit/RadrootsApplePermissionStatus.swift @@ -1,19 +1,19 @@ import Foundation #if canImport(AVFoundation) -import AVFoundation + import AVFoundation #endif #if canImport(CoreLocation) -import CoreLocation + import CoreLocation #endif #if canImport(Photos) -import Photos + import Photos #endif #if canImport(UserNotifications) -import UserNotifications + import UserNotifications #endif public struct RadrootsApplePermissionStatusAdapters: Sendable { @@ -62,125 +62,125 @@ public struct RadrootsApplePermissionStatusAdapters: Sendable { public static func currentNotificationStatus() async -> RadrootsPermissionStatus { #if canImport(UserNotifications) - return await withCheckedContinuation { continuation in - UNUserNotificationCenter.current().getNotificationSettings { settings in - continuation.resume(returning: Self.permissionStatus(for: settings.authorizationStatus)) + return await withCheckedContinuation { continuation in + UNUserNotificationCenter.current().getNotificationSettings { settings in + continuation.resume(returning: Self.permissionStatus(for: settings.authorizationStatus)) + } } - } #else - return .unsupported + return .unsupported #endif } public static func currentCameraStatus() -> RadrootsPermissionStatus { #if canImport(AVFoundation) - return permissionStatus(for: AVCaptureDevice.authorizationStatus(for: .video)) + return permissionStatus(for: AVCaptureDevice.authorizationStatus(for: .video)) #else - return .unsupported + return .unsupported #endif } public static func currentPhotosStatus() -> RadrootsPermissionStatus { #if canImport(Photos) - return permissionStatus(for: PHPhotoLibrary.authorizationStatus(for: .readWrite)) + return permissionStatus(for: PHPhotoLibrary.authorizationStatus(for: .readWrite)) #else - return .unsupported + return .unsupported #endif } public static func currentMicrophoneStatus() -> RadrootsPermissionStatus { #if canImport(AVFoundation) - return permissionStatus(for: AVCaptureDevice.authorizationStatus(for: .audio)) + return permissionStatus(for: AVCaptureDevice.authorizationStatus(for: .audio)) #else - return .unsupported + return .unsupported #endif } public static func currentLocationStatus() -> RadrootsPermissionStatus { #if canImport(CoreLocation) - guard CLLocationManager.locationServicesEnabled() else { - return .unavailable - } - return permissionStatus(for: CLLocationManager().authorizationStatus) + guard CLLocationManager.locationServicesEnabled() else { + return .unavailable + } + return permissionStatus(for: CLLocationManager().authorizationStatus) #else - return .unsupported + return .unsupported #endif } #if canImport(UserNotifications) - public static func permissionStatus(for authorizationStatus: UNAuthorizationStatus) -> RadrootsPermissionStatus { - switch authorizationStatus { - case .notDetermined: - .notDetermined - case .denied: - .denied - case .authorized: - .authorized - case .provisional: - .limited - case .ephemeral: - .limited - @unknown default: - .unavailable + public static func permissionStatus(for authorizationStatus: UNAuthorizationStatus) -> RadrootsPermissionStatus { + switch authorizationStatus { + case .notDetermined: + .notDetermined + case .denied: + .denied + case .authorized: + .authorized + case .provisional: + .limited + case .ephemeral: + .limited + @unknown default: + .unavailable + } } - } #endif #if canImport(AVFoundation) - public static func permissionStatus(for authorizationStatus: AVAuthorizationStatus) -> RadrootsPermissionStatus { - switch authorizationStatus { - case .notDetermined: - .notDetermined - case .restricted: - .restricted - case .denied: - .denied - case .authorized: - .authorized - @unknown default: - .unavailable + public static func permissionStatus(for authorizationStatus: AVAuthorizationStatus) -> RadrootsPermissionStatus { + switch authorizationStatus { + case .notDetermined: + .notDetermined + case .restricted: + .restricted + case .denied: + .denied + case .authorized: + .authorized + @unknown default: + .unavailable + } } - } #endif #if canImport(Photos) - public static func permissionStatus(for authorizationStatus: PHAuthorizationStatus) -> RadrootsPermissionStatus { - switch authorizationStatus { - case .notDetermined: - .notDetermined - case .restricted: - .restricted - case .denied: - .denied - case .authorized: - .authorized - case .limited: - .limited - @unknown default: - .unavailable + public static func permissionStatus(for authorizationStatus: PHAuthorizationStatus) -> RadrootsPermissionStatus { + switch authorizationStatus { + case .notDetermined: + .notDetermined + case .restricted: + .restricted + case .denied: + .denied + case .authorized: + .authorized + case .limited: + .limited + @unknown default: + .unavailable + } } - } #endif #if canImport(CoreLocation) - public static func permissionStatus(for authorizationStatus: CLAuthorizationStatus) -> RadrootsPermissionStatus { - switch authorizationStatus { - case .notDetermined: - .notDetermined - case .restricted: - .restricted - case .denied: - .denied - case .authorizedAlways: - .authorized - #if os(iOS) - case .authorizedWhenInUse: - .authorized - #endif - @unknown default: - .unavailable + public static func permissionStatus(for authorizationStatus: CLAuthorizationStatus) -> RadrootsPermissionStatus { + switch authorizationStatus { + case .notDetermined: + .notDetermined + case .restricted: + .restricted + case .denied: + .denied + case .authorizedAlways: + .authorized + #if os(iOS) + case .authorizedWhenInUse: + .authorized + #endif + @unknown default: + .unavailable + } } - } #endif } diff --git a/Sources/RadrootsKit/RadrootsAppleSecurityError.swift b/Sources/RadrootsKit/RadrootsAppleSecurityError.swift @@ -14,21 +14,21 @@ public enum RadrootsAppleSecurityError: Error, Equatable, Sendable { extension RadrootsAppleSecurityError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message - case .notFound(let message): + case let .notFound(message): message - case .permissionDenied(let message): + case let .permissionDenied(message): message - case .userCancelled(let message): + case let .userCancelled(message): message - case .transientFailure(let message): + case let .transientFailure(message): message - case .unavailable(let message): + case let .unavailable(message): message - case .permanentFailure(let message): + case let .permanentFailure(message): message - case .keychainStatus(_, let message): + case let .keychainStatus(_, message): message } } diff --git a/Sources/RadrootsKit/RadrootsAppleUserPresence.swift b/Sources/RadrootsKit/RadrootsAppleUserPresence.swift @@ -1,7 +1,7 @@ import Foundation #if canImport(LocalAuthentication) -@preconcurrency import LocalAuthentication + @preconcurrency import LocalAuthentication #endif public struct RadrootsAppleUserPresenceAdapters: Sendable { @@ -18,28 +18,28 @@ public struct RadrootsAppleUserPresenceAdapters: Sendable { public static func live(callbackTimeout: TimeInterval = 30) -> Self { #if canImport(LocalAuthentication) - Self( - currentStatus: { - Self.status(for: LAContext()) - }, - verify: { request in - let context = LAContext() - return try await Self.verify( - request, - context: context, - callbackTimeout: callbackTimeout - ) - } - ) + Self( + currentStatus: { + Self.status(for: LAContext()) + }, + verify: { request in + let context = LAContext() + return try await Self.verify( + request, + context: context, + callbackTimeout: callbackTimeout + ) + } + ) #else - Self( - currentStatus: { - throw RadrootsUserPresenceError.unavailable("user presence is unavailable") - }, - verify: { _ in - throw RadrootsUserPresenceError.unavailable("user presence is unavailable") - } - ) + Self( + currentStatus: { + throw RadrootsUserPresenceError.unavailable("user presence is unavailable") + }, + verify: { _ in + throw RadrootsUserPresenceError.unavailable("user presence is unavailable") + } + ) #endif } } @@ -61,106 +61,105 @@ public final class RadrootsAppleUserPresence: RadrootsUserPresence, Sendable { } #if canImport(LocalAuthentication) -extension RadrootsAppleUserPresenceAdapters { - static func platformPolicy(_ policy: RadrootsUserPresencePolicy) -> LAPolicy { - switch policy { - case .deviceOwnerAuthentication: - .deviceOwnerAuthentication - case .deviceOwnerAuthenticationWithBiometrics: - .deviceOwnerAuthenticationWithBiometrics + extension RadrootsAppleUserPresenceAdapters { + static func platformPolicy(_ policy: RadrootsUserPresencePolicy) -> LAPolicy { + switch policy { + case .deviceOwnerAuthentication: + .deviceOwnerAuthentication + case .deviceOwnerAuthenticationWithBiometrics: + .deviceOwnerAuthenticationWithBiometrics + } } - } - static func status(for context: LAContext) -> RadrootsUserPresenceStatus { - var biometricsError: NSError? - let canEvaluateBiometrics = context.canEvaluatePolicy( - .deviceOwnerAuthenticationWithBiometrics, - error: &biometricsError - ) - - var deviceCredentialError: NSError? - let canEvaluateDeviceCredential = context.canEvaluatePolicy( - .deviceOwnerAuthentication, - error: &deviceCredentialError - ) - - let support: RadrootsUserPresenceSupport - if canEvaluateBiometrics { - support = .biometricsOrDeviceCredential - } else if canEvaluateDeviceCredential { - support = .deviceCredential - } else { - support = .none - } + static func status(for context: LAContext) -> RadrootsUserPresenceStatus { + var biometricsError: NSError? + let canEvaluateBiometrics = context.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &biometricsError + ) + + var deviceCredentialError: NSError? + let canEvaluateDeviceCredential = context.canEvaluatePolicy( + .deviceOwnerAuthentication, + error: &deviceCredentialError + ) + + let support: RadrootsUserPresenceSupport = if canEvaluateBiometrics { + .biometricsOrDeviceCredential + } else if canEvaluateDeviceCredential { + .deviceCredential + } else { + .none + } - return RadrootsUserPresenceStatus( - support: support, - biometryKind: biometryKind(context.biometryType), - canEvaluateDeviceCredential: canEvaluateDeviceCredential, - canEvaluateBiometrics: canEvaluateBiometrics - ) - } + return RadrootsUserPresenceStatus( + support: support, + biometryKind: biometryKind(context.biometryType), + canEvaluateDeviceCredential: canEvaluateDeviceCredential, + canEvaluateBiometrics: canEvaluateBiometrics + ) + } - static func biometryKind(_ biometryType: LABiometryType) -> RadrootsBiometryKind { - switch biometryType { - case .none: - .none - case .touchID: - .touchID - case .faceID: - .faceID - case .opticID: - .opticID - @unknown default: - .unknown + static func biometryKind(_ biometryType: LABiometryType) -> RadrootsBiometryKind { + switch biometryType { + case .none: + .none + case .touchID: + .touchID + case .faceID: + .faceID + case .opticID: + .opticID + @unknown default: + .unknown + } } - } - static func verify( - _ request: RadrootsUserPresenceRequest, - context: LAContext, - callbackTimeout: TimeInterval - ) async throws -> RadrootsUserPresenceResult { - try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback( - timeout: callbackTimeout, - timeoutMessage: "timed out while completing user presence verification" - ) { completion in - context.evaluatePolicy( - platformPolicy(request.policy), - localizedReason: request.reason - ) { success, error in - if let error { - completion(.failure(adapt(error: error))) - } else { - completion(.success(RadrootsUserPresenceResult(policy: request.policy, verified: success))) + static func verify( + _ request: RadrootsUserPresenceRequest, + context: LAContext, + callbackTimeout: TimeInterval + ) async throws -> RadrootsUserPresenceResult { + try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback( + timeout: callbackTimeout, + timeoutMessage: "timed out while completing user presence verification" + ) { completion in + context.evaluatePolicy( + platformPolicy(request.policy), + localizedReason: request.reason + ) { success, error in + if let error { + completion(.failure(adapt(error: error))) + } else { + completion(.success(RadrootsUserPresenceResult(policy: request.policy, verified: success))) + } } } } - } - static func adapt(error: Error) -> RadrootsUserPresenceError { - if let error = error as? RadrootsUserPresenceError { - return error - } + static func adapt(error: Error) -> RadrootsUserPresenceError { + if let error = error as? RadrootsUserPresenceError { + return error + } - if let error = error as? LAError { - switch error.code { - case .userCancel, .userFallback: - return .userCancelled(error.localizedDescription) - case .appCancel, .systemCancel, .notInteractive: - return .transientFailure(error.localizedDescription) - case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet: - return .unavailable(error.localizedDescription) - case .authenticationFailed: - return .permissionDenied(error.localizedDescription) - default: - return .permanentFailure(error.localizedDescription) + if let error = error as? LAError { + switch error.code { + case .userCancel, .userFallback: + return .userCancelled(error.localizedDescription) + case .appCancel, .systemCancel, .notInteractive: + return .transientFailure(error.localizedDescription) + case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet: + return .unavailable(error.localizedDescription) + case .authenticationFailed: + return .permissionDenied(error.localizedDescription) + default: + return .permanentFailure(error.localizedDescription) + } } - } - return .permanentFailure(error.localizedDescription) + return .permanentFailure(error.localizedDescription) + } } -} #endif enum RadrootsAppleUserPresenceAsyncSupport { @@ -226,9 +225,9 @@ private final class RadrootsAppleUserPresenceAsyncCallbackState<Value: Sendable> lock.unlock() switch result { - case .success(let value): + case let .success(value): pending?.resume(returning: value) - case .failure(let error): + case let .failure(error): pending?.resume(throwing: error) } } diff --git a/Sources/RadrootsKit/RadrootsBackgroundTasks.swift b/Sources/RadrootsKit/RadrootsBackgroundTasks.swift @@ -14,11 +14,11 @@ public enum RadrootsBackgroundTaskError: Error, Equatable, Sendable { extension RadrootsBackgroundTaskError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message - case .unavailable(let message): + case let .unavailable(message): message - case .schedulerFailure(let message): + case let .schedulerFailure(message): message } } @@ -28,7 +28,7 @@ public struct RadrootsBackgroundTaskIdentifier: Sendable, Equatable, Hashable, C public let rawValue: String public init(_ value: String) throws { - self.rawValue = try RadrootsBackgroundTaskValidation.normalizedIdentifier(value) + rawValue = try RadrootsBackgroundTaskValidation.normalizedIdentifier(value) } public static func < (lhs: Self, rhs: Self) -> Bool { @@ -140,11 +140,11 @@ public struct RadrootsUnavailableBackgroundTaskScheduler: RadrootsBackgroundTask self.reason = trimmedReason.isEmpty ? "background task scheduling is unavailable on this platform" : trimmedReason } - public func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot { + public func submit(_: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot { throw RadrootsBackgroundTaskError.unavailable(reason) } - public func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws { + public func cancel(_: RadrootsBackgroundTaskIdentifier) async throws { throw RadrootsBackgroundTaskError.unavailable(reason) } diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift @@ -10,10 +10,10 @@ public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable { extension RadrootsBackgroundTransferError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): message - case .unavailable(let message): message - case .transferFailure(let message): message - case .persistenceFailure(let message): message + case let .invalidRequest(message): message + case let .unavailable(message): message + case let .transferFailure(message): message + case let .persistenceFailure(message): message } } } @@ -21,13 +21,21 @@ extension RadrootsBackgroundTransferError: LocalizedError { public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, Codable { public let rawValue: String - public init(_ value: String) throws { self.rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value) } + public init(_ value: String) throws { + rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value) + } - public static func generated() -> Self { Self(validatedRawValue: UUID().uuidString.lowercased()) } + public static func generated() -> Self { + Self(validatedRawValue: UUID().uuidString.lowercased()) + } - public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } - private init(validatedRawValue: String) { self.rawValue = validatedRawValue } + private init(validatedRawValue: String) { + rawValue = validatedRawValue + } private enum CodingKeys: String, CodingKey { case rawValue } @@ -77,7 +85,7 @@ public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Has public let acceptedMediaTypes: [String] public init(maximumBodyBytes: Int = 0, acceptedMediaTypes: [String] = []) throws { - guard (0...65_536).contains(maximumBodyBytes), acceptedMediaTypes.count <= 8 else { + guard (0 ... 65536).contains(maximumBodyBytes), acceptedMediaTypes.count <= 8 else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer response policy is invalid") } let normalized = try acceptedMediaTypes.map { try RadrootsBackgroundTransferValidation.normalizedMediaType($0) } @@ -90,11 +98,11 @@ public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Has public static let discard = Self(maximumBodyBytes: 0, acceptedMediaTypes: [], validated: ()) - public static func boundedJSON(maximumBodyBytes: Int = 16_384) throws -> Self { + public static func boundedJSON(maximumBodyBytes: Int = 16384) throws -> Self { try Self(maximumBodyBytes: maximumBodyBytes, acceptedMediaTypes: ["application/json"]) } - private init(maximumBodyBytes: Int, acceptedMediaTypes: [String], validated: Void) { + private init(maximumBodyBytes: Int, acceptedMediaTypes: [String], validated _: Void) { self.maximumBodyBytes = maximumBodyBytes self.acceptedMediaTypes = acceptedMediaTypes } @@ -108,7 +116,8 @@ public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Has let values = try decoder.container(keyedBy: CodingKeys.self) try self.init( maximumBodyBytes: values.decode(Int.self, forKey: .maximumBodyBytes), - acceptedMediaTypes: values.decode([String].self, forKey: .acceptedMediaTypes)) + acceptedMediaTypes: values.decode([String].self, forKey: .acceptedMediaTypes) + ) } public func encode(to encoder: any Encoder) throws { @@ -124,7 +133,7 @@ public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, public let body: Data? public init(statusCode: Int, mediaType: String?, body: Data?) throws { - guard (100...599).contains(statusCode), body?.count ?? 0 <= 65_536 else { + guard (100 ... 599).contains(statusCode), body?.count ?? 0 <= 65536 else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer response is invalid") } self.statusCode = statusCode @@ -142,7 +151,8 @@ public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, let values = try decoder.container(keyedBy: CodingKeys.self) try self.init( statusCode: values.decode(Int.self, forKey: .statusCode), mediaType: values.decodeIfPresent(String.self, forKey: .mediaType), - body: values.decodeIfPresent(Data.self, forKey: .body)) + body: values.decodeIfPresent(Data.self, forKey: .body) + ) } public func encode(to encoder: any Encoder) throws { @@ -172,7 +182,8 @@ public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, ) throws { try RadrootsBackgroundTransferValidation.validate( remoteURL: remoteURL, method: method, operation: operation, headers: headers, metadata: metadata, networkPolicy: networkPolicy, - responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256) + responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256 + ) self.identifier = identifier self.remoteURL = remoteURL self.method = method @@ -191,7 +202,8 @@ public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, func redactedForPersistence() throws -> Self { try Self( identifier: identifier, remoteURL: remoteURL, method: method, operation: operation, headers: [:], metadata: [:], - networkPolicy: networkPolicy, responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256) + networkPolicy: networkPolicy, responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256 + ) } private enum CodingKeys: String, CodingKey { @@ -213,9 +225,10 @@ public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, method: values.decode(RadrootsBackgroundTransferMethod.self, forKey: .method), operation: values.decode(RadrootsBackgroundTransferOperation.self, forKey: .operation), headers: [:], metadata: values.decode([String: String].self, forKey: .metadata), - networkPolicy: try values.decodeIfPresent(RadrootsBackgroundTransferNetworkPolicy.self, forKey: .networkPolicy) ?? .publicHTTPS, - responsePolicy: try values.decodeIfPresent(RadrootsBackgroundTransferResponsePolicy.self, forKey: .responsePolicy) ?? .discard, - expectedSourceSHA256: try values.decodeIfPresent(String.self, forKey: .expectedSourceSHA256)) + networkPolicy: values.decodeIfPresent(RadrootsBackgroundTransferNetworkPolicy.self, forKey: .networkPolicy) ?? .publicHTTPS, + responsePolicy: values.decodeIfPresent(RadrootsBackgroundTransferResponsePolicy.self, forKey: .responsePolicy) ?? .discard, + expectedSourceSHA256: values.decodeIfPresent(String.self, forKey: .expectedSourceSHA256) + ) } public func encode(to encoder: any Encoder) throws { @@ -236,7 +249,7 @@ public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, C public let request: RadrootsBackgroundTransferRequest public init(request: RadrootsBackgroundTransferRequest) { - self.identifier = request.identifier + identifier = request.identifier self.request = request } @@ -280,7 +293,8 @@ public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, } guard totalBytesExpected >= bytesTransferred else { throw RadrootsBackgroundTransferError.invalidRequest( - "background transfer expected byte count cannot be less than transferred bytes") + "background transfer expected byte count cannot be less than transferred bytes" + ) } } self.bytesTransferred = bytesTransferred @@ -290,7 +304,7 @@ public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, public static let zero = RadrootsBackgroundTransferProgress(validatedBytesTransferred: 0, totalBytesExpected: nil) private init(validatedBytesTransferred: Int64, totalBytesExpected: Int64?) { - self.bytesTransferred = validatedBytesTransferred + bytesTransferred = validatedBytesTransferred self.totalBytesExpected = totalBytesExpected } @@ -303,7 +317,8 @@ public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, let values = try decoder.container(keyedBy: CodingKeys.self) try self.init( bytesTransferred: values.decode(Int64.self, forKey: .bytesTransferred), - totalBytesExpected: values.decodeIfPresent(Int64.self, forKey: .totalBytesExpected)) + totalBytesExpected: values.decodeIfPresent(Int64.self, forKey: .totalBytesExpected) + ) } public func encode(to encoder: any Encoder) throws { @@ -334,13 +349,15 @@ public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, guard response == nil || state == .completed else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer response requires completed state") } - if let response { try RadrootsBackgroundTransferValidation.validate(response: response, policy: request.responsePolicy) } + if let response { + try RadrootsBackgroundTransferValidation.validate(response: response, policy: request.responsePolicy) + } if possibleRemoteOrphan { guard case .upload = request.operation, state == .failed || state == .interrupted else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer orphan marker is invalid") } } - self.identifier = request.identifier + identifier = request.identifier self.request = request self.state = state self.progress = progress @@ -359,7 +376,8 @@ public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, func redactedForPersistence() throws -> Self { try Self( request: request.redactedForPersistence(), state: state, progress: progress, errorMessage: errorMessage, response: response, - possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: updatedAt) + possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: updatedAt + ) } private enum CodingKeys: String, CodingKey { @@ -380,8 +398,9 @@ public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, progress: values.decode(RadrootsBackgroundTransferProgress.self, forKey: .progress), errorMessage: values.decodeIfPresent(String.self, forKey: .errorMessage), response: values.decodeIfPresent(RadrootsBackgroundTransferResponse.self, forKey: .response), - possibleRemoteOrphan: try values.decodeIfPresent(Bool.self, forKey: .possibleRemoteOrphan) ?? false, - updatedAt: values.decode(Date.self, forKey: .updatedAt)) + possibleRemoteOrphan: values.decodeIfPresent(Bool.self, forKey: .possibleRemoteOrphan) ?? false, + updatedAt: values.decode(Date.self, forKey: .updatedAt) + ) } public func encode(to encoder: any Encoder) throws { @@ -416,16 +435,18 @@ public protocol RadrootsBackgroundTransferFileResolver: Sendable { func resolve( public struct RadrootsAppleBackgroundTransferFileResolver: RadrootsBackgroundTransferFileResolver, Sendable { private let roots: RadrootsAppleFileRoots - public init(roots: RadrootsAppleFileRoots) { self.roots = roots } + public init(roots: RadrootsAppleFileRoots) { + self.roots = roots + } public func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL { let candidate: URL let root: URL switch file { - case .file(let reference): + case let .file(reference): candidate = try roots.resolvedURL(for: reference) root = roots.root(for: reference.scope) - case .stagedBlob(let blob): + case let .stagedBlob(blob): candidate = try roots.stagedBlobURL(for: blob) root = roots.stagedBlobsRoot } @@ -444,7 +465,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto let snapshots: [RadrootsBackgroundTransferSnapshot] init(snapshots: [RadrootsBackgroundTransferSnapshot]) { - self.schemaVersion = 1 + schemaVersion = 1 self.snapshots = snapshots } } @@ -460,10 +481,10 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto ) { self.roots = roots self.fileManager = fileManager - self.encoder = JSONEncoder() - self.decoder = JSONDecoder() + encoder = JSONEncoder() + decoder = JSONDecoder() self.protectedData = protectedData - self.encoder.outputFormatting = [.sortedKeys] + encoder.outputFormatting = [.sortedKeys] } public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { @@ -496,8 +517,12 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto usedLegacyEncoding = true } let snapshots = try decoded.map { try $0.redactedForPersistence() }.sorted { left, right in left.identifier < right.identifier } - if isLegacy || usedLegacyEncoding { try write(snapshots) } - if fileManager.fileExists(atPath: legacyURL.path) { try fileManager.removeItem(at: legacyURL) } + if isLegacy || usedLegacyEncoding { + try write(snapshots) + } + if fileManager.fileExists(atPath: legacyURL.path) { + try fileManager.removeItem(at: legacyURL) + } return snapshots } catch { throw RadrootsBackgroundTransferError.persistenceFailure("background transfer persistence could not be read") } } @@ -507,7 +532,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto do { var snapshots = try await loadSnapshots() snapshots.removeAll { $0.identifier == snapshot.identifier } - snapshots.append(try snapshot.redactedForPersistence()) + try snapshots.append(snapshot.redactedForPersistence()) try write(snapshots.sorted { left, right in left.identifier < right.identifier }) } catch let error as RadrootsBackgroundTransferError { throw error } catch { throw RadrootsBackgroundTransferError.persistenceFailure("background transfer persistence could not be written") @@ -528,7 +553,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto public func removeAllSnapshots() async throws { try requireProtectedData() do { - for url in [try storeURL(), try legacyStoreURL()] where fileManager.fileExists(atPath: url.path) { + for url in try [storeURL(), legacyStoreURL()] where fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) } } catch let error as RadrootsBackgroundTransferError { throw error } catch { @@ -569,15 +594,15 @@ public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer, self.reason = trimmedReason.isEmpty ? "background transfer is unavailable on this platform" : trimmedReason } - public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { + public func enqueue(_: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { throw RadrootsBackgroundTransferError.unavailable(reason) } - public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + public func cancel(_: RadrootsBackgroundTransferIdentifier) async throws { throw RadrootsBackgroundTransferError.unavailable(reason) } - public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { + public func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { throw RadrootsBackgroundTransferError.unavailable(reason) } @@ -585,7 +610,7 @@ public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer, throw RadrootsBackgroundTransferError.unavailable(reason) } - public func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async { + public func handleEventsForBackgroundURLSession(identifier _: String, completionHandler: @escaping @Sendable () -> Void) async { completionHandler() } } @@ -601,7 +626,8 @@ public enum RadrootsBackgroundTransferValidation { } guard trimmed.range(of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", options: .regularExpression) != nil else { throw RadrootsBackgroundTransferError.invalidRequest( - "background transfer identifier must use lowercase safe identifier characters") + "background transfer identifier must use lowercase safe identifier characters" + ) } guard !trimmed.contains("..") else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components") @@ -641,8 +667,8 @@ public enum RadrootsBackgroundTransferValidation { private static func validate(remoteURL: URL, networkPolicy: RadrootsBackgroundTransferNetworkPolicy) throws { guard let components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false), - components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, components.user == nil, - components.password == nil, components.query == nil, components.fragment == nil + components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, components.user == nil, + components.password == nil, components.query == nil, components.fragment == nil else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL is unsafe") } let scheme = components.scheme?.lowercased() switch networkPolicy { @@ -655,7 +681,7 @@ public enum RadrootsBackgroundTransferValidation { throw RadrootsBackgroundTransferError.invalidRequest("background transfer simulator policy is unavailable on this device") #else guard scheme == "http", let host = components.host?.lowercased(), - host == "localhost" || host == "127.0.0.1" || host == "::1" + host == "localhost" || host == "127.0.0.1" || host == "::1" else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer simulator URL must use loopback HTTP") } #endif } @@ -663,10 +689,10 @@ public enum RadrootsBackgroundTransferValidation { private static func validate(method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation) throws { switch operation { - case .download(let destination): + case let .download(destination): guard method == .get else { throw RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET") } try validateLocalFile(destination) - case .upload(let source): + case let .upload(source): guard method == .post || method == .put else { throw RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT") } @@ -676,15 +702,16 @@ public enum RadrootsBackgroundTransferValidation { static func validateLocalFile(_ localFile: RadrootsBackgroundTransferLocalFile) throws { switch localFile { - case .file(let reference): + case let .file(reference): let path = reference.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) guard !path.isEmpty, !NSString(string: path).isAbsolutePath, - !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." }) + !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." }) else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer local file is unsafe") } - case .stagedBlob(let blob): + case let .stagedBlob(blob): guard (try? RadrootsStagedBlobReference( - blobID: blob.blobID, sizeBytes: blob.sizeBytes, mediaType: blob.mediaType, filenameHint: blob.filenameHint)) != nil + blobID: blob.blobID, sizeBytes: blob.sizeBytes, mediaType: blob.mediaType, filenameHint: blob.filenameHint + )) != nil else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer staged blob is invalid") } } } @@ -696,9 +723,9 @@ public enum RadrootsBackgroundTransferValidation { for (key, value) in headers { try validateSafeText(key, field: "background transfer header name", maximumLength: 80) guard key.range(of: "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$", options: .regularExpression) != nil, - !["connection", "content-length", "host", "transfer-encoding"].contains(key.lowercased()) + !["connection", "content-length", "host", "transfer-encoding"].contains(key.lowercased()) else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer header name is unsafe") } - try validateSafeText(value, field: "background transfer header value", maximumLength: 8_192) + try validateSafeText(value, field: "background transfer header value", maximumLength: 8192) } } @@ -718,13 +745,13 @@ public enum RadrootsBackgroundTransferValidation { static func normalizedMediaType(_ value: String) throws -> String { let normalized = value.split(separator: ";", maxSplits: 1).first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" guard normalized.range(of: "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", options: .regularExpression) != nil, - normalized.utf8.count <= 127 + normalized.utf8.count <= 127 else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer media type is invalid") } return normalized } static func validate(response: RadrootsBackgroundTransferResponse, policy: RadrootsBackgroundTransferResponsePolicy) throws { - guard (200...299).contains(response.statusCode) else { + guard (200 ... 299).contains(response.statusCode) else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer response status is invalid") } if policy.maximumBodyBytes == 0 { @@ -734,7 +761,7 @@ public enum RadrootsBackgroundTransferValidation { return } guard let body = response.body, body.count <= policy.maximumBodyBytes, let mediaType = response.mediaType, - policy.acceptedMediaTypes.contains(mediaType) + policy.acceptedMediaTypes.contains(mediaType) else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer response violates its policy") } } @@ -752,8 +779,8 @@ public enum RadrootsBackgroundTransferValidation { } } -extension RadrootsBackgroundTransferOperation { - fileprivate var redactedLabel: String { +private extension RadrootsBackgroundTransferOperation { + var redactedLabel: String { switch self { case .download: "download" case .upload: "upload" diff --git a/Sources/RadrootsKit/RadrootsCaptureIntake.swift b/Sources/RadrootsKit/RadrootsCaptureIntake.swift @@ -12,17 +12,17 @@ public enum RadrootsCaptureIntakeError: Error, Equatable, Sendable { extension RadrootsCaptureIntakeError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message - case .unavailable(let message): + case let .unavailable(message): message - case .permissionDenied(let message): + case let .permissionDenied(message): message - case .userCancelled(let message): + case let .userCancelled(message): message - case .transientFailure(let message): + case let .transientFailure(message): message - case .permanentFailure(let message): + case let .permanentFailure(message): message } } @@ -126,7 +126,7 @@ public struct RadrootsMediaAsset: Sendable, Equatable, Hashable { self.pixelWidth = try RadrootsCaptureIntakeValidation.normalizedDimension(pixelWidth, field: "pixel width") self.pixelHeight = try RadrootsCaptureIntakeValidation.normalizedDimension(pixelHeight, field: "pixel height") if self.pixelWidth == nil || self.pixelHeight == nil { - guard self.pixelWidth == nil && self.pixelHeight == nil else { + guard self.pixelWidth == nil, self.pixelHeight == nil else { throw RadrootsCaptureIntakeError.invalidRequest("image dimensions must include width and height together") } } @@ -278,13 +278,13 @@ public enum RadrootsCaptureIntakeValidation { guard !trimmed.isEmpty else { throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be empty") } - guard trimmed != "." && trimmed != ".." else { + guard trimmed != ".", trimmed != ".." else { throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be a path segment") } guard !NSString(string: trimmed).isAbsolutePath else { throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be absolute") } - guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { + guard !trimmed.contains("/"), !trimmed.contains("\\"), !trimmed.contains("\0") else { throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot contain path separators") } guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { diff --git a/Sources/RadrootsKit/RadrootsDocumentInterchange.swift b/Sources/RadrootsKit/RadrootsDocumentInterchange.swift @@ -20,17 +20,17 @@ public enum RadrootsDocumentInterchangeError: Error, Equatable, Sendable { extension RadrootsDocumentInterchangeError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message - case .notFound(let message): + case let .notFound(message): message - case .userCancelled(let message): + case let .userCancelled(message): message - case .permissionDenied(let message): + case let .permissionDenied(message): message - case .transientFailure(let message): + case let .transientFailure(message): message - case .permanentFailure(let message): + case let .permanentFailure(message): message } } @@ -120,11 +120,11 @@ public enum RadrootsShareItem: Sendable, Equatable, Hashable { case stagedBlob(RadrootsStagedBlobReference, suggestedFilename: String?) public static func validatedText(_ value: String) throws -> Self { - .text(try RadrootsDocumentInterchangeValidation.normalizedPublicText(value, field: "share text")) + try .text(RadrootsDocumentInterchangeValidation.normalizedPublicText(value, field: "share text")) } public static func validatedURL(_ value: URL) throws -> Self { - .url(try RadrootsDocumentInterchangeValidation.normalizedPublicURL(value)) + try .url(RadrootsDocumentInterchangeValidation.normalizedPublicURL(value)) } public static func validatedFile( @@ -134,10 +134,10 @@ public enum RadrootsShareItem: Sendable, Equatable, Hashable { sizeBytes: UInt64? = nil ) throws -> Self { let normalizedFile = try RadrootsDocumentInterchangeValidation.normalizedScopedFileReference(file) - return .file( + return try .file( normalizedFile, - suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename), - mediaType: try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType), + suggestedFilename: RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename), + mediaType: RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType), sizeBytes: sizeBytes ) } @@ -150,22 +150,22 @@ public enum RadrootsShareItem: Sendable, Equatable, Hashable { stagedBlob.filenameHint, field: "staged blob filename hint" ) - return .stagedBlob( + return try .stagedBlob( stagedBlob, - suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename) + suggestedFilename: RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename) ) } public var normalized: Self { get throws { switch self { - case .text(let text): + case let .text(text): try Self.validatedText(text) - case .url(let url): + case let .url(url): try Self.validatedURL(url) - case .file(let file, let suggestedFilename, let mediaType, let sizeBytes): + case let .file(file, suggestedFilename, mediaType, sizeBytes): try Self.validatedFile(file, suggestedFilename: suggestedFilename, mediaType: mediaType, sizeBytes: sizeBytes) - case .stagedBlob(let stagedBlob, let suggestedFilename): + case let .stagedBlob(stagedBlob, suggestedFilename): try Self.validatedStagedBlob(stagedBlob, suggestedFilename: suggestedFilename) } } @@ -223,7 +223,7 @@ public struct RadrootsExportDocumentRequest: Sendable, Equatable, Hashable { requestedSizeBytes: UInt64? ) throws -> UInt64? { switch source { - case .inlineData(let data): + case let .inlineData(data): let actualSize = UInt64(data.count) if let requestedSizeBytes, requestedSizeBytes != actualSize { throw RadrootsDocumentInterchangeError.invalidRequest("inline export byte count does not match data size") @@ -231,7 +231,7 @@ public struct RadrootsExportDocumentRequest: Sendable, Equatable, Hashable { return actualSize case .file: return requestedSizeBytes - case .stagedBlob(let stagedBlob): + case let .stagedBlob(stagedBlob): let actualSize = UInt64(stagedBlob.sizeBytes) if let requestedSizeBytes, requestedSizeBytes != actualSize { throw RadrootsDocumentInterchangeError.invalidRequest("staged blob export byte count does not match reference size") @@ -304,13 +304,13 @@ public enum RadrootsDocumentInterchangeValidation { guard !trimmed.isEmpty else { throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be empty") } - guard trimmed != "." && trimmed != ".." else { + guard trimmed != ".", trimmed != ".." else { throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be a path segment") } guard !NSString(string: trimmed).isAbsolutePath else { throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be absolute") } - guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { + guard !trimmed.contains("/"), !trimmed.contains("\\"), !trimmed.contains("\0") else { throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot contain path separators") } guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { @@ -383,7 +383,7 @@ public enum RadrootsDocumentInterchangeValidation { guard !NSString(string: trimmed).isAbsolutePath else { throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot be absolute") } - guard !trimmed.contains("\\") && !trimmed.contains("\0") else { + guard !trimmed.contains("\\"), !trimmed.contains("\0") else { throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot contain unsafe separators") } guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { @@ -412,7 +412,7 @@ public enum RadrootsDocumentInterchangeValidation { "private_key", "private key", "secret_key", - "secret key" + "secret key", ] guard !unsafeFragments.contains(where: normalized.contains) else { throw RadrootsDocumentInterchangeError.invalidRequest("\(field) cannot contain secret material") diff --git a/Sources/RadrootsKit/RadrootsDocumentPresentation.swift b/Sources/RadrootsKit/RadrootsDocumentPresentation.swift @@ -1,7 +1,7 @@ +import CoreTransferable import Foundation import SwiftUI import UniformTypeIdentifiers -import CoreTransferable public enum RadrootsDocumentPresentationAdapter { public static func contentTypes(for request: RadrootsDocumentImportRequest) -> [UTType] { @@ -63,18 +63,18 @@ public enum RadrootsDocumentPresentationAdapter { ) throws -> RadrootsShareTransferItem { for item in request.items { switch try item.normalized { - case .text(let text): + case let .text(text): return try RadrootsShareTransferItem(text: text, subject: request.subject) - case .url(let url): + case let .url(url): return try RadrootsShareTransferItem(url: url, subject: request.subject) - case .file(let file, let suggestedFilename, let mediaType, let sizeBytes): + case let .file(file, suggestedFilename, mediaType, sizeBytes): guard let fileAccess else { continue } let export = try fileAccess.prepareExport( RadrootsExportDocumentRequest( source: .file(file), - suggestedFilename: try shareFilename( + suggestedFilename: shareFilename( explicitFilename: suggestedFilename, fallbackFilename: NSString(string: file.relativePath).lastPathComponent ), @@ -83,14 +83,14 @@ public enum RadrootsDocumentPresentationAdapter { ) ) return try RadrootsShareTransferItem(preparedExport: export, subject: request.subject) - case .stagedBlob(let stagedBlob, let suggestedFilename): + case let .stagedBlob(stagedBlob, suggestedFilename): guard let fileAccess else { continue } let export = try fileAccess.prepareExport( RadrootsExportDocumentRequest( source: .stagedBlob(stagedBlob), - suggestedFilename: try shareFilename( + suggestedFilename: shareFilename( explicitFilename: suggestedFilename, fallbackFilename: stagedBlob.filenameHint ?? stagedBlob.blobID ), @@ -127,7 +127,7 @@ public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hash public let subject: String? public init(text: String, subject: String? = nil) throws { - self.payload = .text(try RadrootsDocumentInterchangeValidation.normalizedPublicText(text, field: "share transfer text")) + payload = try .text(RadrootsDocumentInterchangeValidation.normalizedPublicText(text, field: "share transfer text")) self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( subject, field: "share transfer subject" @@ -135,7 +135,7 @@ public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hash } public init(url: URL, subject: String? = nil) throws { - self.payload = .url(try RadrootsDocumentInterchangeValidation.normalizedPublicURL(url)) + payload = try .url(RadrootsDocumentInterchangeValidation.normalizedPublicURL(url)) self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( subject, field: "share transfer subject" @@ -143,7 +143,7 @@ public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hash } public init(preparedExport: RadrootsPreparedExportDocument, subject: String? = nil) throws { - self.payload = .file(preparedExport) + payload = .file(preparedExport) self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( subject, field: "share transfer subject" @@ -152,9 +152,9 @@ public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hash public var text: String? { switch payload { - case .text(let text): + case let .text(text): text - case .url(let url): + case let .url(url): url.absoluteString case .file: nil @@ -162,14 +162,14 @@ public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hash } public var url: URL? { - guard case .url(let url) = payload else { + guard case let .url(url) = payload else { return nil } return url } public var preparedExport: RadrootsPreparedExportDocument? { - guard case .file(let preparedExport) = payload else { + guard case let .file(preparedExport) = payload else { return nil } return preparedExport @@ -177,11 +177,11 @@ public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hash public var transferText: String { switch payload { - case .text(let text): + case let .text(text): text - case .url(let url): + case let .url(url): url.absoluteString - case .file(let preparedExport): + case let .file(preparedExport): preparedExport.suggestedFilename } } @@ -199,14 +199,14 @@ public struct RadrootsPreparedExportFileDocument: FileDocument { public let fileURL: URL public init(preparedExport: RadrootsPreparedExportDocument) { - self.fileURL = preparedExport.fileURL + fileURL = preparedExport.fileURL } - public init(configuration: ReadConfiguration) throws { + public init(configuration _: ReadConfiguration) throws { throw RadrootsDocumentInterchangeError.invalidRequest("prepared export documents are write only") } - public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { try FileWrapper(url: fileURL, options: []) } } @@ -221,7 +221,7 @@ public struct RadrootsDocumentImportPresentationModifier: ViewModifier { fileAccess: any RadrootsFileAccess, onCompletion: @escaping (Result<RadrootsDocumentImportResult, Error>) -> Void ) { - self._request = request + _request = request self.fileAccess = fileAccess self.onCompletion = onCompletion } @@ -266,7 +266,7 @@ public struct RadrootsDocumentImportPresentationModifier: ViewModifier { suggestedFilename: sourceURL.lastPathComponent ) } - onCompletion(.success(try RadrootsDocumentImportResult(documents: documents))) + try onCompletion(.success(RadrootsDocumentImportResult(documents: documents))) } catch { onCompletion(.failure(error)) } @@ -281,7 +281,7 @@ public struct RadrootsDocumentExportPresentationModifier: ViewModifier { preparedExport: Binding<RadrootsPreparedExportDocument?>, onCompletion: @escaping (Result<RadrootsExportDocumentResult, Error>) -> Void ) { - self._preparedExport = preparedExport + _preparedExport = preparedExport self.onCompletion = onCompletion } @@ -310,9 +310,9 @@ public struct RadrootsDocumentExportPresentationModifier: ViewModifier { preparedExport = nil do { let destinationURL = try result.get() - onCompletion( + try onCompletion( .success( - try RadrootsExportDocumentResult( + RadrootsExportDocumentResult( exportedFilename: destinationURL.lastPathComponent.isEmpty ? currentExport.suggestedFilename : destinationURL.lastPathComponent, @@ -335,7 +335,7 @@ public struct RadrootsSharePresentationLink<Label: View>: View { request: RadrootsShareRequest, @ViewBuilder label: @escaping () -> Label ) throws { - self.transferItem = try RadrootsDocumentPresentationAdapter.transferItem(for: request) + transferItem = try RadrootsDocumentPresentationAdapter.transferItem(for: request) self.label = label } @@ -344,31 +344,30 @@ public struct RadrootsSharePresentationLink<Label: View>: View { fileAccess: any RadrootsFileAccess, @ViewBuilder label: @escaping () -> Label ) throws { - self.transferItem = try RadrootsDocumentPresentationAdapter.transferItem( + transferItem = try RadrootsDocumentPresentationAdapter.transferItem( for: request, fileAccess: fileAccess ) self.label = label } - @ViewBuilder public var body: some View { switch transferItem.payload { - case .text(let text): + case let .text(text): ShareLink( item: text, subject: transferItem.subject.map(Text.init) ?? Text(""), message: Text(text), label: label ) - case .url(let url): + case let .url(url): ShareLink( item: url, subject: transferItem.subject.map(Text.init) ?? Text(""), message: Text(url.absoluteString), label: label ) - case .file(let preparedExport): + case let .file(preparedExport): ShareLink( item: preparedExport.fileURL, subject: transferItem.subject.map(Text.init) ?? Text(""), diff --git a/Sources/RadrootsKit/RadrootsExternalActions.swift b/Sources/RadrootsKit/RadrootsExternalActions.swift @@ -70,15 +70,15 @@ public enum RadrootsExternalActionError: Error, Equatable, Sendable { extension RadrootsExternalActionError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message - case .blockedByPolicy(let message): + case let .blockedByPolicy(message): message - case .unavailable(let message): + case let .unavailable(message): message - case .transientFailure(let message): + case let .transientFailure(message): message - case .permanentFailure(let message): + case let .permanentFailure(message): message } } @@ -98,7 +98,8 @@ public enum RadrootsExternalActionValidation { components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, components.user == nil, components.password == nil, - let url = components.url else { + let url = components.url + else { throw RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host") } return url @@ -139,7 +140,8 @@ public enum RadrootsExternalActionValidation { components.host?.lowercased() == "maps.apple.com", components.user == nil, components.password == nil, - let url = components.url else { + let url = components.url + else { throw RadrootsExternalActionError.blockedByPolicy("apple maps urls must use https://maps.apple.com") } return url @@ -157,7 +159,7 @@ public enum RadrootsExternalActionValidation { URLQueryItem( name: "ll", value: "\(coordinate.latitude),\(coordinate.longitude)" - ) + ), ] if let label { let normalizedLabel = try normalizedOptionalLabel(label) diff --git a/Sources/RadrootsKit/RadrootsFileAccess.swift b/Sources/RadrootsKit/RadrootsFileAccess.swift @@ -95,7 +95,7 @@ public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable, Codabl guard !trimmed.isEmpty else { throw RadrootsAppleFileError.invalidRequest("staged blob filename hint cannot be empty") } - guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { + guard !trimmed.contains("/"), !trimmed.contains("\\"), !trimmed.contains("\0") else { throw RadrootsAppleFileError.invalidRequest("staged blob filename hint cannot contain path separators") } return trimmed @@ -155,10 +155,10 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { let url = try roots.resolvedURL(for: file) try createParentDirectory(for: url) switch payload { - case .inline(let inlineData): + case let .inline(inlineData): try inlineData.write(to: url, options: [.atomic]) - case .stagedBlob(let stagedBlob): - try copyReplacingItem(from: try stagedBlobURL(for: stagedBlob), to: url) + case let .stagedBlob(stagedBlob): + try copyReplacingItem(from: stagedBlobURL(for: stagedBlob), to: url) } } @@ -169,14 +169,14 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { } switch mode { case .inline: - return .inline(try Data(contentsOf: url)) - case .preferInline(let maxBytes): + return try .inline(Data(contentsOf: url)) + case let .preferInline(maxBytes): guard maxBytes >= 0 else { throw RadrootsAppleFileError.invalidRequest("inline byte limit cannot be negative") } let size = try fileSize(at: url) if size <= maxBytes { - return .inline(try Data(contentsOf: url)) + return try .inline(Data(contentsOf: url)) } let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent) return .stagedBlob(staged) @@ -313,18 +313,17 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { let fileURL = directoryURL.appendingPathComponent(request.suggestedFilename).standardizedFileURL try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) switch request.source { - case .inlineData(let data): + case let .inlineData(data): try data.write(to: fileURL, options: [.atomic]) - case .file(let file): - try copyReplacingItem(from: try roots.resolvedURL(for: file), to: fileURL) - case .stagedBlob(let stagedBlob): - try copyReplacingItem(from: try stagedBlobURL(for: stagedBlob), to: fileURL) - } - let sizeBytes: UInt64 - if let requestSizeBytes = request.sizeBytes { - sizeBytes = requestSizeBytes + case let .file(file): + try copyReplacingItem(from: roots.resolvedURL(for: file), to: fileURL) + case let .stagedBlob(stagedBlob): + try copyReplacingItem(from: stagedBlobURL(for: stagedBlob), to: fileURL) + } + let sizeBytes: UInt64 = if let requestSizeBytes = request.sizeBytes { + requestSizeBytes } else { - sizeBytes = try fileSizeUInt64(at: fileURL) + try fileSizeUInt64(at: fileURL) } return try RadrootsPreparedExportDocument( preparedID: preparedID, @@ -497,7 +496,7 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { } private func fileSizeUInt64(at url: URL) throws -> UInt64 { - UInt64(try fileSizeInt(at: url)) + try UInt64(fileSizeInt(at: url)) } private func relativePath(for url: URL, under rootURL: URL) throws -> String { diff --git a/Sources/RadrootsKit/RadrootsIdentityCryptography.swift b/Sources/RadrootsKit/RadrootsIdentityCryptography.swift @@ -4,329 +4,330 @@ import P256K import Security public struct RadrootsIdentityPortabilityEnvelope: Sendable, CustomDebugStringConvertible { - private let serialized: Data + private let serialized: Data - public init(serializedRepresentation: Data) throws { - _ = try RadrootsIdentityPortabilityCodec.decodeWire(serializedRepresentation) - self.serialized = serializedRepresentation - } + public init(serializedRepresentation: Data) throws { + _ = try RadrootsIdentityPortabilityCodec.decodeWire(serializedRepresentation) + serialized = serializedRepresentation + } - public var serializedRepresentation: Data { - serialized - } + public var serializedRepresentation: Data { + serialized + } - public var version: UInt16 { - (try? RadrootsIdentityPortabilityCodec.decodeWire(serialized).version) ?? 0 - } + public var version: UInt16 { + (try? RadrootsIdentityPortabilityCodec.decodeWire(serialized).version) ?? 0 + } - public var debugDescription: String { - "RadrootsIdentityPortabilityEnvelope(version: \(version), encryptedPayload: <redacted>)" - } + public var debugDescription: String { + "RadrootsIdentityPortabilityEnvelope(version: \(version), encryptedPayload: <redacted>)" + } } struct RadrootsIdentityCryptography: Sendable { - func generateSecret() throws -> Data { - do { - return try P256K.Schnorr.PrivateKey().dataRepresentation - } catch { - throw RadrootsIdentityCustodyError.cryptographyFailed + func generateSecret() throws -> Data { + do { + return try P256K.Schnorr.PrivateKey().dataRepresentation + } catch { + throw RadrootsIdentityCustodyError.cryptographyFailed + } } - } - func publicKeyHex(for secret: Data) throws -> String { - do { - return Self.hex(try P256K.Schnorr.PrivateKey(dataRepresentation: secret).xonly.bytes) - } catch { - throw RadrootsIdentityCustodyError.invalidSecret + func publicKeyHex(for secret: Data) throws -> String { + do { + return try Self.hex(P256K.Schnorr.PrivateKey(dataRepresentation: secret).xonly.bytes) + } catch { + throw RadrootsIdentityCustodyError.invalidSecret + } } - } - func identityHandle(forPublicKeyHex publicKeyHex: String) throws -> String { - guard let bytes = Self.decodeHex(publicKeyHex), bytes.count == 32 else { - throw RadrootsIdentityCustodyError.invalidMetadata + func identityHandle(forPublicKeyHex publicKeyHex: String) throws -> String { + guard let bytes = Self.decodeHex(publicKeyHex), bytes.count == 32 else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + return "rrid1_\(Self.hex(CryptoKit.SHA256.hash(data: bytes)))" } - return "rrid1_\(Self.hex(CryptoKit.SHA256.hash(data: bytes)))" - } - func sign(secret: Data, digest: Data) throws -> Data { - guard digest.count == 32 else { - throw RadrootsIdentityCustodyError.invalidSignRequest - } - do { - let key = try P256K.Schnorr.PrivateKey(dataRepresentation: secret) - var message = [UInt8](digest) - var auxiliary = [UInt8](repeating: 0, count: 32) - guard SecRandomCopyBytes(kSecRandomDefault, auxiliary.count, &auxiliary) == errSecSuccess - else { - throw RadrootsIdentityCustodyError.cryptographyFailed - } - let signature = try auxiliary.withUnsafeMutableBytes { buffer in - try key.signature( - message: &message, - auxiliaryRand: buffer.baseAddress, - strict: true - ) - } - let signatureData = signature.dataRepresentation - guard key.xonly.isValid(signature, for: &message) else { - throw RadrootsIdentityCustodyError.invalidSignature - } - return signatureData - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch { - throw RadrootsIdentityCustodyError.cryptographyFailed + func sign(secret: Data, digest: Data) throws -> Data { + guard digest.count == 32 else { + throw RadrootsIdentityCustodyError.invalidSignRequest + } + do { + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: secret) + var message = [UInt8](digest) + var auxiliary = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, auxiliary.count, &auxiliary) == errSecSuccess + else { + throw RadrootsIdentityCustodyError.cryptographyFailed + } + let signature = try auxiliary.withUnsafeMutableBytes { buffer in + try key.signature( + message: &message, + auxiliaryRand: buffer.baseAddress, + strict: true + ) + } + let signatureData = signature.dataRepresentation + guard key.xonly.isValid(signature, for: &message) else { + throw RadrootsIdentityCustodyError.invalidSignature + } + return signatureData + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.cryptographyFailed + } } - } - func verify(signature: Data, digest: Data, publicKeyHex: String) -> Bool { - guard signature.count == 64, - digest.count == 32, - let publicKey = Self.decodeHex(publicKeyHex), - publicKey.count == 32, - let parsedSignature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: signature) - else { - return false + func verify(signature: Data, digest: Data, publicKeyHex: String) -> Bool { + guard signature.count == 64, + digest.count == 32, + let publicKey = Self.decodeHex(publicKeyHex), + publicKey.count == 32, + let parsedSignature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: signature) + else { + return false + } + let key = P256K.Schnorr.XonlyKey(dataRepresentation: publicKey) + var message = [UInt8](digest) + return key.isValid(parsedSignature, for: &message) } - let key = P256K.Schnorr.XonlyKey(dataRepresentation: publicKey) - var message = [UInt8](digest) - return key.isValid(parsedSignature, for: &message) - } - - static func hex<S: Sequence>(_ bytes: S) -> String where S.Element == UInt8 { - bytes.map { String(format: "%02x", $0) }.joined() - } - static func decodeHex(_ value: String) -> Data? { - guard value.count.isMultiple(of: 2), - value.unicodeScalars.allSatisfy({ - (48...57).contains($0.value) || (97...102).contains($0.value) - }) - else { - return nil + static func hex(_ bytes: some Sequence<UInt8>) -> String { + bytes.map { String(format: "%02x", $0) }.joined() } - var output = Data(capacity: value.count / 2) - var index = value.startIndex - while index < value.endIndex { - let next = value.index(index, offsetBy: 2) - guard let byte = UInt8(value[index..<next], radix: 16) else { - return nil - } - output.append(byte) - index = next + + static func decodeHex(_ value: String) -> Data? { + guard value.count.isMultiple(of: 2), + value.unicodeScalars.allSatisfy({ + (48 ... 57).contains($0.value) || (97 ... 102).contains($0.value) + }) + else { + return nil + } + var output = Data(capacity: value.count / 2) + var index = value.startIndex + while index < value.endIndex { + let next = value.index(index, offsetBy: 2) + guard let byte = UInt8(value[index ..< next], radix: 16) else { + return nil + } + output.append(byte) + index = next + } + return output } - return output - } } enum RadrootsIdentityPortabilityCodec { - static let version: UInt16 = 1 - static let kdf = "pbkdf2-hmac-sha256" - static let cipher = "aes-256-gcm" - static let iterations: UInt32 = 210_000 - static let maximumEnvelopeBytes = 128 * 1_024 - - struct Wire: Codable { - let version: UInt16 - let kdf: String - let iterations: UInt32 - let cipher: String - let publicKeyHex: String - let salt: Data - let nonce: Data - let ciphertext: Data - let tag: Data - } + static let version: UInt16 = 1 + static let kdf = "pbkdf2-hmac-sha256" + static let cipher = "aes-256-gcm" + static let iterations: UInt32 = 210_000 + static let maximumEnvelopeBytes = 128 * 1024 - struct Plaintext: Codable { - let version: UInt16 - let secret: Data - let publicKeyHex: String - let label: String? - let createdAtUnixMilliseconds: UInt64 - } - - static func seal( - secret: Data, - record: RadrootsIdentityPublicRecord, - passphrase: RadrootsIdentityPassphrase - ) throws -> RadrootsIdentityPortabilityEnvelope { - var salt = [UInt8](repeating: 0, count: 16) - guard SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) == errSecSuccess else { - throw RadrootsIdentityCustodyError.cryptographyFailed + struct Wire: Codable { + let version: UInt16 + let kdf: String + let iterations: UInt32 + let cipher: String + let publicKeyHex: String + let salt: Data + let nonce: Data + let ciphertext: Data + let tag: Data } - let plaintext = Plaintext( - version: version, - secret: secret, - publicKeyHex: record.publicKeyHex, - label: record.label, - createdAtUnixMilliseconds: record.createdAtUnixMilliseconds - ) - var cleartext = try encoder().encode(plaintext) - defer { cleartext.resetBytes(in: cleartext.startIndex..<cleartext.endIndex) } - do { - let key = try deriveKey( - passphrase: passphrase.copyBytes(), - salt: Data(salt), - iterations: iterations - ) - let nonce = AES.GCM.Nonce() - let sealed = try AES.GCM.seal( - cleartext, - using: key, - nonce: nonce, - authenticating: aad( - version: version, - kdf: kdf, - iterations: iterations, - cipher: cipher, - publicKeyHex: record.publicKeyHex - ) - ) - let wire = Wire( - version: version, - kdf: kdf, - iterations: iterations, - cipher: cipher, - publicKeyHex: record.publicKeyHex, - salt: Data(salt), - nonce: nonce.withUnsafeBytes { Data($0) }, - ciphertext: sealed.ciphertext, - tag: sealed.tag - ) - return try RadrootsIdentityPortabilityEnvelope( - serializedRepresentation: encoder().encode(wire) - ) - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch { - throw RadrootsIdentityCustodyError.cryptographyFailed + + struct Plaintext: Codable { + let version: UInt16 + let secret: Data + let publicKeyHex: String + let label: String? + let createdAtUnixMilliseconds: UInt64 } - } - static func open( - _ envelope: RadrootsIdentityPortabilityEnvelope, - passphrase: RadrootsIdentityPassphrase - ) throws -> (RadrootsIdentitySecretMaterial, String?, UInt64) { - let wire = try decodeWire(envelope.serializedRepresentation) - do { - let key = try deriveKey( - passphrase: passphrase.copyBytes(), - salt: wire.salt, - iterations: wire.iterations - ) - let nonce = try AES.GCM.Nonce(data: wire.nonce) - let box = try AES.GCM.SealedBox( - nonce: nonce, - ciphertext: wire.ciphertext, - tag: wire.tag - ) - var cleartext = try AES.GCM.open( - box, - using: key, - authenticating: aad( - version: wire.version, - kdf: wire.kdf, - iterations: wire.iterations, - cipher: wire.cipher, - publicKeyHex: wire.publicKeyHex + static func seal( + secret: Data, + record: RadrootsIdentityPublicRecord, + passphrase: RadrootsIdentityPassphrase + ) throws -> RadrootsIdentityPortabilityEnvelope { + var salt = [UInt8](repeating: 0, count: 16) + guard SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) == errSecSuccess else { + throw RadrootsIdentityCustodyError.cryptographyFailed + } + let plaintext = Plaintext( + version: version, + secret: secret, + publicKeyHex: record.publicKeyHex, + label: record.label, + createdAtUnixMilliseconds: record.createdAtUnixMilliseconds ) - ) - defer { cleartext.resetBytes(in: cleartext.startIndex..<cleartext.endIndex) } - let plaintext = try decoder().decode(Plaintext.self, from: cleartext) - guard plaintext.version == version, - plaintext.publicKeyHex == wire.publicKeyHex, - plaintext.createdAtUnixMilliseconds > 0 - else { - throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed - } - let material = try RadrootsIdentitySecretMaterial(rawRepresentation: plaintext.secret) - let derived = try RadrootsIdentityCryptography().publicKeyHex(for: plaintext.secret) - guard derived == wire.publicKeyHex else { - throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed - } - return (material, plaintext.label, plaintext.createdAtUnixMilliseconds) - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch { - throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed + var cleartext = try encoder().encode(plaintext) + defer { cleartext.resetBytes(in: cleartext.startIndex ..< cleartext.endIndex) } + do { + let key = try deriveKey( + passphrase: passphrase.copyBytes(), + salt: Data(salt), + iterations: iterations + ) + let nonce = AES.GCM.Nonce() + let sealed = try AES.GCM.seal( + cleartext, + using: key, + nonce: nonce, + authenticating: aad( + version: version, + kdf: kdf, + iterations: iterations, + cipher: cipher, + publicKeyHex: record.publicKeyHex + ) + ) + let wire = Wire( + version: version, + kdf: kdf, + iterations: iterations, + cipher: cipher, + publicKeyHex: record.publicKeyHex, + salt: Data(salt), + nonce: nonce.withUnsafeBytes { Data($0) }, + ciphertext: sealed.ciphertext, + tag: sealed.tag + ) + return try RadrootsIdentityPortabilityEnvelope( + serializedRepresentation: encoder().encode(wire) + ) + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.cryptographyFailed + } } - } - static func decodeWire(_ serialized: Data) throws -> Wire { - guard !serialized.isEmpty, serialized.count <= maximumEnvelopeBytes else { - throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope - } - do { - let wire = try decoder().decode(Wire.self, from: serialized) - guard wire.version == version, - wire.kdf == kdf, - wire.iterations == iterations, - wire.cipher == cipher, - RadrootsIdentityPublicRecord.validHex(wire.publicKeyHex, byteCount: 32), - wire.salt.count == 16, - wire.nonce.count == 12, - !wire.ciphertext.isEmpty, - wire.ciphertext.count <= 64 * 1_024, - wire.tag.count == 16 - else { - throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope - } - return wire - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch { - throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope + static func open( + _ envelope: RadrootsIdentityPortabilityEnvelope, + passphrase: RadrootsIdentityPassphrase + ) throws -> (RadrootsIdentitySecretMaterial, String?, UInt64) { + let wire = try decodeWire(envelope.serializedRepresentation) + do { + let key = try deriveKey( + passphrase: passphrase.copyBytes(), + salt: wire.salt, + iterations: wire.iterations + ) + let nonce = try AES.GCM.Nonce(data: wire.nonce) + let box = try AES.GCM.SealedBox( + nonce: nonce, + ciphertext: wire.ciphertext, + tag: wire.tag + ) + var cleartext = try AES.GCM.open( + box, + using: key, + authenticating: aad( + version: wire.version, + kdf: wire.kdf, + iterations: wire.iterations, + cipher: wire.cipher, + publicKeyHex: wire.publicKeyHex + ) + ) + defer { cleartext.resetBytes(in: cleartext.startIndex ..< cleartext.endIndex) } + let plaintext = try decoder().decode(Plaintext.self, from: cleartext) + guard plaintext.version == version, + plaintext.publicKeyHex == wire.publicKeyHex, + plaintext.createdAtUnixMilliseconds > 0 + else { + throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed + } + let material = try RadrootsIdentitySecretMaterial(rawRepresentation: plaintext.secret) + let derived = try RadrootsIdentityCryptography().publicKeyHex(for: plaintext.secret) + guard derived == wire.publicKeyHex else { + throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed + } + return (material, plaintext.label, plaintext.createdAtUnixMilliseconds) + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed + } } - } - private static func aad( - version: UInt16, - kdf: String, - iterations: UInt32, - cipher: String, - publicKeyHex: String - ) -> Data { - Data( - "org.radroots.identity.portability|\(version)|\(kdf)|\(iterations)|\(cipher)|\(publicKeyHex)" - .utf8) - } + static func decodeWire(_ serialized: Data) throws -> Wire { + guard !serialized.isEmpty, serialized.count <= maximumEnvelopeBytes else { + throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope + } + do { + let wire = try decoder().decode(Wire.self, from: serialized) + guard wire.version == version, + wire.kdf == kdf, + wire.iterations == iterations, + wire.cipher == cipher, + RadrootsIdentityPublicRecord.validHex(wire.publicKeyHex, byteCount: 32), + wire.salt.count == 16, + wire.nonce.count == 12, + !wire.ciphertext.isEmpty, + wire.ciphertext.count <= 64 * 1024, + wire.tag.count == 16 + else { + throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope + } + return wire + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope + } + } - private static func deriveKey( - passphrase: Data, - salt: Data, - iterations: UInt32 - ) throws -> SymmetricKey { - guard !passphrase.isEmpty, iterations == Self.iterations else { - throw RadrootsIdentityCustodyError.invalidPassphrase + private static func aad( + version: UInt16, + kdf: String, + iterations: UInt32, + cipher: String, + publicKeyHex: String + ) -> Data { + Data( + "org.radroots.identity.portability|\(version)|\(kdf)|\(iterations)|\(cipher)|\(publicKeyHex)" + .utf8 + ) } - let key = SymmetricKey(data: passphrase) - var block = salt - block.append(contentsOf: [0, 0, 0, 1]) - var previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: block, using: key)) - var output = previous - if iterations > 1 { - for _ in 2...iterations { - previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: previous, using: key)) - for index in output.indices { - output[index] ^= previous[index] + + private static func deriveKey( + passphrase: Data, + salt: Data, + iterations: UInt32 + ) throws -> SymmetricKey { + guard !passphrase.isEmpty, iterations == Self.iterations else { + throw RadrootsIdentityCustodyError.invalidPassphrase } - } - } - defer { - previous.resetBytes(in: previous.startIndex..<previous.endIndex) - output.resetBytes(in: output.startIndex..<output.endIndex) + let key = SymmetricKey(data: passphrase) + var block = salt + block.append(contentsOf: [0, 0, 0, 1]) + var previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: block, using: key)) + var output = previous + if iterations > 1 { + for _ in 2 ... iterations { + previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: previous, using: key)) + for index in output.indices { + output[index] ^= previous[index] + } + } + } + defer { + previous.resetBytes(in: previous.startIndex ..< previous.endIndex) + output.resetBytes(in: output.startIndex ..< output.endIndex) + } + return SymmetricKey(data: output) } - return SymmetricKey(data: output) - } - private static func encoder() -> JSONEncoder { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - return encoder - } + private static func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + } - private static func decoder() -> JSONDecoder { - JSONDecoder() - } + private static func decoder() -> JSONDecoder { + JSONDecoder() + } } diff --git a/Sources/RadrootsKit/RadrootsIdentityCustody.swift b/Sources/RadrootsKit/RadrootsIdentityCustody.swift @@ -1,886 +1,888 @@ import Foundation public struct RadrootsIdentityCustodyConfiguration: Sendable { - public let namespace: String - public let secretPolicy: RadrootsSecretAccessPolicy - - public init( - namespace: String, - secretPolicy: RadrootsSecretAccessPolicy = .userPresenceLocalSecret - ) throws { - let normalized = namespace.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty, - normalized.utf8.count <= 128, - !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) - else { - throw RadrootsIdentityCustodyError.invalidConfiguration - } - self.namespace = normalized - self.secretPolicy = secretPolicy - } + public let namespace: String + public let secretPolicy: RadrootsSecretAccessPolicy + + public init( + namespace: String, + secretPolicy: RadrootsSecretAccessPolicy = .userPresenceLocalSecret + ) throws { + let normalized = namespace.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, + normalized.utf8.count <= 128, + !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { + throw RadrootsIdentityCustodyError.invalidConfiguration + } + self.namespace = normalized + self.secretPolicy = secretPolicy + } } public actor RadrootsIdentityCustody { - private struct UnlockedSession { - let record: RadrootsIdentityPublicRecord - let signerHandle: String - let generation: UInt64 - } - - private enum TransactionKind: String, Codable { - case replace - case delete - } - - private enum TransactionPhase: String, Codable { - case prepared - case activeSecretCommitted - case metadataCommitted - case activeSecretRemoved - case metadataRemoved - } - - private struct TransactionJournal: Codable { - let version: UInt16 - let operationID: String - let kind: TransactionKind - var phase: TransactionPhase - let previous: RadrootsIdentityPublicRecord? - let candidate: RadrootsIdentityPublicRecord? - let startedAtUnixMilliseconds: UInt64 - - func validated() throws -> Self { - guard version == 1, - RadrootsOpaqueSignRequest.canonicalUUID(operationID), - startedAtUnixMilliseconds > 0, - (kind == .replace) == (candidate != nil) - else { - throw RadrootsIdentityCustodyError.corruptMetadata - } - if let previous { - _ = try RadrootsIdentityPublicRecord( - identityHandle: previous.identityHandle, - publicKeyHex: previous.publicKeyHex, - label: previous.label, - createdAtUnixMilliseconds: previous.createdAtUnixMilliseconds, - updatedAtUnixMilliseconds: previous.updatedAtUnixMilliseconds + private struct UnlockedSession { + let record: RadrootsIdentityPublicRecord + let signerHandle: String + let generation: UInt64 + } + + private enum TransactionKind: String, Codable { + case replace + case delete + } + + private enum TransactionPhase: String, Codable { + case prepared + case activeSecretCommitted + case metadataCommitted + case activeSecretRemoved + case metadataRemoved + } + + private struct TransactionJournal: Codable { + let version: UInt16 + let operationID: String + let kind: TransactionKind + var phase: TransactionPhase + let previous: RadrootsIdentityPublicRecord? + let candidate: RadrootsIdentityPublicRecord? + let startedAtUnixMilliseconds: UInt64 + + func validated() throws -> Self { + guard version == 1, + RadrootsOpaqueSignRequest.canonicalUUID(operationID), + startedAtUnixMilliseconds > 0, + (kind == .replace) == (candidate != nil) + else { + throw RadrootsIdentityCustodyError.corruptMetadata + } + if let previous { + _ = try RadrootsIdentityPublicRecord( + identityHandle: previous.identityHandle, + publicKeyHex: previous.publicKeyHex, + label: previous.label, + createdAtUnixMilliseconds: previous.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: previous.updatedAtUnixMilliseconds + ) + } + if let candidate { + _ = try RadrootsIdentityPublicRecord( + identityHandle: candidate.identityHandle, + publicKeyHex: candidate.publicKeyHex, + label: candidate.label, + createdAtUnixMilliseconds: candidate.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: candidate.updatedAtUnixMilliseconds + ) + } + return self + } + } + + private let configuration: RadrootsIdentityCustodyConfiguration + private let secureStore: any RadrootsSecureStore + private let metadataStore: any RadrootsIdentityMetadataStore + private let userPresence: any RadrootsUserPresence + private let protectedData: RadrootsProtectedDataProvider + private let now: @Sendable () -> UInt64 + private let cryptography = RadrootsIdentityCryptography() + private var session: UnlockedSession? + private var generation: UInt64 = 0 + private var activeOperations = Set<String>() + private var cancelledOperations: [String: UInt64] = [:] + private let maximumActiveSigningOperations = 8 + + public init( + configuration: RadrootsIdentityCustodyConfiguration, + secureStore: any RadrootsSecureStore, + metadataStore: any RadrootsIdentityMetadataStore, + userPresence: any RadrootsUserPresence, + protectedData: RadrootsProtectedDataProvider = .available, + now: @escaping @Sendable () -> UInt64 = { + UInt64(Date().timeIntervalSince1970 * 1000) + } + ) { + self.configuration = configuration + self.secureStore = secureStore + self.metadataStore = metadataStore + self.userPresence = userPresence + self.protectedData = protectedData + self.now = now + } + + public func snapshot() -> RadrootsIdentitySnapshot { + do { + let record = try loadRecord() + if try loadJournal() != nil { + return Self.snapshot(.recoveryRequired, record, nil, "identity.transaction_pending") + } + let hasSecret = try secureStore.contains(secretKey(.active)) + guard record != nil || hasSecret else { + return Self.snapshot(.absent, nil, nil, nil) + } + guard let record, hasSecret else { + return Self.snapshot(.corrupt, record, nil, "identity.inconsistent_state") + } + guard protectedData.currentState() == .available else { + return Self.snapshot( + .protectedDataUnavailable, record, nil, "identity.protected_data_unavailable" + ) + } + if let session, session.record == record { + return Self.snapshot(.unlocked, record, session.signerHandle, nil) + } + return Self.snapshot(.locked, record, nil, nil) + } catch RadrootsIdentityCustodyError.corruptMetadata { + return Self.snapshot(.corrupt, nil, nil, "identity.corrupt_metadata") + } catch { + return Self.snapshot(.recoveryRequired, nil, nil, "identity.storage_unavailable") + } + } + + @discardableResult + public func recover() throws -> RadrootsIdentitySnapshot { + try requireProtectedData() + guard var journal = try loadJournal() else { + try cleanupTransactionSecrets() + return snapshot() + } + session = nil + generation &+= 1 + do { + switch journal.kind { + case .replace: + try recoverReplace(&journal) + case .delete: + try recoverDelete(&journal) + } + return snapshot() + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.recoveryRequired + } + } + + @discardableResult + public func createIdentity(label: String? = nil) async throws -> RadrootsIdentitySnapshot { + _ = try recover() + guard try loadRecord() == nil, try !secureStore.contains(secretKey(.active)) else { + throw RadrootsIdentityCustodyError.identityAlreadyExists + } + let expectedGeneration = generation + try await requireUserPresence(reason: "Create your local Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + let secret = try cryptography.generateSecret() + return try commitReplacement( + material: RadrootsIdentitySecretMaterial(rawRepresentation: secret), + label: label, + importedCreatedAt: nil, + replaceExisting: false + ) + } + + @discardableResult + public func importIdentity( + _ material: RadrootsIdentitySecretMaterial, + label: String? = nil, + replaceExisting: Bool = false + ) async throws -> RadrootsIdentitySnapshot { + _ = try recover() + _ = try cryptography.publicKeyHex(for: material.copyBytes()) + let existing = try loadRecord() + let hasActive = try secureStore.contains(secretKey(.active)) + guard (existing != nil) == hasActive else { + throw RadrootsIdentityCustodyError.inconsistentState + } + if existing != nil, !replaceExisting { + throw RadrootsIdentityCustodyError.identityAlreadyExists + } + let expectedGeneration = generation + try await requireUserPresence(reason: "Import your local Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + return try commitReplacement( + material: material, + label: label, + importedCreatedAt: nil, + replaceExisting: replaceExisting + ) + } + + @discardableResult + public func importPortableIdentity( + _ envelope: RadrootsIdentityPortabilityEnvelope, + passphrase: RadrootsIdentityPassphrase, + replaceExisting: Bool = false + ) async throws -> RadrootsIdentitySnapshot { + let opened = try RadrootsIdentityPortabilityCodec.open(envelope, passphrase: passphrase) + _ = try recover() + let existing = try loadRecord() + let hasActive = try secureStore.contains(secretKey(.active)) + guard (existing != nil) == hasActive else { + throw RadrootsIdentityCustodyError.inconsistentState + } + if existing != nil, !replaceExisting { + throw RadrootsIdentityCustodyError.identityAlreadyExists + } + let expectedGeneration = generation + try await requireUserPresence(reason: "Import your encrypted Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + return try commitReplacement( + material: opened.0, + label: opened.1, + importedCreatedAt: opened.2, + replaceExisting: replaceExisting + ) + } + + public func exportPortableIdentity( + passphrase: RadrootsIdentityPassphrase + ) async throws -> RadrootsIdentityPortabilityEnvelope { + try requireProtectedData() + guard let session else { + throw RadrootsIdentityCustodyError.identityLocked + } + let expectedGeneration = session.generation + try await requireUserPresence(reason: "Export your encrypted Nostr identity.") + guard let current = self.session, + current.generation == expectedGeneration, + current.signerHandle == session.signerHandle + else { + throw RadrootsIdentityCustodyError.staleSigner + } + var secret = try readSecret(.active) + defer { secret.resetBytes(in: secret.startIndex ..< secret.endIndex) } + guard try cryptography.publicKeyHex(for: secret) == current.record.publicKeyHex else { + throw RadrootsIdentityCustodyError.inconsistentState + } + return try RadrootsIdentityPortabilityCodec.seal( + secret: secret, + record: current.record, + passphrase: passphrase + ) + } + + @discardableResult + public func migrateLegacyIdentity( + from legacyKey: RadrootsSecureStoreKey, + label: String? = nil + ) async throws -> RadrootsIdentitySnapshot { + if let existing = try loadRecord(), try secureStore.contains(secretKey(.active)) { + guard let legacy = try secureStore.get(legacyKey) else { + return snapshot() + } + guard let text = String(data: legacy, encoding: .utf8), + let material = try? RadrootsIdentitySecretMaterial(importText: text), + try cryptography.publicKeyHex(for: material.copyBytes()) == existing.publicKeyHex + else { + throw RadrootsIdentityCustodyError.inconsistentState + } + try secureStore.delete(legacyKey) + return snapshot() + } + guard let legacy = try secureStore.get(legacyKey), + let text = String(data: legacy, encoding: .utf8) + else { + throw RadrootsIdentityCustodyError.identityNotFound + } + let material = try RadrootsIdentitySecretMaterial(importText: text) + let result = try await importIdentity(material, label: label) + do { + try secureStore.delete(legacyKey) + } catch { + throw RadrootsIdentityCustodyError.recoveryRequired + } + return result + } + + @discardableResult + public func unlockIdentity() async throws -> RadrootsIdentitySnapshot { + _ = try recover() + try requireProtectedData() + let record = try requiredRecord() + let expectedGeneration = generation + try await requireUserPresence(reason: "Unlock your local Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.staleSigner + } + var secret = try readSecret(.active) + defer { secret.resetBytes(in: secret.startIndex ..< secret.endIndex) } + guard try cryptography.publicKeyHex(for: secret) == record.publicKeyHex else { + throw RadrootsIdentityCustodyError.inconsistentState + } + generation &+= 1 + let handle = UUID().uuidString.lowercased() + session = UnlockedSession(record: record, signerHandle: handle, generation: generation) + return Self.snapshot(.unlocked, record, handle, nil) + } + + @discardableResult + public func selectIdentity(identityHandle: String) async throws -> RadrootsIdentitySnapshot { + guard RadrootsIdentityPublicRecord.validHandle(identityHandle) else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + let record = try requiredRecord() + guard record.identityHandle == identityHandle else { + throw RadrootsIdentityCustodyError.identityNotFound + } + return try await unlockIdentity() + } + + public func lockIdentity() { + generation &+= 1 + session = nil + let cancellationTime = now() + for operationID in activeOperations { + cancelledOperations[operationID] = cancellationTime + } + pruneCancellations() + } + + @discardableResult + public func deleteIdentity() async throws -> RadrootsIdentitySnapshot { + _ = try recover() + try requireProtectedData() + let record = try requiredRecord() + let expectedGeneration = generation + try await requireUserPresence(reason: "Delete your local Nostr identity.") + guard generation == expectedGeneration, + try requiredRecord() == record + else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + lockIdentity() + let operationID = UUID().uuidString.lowercased() + let backup = try readSecret(.active) + try secureStore.put(backup, for: secretKey(.backup), policy: configuration.secretPolicy) + var journal = TransactionJournal( + version: 1, + operationID: operationID, + kind: .delete, + phase: .prepared, + previous: record, + candidate: nil, + startedAtUnixMilliseconds: now() + ) + try writeJournal(journal) + do { + try secureStore.delete(secretKey(.active)) + journal.phase = .activeSecretRemoved + try writeJournal(journal) + try metadataStore.delete(.activeIdentity) + journal.phase = .metadataRemoved + try writeJournal(journal) + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + return Self.snapshot(.absent, nil, nil, nil) + } catch { + throw RadrootsIdentityCustodyError.recoveryRequired + } + } + + @discardableResult + public func repairCorruptMetadata(label: String? = nil) async throws -> RadrootsIdentitySnapshot { + try requireProtectedData() + guard try loadJournal() == nil, try secureStore.contains(secretKey(.active)) else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + let expectedGeneration = generation + try await requireUserPresence(reason: "Repair your local Nostr identity.") + guard generation == expectedGeneration else { + throw RadrootsIdentityCustodyError.staleSigner + } + var secret = try readSecret(.active) + defer { secret.resetBytes(in: secret.startIndex ..< secret.endIndex) } + let publicKey = try cryptography.publicKeyHex(for: secret) + let timestamp = now() + let record = try makeRecord( + publicKeyHex: publicKey, + label: label, + createdAt: timestamp, + updatedAt: timestamp + ) + try saveRecord(record) + try metadataStore.delete(.quarantinedMetadata) + generation &+= 1 + let handle = UUID().uuidString.lowercased() + session = UnlockedSession(record: record, signerHandle: handle, generation: generation) + return Self.snapshot(.unlocked, record, handle, nil) + } + + public func sign(_ request: RadrootsOpaqueSignRequest) async throws -> RadrootsOpaqueSignature { + try requireProtectedData() + guard now() <= request.deadlineUnixMilliseconds else { + throw RadrootsIdentityCustodyError.timedOut + } + guard cancelledOperations.removeValue(forKey: request.operationID) == nil else { + throw RadrootsIdentityCustodyError.cancelled + } + guard activeOperations.count < maximumActiveSigningOperations else { + throw RadrootsIdentityCustodyError.signingSaturated + } + guard activeOperations.insert(request.operationID).inserted else { + throw RadrootsIdentityCustodyError.duplicateOperation + } + defer { activeOperations.remove(request.operationID) } + guard let session else { + throw RadrootsIdentityCustodyError.identityLocked + } + guard session.signerHandle == request.signerHandle, + session.record.publicKeyHex == request.publicKeyHex + else { + throw RadrootsIdentityCustodyError.staleSigner + } + let expectedGeneration = session.generation + try await requireUserPresence(reason: signingReason(request.purpose)) + guard cancelledOperations.removeValue(forKey: request.operationID) == nil else { + throw RadrootsIdentityCustodyError.cancelled + } + guard now() <= request.deadlineUnixMilliseconds else { + throw RadrootsIdentityCustodyError.timedOut + } + guard let current = self.session, + current.generation == expectedGeneration, + current.signerHandle == request.signerHandle, + current.record.publicKeyHex == request.publicKeyHex + else { + throw RadrootsIdentityCustodyError.staleSigner + } + var secret = try readSecret(.active) + defer { secret.resetBytes(in: secret.startIndex ..< secret.endIndex) } + guard try cryptography.publicKeyHex(for: secret) == request.publicKeyHex else { + throw RadrootsIdentityCustodyError.inconsistentState + } + let signature = try cryptography.sign(secret: secret, digest: request.digest) + guard + cryptography.verify( + signature: signature, + digest: request.digest, + publicKeyHex: request.publicKeyHex + ) + else { + throw RadrootsIdentityCustodyError.invalidSignature + } + return try RadrootsOpaqueSignature( + operationID: request.operationID, + publicKeyHex: request.publicKeyHex, + signature: signature, + purpose: request.purpose ) - } - if let candidate { - _ = try RadrootsIdentityPublicRecord( - identityHandle: candidate.identityHandle, - publicKeyHex: candidate.publicKeyHex, - label: candidate.label, - createdAtUnixMilliseconds: candidate.createdAtUnixMilliseconds, - updatedAtUnixMilliseconds: candidate.updatedAtUnixMilliseconds + } + + public func cancelSigning(operationID: String) throws { + guard RadrootsOpaqueSignRequest.canonicalUUID(operationID) else { + throw RadrootsIdentityCustodyError.invalidSignRequest + } + cancelledOperations[operationID] = now() + pruneCancellations() + } + + private func commitReplacement( + material: RadrootsIdentitySecretMaterial, + label: String?, + importedCreatedAt: UInt64?, + replaceExisting: Bool + ) throws -> RadrootsIdentitySnapshot { + try requireProtectedData() + let previous = try loadRecord() + let hasActive = try secureStore.contains(secretKey(.active)) + guard (previous != nil) == hasActive else { + throw RadrootsIdentityCustodyError.inconsistentState + } + if previous != nil, !replaceExisting { + throw RadrootsIdentityCustodyError.identityAlreadyExists + } + let candidateSecret = material.copyBytes() + let publicKey = try cryptography.publicKeyHex(for: candidateSecret) + let timestamp = now() + let createdAt: UInt64 = if let previous, previous.publicKeyHex == publicKey { + previous.createdAtUnixMilliseconds + } else { + importedCreatedAt ?? timestamp + } + let candidate = try makeRecord( + publicKeyHex: publicKey, + label: label, + createdAt: createdAt, + updatedAt: max(timestamp, createdAt) ) - } - return self - } - } - - private let configuration: RadrootsIdentityCustodyConfiguration - private let secureStore: any RadrootsSecureStore - private let metadataStore: any RadrootsIdentityMetadataStore - private let userPresence: any RadrootsUserPresence - private let protectedData: RadrootsProtectedDataProvider - private let now: @Sendable () -> UInt64 - private let cryptography = RadrootsIdentityCryptography() - private var session: UnlockedSession? - private var generation: UInt64 = 0 - private var activeOperations = Set<String>() - private var cancelledOperations: [String: UInt64] = [:] - private let maximumActiveSigningOperations = 8 - - public init( - configuration: RadrootsIdentityCustodyConfiguration, - secureStore: any RadrootsSecureStore, - metadataStore: any RadrootsIdentityMetadataStore, - userPresence: any RadrootsUserPresence, - protectedData: RadrootsProtectedDataProvider = .available, - now: @escaping @Sendable () -> UInt64 = { - UInt64(Date().timeIntervalSince1970 * 1_000) - } - ) { - self.configuration = configuration - self.secureStore = secureStore - self.metadataStore = metadataStore - self.userPresence = userPresence - self.protectedData = protectedData - self.now = now - } - - public func snapshot() -> RadrootsIdentitySnapshot { - do { - let record = try loadRecord() - if try loadJournal() != nil { - return Self.snapshot(.recoveryRequired, record, nil, "identity.transaction_pending") - } - let hasSecret = try secureStore.contains(secretKey(.active)) - guard record != nil || hasSecret else { - return Self.snapshot(.absent, nil, nil, nil) - } - guard let record, hasSecret else { - return Self.snapshot(.corrupt, record, nil, "identity.inconsistent_state") - } - guard protectedData.currentState() == .available else { - return Self.snapshot( - .protectedDataUnavailable, record, nil, "identity.protected_data_unavailable") - } - if let session, session.record == record { - return Self.snapshot(.unlocked, record, session.signerHandle, nil) - } - return Self.snapshot(.locked, record, nil, nil) - } catch RadrootsIdentityCustodyError.corruptMetadata { - return Self.snapshot(.corrupt, nil, nil, "identity.corrupt_metadata") - } catch { - return Self.snapshot(.recoveryRequired, nil, nil, "identity.storage_unavailable") - } - } - - @discardableResult - public func recover() throws -> RadrootsIdentitySnapshot { - try requireProtectedData() - guard var journal = try loadJournal() else { - try cleanupTransactionSecrets() - return snapshot() - } - session = nil - generation &+= 1 - do { - switch journal.kind { - case .replace: - try recoverReplace(&journal) - case .delete: - try recoverDelete(&journal) - } - return snapshot() - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch { - throw RadrootsIdentityCustodyError.recoveryRequired - } - } - - @discardableResult - public func createIdentity(label: String? = nil) async throws -> RadrootsIdentitySnapshot { - _ = try recover() - guard try loadRecord() == nil, try !secureStore.contains(secretKey(.active)) else { - throw RadrootsIdentityCustodyError.identityAlreadyExists - } - let expectedGeneration = generation - try await requireUserPresence(reason: "Create your local Nostr identity.") - guard generation == expectedGeneration else { - throw RadrootsIdentityCustodyError.recoveryRequired - } - let secret = try cryptography.generateSecret() - return try commitReplacement( - material: RadrootsIdentitySecretMaterial(rawRepresentation: secret), - label: label, - importedCreatedAt: nil, - replaceExisting: false - ) - } - - @discardableResult - public func importIdentity( - _ material: RadrootsIdentitySecretMaterial, - label: String? = nil, - replaceExisting: Bool = false - ) async throws -> RadrootsIdentitySnapshot { - _ = try recover() - _ = try cryptography.publicKeyHex(for: material.copyBytes()) - let existing = try loadRecord() - let hasActive = try secureStore.contains(secretKey(.active)) - guard (existing != nil) == hasActive else { - throw RadrootsIdentityCustodyError.inconsistentState - } - if existing != nil, !replaceExisting { - throw RadrootsIdentityCustodyError.identityAlreadyExists - } - let expectedGeneration = generation - try await requireUserPresence(reason: "Import your local Nostr identity.") - guard generation == expectedGeneration else { - throw RadrootsIdentityCustodyError.recoveryRequired - } - return try commitReplacement( - material: material, - label: label, - importedCreatedAt: nil, - replaceExisting: replaceExisting - ) - } - - @discardableResult - public func importPortableIdentity( - _ envelope: RadrootsIdentityPortabilityEnvelope, - passphrase: RadrootsIdentityPassphrase, - replaceExisting: Bool = false - ) async throws -> RadrootsIdentitySnapshot { - let opened = try RadrootsIdentityPortabilityCodec.open(envelope, passphrase: passphrase) - _ = try recover() - let existing = try loadRecord() - let hasActive = try secureStore.contains(secretKey(.active)) - guard (existing != nil) == hasActive else { - throw RadrootsIdentityCustodyError.inconsistentState - } - if existing != nil, !replaceExisting { - throw RadrootsIdentityCustodyError.identityAlreadyExists - } - let expectedGeneration = generation - try await requireUserPresence(reason: "Import your encrypted Nostr identity.") - guard generation == expectedGeneration else { - throw RadrootsIdentityCustodyError.recoveryRequired - } - return try commitReplacement( - material: opened.0, - label: opened.1, - importedCreatedAt: opened.2, - replaceExisting: replaceExisting - ) - } - - public func exportPortableIdentity( - passphrase: RadrootsIdentityPassphrase - ) async throws -> RadrootsIdentityPortabilityEnvelope { - try requireProtectedData() - guard let session else { - throw RadrootsIdentityCustodyError.identityLocked - } - let expectedGeneration = session.generation - try await requireUserPresence(reason: "Export your encrypted Nostr identity.") - guard let current = self.session, - current.generation == expectedGeneration, - current.signerHandle == session.signerHandle - else { - throw RadrootsIdentityCustodyError.staleSigner - } - var secret = try readSecret(.active) - defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) } - guard try cryptography.publicKeyHex(for: secret) == current.record.publicKeyHex else { - throw RadrootsIdentityCustodyError.inconsistentState - } - return try RadrootsIdentityPortabilityCodec.seal( - secret: secret, - record: current.record, - passphrase: passphrase - ) - } - - @discardableResult - public func migrateLegacyIdentity( - from legacyKey: RadrootsSecureStoreKey, - label: String? = nil - ) async throws -> RadrootsIdentitySnapshot { - if let existing = try loadRecord(), try secureStore.contains(secretKey(.active)) { - guard let legacy = try secureStore.get(legacyKey) else { - return snapshot() - } - guard let text = String(data: legacy, encoding: .utf8), - let material = try? RadrootsIdentitySecretMaterial(importText: text), - try cryptography.publicKeyHex(for: material.copyBytes()) == existing.publicKeyHex - else { - throw RadrootsIdentityCustodyError.inconsistentState - } - try secureStore.delete(legacyKey) - return snapshot() - } - guard let legacy = try secureStore.get(legacyKey), - let text = String(data: legacy, encoding: .utf8) - else { - throw RadrootsIdentityCustodyError.identityNotFound - } - let material = try RadrootsIdentitySecretMaterial(importText: text) - let result = try await importIdentity(material, label: label) - do { - try secureStore.delete(legacyKey) - } catch { - throw RadrootsIdentityCustodyError.recoveryRequired - } - return result - } - - @discardableResult - public func unlockIdentity() async throws -> RadrootsIdentitySnapshot { - _ = try recover() - try requireProtectedData() - let record = try requiredRecord() - let expectedGeneration = generation - try await requireUserPresence(reason: "Unlock your local Nostr identity.") - guard generation == expectedGeneration else { - throw RadrootsIdentityCustodyError.staleSigner - } - var secret = try readSecret(.active) - defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) } - guard try cryptography.publicKeyHex(for: secret) == record.publicKeyHex else { - throw RadrootsIdentityCustodyError.inconsistentState - } - generation &+= 1 - let handle = UUID().uuidString.lowercased() - session = UnlockedSession(record: record, signerHandle: handle, generation: generation) - return Self.snapshot(.unlocked, record, handle, nil) - } - - @discardableResult - public func selectIdentity(identityHandle: String) async throws -> RadrootsIdentitySnapshot { - guard RadrootsIdentityPublicRecord.validHandle(identityHandle) else { - throw RadrootsIdentityCustodyError.invalidMetadata - } - let record = try requiredRecord() - guard record.identityHandle == identityHandle else { - throw RadrootsIdentityCustodyError.identityNotFound - } - return try await unlockIdentity() - } - - public func lockIdentity() { - generation &+= 1 - session = nil - let cancellationTime = now() - for operationID in activeOperations { - cancelledOperations[operationID] = cancellationTime - } - pruneCancellations() - } - - @discardableResult - public func deleteIdentity() async throws -> RadrootsIdentitySnapshot { - _ = try recover() - try requireProtectedData() - let record = try requiredRecord() - let expectedGeneration = generation - try await requireUserPresence(reason: "Delete your local Nostr identity.") - guard generation == expectedGeneration, - try requiredRecord() == record - else { - throw RadrootsIdentityCustodyError.recoveryRequired - } - lockIdentity() - let operationID = UUID().uuidString.lowercased() - let backup = try readSecret(.active) - try secureStore.put(backup, for: secretKey(.backup), policy: configuration.secretPolicy) - var journal = TransactionJournal( - version: 1, - operationID: operationID, - kind: .delete, - phase: .prepared, - previous: record, - candidate: nil, - startedAtUnixMilliseconds: now() - ) - try writeJournal(journal) - do { - try secureStore.delete(secretKey(.active)) - journal.phase = .activeSecretRemoved - try writeJournal(journal) - try metadataStore.delete(.activeIdentity) - journal.phase = .metadataRemoved - try writeJournal(journal) - try cleanupTransactionSecrets() - try metadataStore.delete(.transactionJournal) - return Self.snapshot(.absent, nil, nil, nil) - } catch { - throw RadrootsIdentityCustodyError.recoveryRequired - } - } - - @discardableResult - public func repairCorruptMetadata(label: String? = nil) async throws -> RadrootsIdentitySnapshot { - try requireProtectedData() - guard try loadJournal() == nil, try secureStore.contains(secretKey(.active)) else { - throw RadrootsIdentityCustodyError.recoveryRequired - } - let expectedGeneration = generation - try await requireUserPresence(reason: "Repair your local Nostr identity.") - guard generation == expectedGeneration else { - throw RadrootsIdentityCustodyError.staleSigner - } - var secret = try readSecret(.active) - defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) } - let publicKey = try cryptography.publicKeyHex(for: secret) - let timestamp = now() - let record = try makeRecord( - publicKeyHex: publicKey, - label: label, - createdAt: timestamp, - updatedAt: timestamp - ) - try saveRecord(record) - try metadataStore.delete(.quarantinedMetadata) - generation &+= 1 - let handle = UUID().uuidString.lowercased() - session = UnlockedSession(record: record, signerHandle: handle, generation: generation) - return Self.snapshot(.unlocked, record, handle, nil) - } - - public func sign(_ request: RadrootsOpaqueSignRequest) async throws -> RadrootsOpaqueSignature { - try requireProtectedData() - guard now() <= request.deadlineUnixMilliseconds else { - throw RadrootsIdentityCustodyError.timedOut - } - guard cancelledOperations.removeValue(forKey: request.operationID) == nil else { - throw RadrootsIdentityCustodyError.cancelled - } - guard activeOperations.count < maximumActiveSigningOperations else { - throw RadrootsIdentityCustodyError.signingSaturated - } - guard activeOperations.insert(request.operationID).inserted else { - throw RadrootsIdentityCustodyError.duplicateOperation - } - defer { activeOperations.remove(request.operationID) } - guard let session else { - throw RadrootsIdentityCustodyError.identityLocked - } - guard session.signerHandle == request.signerHandle, - session.record.publicKeyHex == request.publicKeyHex - else { - throw RadrootsIdentityCustodyError.staleSigner - } - let expectedGeneration = session.generation - try await requireUserPresence(reason: signingReason(request.purpose)) - guard cancelledOperations.removeValue(forKey: request.operationID) == nil else { - throw RadrootsIdentityCustodyError.cancelled - } - guard now() <= request.deadlineUnixMilliseconds else { - throw RadrootsIdentityCustodyError.timedOut - } - guard let current = self.session, - current.generation == expectedGeneration, - current.signerHandle == request.signerHandle, - current.record.publicKeyHex == request.publicKeyHex - else { - throw RadrootsIdentityCustodyError.staleSigner - } - var secret = try readSecret(.active) - defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) } - guard try cryptography.publicKeyHex(for: secret) == request.publicKeyHex else { - throw RadrootsIdentityCustodyError.inconsistentState - } - let signature = try cryptography.sign(secret: secret, digest: request.digest) - guard - cryptography.verify( - signature: signature, - digest: request.digest, - publicKeyHex: request.publicKeyHex - ) - else { - throw RadrootsIdentityCustodyError.invalidSignature - } - return try RadrootsOpaqueSignature( - operationID: request.operationID, - publicKeyHex: request.publicKeyHex, - signature: signature, - purpose: request.purpose - ) - } - - public func cancelSigning(operationID: String) throws { - guard RadrootsOpaqueSignRequest.canonicalUUID(operationID) else { - throw RadrootsIdentityCustodyError.invalidSignRequest - } - cancelledOperations[operationID] = now() - pruneCancellations() - } - - private func commitReplacement( - material: RadrootsIdentitySecretMaterial, - label: String?, - importedCreatedAt: UInt64?, - replaceExisting: Bool - ) throws -> RadrootsIdentitySnapshot { - try requireProtectedData() - let previous = try loadRecord() - let hasActive = try secureStore.contains(secretKey(.active)) - guard (previous != nil) == hasActive else { - throw RadrootsIdentityCustodyError.inconsistentState - } - if previous != nil, !replaceExisting { - throw RadrootsIdentityCustodyError.identityAlreadyExists - } - let candidateSecret = material.copyBytes() - let publicKey = try cryptography.publicKeyHex(for: candidateSecret) - let timestamp = now() - let createdAt: UInt64 - if let previous, previous.publicKeyHex == publicKey { - createdAt = previous.createdAtUnixMilliseconds - } else { - createdAt = importedCreatedAt ?? timestamp - } - let candidate = try makeRecord( - publicKeyHex: publicKey, - label: label, - createdAt: createdAt, - updatedAt: max(timestamp, createdAt) - ) - if hasActive { - let current = try readSecret(.active) - try secureStore.put(current, for: secretKey(.backup), policy: configuration.secretPolicy) - } else { - try secureStore.delete(secretKey(.backup)) - } - try secureStore.put( - candidateSecret, for: secretKey(.candidate), policy: configuration.secretPolicy) - var journal = TransactionJournal( - version: 1, - operationID: UUID().uuidString.lowercased(), - kind: .replace, - phase: .prepared, - previous: previous, - candidate: candidate, - startedAtUnixMilliseconds: timestamp - ) - try writeJournal(journal) - do { - try secureStore.put( - candidateSecret, for: secretKey(.active), policy: configuration.secretPolicy) - journal.phase = .activeSecretCommitted - try writeJournal(journal) - try saveRecord(candidate) - journal.phase = .metadataCommitted - try writeJournal(journal) - try cleanupTransactionSecrets() - try metadataStore.delete(.transactionJournal) - } catch { - session = nil - generation &+= 1 - throw RadrootsIdentityCustodyError.recoveryRequired - } - generation &+= 1 - let handle = UUID().uuidString.lowercased() - session = UnlockedSession(record: candidate, signerHandle: handle, generation: generation) - return Self.snapshot(.unlocked, candidate, handle, nil) - } - - private func recoverReplace(_ journal: inout TransactionJournal) throws { - guard let candidate = journal.candidate else { - throw RadrootsIdentityCustodyError.corruptMetadata - } - var activeMatches = - try secret(.active).map { - try cryptography.publicKeyHex(for: $0) == candidate.publicKeyHex - } ?? false - if !activeMatches { - if let candidateSecret = try secret(.candidate) { - guard try cryptography.publicKeyHex(for: candidateSecret) == candidate.publicKeyHex else { - throw RadrootsIdentityCustodyError.corruptMetadata + if hasActive { + let current = try readSecret(.active) + try secureStore.put(current, for: secretKey(.backup), policy: configuration.secretPolicy) + } else { + try secureStore.delete(secretKey(.backup)) } try secureStore.put( - candidateSecret, - for: secretKey(.active), - policy: configuration.secretPolicy + candidateSecret, for: secretKey(.candidate), policy: configuration.secretPolicy + ) + var journal = TransactionJournal( + version: 1, + operationID: UUID().uuidString.lowercased(), + kind: .replace, + phase: .prepared, + previous: previous, + candidate: candidate, + startedAtUnixMilliseconds: timestamp + ) + try writeJournal(journal) + do { + try secureStore.put( + candidateSecret, for: secretKey(.active), policy: configuration.secretPolicy + ) + journal.phase = .activeSecretCommitted + try writeJournal(journal) + try saveRecord(candidate) + journal.phase = .metadataCommitted + try writeJournal(journal) + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + } catch { + session = nil + generation &+= 1 + throw RadrootsIdentityCustodyError.recoveryRequired + } + generation &+= 1 + let handle = UUID().uuidString.lowercased() + session = UnlockedSession(record: candidate, signerHandle: handle, generation: generation) + return Self.snapshot(.unlocked, candidate, handle, nil) + } + + private func recoverReplace(_ journal: inout TransactionJournal) throws { + guard let candidate = journal.candidate else { + throw RadrootsIdentityCustodyError.corruptMetadata + } + var activeMatches = + try secret(.active).map { + try cryptography.publicKeyHex(for: $0) == candidate.publicKeyHex + } ?? false + if !activeMatches { + if let candidateSecret = try secret(.candidate) { + guard try cryptography.publicKeyHex(for: candidateSecret) == candidate.publicKeyHex else { + throw RadrootsIdentityCustodyError.corruptMetadata + } + try secureStore.put( + candidateSecret, + for: secretKey(.active), + policy: configuration.secretPolicy + ) + activeMatches = true + } else { + try rollbackReplacement(journal) + return + } + } + guard activeMatches else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + journal.phase = .activeSecretCommitted + try writeJournal(journal) + try saveRecord(candidate) + journal.phase = .metadataCommitted + try writeJournal(journal) + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + } + + private func rollbackReplacement(_ journal: TransactionJournal) throws { + if let previous = journal.previous { + guard let backup = try secret(.backup), + try cryptography.publicKeyHex(for: backup) == previous.publicKeyHex + else { + throw RadrootsIdentityCustodyError.recoveryRequired + } + try secureStore.put(backup, for: secretKey(.active), policy: configuration.secretPolicy) + try saveRecord(previous) + } else { + try secureStore.delete(secretKey(.active)) + try metadataStore.delete(.activeIdentity) + } + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + } + + private func recoverDelete(_ journal: inout TransactionJournal) throws { + try secureStore.delete(secretKey(.active)) + journal.phase = .activeSecretRemoved + try writeJournal(journal) + try metadataStore.delete(.activeIdentity) + journal.phase = .metadataRemoved + try writeJournal(journal) + try cleanupTransactionSecrets() + try metadataStore.delete(.transactionJournal) + } + + private func requireUserPresence(reason: String) async throws { + try requireProtectedData() + let request: RadrootsUserPresenceRequest + do { + request = try RadrootsUserPresenceRequest(reason: reason) + } catch { + throw RadrootsIdentityCustodyError.invalidConfiguration + } + do { + let result = try await userPresence.verify(request) + guard result.verified else { + throw RadrootsIdentityCustodyError.userPresenceRequired + } + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch let error as RadrootsUserPresenceError { + switch error { + case .userCancelled: + throw RadrootsIdentityCustodyError.cancelled + case .timeout: + throw RadrootsIdentityCustodyError.timedOut + default: + throw RadrootsIdentityCustodyError.userPresenceRequired + } + } catch { + throw RadrootsIdentityCustodyError.userPresenceRequired + } + try requireProtectedData() + } + + private func requireProtectedData() throws { + guard protectedData.currentState() == .available else { + throw RadrootsIdentityCustodyError.protectedDataUnavailable + } + } + + private func requiredRecord() throws -> RadrootsIdentityPublicRecord { + guard let record = try loadRecord() else { + throw RadrootsIdentityCustodyError.identityNotFound + } + guard try secureStore.contains(secretKey(.active)) else { + throw RadrootsIdentityCustodyError.inconsistentState + } + return record + } + + private func loadRecord() throws -> RadrootsIdentityPublicRecord? { + guard let data = try metadataStore.data(for: .activeIdentity) else { + return nil + } + do { + let decoded = try JSONDecoder().decode(RadrootsIdentityPublicRecord.self, from: data) + return try RadrootsIdentityPublicRecord( + identityHandle: decoded.identityHandle, + publicKeyHex: decoded.publicKeyHex, + label: decoded.label, + createdAtUnixMilliseconds: decoded.createdAtUnixMilliseconds, + updatedAtUnixMilliseconds: decoded.updatedAtUnixMilliseconds + ) + } catch { + do { + try metadataStore.put(data, for: .quarantinedMetadata) + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + throw RadrootsIdentityCustodyError.corruptMetadata + } + } + + private func saveRecord(_ record: RadrootsIdentityPublicRecord) throws { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + try metadataStore.put(encoder.encode(record), for: .activeIdentity) + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + } + + private func loadJournal() throws -> TransactionJournal? { + guard let data = try metadataStore.data(for: .transactionJournal) else { + return nil + } + do { + return try JSONDecoder().decode(TransactionJournal.self, from: data).validated() + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.corruptMetadata + } + } + + private func writeJournal(_ journal: TransactionJournal) throws { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + try metadataStore.put(encoder.encode(journal.validated()), for: .transactionJournal) + } catch let error as RadrootsIdentityCustodyError { + throw error + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + } + + private enum SecretSlot: String { + case active = "active_secret_v1" + case candidate = "candidate_secret_v1" + case backup = "backup_secret_v1" + } + + private func secretKey(_ slot: SecretSlot) -> RadrootsSecureStoreKey { + RadrootsSecureStoreKey(namespace: configuration.namespace, name: slot.rawValue) + } + + private func secret(_ slot: SecretSlot) throws -> Data? { + do { + return try secureStore.get(secretKey(slot)) + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + } + + private func readSecret(_ slot: SecretSlot) throws -> Data { + guard let secret = try secret(slot) else { + throw RadrootsIdentityCustodyError.inconsistentState + } + guard secret.count == 32 else { + throw RadrootsIdentityCustodyError.invalidSecret + } + return secret + } + + private func cleanupTransactionSecrets() throws { + do { + try secureStore.delete(secretKey(.candidate)) + try secureStore.delete(secretKey(.backup)) + } catch { + throw RadrootsIdentityCustodyError.storageUnavailable + } + } + + private func makeRecord( + publicKeyHex: String, + label: String?, + createdAt: UInt64, + updatedAt: UInt64 + ) throws -> RadrootsIdentityPublicRecord { + try RadrootsIdentityPublicRecord( + identityHandle: cryptography.identityHandle(forPublicKeyHex: publicKeyHex), + publicKeyHex: publicKeyHex, + label: label, + createdAtUnixMilliseconds: createdAt, + updatedAtUnixMilliseconds: updatedAt + ) + } + + private func signingReason(_ purpose: RadrootsOpaqueSignPurpose) -> String { + switch purpose { + case .nostrEvent: + "Sign this Nostr event with your local identity." + case .blossomUpload: + "Authorize this Blossom media upload with your local identity." + } + } + + private func pruneCancellations() { + guard cancelledOperations.count > 128 else { + return + } + let ordered = cancelledOperations.sorted { $0.value < $1.value } + for (operationID, _) in ordered.prefix(cancelledOperations.count - 128) { + cancelledOperations.removeValue(forKey: operationID) + } + } + + private static func snapshot( + _ state: RadrootsIdentityState, + _ identity: RadrootsIdentityPublicRecord?, + _ signerHandle: String?, + _ recoveryCode: String? + ) -> RadrootsIdentitySnapshot { + RadrootsIdentitySnapshot( + state: state, + identity: identity, + signerHandle: signerHandle, + recoveryCode: recoveryCode ) - activeMatches = true - } else { - try rollbackReplacement(journal) - return - } - } - guard activeMatches else { - throw RadrootsIdentityCustodyError.recoveryRequired - } - journal.phase = .activeSecretCommitted - try writeJournal(journal) - try saveRecord(candidate) - journal.phase = .metadataCommitted - try writeJournal(journal) - try cleanupTransactionSecrets() - try metadataStore.delete(.transactionJournal) - } - - private func rollbackReplacement(_ journal: TransactionJournal) throws { - if let previous = journal.previous { - guard let backup = try secret(.backup), - try cryptography.publicKeyHex(for: backup) == previous.publicKeyHex - else { - throw RadrootsIdentityCustodyError.recoveryRequired - } - try secureStore.put(backup, for: secretKey(.active), policy: configuration.secretPolicy) - try saveRecord(previous) - } else { - try secureStore.delete(secretKey(.active)) - try metadataStore.delete(.activeIdentity) - } - try cleanupTransactionSecrets() - try metadataStore.delete(.transactionJournal) - } - - private func recoverDelete(_ journal: inout TransactionJournal) throws { - try secureStore.delete(secretKey(.active)) - journal.phase = .activeSecretRemoved - try writeJournal(journal) - try metadataStore.delete(.activeIdentity) - journal.phase = .metadataRemoved - try writeJournal(journal) - try cleanupTransactionSecrets() - try metadataStore.delete(.transactionJournal) - } - - private func requireUserPresence(reason: String) async throws { - try requireProtectedData() - let request: RadrootsUserPresenceRequest - do { - request = try RadrootsUserPresenceRequest(reason: reason) - } catch { - throw RadrootsIdentityCustodyError.invalidConfiguration - } - do { - let result = try await userPresence.verify(request) - guard result.verified else { - throw RadrootsIdentityCustodyError.userPresenceRequired - } - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch let error as RadrootsUserPresenceError { - switch error { - case .userCancelled: - throw RadrootsIdentityCustodyError.cancelled - case .timeout: - throw RadrootsIdentityCustodyError.timedOut - default: - throw RadrootsIdentityCustodyError.userPresenceRequired - } - } catch { - throw RadrootsIdentityCustodyError.userPresenceRequired - } - try requireProtectedData() - } - - private func requireProtectedData() throws { - guard protectedData.currentState() == .available else { - throw RadrootsIdentityCustodyError.protectedDataUnavailable - } - } - - private func requiredRecord() throws -> RadrootsIdentityPublicRecord { - guard let record = try loadRecord() else { - throw RadrootsIdentityCustodyError.identityNotFound - } - guard try secureStore.contains(secretKey(.active)) else { - throw RadrootsIdentityCustodyError.inconsistentState - } - return record - } - - private func loadRecord() throws -> RadrootsIdentityPublicRecord? { - guard let data = try metadataStore.data(for: .activeIdentity) else { - return nil - } - do { - let decoded = try JSONDecoder().decode(RadrootsIdentityPublicRecord.self, from: data) - return try RadrootsIdentityPublicRecord( - identityHandle: decoded.identityHandle, - publicKeyHex: decoded.publicKeyHex, - label: decoded.label, - createdAtUnixMilliseconds: decoded.createdAtUnixMilliseconds, - updatedAtUnixMilliseconds: decoded.updatedAtUnixMilliseconds - ) - } catch { - do { - try metadataStore.put(data, for: .quarantinedMetadata) - } catch { - throw RadrootsIdentityCustodyError.storageUnavailable - } - throw RadrootsIdentityCustodyError.corruptMetadata - } - } - - private func saveRecord(_ record: RadrootsIdentityPublicRecord) throws { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - try metadataStore.put(try encoder.encode(record), for: .activeIdentity) - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch { - throw RadrootsIdentityCustodyError.storageUnavailable - } - } - - private func loadJournal() throws -> TransactionJournal? { - guard let data = try metadataStore.data(for: .transactionJournal) else { - return nil - } - do { - return try JSONDecoder().decode(TransactionJournal.self, from: data).validated() - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch { - throw RadrootsIdentityCustodyError.corruptMetadata - } - } - - private func writeJournal(_ journal: TransactionJournal) throws { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - try metadataStore.put(try encoder.encode(journal.validated()), for: .transactionJournal) - } catch let error as RadrootsIdentityCustodyError { - throw error - } catch { - throw RadrootsIdentityCustodyError.storageUnavailable - } - } - - private enum SecretSlot: String { - case active = "active_secret_v1" - case candidate = "candidate_secret_v1" - case backup = "backup_secret_v1" - } - - private func secretKey(_ slot: SecretSlot) -> RadrootsSecureStoreKey { - RadrootsSecureStoreKey(namespace: configuration.namespace, name: slot.rawValue) - } - - private func secret(_ slot: SecretSlot) throws -> Data? { - do { - return try secureStore.get(secretKey(slot)) - } catch { - throw RadrootsIdentityCustodyError.storageUnavailable - } - } - - private func readSecret(_ slot: SecretSlot) throws -> Data { - guard let secret = try secret(slot) else { - throw RadrootsIdentityCustodyError.inconsistentState - } - guard secret.count == 32 else { - throw RadrootsIdentityCustodyError.invalidSecret - } - return secret - } - - private func cleanupTransactionSecrets() throws { - do { - try secureStore.delete(secretKey(.candidate)) - try secureStore.delete(secretKey(.backup)) - } catch { - throw RadrootsIdentityCustodyError.storageUnavailable - } - } - - private func makeRecord( - publicKeyHex: String, - label: String?, - createdAt: UInt64, - updatedAt: UInt64 - ) throws -> RadrootsIdentityPublicRecord { - try RadrootsIdentityPublicRecord( - identityHandle: cryptography.identityHandle(forPublicKeyHex: publicKeyHex), - publicKeyHex: publicKeyHex, - label: label, - createdAtUnixMilliseconds: createdAt, - updatedAtUnixMilliseconds: updatedAt - ) - } - - private func signingReason(_ purpose: RadrootsOpaqueSignPurpose) -> String { - switch purpose { - case .nostrEvent: - "Sign this Nostr event with your local identity." - case .blossomUpload: - "Authorize this Blossom media upload with your local identity." - } - } - - private func pruneCancellations() { - guard cancelledOperations.count > 128 else { - return - } - let ordered = cancelledOperations.sorted { $0.value < $1.value } - for (operationID, _) in ordered.prefix(cancelledOperations.count - 128) { - cancelledOperations.removeValue(forKey: operationID) - } - } - - private static func snapshot( - _ state: RadrootsIdentityState, - _ identity: RadrootsIdentityPublicRecord?, - _ signerHandle: String?, - _ recoveryCode: String? - ) -> RadrootsIdentitySnapshot { - RadrootsIdentitySnapshot( - state: state, - identity: identity, - signerHandle: signerHandle, - recoveryCode: recoveryCode - ) - } + } } public final class RadrootsOpaqueSignerCancellation: @unchecked Sendable { - private let lock = NSLock() - private var isCancelled = false - private let cancelAction: @Sendable () -> Void - - init(cancelAction: @escaping @Sendable () -> Void) { - self.cancelAction = cancelAction - } - - public func cancel() { - lock.lock() - guard !isCancelled else { - lock.unlock() - return - } - isCancelled = true - lock.unlock() - cancelAction() - } + private let lock = NSLock() + private var isCancelled = false + private let cancelAction: @Sendable () -> Void + + init(cancelAction: @escaping @Sendable () -> Void) { + self.cancelAction = cancelAction + } + + public func cancel() { + lock.lock() + guard !isCancelled else { + lock.unlock() + return + } + isCancelled = true + lock.unlock() + cancelAction() + } } public final class RadrootsOpaqueSignerBridge: Sendable { - private let custody: RadrootsIdentityCustody - - public init(custody: RadrootsIdentityCustody) { - self.custody = custody - } - - @discardableResult - public func submit( - _ request: RadrootsOpaqueSignRequest, - completion: - @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void - ) -> RadrootsOpaqueSignerCancellation { - let completionState = RadrootsOpaqueSignerCompletion(completion: completion) - let custody = custody - let task = Task { - do { - if Task.isCancelled { - try await custody.cancelSigning(operationID: request.operationID) - } - completionState.finish(.success(try await custody.sign(request))) - } catch let error as RadrootsIdentityCustodyError { - completionState.finish(.failure(error)) - } catch { - completionState.finish(.failure(.cryptographyFailed)) - } - } - return RadrootsOpaqueSignerCancellation { - task.cancel() - Task { - try? await custody.cancelSigning(operationID: request.operationID) - } - } - } + private let custody: RadrootsIdentityCustody + + public init(custody: RadrootsIdentityCustody) { + self.custody = custody + } + + @discardableResult + public func submit( + _ request: RadrootsOpaqueSignRequest, + completion: + @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void + ) -> RadrootsOpaqueSignerCancellation { + let completionState = RadrootsOpaqueSignerCompletion(completion: completion) + let custody = custody + let task = Task { + do { + if Task.isCancelled { + try await custody.cancelSigning(operationID: request.operationID) + } + try await completionState.finish(.success(custody.sign(request))) + } catch let error as RadrootsIdentityCustodyError { + completionState.finish(.failure(error)) + } catch { + completionState.finish(.failure(.cryptographyFailed)) + } + } + return RadrootsOpaqueSignerCancellation { + task.cancel() + Task { + try? await custody.cancelSigning(operationID: request.operationID) + } + } + } } private final class RadrootsOpaqueSignerCompletion: @unchecked Sendable { - private let lock = NSLock() - private var completion: - (@Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void)? - - init( - completion: - @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void - ) { - self.completion = completion - } - - func finish(_ result: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) { - lock.lock() - let callback = completion - completion = nil - lock.unlock() - callback?(result) - } + private let lock = NSLock() + private var completion: + (@Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void)? + + init( + completion: + @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void + ) { + self.completion = completion + } + + func finish(_ result: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) { + lock.lock() + let callback = completion + completion = nil + lock.unlock() + callback?(result) + } } diff --git a/Sources/RadrootsKit/RadrootsIdentityCustodyTypes.swift b/Sources/RadrootsKit/RadrootsIdentityCustodyTypes.swift @@ -1,399 +1,399 @@ import Foundation public enum RadrootsProtectedDataState: String, Codable, Sendable { - case available - case locked - case unavailable + case available + case locked + case unavailable } public struct RadrootsProtectedDataProvider: Sendable { - private let readState: @Sendable () -> RadrootsProtectedDataState + private let readState: @Sendable () -> RadrootsProtectedDataState - public init(readState: @escaping @Sendable () -> RadrootsProtectedDataState) { - self.readState = readState - } + public init(readState: @escaping @Sendable () -> RadrootsProtectedDataState) { + self.readState = readState + } - public func currentState() -> RadrootsProtectedDataState { - readState() - } + public func currentState() -> RadrootsProtectedDataState { + readState() + } - public static let available = Self { .available } + public static let available = Self { .available } } public enum RadrootsIdentityMetadataSlot: String, Sendable { - case activeIdentity - case transactionJournal - case quarantinedMetadata + case activeIdentity + case transactionJournal + case quarantinedMetadata } public protocol RadrootsIdentityMetadataStore: AnyObject, Sendable { - func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? - func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws - func delete(_ slot: RadrootsIdentityMetadataSlot) throws + func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? + func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws + func delete(_ slot: RadrootsIdentityMetadataSlot) throws } public struct RadrootsIdentitySecretMaterial: Sendable, CustomDebugStringConvertible { - private let bytes: Data + private let bytes: Data - public init(rawRepresentation: Data) throws { - guard rawRepresentation.count == 32 else { - throw RadrootsIdentityCustodyError.invalidSecret - } - self.bytes = rawRepresentation - } - - public init(importText: String) throws { - let normalized = importText.trimmingCharacters(in: .whitespacesAndNewlines) - if normalized.count == 64, let decoded = Self.decodeHex(normalized) { - try self.init(rawRepresentation: decoded) - return - } - guard let decoded = Self.decodeNsec(normalized) else { - throw RadrootsIdentityCustodyError.invalidSecret + public init(rawRepresentation: Data) throws { + guard rawRepresentation.count == 32 else { + throw RadrootsIdentityCustodyError.invalidSecret + } + bytes = rawRepresentation } - try self.init(rawRepresentation: decoded) - } - - public var debugDescription: String { - "RadrootsIdentitySecretMaterial(<redacted>)" - } - - func copyBytes() -> Data { - bytes - } - - private static func decodeHex(_ value: String) -> Data? { - guard - value.unicodeScalars.allSatisfy({ - (48...57).contains($0.value) || (65...70).contains($0.value) - || (97...102).contains($0.value) - }) - else { - return nil - } - var output = Data(capacity: 32) - var index = value.startIndex - for _ in 0..<32 { - let next = value.index(index, offsetBy: 2) - guard let byte = UInt8(value[index..<next], radix: 16) else { - return nil - } - output.append(byte) - index = next - } - return output - } - - private static func decodeNsec(_ value: String) -> Data? { - guard value == value.lowercased(), - value.count <= 90, - let separator = value.lastIndex(of: "1"), - value[..<separator] == "nsec" - else { - return nil + + public init(importText: String) throws { + let normalized = importText.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.count == 64, let decoded = Self.decodeHex(normalized) { + try self.init(rawRepresentation: decoded) + return + } + guard let decoded = Self.decodeNsec(normalized) else { + throw RadrootsIdentityCustodyError.invalidSecret + } + try self.init(rawRepresentation: decoded) } - let payloadStart = value.index(after: separator) - let encoded = value[payloadStart...] - guard encoded.count >= 6 else { - return nil + + public var debugDescription: String { + "RadrootsIdentitySecretMaterial(<redacted>)" } - let alphabet = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") - let reverse = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt8($0)) }) - var values = [UInt8]() - values.reserveCapacity(encoded.count) - for character in encoded { - guard let value = reverse[character] else { - return nil - } - values.append(value) + + func copyBytes() -> Data { + bytes } - guard bech32Polymod(hrp: "nsec", values: values) == 1 else { - return nil + + private static func decodeHex(_ value: String) -> Data? { + guard + value.unicodeScalars.allSatisfy({ + (48 ... 57).contains($0.value) || (65 ... 70).contains($0.value) + || (97 ... 102).contains($0.value) + }) + else { + return nil + } + var output = Data(capacity: 32) + var index = value.startIndex + for _ in 0 ..< 32 { + let next = value.index(index, offsetBy: 2) + guard let byte = UInt8(value[index ..< next], radix: 16) else { + return nil + } + output.append(byte) + index = next + } + return output } - return convertBits(Array(values.dropLast(6)), from: 5, to: 8, pad: false) - } - - private static func bech32Polymod(hrp: String, values: [UInt8]) -> UInt32 { - let generators: [UInt32] = [0x3b6a_57b2, 0x2650_8e6d, 0x1ea1_19fa, 0x3d42_33dd, 0x2a14_62b3] - let expanded = hrp.utf8.map { $0 >> 5 } + [0] + hrp.utf8.map { $0 & 31 } + values - var checksum: UInt32 = 1 - for value in expanded { - let top = checksum >> 25 - checksum = (checksum & 0x01ff_ffff) << 5 ^ UInt32(value) - for (index, generator) in generators.enumerated() where ((top >> index) & 1) == 1 { - checksum ^= generator - } + + private static func decodeNsec(_ value: String) -> Data? { + guard value == value.lowercased(), + value.count <= 90, + let separator = value.lastIndex(of: "1"), + value[..<separator] == "nsec" + else { + return nil + } + let payloadStart = value.index(after: separator) + let encoded = value[payloadStart...] + guard encoded.count >= 6 else { + return nil + } + let alphabet = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") + let reverse = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt8($0)) }) + var values = [UInt8]() + values.reserveCapacity(encoded.count) + for character in encoded { + guard let value = reverse[character] else { + return nil + } + values.append(value) + } + guard bech32Polymod(hrp: "nsec", values: values) == 1 else { + return nil + } + return convertBits(Array(values.dropLast(6)), from: 5, to: 8, pad: false) } - return checksum - } - - private static func convertBits( - _ values: [UInt8], - from sourceBits: Int, - to targetBits: Int, - pad: Bool - ) -> Data? { - var accumulator = 0 - var bitCount = 0 - let maxValue = (1 << targetBits) - 1 - var output = Data() - for value in values { - guard Int(value) >> sourceBits == 0 else { - return nil - } - accumulator = (accumulator << sourceBits) | Int(value) - bitCount += sourceBits - while bitCount >= targetBits { - bitCount -= targetBits - output.append(UInt8((accumulator >> bitCount) & maxValue)) - } + + private static func bech32Polymod(hrp: String, values: [UInt8]) -> UInt32 { + let generators: [UInt32] = [0x3B6A_57B2, 0x2650_8E6D, 0x1EA1_19FA, 0x3D42_33DD, 0x2A14_62B3] + let expanded = hrp.utf8.map { $0 >> 5 } + [0] + hrp.utf8.map { $0 & 31 } + values + var checksum: UInt32 = 1 + for value in expanded { + let top = checksum >> 25 + checksum = (checksum & 0x01FF_FFFF) << 5 ^ UInt32(value) + for (index, generator) in generators.enumerated() where ((top >> index) & 1) == 1 { + checksum ^= generator + } + } + return checksum } - if pad, bitCount > 0 { - output.append(UInt8((accumulator << (targetBits - bitCount)) & maxValue)) - } else if bitCount >= sourceBits || ((accumulator << (targetBits - bitCount)) & maxValue) != 0 { - return nil + + private static func convertBits( + _ values: [UInt8], + from sourceBits: Int, + to targetBits: Int, + pad: Bool + ) -> Data? { + var accumulator = 0 + var bitCount = 0 + let maxValue = (1 << targetBits) - 1 + var output = Data() + for value in values { + guard Int(value) >> sourceBits == 0 else { + return nil + } + accumulator = (accumulator << sourceBits) | Int(value) + bitCount += sourceBits + while bitCount >= targetBits { + bitCount -= targetBits + output.append(UInt8((accumulator >> bitCount) & maxValue)) + } + } + if pad, bitCount > 0 { + output.append(UInt8((accumulator << (targetBits - bitCount)) & maxValue)) + } else if bitCount >= sourceBits || ((accumulator << (targetBits - bitCount)) & maxValue) != 0 { + return nil + } + return output.count == 32 ? output : nil } - return output.count == 32 ? output : nil - } } public struct RadrootsIdentityPassphrase: Sendable, CustomDebugStringConvertible { - private let bytes: Data - - public init(_ value: String) throws { - let bytes = Data(value.utf8) - guard (12...1_024).contains(bytes.count), - !value.unicodeScalars.contains(where: { $0.value == 0 }) - else { - throw RadrootsIdentityCustodyError.invalidPassphrase + private let bytes: Data + + public init(_ value: String) throws { + let bytes = Data(value.utf8) + guard (12 ... 1024).contains(bytes.count), + !value.unicodeScalars.contains(where: { $0.value == 0 }) + else { + throw RadrootsIdentityCustodyError.invalidPassphrase + } + self.bytes = bytes } - self.bytes = bytes - } - public var debugDescription: String { - "RadrootsIdentityPassphrase(<redacted>)" - } + public var debugDescription: String { + "RadrootsIdentityPassphrase(<redacted>)" + } - func copyBytes() -> Data { - bytes - } + func copyBytes() -> Data { + bytes + } } public struct RadrootsIdentityPublicRecord: Codable, Equatable, Hashable, Sendable { - public let identityHandle: String - public let publicKeyHex: String - public let label: String? - public let createdAtUnixMilliseconds: UInt64 - public let updatedAtUnixMilliseconds: UInt64 - - public init( - identityHandle: String, - publicKeyHex: String, - label: String?, - createdAtUnixMilliseconds: UInt64, - updatedAtUnixMilliseconds: UInt64 - ) throws { - guard Self.validHandle(identityHandle), - Self.validHex(publicKeyHex, byteCount: 32), - createdAtUnixMilliseconds > 0, - updatedAtUnixMilliseconds >= createdAtUnixMilliseconds - else { - throw RadrootsIdentityCustodyError.invalidMetadata + public let identityHandle: String + public let publicKeyHex: String + public let label: String? + public let createdAtUnixMilliseconds: UInt64 + public let updatedAtUnixMilliseconds: UInt64 + + public init( + identityHandle: String, + publicKeyHex: String, + label: String?, + createdAtUnixMilliseconds: UInt64, + updatedAtUnixMilliseconds: UInt64 + ) throws { + guard Self.validHandle(identityHandle), + Self.validHex(publicKeyHex, byteCount: 32), + createdAtUnixMilliseconds > 0, + updatedAtUnixMilliseconds >= createdAtUnixMilliseconds + else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + self.identityHandle = identityHandle + self.publicKeyHex = publicKeyHex + self.label = try Self.normalizedLabel(label) + self.createdAtUnixMilliseconds = createdAtUnixMilliseconds + self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds } - self.identityHandle = identityHandle - self.publicKeyHex = publicKeyHex - self.label = try Self.normalizedLabel(label) - self.createdAtUnixMilliseconds = createdAtUnixMilliseconds - self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds - } - - static func normalizedLabel(_ value: String?) throws -> String? { - guard let value else { - return nil + + static func normalizedLabel(_ value: String?) throws -> String? { + guard let value else { + return nil + } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, + normalized.utf8.count <= 128, + !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { + throw RadrootsIdentityCustodyError.invalidMetadata + } + return normalized } - let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty, - normalized.utf8.count <= 128, - !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) - else { - throw RadrootsIdentityCustodyError.invalidMetadata + + static func validHandle(_ value: String) -> Bool { + value.hasPrefix("rrid1_") && value.count == 70 + && validHex(String(value.dropFirst(6)), byteCount: 32) + } + + static func validHex(_ value: String, byteCount: Int) -> Bool { + value.count == byteCount * 2 + && value.unicodeScalars.allSatisfy { + (48 ... 57).contains($0.value) || (97 ... 102).contains($0.value) + } } - return normalized - } - - static func validHandle(_ value: String) -> Bool { - value.hasPrefix("rrid1_") && value.count == 70 - && validHex(String(value.dropFirst(6)), byteCount: 32) - } - - static func validHex(_ value: String, byteCount: Int) -> Bool { - value.count == byteCount * 2 - && value.unicodeScalars.allSatisfy { - (48...57).contains($0.value) || (97...102).contains($0.value) - } - } } public enum RadrootsIdentityState: String, Codable, Sendable { - case absent - case locked - case unlocked - case protectedDataUnavailable - case recoveryRequired - case corrupt + case absent + case locked + case unlocked + case protectedDataUnavailable + case recoveryRequired + case corrupt } public struct RadrootsIdentitySnapshot: Equatable, Sendable { - public let state: RadrootsIdentityState - public let identity: RadrootsIdentityPublicRecord? - public let signerHandle: String? - public let recoveryCode: String? - - public init( - state: RadrootsIdentityState, - identity: RadrootsIdentityPublicRecord?, - signerHandle: String?, - recoveryCode: String? - ) { - self.state = state - self.identity = identity - self.signerHandle = signerHandle - self.recoveryCode = recoveryCode - } + public let state: RadrootsIdentityState + public let identity: RadrootsIdentityPublicRecord? + public let signerHandle: String? + public let recoveryCode: String? + + public init( + state: RadrootsIdentityState, + identity: RadrootsIdentityPublicRecord?, + signerHandle: String?, + recoveryCode: String? + ) { + self.state = state + self.identity = identity + self.signerHandle = signerHandle + self.recoveryCode = recoveryCode + } } public enum RadrootsOpaqueSignPurpose: String, Codable, Sendable { - case nostrEvent = "nostr_event" - case blossomUpload = "blossom_upload" + case nostrEvent = "nostr_event" + case blossomUpload = "blossom_upload" } public struct RadrootsOpaqueSignRequest: Sendable, CustomDebugStringConvertible { - public let operationID: String - public let signerHandle: String - public let publicKeyHex: String - public let digest: Data - public let purpose: RadrootsOpaqueSignPurpose - public let deadlineUnixMilliseconds: UInt64 - - public init( - operationID: String, - signerHandle: String, - publicKeyHex: String, - digest: Data, - purpose: RadrootsOpaqueSignPurpose, - deadlineUnixMilliseconds: UInt64 - ) throws { - guard Self.canonicalUUID(operationID), - Self.canonicalUUID(signerHandle), - RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32), - digest.count == 32, - deadlineUnixMilliseconds > 0 - else { - throw RadrootsIdentityCustodyError.invalidSignRequest + public let operationID: String + public let signerHandle: String + public let publicKeyHex: String + public let digest: Data + public let purpose: RadrootsOpaqueSignPurpose + public let deadlineUnixMilliseconds: UInt64 + + public init( + operationID: String, + signerHandle: String, + publicKeyHex: String, + digest: Data, + purpose: RadrootsOpaqueSignPurpose, + deadlineUnixMilliseconds: UInt64 + ) throws { + guard Self.canonicalUUID(operationID), + Self.canonicalUUID(signerHandle), + RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32), + digest.count == 32, + deadlineUnixMilliseconds > 0 + else { + throw RadrootsIdentityCustodyError.invalidSignRequest + } + self.operationID = operationID + self.signerHandle = signerHandle + self.publicKeyHex = publicKeyHex + self.digest = digest + self.purpose = purpose + self.deadlineUnixMilliseconds = deadlineUnixMilliseconds + } + + public var debugDescription: String { + "RadrootsOpaqueSignRequest(operationID: \(operationID), purpose: \(purpose.rawValue), payload: <redacted>)" + } + + static func canonicalUUID(_ value: String) -> Bool { + UUID(uuidString: value)?.uuidString.lowercased() == value } - self.operationID = operationID - self.signerHandle = signerHandle - self.publicKeyHex = publicKeyHex - self.digest = digest - self.purpose = purpose - self.deadlineUnixMilliseconds = deadlineUnixMilliseconds - } - - public var debugDescription: String { - "RadrootsOpaqueSignRequest(operationID: \(operationID), purpose: \(purpose.rawValue), payload: <redacted>)" - } - - static func canonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } } public struct RadrootsOpaqueSignature: Equatable, Sendable, CustomDebugStringConvertible { - public let operationID: String - public let publicKeyHex: String - public let signature: Data - public let purpose: RadrootsOpaqueSignPurpose - - public init( - operationID: String, - publicKeyHex: String, - signature: Data, - purpose: RadrootsOpaqueSignPurpose - ) throws { - guard RadrootsOpaqueSignRequest.canonicalUUID(operationID), - RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32), - signature.count == 64 - else { - throw RadrootsIdentityCustodyError.invalidSignature + public let operationID: String + public let publicKeyHex: String + public let signature: Data + public let purpose: RadrootsOpaqueSignPurpose + + public init( + operationID: String, + publicKeyHex: String, + signature: Data, + purpose: RadrootsOpaqueSignPurpose + ) throws { + guard RadrootsOpaqueSignRequest.canonicalUUID(operationID), + RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32), + signature.count == 64 + else { + throw RadrootsIdentityCustodyError.invalidSignature + } + self.operationID = operationID + self.publicKeyHex = publicKeyHex + self.signature = signature + self.purpose = purpose + } + + public var debugDescription: String { + "RadrootsOpaqueSignature(operationID: \(operationID), purpose: \(purpose.rawValue), signature: <redacted>)" } - self.operationID = operationID - self.publicKeyHex = publicKeyHex - self.signature = signature - self.purpose = purpose - } - - public var debugDescription: String { - "RadrootsOpaqueSignature(operationID: \(operationID), purpose: \(purpose.rawValue), signature: <redacted>)" - } } public enum RadrootsIdentityCustodyError: String, Error, Sendable { - case invalidConfiguration = "identity.invalid_configuration" - case invalidSecret = "identity.invalid_secret" - case invalidPassphrase = "identity.invalid_passphrase" - case invalidMetadata = "identity.invalid_metadata" - case corruptMetadata = "identity.corrupt_metadata" - case inconsistentState = "identity.inconsistent_state" - case identityAlreadyExists = "identity.already_exists" - case identityNotFound = "identity.not_found" - case identityLocked = "identity.locked" - case protectedDataUnavailable = "identity.protected_data_unavailable" - case userPresenceRequired = "identity.user_presence_required" - case invalidSignRequest = "identity.invalid_sign_request" - case staleSigner = "identity.stale_signer" - case duplicateOperation = "identity.duplicate_operation" - case signingSaturated = "identity.signing_saturated" - case cancelled = "identity.cancelled" - case timedOut = "identity.timed_out" - case invalidSignature = "identity.invalid_signature" - case recoveryRequired = "identity.recovery_required" - case unsupportedPortabilityEnvelope = "identity.unsupported_portability_envelope" - case portabilityAuthenticationFailed = "identity.portability_authentication_failed" - case storageUnavailable = "identity.storage_unavailable" - case cryptographyFailed = "identity.cryptography_failed" - - public var code: String { - rawValue - } + case invalidConfiguration = "identity.invalid_configuration" + case invalidSecret = "identity.invalid_secret" + case invalidPassphrase = "identity.invalid_passphrase" + case invalidMetadata = "identity.invalid_metadata" + case corruptMetadata = "identity.corrupt_metadata" + case inconsistentState = "identity.inconsistent_state" + case identityAlreadyExists = "identity.already_exists" + case identityNotFound = "identity.not_found" + case identityLocked = "identity.locked" + case protectedDataUnavailable = "identity.protected_data_unavailable" + case userPresenceRequired = "identity.user_presence_required" + case invalidSignRequest = "identity.invalid_sign_request" + case staleSigner = "identity.stale_signer" + case duplicateOperation = "identity.duplicate_operation" + case signingSaturated = "identity.signing_saturated" + case cancelled = "identity.cancelled" + case timedOut = "identity.timed_out" + case invalidSignature = "identity.invalid_signature" + case recoveryRequired = "identity.recovery_required" + case unsupportedPortabilityEnvelope = "identity.unsupported_portability_envelope" + case portabilityAuthenticationFailed = "identity.portability_authentication_failed" + case storageUnavailable = "identity.storage_unavailable" + case cryptographyFailed = "identity.cryptography_failed" + + public var code: String { + rawValue + } } extension RadrootsIdentityCustodyError: LocalizedError { - public var errorDescription: String? { - switch self { - case .invalidConfiguration: "Identity storage configuration is invalid." - case .invalidSecret: "The identity secret is invalid." - case .invalidPassphrase: "The portability passphrase is invalid." - case .invalidMetadata: "Identity metadata is invalid." - case .corruptMetadata: "Identity metadata is corrupt and requires recovery." - case .inconsistentState: "Identity storage is inconsistent and requires recovery." - case .identityAlreadyExists: "A local identity already exists." - case .identityNotFound: "No local identity is available." - case .identityLocked: "The local identity is locked." - case .protectedDataUnavailable: "Protected identity data is unavailable." - case .userPresenceRequired: "User presence was not verified." - case .invalidSignRequest: "The signing request is invalid." - case .staleSigner: "The signer handle is no longer active." - case .duplicateOperation: "The signing operation is already active." - case .signingSaturated: "The signer is temporarily at capacity." - case .cancelled: "The signing operation was cancelled." - case .timedOut: "The signing operation timed out." - case .invalidSignature: "The signing result failed verification." - case .recoveryRequired: "Identity recovery must complete before continuing." - case .unsupportedPortabilityEnvelope: "The encrypted identity envelope is unsupported." - case .portabilityAuthenticationFailed: - "The encrypted identity envelope could not be authenticated." - case .storageUnavailable: "Identity storage is unavailable." - case .cryptographyFailed: "The identity cryptography operation failed." + public var errorDescription: String? { + switch self { + case .invalidConfiguration: "Identity storage configuration is invalid." + case .invalidSecret: "The identity secret is invalid." + case .invalidPassphrase: "The portability passphrase is invalid." + case .invalidMetadata: "Identity metadata is invalid." + case .corruptMetadata: "Identity metadata is corrupt and requires recovery." + case .inconsistentState: "Identity storage is inconsistent and requires recovery." + case .identityAlreadyExists: "A local identity already exists." + case .identityNotFound: "No local identity is available." + case .identityLocked: "The local identity is locked." + case .protectedDataUnavailable: "Protected identity data is unavailable." + case .userPresenceRequired: "User presence was not verified." + case .invalidSignRequest: "The signing request is invalid." + case .staleSigner: "The signer handle is no longer active." + case .duplicateOperation: "The signing operation is already active." + case .signingSaturated: "The signer is temporarily at capacity." + case .cancelled: "The signing operation was cancelled." + case .timedOut: "The signing operation timed out." + case .invalidSignature: "The signing result failed verification." + case .recoveryRequired: "Identity recovery must complete before continuing." + case .unsupportedPortabilityEnvelope: "The encrypted identity envelope is unsupported." + case .portabilityAuthenticationFailed: + "The encrypted identity envelope could not be authenticated." + case .storageUnavailable: "Identity storage is unavailable." + case .cryptographyFailed: "The identity cryptography operation failed." + } } - } } diff --git a/Sources/RadrootsKit/RadrootsPermissionLocation.swift b/Sources/RadrootsKit/RadrootsPermissionLocation.swift @@ -35,12 +35,12 @@ public protocol RadrootsPermissionStatusProvider: Sendable { func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot] } -extension RadrootsPermissionStatusProvider { - public func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot] { +public extension RadrootsPermissionStatusProvider { + func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot] { var snapshots: [RadrootsPermissionSnapshot] = [] snapshots.reserveCapacity(kinds.count) for kind in kinds { - snapshots.append(try await snapshot(for: kind)) + try await snapshots.append(snapshot(for: kind)) } return snapshots } @@ -98,10 +98,10 @@ public struct RadrootsLocationCoordinate: Sendable, Equatable, Hashable { public let longitude: Double public init(latitude: Double, longitude: Double) throws { - guard latitude.isFinite, (-90.0...90.0).contains(latitude) else { + guard latitude.isFinite, (-90.0 ... 90.0).contains(latitude) else { throw RadrootsLocationServicesError.invalidRequest("latitude must be between -90 and 90") } - guard longitude.isFinite, (-180.0...180.0).contains(longitude) else { + guard longitude.isFinite, (-180.0 ... 180.0).contains(longitude) else { throw RadrootsLocationServicesError.invalidRequest("longitude must be between -180 and 180") } self.latitude = latitude @@ -189,7 +189,7 @@ public struct RadrootsLocationReading: Sendable, Equatable, Hashable { guard let value else { return nil } - guard value.isFinite, (0.0..<360.0).contains(value) else { + guard value.isFinite, (0.0 ..< 360.0).contains(value) else { throw RadrootsLocationServicesError.invalidRequest("course must be between 0 and 359.999 degrees") } return value @@ -253,19 +253,19 @@ public enum RadrootsLocationServicesError: Error, Equatable, Sendable { extension RadrootsLocationServicesError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message - case .permissionDenied(let message): + case let .permissionDenied(message): message - case .unavailable(let message): + case let .unavailable(message): message - case .timeout(let message): + case let .timeout(message): message - case .cancelled(let message): + case let .cancelled(message): message - case .transientFailure(let message): + case let .transientFailure(message): message - case .permanentFailure(let message): + case let .permanentFailure(message): message } } diff --git a/Sources/RadrootsKit/RadrootsSecureStore.swift b/Sources/RadrootsKit/RadrootsSecureStore.swift @@ -43,9 +43,9 @@ public struct RadrootsSecureStoreKey: Hashable, Sendable { } public func normalized() throws -> Self { - Self( - namespace: try Self.normalizedNamespace(namespace), - name: try Self.normalizedName(name) + try Self( + namespace: Self.normalizedNamespace(namespace), + name: Self.normalizedName(name) ) } @@ -54,7 +54,7 @@ public struct RadrootsSecureStoreKey: Hashable, Sendable { } public static func serviceName(servicePrefix: String, namespace: String) throws -> String { - "\(try normalizedServicePrefix(servicePrefix)).\(try normalizedNamespace(namespace))" + try "\(normalizedServicePrefix(servicePrefix)).\(normalizedNamespace(namespace))" } public static func normalizedServicePrefix(_ servicePrefix: String) throws -> String { @@ -94,8 +94,8 @@ public protocol RadrootsSecureStore: AnyObject, Sendable { func deleteNamespace(_ namespace: String) throws } -extension RadrootsSecureStore { - public func put(_ value: Data, for key: RadrootsSecureStoreKey) throws { +public extension RadrootsSecureStore { + func put(_ value: Data, for key: RadrootsSecureStoreKey) throws { try put(value, for: key, policy: .secureLocalSecret) } } diff --git a/Sources/RadrootsKit/RadrootsTelemetry.swift b/Sources/RadrootsKit/RadrootsTelemetry.swift @@ -7,7 +7,7 @@ public enum RadrootsTelemetryError: Error, Equatable, Sendable { extension RadrootsTelemetryError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message } } @@ -55,15 +55,15 @@ public enum RadrootsTelemetryFieldValue: Sendable, Equatable, Hashable { public var renderedValue: String { switch self { - case .string(let value): + case let .string(value): value - case .integer(let value): + case let .integer(value): String(value) - case .double(let value): + case let .double(value): String(value) - case .bool(let value): + case let .bool(value): value ? "true" : "false" - case .stringList(let value): + case let .stringList(value): value.joined(separator: ",") } } @@ -73,11 +73,11 @@ public enum RadrootsTelemetryFieldValue: Sendable, Equatable, Hashable { policy: RadrootsTelemetryRedactionPolicy ) -> RadrootsTelemetryFieldValue { switch self { - case .string(let value): + case let .string(value): return .string(policy.redactedString(value, key: key)) case .integer, .double, .bool: return policy.shouldRedactKey(key) ? .string(policy.replacement) : self - case .stringList(let values): + case let .stringList(values): if policy.shouldRedactKey(key) { return .string(policy.replacement) } @@ -126,7 +126,7 @@ public struct RadrootsTelemetryField: Sendable, Equatable, Hashable { } fileprivate init(validatedKey: String, value: RadrootsTelemetryFieldValue) { - self.key = validatedKey + key = validatedKey self.value = value } @@ -185,8 +185,8 @@ public struct RadrootsTelemetryEvent: Sendable, Equatable, Hashable { fields: [RadrootsTelemetryField], occurredAt: Date ) { - self.name = validatedName - self.category = validatedCategory + name = validatedName + category = validatedCategory self.level = level self.message = message self.fields = fields @@ -254,7 +254,7 @@ public struct RadrootsTelemetryRedactionPolicy: Sendable, Equatable, Hashable { "secret", "selected_secret", "text", - "token" + "token", ] return unsafeFragments.contains { normalized.contains($0) } } @@ -269,7 +269,7 @@ public struct RadrootsTelemetryRedactionPolicy: Sendable, Equatable, Hashable { "/private/var/", "/var/mobile/containers/", "/var/folders/", - "file:///" + "file:///", ] if unsafePathFragments.contains(where: { normalized.contains($0) }) { return true @@ -290,7 +290,7 @@ public protocol RadrootsTelemetry: Sendable { public struct RadrootsNoopTelemetry: RadrootsTelemetry, Sendable { public init() {} - public func record(_ event: RadrootsTelemetryEvent) async {} + public func record(_: RadrootsTelemetryEvent) async {} } public struct RadrootsRedactingTelemetry: RadrootsTelemetry, Sendable { @@ -365,17 +365,17 @@ public enum RadrootsTelemetryValidation { public static func validate(_ value: RadrootsTelemetryFieldValue) throws { switch value { - case .string(let string): + case let .string(string): try validateStringValue(string) case .integer: return - case .double(let double): + case let .double(double): guard double.isFinite else { throw RadrootsTelemetryError.invalidRequest("telemetry double field must be finite") } case .bool: return - case .stringList(let values): + case let .stringList(values): guard values.count <= 24 else { throw RadrootsTelemetryError.invalidRequest("telemetry string list field is too long") } diff --git a/Sources/RadrootsKit/RadrootsUserPresence.swift b/Sources/RadrootsKit/RadrootsUserPresence.swift @@ -85,19 +85,19 @@ public enum RadrootsUserPresenceError: Error, Equatable, Sendable { extension RadrootsUserPresenceError: LocalizedError { public var errorDescription: String? { switch self { - case .invalidRequest(let message): + case let .invalidRequest(message): message - case .userCancelled(let message): + case let .userCancelled(message): message - case .permissionDenied(let message): + case let .permissionDenied(message): message - case .unavailable(let message): + case let .unavailable(message): message - case .timeout(let message): + case let .timeout(message): message - case .transientFailure(let message): + case let .transientFailure(message): message - case .permanentFailure(let message): + case let .permanentFailure(message): message } } diff --git a/Sources/RadrootsKitTesting/RadrootsBackgroundTaskTesting.swift b/Sources/RadrootsKitTesting/RadrootsBackgroundTaskTesting.swift @@ -14,10 +14,10 @@ public actor RadrootsFakeBackgroundTaskScheduler: RadrootsBackgroundTaskSchedule submitOutcome: Result<Void, RadrootsBackgroundTaskError> = .success(()), submittedAt: Date = Date(timeIntervalSince1970: 0) ) { - self.pendingTaskSnapshots = Dictionary(uniqueKeysWithValues: pendingTasks.map { ($0.identifier, $0) }) - self.submittedRequestsValue = [] - self.cancelledIdentifiersValue = [] - self.cancelAllCountValue = 0 + pendingTaskSnapshots = Dictionary(uniqueKeysWithValues: pendingTasks.map { ($0.identifier, $0) }) + submittedRequestsValue = [] + cancelledIdentifiersValue = [] + cancelAllCountValue = 0 self.submitOutcome = submitOutcome self.submittedAt = submittedAt } @@ -33,7 +33,7 @@ public actor RadrootsFakeBackgroundTaskScheduler: RadrootsBackgroundTaskSchedule let snapshot = try RadrootsBackgroundTaskSnapshot(request: request, submittedAt: submittedAt) pendingTaskSnapshots[request.identifier] = snapshot return snapshot - case .failure(let error): + case let .failure(error): throw error } } diff --git a/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift b/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift @@ -5,7 +5,7 @@ public actor RadrootsInMemoryBackgroundTransferStore: RadrootsBackgroundTransfer private var snapshotsByIdentifier: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) { - self.snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) }) + snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) }) } public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { @@ -42,9 +42,9 @@ public actor RadrootsFakeBackgroundTransfer: RadrootsBackgroundTransfer { ) { self.store = store self.enqueueOutcome = enqueueOutcome - self.enqueuedRequestsValue = [] - self.cancelledIdentifiersValue = [] - self.handledBackgroundEventIdentifiersValue = [] + enqueuedRequestsValue = [] + cancelledIdentifiersValue = [] + handledBackgroundEventIdentifiersValue = [] self.updatedAt = updatedAt } @@ -63,7 +63,7 @@ public actor RadrootsFakeBackgroundTransfer: RadrootsBackgroundTransfer { ) try await store.saveSnapshot(snapshot) return RadrootsBackgroundTransferHandle(request: request) - case .failure(let error): + case let .failure(error): throw error } } diff --git a/Sources/RadrootsKitTesting/RadrootsCaptureIntakeTesting.swift b/Sources/RadrootsKitTesting/RadrootsCaptureIntakeTesting.swift @@ -19,11 +19,11 @@ public actor RadrootsFakeMediaPicker: RadrootsMediaPicker { self.support = support self.importOutcome = importOutcome self.captureOutcome = captureOutcome - self.importRequestCountValue = 0 - self.captureRequestCountValue = 0 - self.supportRequestCountValue = 0 - self.lastImportRequestValue = nil - self.lastCaptureRequestValue = nil + importRequestCountValue = 0 + captureRequestCountValue = 0 + supportRequestCountValue = 0 + lastImportRequestValue = nil + lastCaptureRequestValue = nil } public func setSupport(_ support: RadrootsMediaPickerSupport) { @@ -31,11 +31,11 @@ public actor RadrootsFakeMediaPicker: RadrootsMediaPicker { } public func setImportOutcome(_ outcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) { - self.importOutcome = outcome + importOutcome = outcome } public func setCaptureOutcome(_ outcome: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) { - self.captureOutcome = outcome + captureOutcome = outcome } public func currentSupport() async throws -> RadrootsMediaPickerSupport { @@ -47,9 +47,9 @@ public actor RadrootsFakeMediaPicker: RadrootsMediaPicker { importRequestCountValue += 1 lastImportRequestValue = request switch importOutcome { - case .success(let result): + case let .success(result): return result - case .failure(let error): + case let .failure(error): throw error } } @@ -58,9 +58,9 @@ public actor RadrootsFakeMediaPicker: RadrootsMediaPicker { captureRequestCountValue += 1 lastCaptureRequestValue = request switch captureOutcome { - case .success(let result): + case let .success(result): return result - case .failure(let error): + case let .failure(error): throw error } } @@ -99,9 +99,9 @@ public actor RadrootsFakeDocumentScanner: RadrootsDocumentScanner { ) { self.support = support self.scanOutcome = scanOutcome - self.supportRequestCountValue = 0 - self.scanRequestCountValue = 0 - self.lastScanRequestValue = nil + supportRequestCountValue = 0 + scanRequestCountValue = 0 + lastScanRequestValue = nil } public func setSupport(_ support: RadrootsDocumentScannerSupport) { @@ -109,7 +109,7 @@ public actor RadrootsFakeDocumentScanner: RadrootsDocumentScanner { } public func setScanOutcome(_ outcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) { - self.scanOutcome = outcome + scanOutcome = outcome } public func currentSupport() async throws -> RadrootsDocumentScannerSupport { @@ -121,9 +121,9 @@ public actor RadrootsFakeDocumentScanner: RadrootsDocumentScanner { scanRequestCountValue += 1 lastScanRequestValue = request switch scanOutcome { - case .success(let document): + case let .success(document): return document - case .failure(let error): + case let .failure(error): throw error } } diff --git a/Sources/RadrootsKitTesting/RadrootsExternalActionsTesting.swift b/Sources/RadrootsKitTesting/RadrootsExternalActionsTesting.swift @@ -18,10 +18,10 @@ public actor RadrootsFakeExternalActions: RadrootsExternalActions { self.capabilityOverrides = capabilityOverrides self.defaultCanOpen = defaultCanOpen self.openOutcome = openOutcome - self.capabilityRequestCountValue = 0 - self.openRequestCountValue = 0 - self.lastCapabilityDestinationValue = nil - self.openedDestinationsValue = [] + capabilityRequestCountValue = 0 + openRequestCountValue = 0 + lastCapabilityDestinationValue = nil + openedDestinationsValue = [] } public func setCapability(_ canOpen: Bool, for destination: RadrootsExternalActionDestination) { @@ -51,7 +51,7 @@ public actor RadrootsFakeExternalActions: RadrootsExternalActions { switch openOutcome { case .success: return - case .failure(let error): + case let .failure(error): throw error } } diff --git a/Sources/RadrootsKitTesting/RadrootsIdentityMetadataTesting.swift b/Sources/RadrootsKitTesting/RadrootsIdentityMetadataTesting.swift @@ -2,71 +2,71 @@ import Foundation import RadrootsKit public final class RadrootsInMemoryIdentityMetadataStore: RadrootsIdentityMetadataStore, - @unchecked Sendable + @unchecked Sendable { - public enum Operation: Equatable, Sendable { - case read - case write - case delete - } + public enum Operation: Equatable, Sendable { + case read + case write + case delete + } - public enum Failure: Error, Sendable { - case forced - } + public enum Failure: Error, Sendable { + case forced + } - private let lock = NSLock() - private var values: [RadrootsIdentityMetadataSlot: Data] = [:] - private var nextFailure: (Operation, RadrootsIdentityMetadataSlot)? + private let lock = NSLock() + private var values: [RadrootsIdentityMetadataSlot: Data] = [:] + private var nextFailure: (Operation, RadrootsIdentityMetadataSlot)? - public init() {} + public init() {} - public func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? { - lock.lock() - defer { lock.unlock() } - try failIfRequested(.read, slot: slot) - return values[slot] - } + public func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? { + lock.lock() + defer { lock.unlock() } + try failIfRequested(.read, slot: slot) + return values[slot] + } - public func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws { - lock.lock() - defer { lock.unlock() } - try failIfRequested(.write, slot: slot) - values[slot] = data - } + public func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws { + lock.lock() + defer { lock.unlock() } + try failIfRequested(.write, slot: slot) + values[slot] = data + } - public func delete(_ slot: RadrootsIdentityMetadataSlot) throws { - lock.lock() - defer { lock.unlock() } - try failIfRequested(.delete, slot: slot) - values.removeValue(forKey: slot) - } + public func delete(_ slot: RadrootsIdentityMetadataSlot) throws { + lock.lock() + defer { lock.unlock() } + try failIfRequested(.delete, slot: slot) + values.removeValue(forKey: slot) + } - public func failNext(_ operation: Operation, slot: RadrootsIdentityMetadataSlot) { - lock.lock() - nextFailure = (operation, slot) - lock.unlock() - } + public func failNext(_ operation: Operation, slot: RadrootsIdentityMetadataSlot) { + lock.lock() + nextFailure = (operation, slot) + lock.unlock() + } - public func rawData(for slot: RadrootsIdentityMetadataSlot) -> Data? { - lock.lock() - defer { lock.unlock() } - return values[slot] - } + public func rawData(for slot: RadrootsIdentityMetadataSlot) -> Data? { + lock.lock() + defer { lock.unlock() } + return values[slot] + } - public func replaceRawData(_ data: Data?, for slot: RadrootsIdentityMetadataSlot) { - lock.lock() - values[slot] = data - lock.unlock() - } + public func replaceRawData(_ data: Data?, for slot: RadrootsIdentityMetadataSlot) { + lock.lock() + values[slot] = data + lock.unlock() + } - private func failIfRequested(_ operation: Operation, slot: RadrootsIdentityMetadataSlot) throws { - guard let failure = nextFailure, - failure.0 == operation, - failure.1 == slot - else { - return + private func failIfRequested(_ operation: Operation, slot: RadrootsIdentityMetadataSlot) throws { + guard let failure = nextFailure, + failure.0 == operation, + failure.1 == slot + else { + return + } + nextFailure = nil + throw Failure.forced } - nextFailure = nil - throw Failure.forced - } } diff --git a/Sources/RadrootsKitTesting/RadrootsInMemorySecureStore.swift b/Sources/RadrootsKitTesting/RadrootsInMemorySecureStore.swift @@ -11,7 +11,7 @@ public final class RadrootsInMemorySecureStore: RadrootsSecureStore, @unchecked private var entries: [RadrootsSecureStoreKey: Entry] public init() { - self.entries = [:] + entries = [:] } public func put( diff --git a/Sources/RadrootsKitTesting/RadrootsPermissionLocationTesting.swift b/Sources/RadrootsKitTesting/RadrootsPermissionLocationTesting.swift @@ -50,8 +50,8 @@ public actor RadrootsFakeLocationServices: RadrootsLocationServices { self.availability = availability self.authorizationAfterRequest = authorizationAfterRequest self.currentLocationOutcome = currentLocationOutcome - self.requestAuthorizationCountValue = 0 - self.currentLocationRequestCountValue = 0 + requestAuthorizationCountValue = 0 + currentLocationRequestCountValue = 0 } public func setAvailability(_ availability: RadrootsLocationServicesAvailability) { @@ -59,7 +59,7 @@ public actor RadrootsFakeLocationServices: RadrootsLocationServices { } public func setAuthorizationAfterRequest(_ authorization: RadrootsLocationAuthorization) { - self.authorizationAfterRequest = authorization + authorizationAfterRequest = authorization } public func setCurrentLocationOutcome(_ outcome: Result<RadrootsLocationReading, RadrootsLocationServicesError>) { @@ -79,15 +79,15 @@ public actor RadrootsFakeLocationServices: RadrootsLocationServices { return authorizationAfterRequest } - public func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult { + public func currentLocation(_: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult { currentLocationRequestCountValue += 1 switch currentLocationOutcome { - case .success(let reading): + case let .success(reading): return try RadrootsCurrentLocationResult( reading: reading, authorization: availability.authorization ) - case .failure(let error): + case let .failure(error): throw error } } diff --git a/Sources/RadrootsKitTesting/RadrootsTelemetryTesting.swift b/Sources/RadrootsKitTesting/RadrootsTelemetryTesting.swift @@ -7,7 +7,7 @@ public actor RadrootsRecordingTelemetry: RadrootsTelemetry { public init(minimumLevel: RadrootsTelemetryLevel = .trace) { self.minimumLevel = minimumLevel - self.recordedEventsValue = [] + recordedEventsValue = [] } public func record(_ event: RadrootsTelemetryEvent) async { diff --git a/Sources/RadrootsKitTesting/RadrootsUITestLaunchConfiguration.swift b/Sources/RadrootsKitTesting/RadrootsUITestLaunchConfiguration.swift @@ -21,7 +21,7 @@ public struct RadrootsUITestLaunchConfiguration: Sendable, Equatable { "-AppleLanguages", "(\(language))", "-AppleLocale", - locale + locale, ] ) } diff --git a/Sources/RadrootsKitTesting/RadrootsUserPresenceTesting.swift b/Sources/RadrootsKitTesting/RadrootsUserPresenceTesting.swift @@ -16,10 +16,10 @@ public actor RadrootsFakeUserPresence: RadrootsUserPresence { ), verificationOutcome: Result<Bool, RadrootsUserPresenceError> = .success(true) ) { - self.statusValue = status + statusValue = status self.verificationOutcome = verificationOutcome - self.statusRequestCountValue = 0 - self.verificationRequestsValue = [] + statusRequestCountValue = 0 + verificationRequestsValue = [] } public func setStatus(_ status: RadrootsUserPresenceStatus) { @@ -38,9 +38,9 @@ public actor RadrootsFakeUserPresence: RadrootsUserPresence { public func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { verificationRequestsValue.append(request) switch verificationOutcome { - case .success(let verified): + case let .success(verified): return RadrootsUserPresenceResult(policy: request.policy, verified: verified) - case .failure(let error): + case let .failure(error): throw error } } diff --git a/Tests/RadrootsKitTestingTests/RadrootsBackgroundTaskTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsBackgroundTaskTestingTests.swift @@ -1,7 +1,7 @@ import Foundation -import Testing import RadrootsKit import RadrootsKitTesting +import Testing @Test func fakeBackgroundTaskSchedulerRecordsSubmittedRequestsAndPendingTasks() async throws { let scheduler = RadrootsFakeBackgroundTaskScheduler( diff --git a/Tests/RadrootsKitTestingTests/RadrootsBackgroundTransferTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsBackgroundTransferTestingTests.swift @@ -1,7 +1,7 @@ import Foundation -import Testing import RadrootsKit import RadrootsKitTesting +import Testing @Test func inMemoryBackgroundTransferStorePersistsSnapshotsInIdentifierOrder() async throws { let first = try RadrootsBackgroundTransferSnapshot( @@ -17,7 +17,7 @@ import RadrootsKitTesting #expect(try await store.loadSnapshots().map(\.identifier.rawValue) == [ "field.transfer.a", - "field.transfer.b" + "field.transfer.b", ]) try await store.removeSnapshot(for: second.identifier) diff --git a/Tests/RadrootsKitTestingTests/RadrootsCaptureIntakeTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsCaptureIntakeTestingTests.swift @@ -1,14 +1,14 @@ import Foundation -import Testing import RadrootsKit import RadrootsKitTesting +import Testing @Test func fakeMediaPickerReturnsConfiguredSupportAndResults() async throws { let asset = try testMediaAsset() let importResult = try RadrootsMediaImportResult(items: [asset]) let captureResult = RadrootsMediaCaptureResult(item: asset) - let picker = RadrootsFakeMediaPicker( - support: try RadrootsMediaPickerSupport( + let picker = try RadrootsFakeMediaPicker( + support: RadrootsMediaPickerSupport( importAvailable: true, cameraCaptureAvailable: true, supportedImportKinds: [.image], @@ -33,8 +33,8 @@ import RadrootsKitTesting @Test func fakeMediaPickerReturnsTypedFailures() async throws { let asset = try testMediaAsset() - let picker = RadrootsFakeMediaPicker( - support: try RadrootsMediaPickerSupport( + let picker = try RadrootsFakeMediaPicker( + support: RadrootsMediaPickerSupport( importAvailable: true, cameraCaptureAvailable: true, supportedImportKinds: [.image], @@ -46,20 +46,20 @@ import RadrootsKitTesting ) await #expect(throws: RadrootsCaptureIntakeError.userCancelled("media import was cancelled")) { - _ = try await picker.importMedia(try RadrootsMediaImportRequest()) + _ = try await picker.importMedia(RadrootsMediaImportRequest()) } await picker.setCaptureOutcome(.failure(.permissionDenied("camera access is denied"))) await #expect(throws: RadrootsCaptureIntakeError.permissionDenied("camera access is denied")) { - _ = try await picker.captureMedia(try RadrootsMediaCaptureRequest()) + _ = try await picker.captureMedia(RadrootsMediaCaptureRequest()) } } @Test func fakeDocumentScannerReturnsConfiguredSupportAndResults() async throws { let document = try testScannedDocument() - let scanner = RadrootsFakeDocumentScanner( - support: try RadrootsDocumentScannerSupport( + let scanner = try RadrootsFakeDocumentScanner( + support: RadrootsDocumentScannerSupport( interactiveScanAvailable: true, multiPageSupported: true, supportedOutputKinds: [.pdf] @@ -76,8 +76,8 @@ import RadrootsKitTesting } @Test func fakeDocumentScannerReturnsTypedFailures() async throws { - let scanner = RadrootsFakeDocumentScanner( - support: try RadrootsDocumentScannerSupport( + let scanner = try RadrootsFakeDocumentScanner( + support: RadrootsDocumentScannerSupport( interactiveScanAvailable: false, multiPageSupported: false, supportedOutputKinds: [] diff --git a/Tests/RadrootsKitTestingTests/RadrootsExternalActionsTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsExternalActionsTestingTests.swift @@ -1,7 +1,7 @@ import Foundation -import Testing import RadrootsKit import RadrootsKitTesting +import Testing @Test func fakeExternalActionsRecordsCapabilityAndOpenRequests() async throws { let web = try RadrootsExternalActionDestination.web("https://radroots.org") @@ -12,7 +12,7 @@ import RadrootsKitTesting ) #expect(await actions.canOpen(web).canOpen) - #expect(!(await actions.canOpen(nostr).canOpen)) + #expect(await !(actions.canOpen(nostr).canOpen)) try await actions.open(RadrootsExternalActionRequest(destination: web)) #expect(await actions.capabilityRequestCount == 2) diff --git a/Tests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsKitTestingTests.swift @@ -1,7 +1,7 @@ import Foundation -import Testing import RadrootsKit import RadrootsKitTesting +import Testing @Test func deterministicLaunchConfigurationAddsStableLocaleArguments() { let config = RadrootsUITestLaunchConfiguration.deterministic( @@ -15,7 +15,7 @@ import RadrootsKitTesting "-AppleLanguages", "(en)", "-AppleLocale", - "en_US_POSIX" + "en_US_POSIX", ]) } @@ -28,7 +28,7 @@ import RadrootsKitTesting #expect(config.mergedEnvironment(over: ["A": "old", "C": "keep"]) == [ "A": "override", "B": "new", - "C": "keep" + "C": "keep", ]) } @@ -106,7 +106,7 @@ import RadrootsKitTesting #expect(try await service.requestWhenInUseAuthorization() == .authorizedWhenInUse) #expect(await service.currentAvailability().authorization == .authorizedWhenInUse) - let result = try await service.currentLocation(try RadrootsCurrentLocationRequest(timeoutSeconds: 2)) + let result = try await service.currentLocation(RadrootsCurrentLocationRequest(timeoutSeconds: 2)) #expect(result.reading == reading) #expect(result.authorization == .authorizedWhenInUse) #expect(await service.requestAuthorizationCount == 1) @@ -129,6 +129,6 @@ import RadrootsKitTesting await service.setCurrentLocationOutcome(.failure(.timeout("timed out"))) await #expect(throws: RadrootsLocationServicesError.timeout("timed out")) { - _ = try await service.currentLocation(try RadrootsCurrentLocationRequest(timeoutSeconds: 2)) + _ = try await service.currentLocation(RadrootsCurrentLocationRequest(timeoutSeconds: 2)) } } diff --git a/Tests/RadrootsKitTestingTests/RadrootsTelemetryTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsTelemetryTestingTests.swift @@ -1,6 +1,6 @@ -import Testing import RadrootsKit import RadrootsKitTesting +import Testing @Test func recordingTelemetryStoresEventsInOrderAndFiltersByLevel() async throws { let telemetry = RadrootsRecordingTelemetry(minimumLevel: .warning) @@ -15,7 +15,7 @@ import RadrootsKitTesting #expect(await telemetry.recordedEventCount == 2) #expect(await telemetry.recordedEventNames == [ "field_ios.relay.warning", - "field_ios.identity.critical" + "field_ios.identity.critical", ]) #expect(await telemetry.events(named: "field_ios.relay.warning").count == 1) diff --git a/Tests/RadrootsKitTestingTests/RadrootsUserPresenceTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsUserPresenceTestingTests.swift @@ -1,7 +1,7 @@ import Foundation -import Testing import RadrootsKit import RadrootsKitTesting +import Testing @Test func fakeUserPresenceRecordsStatusAndVerificationRequests() async throws { let presence = RadrootsFakeUserPresence() diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTaskSchedulerTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTaskSchedulerTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func appleBackgroundTaskSchedulerRegistersAndSubmitsThroughAdapters() async throws { let probe = RadrootsAppleBackgroundTaskSchedulerProbe(now: Date(timeIntervalSince1970: 100)) @@ -95,14 +95,14 @@ private actor RadrootsAppleBackgroundTaskSchedulerProbe { submitOutcome: Result<Void, RadrootsBackgroundTaskError> = .success(()), pendingSnapshots: [RadrootsBackgroundTaskSnapshot] = [] ) { - self.nowValue = now + nowValue = now self.registerResult = registerResult self.submitOutcome = submitOutcome - self.pendingSnapshotsValue = pendingSnapshots - self.registeredIdentifiersValue = [] - self.submittedRequestsValue = [] - self.cancelledIdentifiersValue = [] - self.cancelAllCountValue = 0 + pendingSnapshotsValue = pendingSnapshots + registeredIdentifiersValue = [] + submittedRequestsValue = [] + cancelledIdentifiersValue = [] + cancelAllCountValue = 0 } nonisolated func adapters() -> RadrootsAppleBackgroundTaskSchedulerAdapters { @@ -138,7 +138,7 @@ private actor RadrootsAppleBackgroundTaskSchedulerProbe { switch submitOutcome { case .success: return - case .failure(let error): + case let .failure(error): throw error } } diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift @@ -1,9 +1,8 @@ import Foundation +@testable import RadrootsKit import RadrootsKitTesting import Testing -@testable import RadrootsKit - @Test func appleBackgroundTransferPersistsRunningSnapshotAfterEnqueue() async throws { let store = RadrootsInMemoryBackgroundTransferStore() let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 100)) @@ -36,7 +35,8 @@ import Testing @Test func appleBackgroundTransferRecordsFailedSnapshotWhenAdapterRejectsEnqueue() async throws { let store = RadrootsInMemoryBackgroundTransferStore() let probe = RadrootsAppleBackgroundTransferProbe( - now: Date(timeIntervalSince1970: 200), enqueueOutcome: .failure(.transferFailure("adapter rejected transfer"))) + now: Date(timeIntervalSince1970: 200), enqueueOutcome: .failure(.transferFailure("adapter rejected transfer")) + ) let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) let request = try appleTransferRequest(identifier: "field.transfer.failed") @@ -83,7 +83,8 @@ import Testing let request = try appleUploadRequest(identifier: "field.transfer.interrupted") let running = try RadrootsBackgroundTransferSnapshot( request: request, state: .running, progress: RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10), - updatedAt: Date(timeIntervalSince1970: 1)) + updatedAt: Date(timeIntervalSince1970: 1) + ) let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running]) let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 401)) let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) @@ -96,7 +97,7 @@ import Testing #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 401)) } -@Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async throws { +@Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async { let probe = RadrootsAppleBackgroundTransferProbe() let transfer = RadrootsAppleBackgroundTransfer(store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters()) let completion = RadrootsCompletionProbe() @@ -115,7 +116,8 @@ import Testing let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) let coordinator = RadrootsAppleBackgroundTransferCoordinator( sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, fileResolver: resolver, - now: { Date(timeIntervalSince1970: 500) }) + now: { Date(timeIntervalSince1970: 500) } + ) let request = try appleTransferRequest(identifier: "field.transfer.completed") let running = try RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)) try await store.saveSnapshot(running) @@ -127,7 +129,8 @@ import Testing await coordinator.complete( identifier: request.identifier, platformError: nil, stagedDownloadResult: .file(stagedFile), httpResult: successfulHTTPResult(), - bytesTransferred: 0, totalBytesExpected: nil) + bytesTransferred: 0, totalBytesExpected: nil + ) let snapshot = try await store.loadSnapshots().first let destination = try resolver.resolve(.file(RadrootsFileReference(scope: .cache, relativePath: "field.transfer.completed.json"))) @@ -145,14 +148,17 @@ import Testing let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) let coordinator = RadrootsAppleBackgroundTransferCoordinator( sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, fileResolver: resolver, - now: { Date(timeIntervalSince1970: 600) }) + now: { Date(timeIntervalSince1970: 600) } + ) let request = try appleTransferRequest(identifier: "field.transfer.download.failed") try await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1))) + RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)) + ) await coordinator.complete( identifier: request.identifier, platformError: nil, stagedDownloadResult: .failure, httpResult: successfulHTTPResult(), - bytesTransferred: 0, totalBytesExpected: nil) + bytesTransferred: 0, totalBytesExpected: nil + ) let snapshot = try await store.loadSnapshots().first #expect(snapshot?.state == .failed) @@ -166,15 +172,18 @@ import Testing let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) let coordinator = RadrootsAppleBackgroundTransferCoordinator( sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, fileResolver: resolver, - now: { Date(timeIntervalSince1970: 700) }) + now: { Date(timeIntervalSince1970: 700) } + ) let request = try appleUploadRequest(identifier: "field.transfer.upload.completed") try await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1))) + RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)) + ) await coordinator.updateProgress(identifier: request.identifier, bytesTransferred: 4, totalBytesExpected: 10) await coordinator.complete( identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, httpResult: successfulHTTPResult(), - bytesTransferred: 10, totalBytesExpected: 10) + bytesTransferred: 10, totalBytesExpected: 10 + ) let snapshot = try await store.loadSnapshots().first #expect(snapshot?.state == .completed) @@ -189,16 +198,19 @@ import Testing let store = RadrootsInMemoryBackgroundTransferStore() let coordinator = RadrootsAppleBackgroundTransferCoordinator( sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), now: { Date(timeIntervalSince1970: 710) }) + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), now: { Date(timeIntervalSince1970: 710) } + ) let body = Data(#"{"url":"https://cdn.radroots.org/a.png"}"#.utf8) let request = try appleUploadRequest( - identifier: "field.transfer.upload.descriptor", responsePolicy: .boundedJSON(maximumBodyBytes: 1_024)) - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: request, state: .running)) + identifier: "field.transfer.upload.descriptor", responsePolicy: .boundedJSON(maximumBodyBytes: 1024) + ) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .running)) await coordinator.complete( identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, httpResult: RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: "application/json", body: body, bodyExceeded: false), - bytesTransferred: 10, totalBytesExpected: 10) + bytesTransferred: 10, totalBytesExpected: 10 + ) let snapshot = try #require(try await store.loadSnapshots().first) #expect(snapshot.state == .completed) @@ -209,7 +221,8 @@ import Testing await coordinator.complete( identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, httpResult: RadrootsBackgroundHTTPResult(statusCode: 500, mediaType: nil, body: nil, bodyExceeded: false), bytesTransferred: 10, - totalBytesExpected: 10) + totalBytesExpected: 10 + ) #expect(try await store.loadSnapshots().first?.state == .completed) } @@ -218,22 +231,26 @@ import Testing let store = RadrootsInMemoryBackgroundTransferStore() let coordinator = RadrootsAppleBackgroundTransferCoordinator( sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), now: { Date(timeIntervalSince1970: 720) }) + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), now: { Date(timeIntervalSince1970: 720) } + ) let statusRequest = try appleUploadRequest(identifier: "field.transfer.upload.status") - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: statusRequest, state: .running)) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: statusRequest, state: .running)) await coordinator.complete( identifier: statusRequest.identifier, platformError: nil, stagedDownloadResult: nil, httpResult: RadrootsBackgroundHTTPResult(statusCode: 503, mediaType: nil, body: nil, bodyExceeded: false), bytesTransferred: 10, - totalBytesExpected: 10) + totalBytesExpected: 10 + ) #expect(try await store.loadSnapshots().first?.errorMessage == "background_transfer_http_status_503") let bodyRequest = try appleUploadRequest( - identifier: "field.transfer.upload.oversized", responsePolicy: .boundedJSON(maximumBodyBytes: 32)) - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: bodyRequest, state: .running)) + identifier: "field.transfer.upload.oversized", responsePolicy: .boundedJSON(maximumBodyBytes: 32) + ) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: bodyRequest, state: .running)) await coordinator.complete( identifier: bodyRequest.identifier, platformError: CancellationError(), stagedDownloadResult: nil, httpResult: RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: "application/json", body: nil, bodyExceeded: true), - bytesTransferred: 10, totalBytesExpected: 10) + bytesTransferred: 10, totalBytesExpected: 10 + ) let oversized = try #require(try await store.loadSnapshots().first { $0.identifier == bodyRequest.identifier }) #expect(oversized.state == .failed) #expect(oversized.errorMessage == "background_transfer_response_too_large") @@ -245,13 +262,15 @@ import Testing let store = RadrootsInMemoryBackgroundTransferStore() let coordinator = RadrootsAppleBackgroundTransferCoordinator( sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)) + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) let request = try appleUploadRequest(identifier: "field.transfer.upload.cancelled") - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: request, state: .cancelled)) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .cancelled)) await coordinator.complete( identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, httpResult: successfulHTTPResult(), - bytesTransferred: 10, totalBytesExpected: 10) + bytesTransferred: 10, totalBytesExpected: 10 + ) #expect(try await store.loadSnapshots().first?.state == .cancelled) } @@ -261,15 +280,17 @@ import Testing let protectedData = RadrootsProtectedDataProbe(state: .available) let store = RadrootsAppleBackgroundTransferStore(roots: roots, protectedData: RadrootsProtectedDataProvider { protectedData.state }) let request = try appleUploadRequest(identifier: "field.transfer.upload.locked") - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: request, state: .running)) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .running)) let coordinator = RadrootsAppleBackgroundTransferCoordinator( sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)) + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) protectedData.state = .locked await coordinator.complete( identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, httpResult: successfulHTTPResult(), - bytesTransferred: 10, totalBytesExpected: 10) + bytesTransferred: 10, totalBytesExpected: 10 + ) protectedData.state = .available let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: RadrootsAppleBackgroundTransferProbe().adapters()) @@ -283,7 +304,8 @@ import Testing let store = RadrootsInMemoryBackgroundTransferStore() let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, fileResolver: resolver) + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, fileResolver: resolver + ) let completion = RadrootsCompletionProbe() let secondCompletion = RadrootsCompletionProbe() let unrelated = RadrootsCompletionProbe() @@ -307,7 +329,8 @@ import Testing let roots = try appleTransferRoots() let coordinator = RadrootsAppleBackgroundTransferCoordinator( sessionIdentifier: "org.radroots.field-ios.background.transfer", store: RadrootsInMemoryBackgroundTransferStore(), - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots)) + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) let completion = RadrootsCompletionProbe() await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") @@ -328,12 +351,12 @@ private actor RadrootsAppleBackgroundTransferProbe { now: Date = Date(timeIntervalSince1970: 0), enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()), activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> = [] ) { - self.nowValue = now + nowValue = now self.enqueueOutcome = enqueueOutcome - self.activeIdentifiersValue = activeIdentifiers - self.enqueuedRequestsValue = [] - self.cancelledIdentifiersValue = [] - self.handledBackgroundEventIdentifiersValue = [] + activeIdentifiersValue = activeIdentifiers + enqueuedRequestsValue = [] + cancelledIdentifiersValue = [] + handledBackgroundEventIdentifiersValue = [] } nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters { @@ -342,14 +365,15 @@ private actor RadrootsAppleBackgroundTransferProbe { cancel: { identifier in await self.cancel(identifier) }, activeTransferIdentifiers: { await self.activeIdentifiers() }, handleBackgroundEvents: { identifier, completionHandler in await self.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler) - }) + } + ) } private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws { enqueuedRequestsValue.append(request) switch enqueueOutcome { case .success: activeIdentifiersValue.insert(request.identifier) - case .failure(let error): throw error + case let .failure(error): throw error } } @@ -358,18 +382,26 @@ private actor RadrootsAppleBackgroundTransferProbe { activeIdentifiersValue.remove(identifier) } - private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> { activeIdentifiersValue } + private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> { + activeIdentifiersValue + } private func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) { handledBackgroundEventIdentifiersValue.append(identifier) completionHandler() } - var enqueuedRequests: [RadrootsBackgroundTransferRequest] { enqueuedRequestsValue } + var enqueuedRequests: [RadrootsBackgroundTransferRequest] { + enqueuedRequestsValue + } - var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { cancelledIdentifiersValue } + var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { + cancelledIdentifiersValue + } - var handledBackgroundEventIdentifiers: [String] { handledBackgroundEventIdentifiersValue } + var handledBackgroundEventIdentifiers: [String] { + handledBackgroundEventIdentifiersValue + } } private final class RadrootsCompletionProbe: @unchecked Sendable { @@ -382,7 +414,9 @@ private final class RadrootsCompletionProbe: @unchecked Sendable { lock.unlock() } - var completed: Bool { completionCount > 0 } + var completed: Bool { + completionCount > 0 + } var completionCount: Int { lock.lock() @@ -395,7 +429,9 @@ private final class RadrootsProtectedDataProbe: @unchecked Sendable { private let lock = NSLock() private var stateValue: RadrootsProtectedDataState - init(state: RadrootsProtectedDataState) { self.stateValue = state } + init(state: RadrootsProtectedDataState) { + stateValue = state + } var state: RadrootsProtectedDataState { get { @@ -414,7 +450,8 @@ private final class RadrootsProtectedDataProbe: @unchecked Sendable { private func appleTransferRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest { try RadrootsBackgroundTransferRequest( identifier: RadrootsBackgroundTransferIdentifier(identifier), remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, - method: .get, operation: .download(destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")))) + method: .get, operation: .download(destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))) + ) } private func appleUploadRequest(identifier: String, responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard) throws @@ -423,7 +460,8 @@ private func appleUploadRequest(identifier: String, responsePolicy: RadrootsBack try RadrootsBackgroundTransferRequest( identifier: RadrootsBackgroundTransferIdentifier(identifier), remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, method: .put, operation: .upload(source: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))), - responsePolicy: responsePolicy) + responsePolicy: responsePolicy + ) } private func successfulHTTPResult() -> RadrootsBackgroundHTTPResult { @@ -432,9 +470,11 @@ private func successfulHTTPResult() -> RadrootsBackgroundHTTPResult { private func appleTransferRoots() throws -> RadrootsAppleFileRoots { let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "radroots-apple-background-transfer-\(UUID().uuidString)", isDirectory: true) + "radroots-apple-background-transfer-\(UUID().uuidString)", isDirectory: true + ) return try RadrootsAppleFileRoots( appIdentifier: "org.radroots.tests", dataRoot: root.appendingPathComponent("data", isDirectory: true), cacheRoot: root.appendingPathComponent("cache", isDirectory: true), - temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)) + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) } diff --git a/Tests/RadrootsKitTests/RadrootsAppleDocumentScannerTests.swift b/Tests/RadrootsKitTests/RadrootsAppleDocumentScannerTests.swift @@ -1,20 +1,20 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing #if !(canImport(UIKit) && canImport(VisionKit)) -@Test func appleDocumentScannerReportsUnavailableWithoutVisionKitScanner() async throws { - let scanner = try RadrootsAppleDocumentScanner(fileAccess: documentScannerTestFileAccess()) - let support = try await scanner.currentSupport() + @Test func appleDocumentScannerReportsUnavailableWithoutVisionKitScanner() async throws { + let scanner = try RadrootsAppleDocumentScanner(fileAccess: documentScannerTestFileAccess()) + let support = try await scanner.currentSupport() - #expect(!support.interactiveScanAvailable) - #expect(!support.multiPageSupported) - #expect(support.supportedOutputKinds.isEmpty) + #expect(!support.interactiveScanAvailable) + #expect(!support.multiPageSupported) + #expect(support.supportedOutputKinds.isEmpty) - await #expect(throws: RadrootsCaptureIntakeError.unavailable("document scanner is unavailable")) { - _ = try await scanner.scanDocument(RadrootsDocumentScanRequest()) + await #expect(throws: RadrootsCaptureIntakeError.unavailable("document scanner is unavailable")) { + _ = try await scanner.scanDocument(RadrootsDocumentScanRequest()) + } } -} #endif private func documentScannerTestFileAccess() throws -> RadrootsAppleFileAccess { diff --git a/Tests/RadrootsKitTests/RadrootsAppleExternalActionsTests.swift b/Tests/RadrootsKitTests/RadrootsAppleExternalActionsTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func appleExternalActionsOpensAppSettingsThroughAdapter() async throws { let settingsURL = try #require(URL(string: "app-settings:radroots")) @@ -68,11 +68,11 @@ private actor RadrootsExternalActionAdapterProbe { canOpenResult: Bool = true, openResult: Bool = true ) { - self.appSettingsURLValue = appSettingsURL + appSettingsURLValue = appSettingsURL self.canOpenResult = canOpenResult self.openResult = openResult - self.canOpenURLsValue = [] - self.openedURLsValue = [] + canOpenURLsValue = [] + openedURLsValue = [] } nonisolated func adapters() -> RadrootsAppleExternalActionsAdapters { diff --git a/Tests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift b/Tests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func appleFileAccessWritesReadsListsAndDeletesInlineFiles() throws { let access = try testFileAccess() @@ -44,7 +44,7 @@ import Testing try access.write(.inline(data), to: file) - guard case .stagedBlob(let staged) = try access.read(file, mode: .preferInline(maxBytes: 4)) else { + guard case let .stagedBlob(staged) = try access.read(file, mode: .preferInline(maxBytes: 4)) else { Issue.record("expected staged blob result") return } @@ -108,21 +108,21 @@ import Testing let staged = try access.stageFile(file, mediaType: "application/json", filenameHint: "diagnostics.json") let filePrepared = try access.prepareExport( - try RadrootsExportDocumentRequest( + RadrootsExportDocumentRequest( source: .file(file), suggestedFilename: "diagnostics.json", mediaType: "application/json" ) ) let stagedPrepared = try access.prepareExport( - try RadrootsExportDocumentRequest( + RadrootsExportDocumentRequest( source: .stagedBlob(staged), suggestedFilename: "staged-diagnostics.json", mediaType: "application/json" ) ) let inlinePrepared = try access.prepareExport( - try RadrootsExportDocumentRequest( + RadrootsExportDocumentRequest( source: .inlineData(data), suggestedFilename: "inline-diagnostics.json", mediaType: "application/json" @@ -143,9 +143,9 @@ import Testing try access.releasePreparedExport(stagedPrepared) try access.releasePreparedExport(inlinePrepared) - #expect(!(try access.preparedExportExists(filePrepared))) - #expect(!(try access.preparedExportExists(stagedPrepared))) - #expect(!(try access.preparedExportExists(inlinePrepared))) + #expect(try !(access.preparedExportExists(filePrepared))) + #expect(try !(access.preparedExportExists(stagedPrepared))) + #expect(try !(access.preparedExportExists(inlinePrepared))) } @Test func appleFileAccessKeepsSmallReadsInlineWhenLimitAllowsIt() throws { @@ -171,14 +171,14 @@ import Testing _ = try access.read(RadrootsFileReference(scope: .data, relativePath: "missing.json"), mode: .inline) } #expect(throws: RadrootsAppleFileError.self) { - _ = try access.stageExternalFile(URL(string: "https://radroots.org/file.json")!, mediaType: nil, filenameHint: nil) + _ = try access.stageExternalFile(#require(URL(string: "https://radroots.org/file.json")), mediaType: nil, filenameHint: nil) } #expect(throws: RadrootsAppleFileError.self) { - _ = try access.stageExternalFile(try writeExternalTestFile(name: "bad.txt", data: Data("bad".utf8)), mediaType: nil, filenameHint: "../bad.txt") + _ = try access.stageExternalFile(writeExternalTestFile(name: "bad.txt", data: Data("bad".utf8)), mediaType: nil, filenameHint: "../bad.txt") } #expect(throws: RadrootsDocumentInterchangeError.self) { _ = try access.prepareExport( - try RadrootsExportDocumentRequest( + RadrootsExportDocumentRequest( source: .inlineData(Data("bad".utf8)), suggestedFilename: "../bad.txt", mediaType: "text/plain" diff --git a/Tests/RadrootsKitTests/RadrootsAppleFileRootsTests.swift b/Tests/RadrootsKitTests/RadrootsAppleFileRootsTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func fileRootsDeriveDefaultLogsAndStagedBlobRoots() throws { let root = FileManager.default.temporaryDirectory @@ -34,7 +34,7 @@ import Testing #expect(throws: RadrootsAppleFileError.self) { _ = try RadrootsAppleFileRoots( appIdentifier: "org.radroots.tests", - dataRoot: URL(string: "https://radroots.org/data")!, + dataRoot: #require(URL(string: "https://radroots.org/data")), cacheRoot: root, temporaryRoot: root ) diff --git a/Tests/RadrootsKitTests/RadrootsAppleLocationServicesTests.swift b/Tests/RadrootsKitTests/RadrootsAppleLocationServicesTests.swift @@ -1,9 +1,9 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing #if canImport(CoreLocation) -import CoreLocation + import CoreLocation #endif @Test func appleLocationServicesReportsCurrentAvailability() async { @@ -97,7 +97,7 @@ import CoreLocation ) ) - let result = try await service.currentLocation(try RadrootsCurrentLocationRequest( + let result = try await service.currentLocation(RadrootsCurrentLocationRequest( timeoutSeconds: 3, maximumCachedReadingAgeSeconds: 5 )) @@ -119,7 +119,7 @@ import CoreLocation ) await #expect(throws: RadrootsLocationServicesError.permissionDenied("location permission has not been requested")) { - _ = try await service.currentLocation(try RadrootsCurrentLocationRequest(timeoutSeconds: 1)) + _ = try await service.currentLocation(RadrootsCurrentLocationRequest(timeoutSeconds: 1)) } } @@ -140,7 +140,7 @@ import CoreLocation ) await #expect(throws: RadrootsLocationServicesError.transientFailure("location reading is older than the requested maximum age")) { - _ = try await service.currentLocation(try RadrootsCurrentLocationRequest( + _ = try await service.currentLocation(RadrootsCurrentLocationRequest( timeoutSeconds: 1, maximumCachedReadingAgeSeconds: 5 )) @@ -160,33 +160,33 @@ import CoreLocation ) await #expect(throws: RadrootsLocationServicesError.timeout("timed out")) { - _ = try await service.currentLocation(try RadrootsCurrentLocationRequest(timeoutSeconds: 1)) + _ = try await service.currentLocation(RadrootsCurrentLocationRequest(timeoutSeconds: 1)) } } #if canImport(CoreLocation) -@Test func appleLocationServicesMapsCoreLocationAuthorization() { - #expect(RadrootsAppleLocationServicesAdapters.authorization( - for: CLAuthorizationStatus.notDetermined, - locationServicesEnabled: true - ) == .notDetermined) - #expect(RadrootsAppleLocationServicesAdapters.authorization( - for: CLAuthorizationStatus.denied, - locationServicesEnabled: true - ) == .denied) - #expect(RadrootsAppleLocationServicesAdapters.authorization( - for: CLAuthorizationStatus.authorizedAlways, - locationServicesEnabled: true - ) == .authorizedAlways) - #expect(RadrootsAppleLocationServicesAdapters.authorization( - for: CLAuthorizationStatus.authorizedAlways, - locationServicesEnabled: false - ) == .unavailable) - #if os(iOS) - #expect(RadrootsAppleLocationServicesAdapters.authorization( - for: CLAuthorizationStatus.authorizedWhenInUse, - locationServicesEnabled: true - ) == .authorizedWhenInUse) - #endif -} + @Test func appleLocationServicesMapsCoreLocationAuthorization() { + #expect(RadrootsAppleLocationServicesAdapters.authorization( + for: CLAuthorizationStatus.notDetermined, + locationServicesEnabled: true + ) == .notDetermined) + #expect(RadrootsAppleLocationServicesAdapters.authorization( + for: CLAuthorizationStatus.denied, + locationServicesEnabled: true + ) == .denied) + #expect(RadrootsAppleLocationServicesAdapters.authorization( + for: CLAuthorizationStatus.authorizedAlways, + locationServicesEnabled: true + ) == .authorizedAlways) + #expect(RadrootsAppleLocationServicesAdapters.authorization( + for: CLAuthorizationStatus.authorizedAlways, + locationServicesEnabled: false + ) == .unavailable) + #if os(iOS) + #expect(RadrootsAppleLocationServicesAdapters.authorization( + for: CLAuthorizationStatus.authorizedWhenInUse, + locationServicesEnabled: true + ) == .authorizedWhenInUse) + #endif + } #endif diff --git a/Tests/RadrootsKitTests/RadrootsAppleLoggerTelemetryTests.swift b/Tests/RadrootsKitTests/RadrootsAppleLoggerTelemetryTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func appleLoggerTelemetryEmitsRedactedBoundedRecords() async throws { let probe = RadrootsAppleLoggerTelemetryProbe() @@ -15,8 +15,8 @@ import Testing level: .error, message: "imported nsec1secret", fields: [ - try .string("relay_light", "red"), - try .string("selected_secret_key_name", "field identity") + .string("relay_light", "red"), + .string("selected_secret_key_name", "field identity"), ], occurredAt: Date(timeIntervalSince1970: 10) ) @@ -35,7 +35,7 @@ import Testing #expect(record.renderedMessage.count <= 220) } -@Test func appleLoggerTelemetrySanitizesSubsystemAndCategory() async throws { +@Test func appleLoggerTelemetrySanitizesSubsystemAndCategory() { #expect(RadrootsAppleLoggerTelemetry.normalizedSubsystem(" Field iOS / Local ") == "Field_iOS_Local") #expect(RadrootsAppleLoggerTelemetry.normalizedSubsystem(" ") == "org.radroots.apple_kit") #expect(RadrootsAppleLoggerTelemetry.normalizedCategory(" relay/status ") == "relay_status") @@ -81,8 +81,8 @@ import Testing category: "field_ios", level: .notice, fields: [ - try .integer("connecting_count", 1), - try .integer("connected_count", 2) + .integer("connecting_count", 1), + .integer("connected_count", 2), ], occurredAt: Date(timeIntervalSince1970: 1) ) diff --git a/Tests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift b/Tests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func captureAsyncSupportRunsCleanupOnTimeout() async throws { let probe = RadrootsCaptureCleanupProbe() @@ -62,22 +62,22 @@ import Testing } #if !canImport(UIKit) -@Test func appleMediaPickerReportsUnavailableWithoutUIKit() async throws { - let picker = try RadrootsAppleMediaPicker(fileAccess: mediaPickerTestFileAccess()) - let support = try await picker.currentSupport() + @Test func appleMediaPickerReportsUnavailableWithoutUIKit() async throws { + let picker = try RadrootsAppleMediaPicker(fileAccess: mediaPickerTestFileAccess()) + let support = try await picker.currentSupport() - #expect(!support.importAvailable) - #expect(!support.cameraCaptureAvailable) - #expect(support.supportedImportKinds.isEmpty) - #expect(support.supportedCaptureKinds.isEmpty) + #expect(!support.importAvailable) + #expect(!support.cameraCaptureAvailable) + #expect(support.supportedImportKinds.isEmpty) + #expect(support.supportedCaptureKinds.isEmpty) - await #expect(throws: RadrootsCaptureIntakeError.unavailable("media import is unavailable")) { - _ = try await picker.importMedia(try RadrootsMediaImportRequest()) - } - await #expect(throws: RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable")) { - _ = try await picker.captureMedia(try RadrootsMediaCaptureRequest()) + await #expect(throws: RadrootsCaptureIntakeError.unavailable("media import is unavailable")) { + _ = try await picker.importMedia(RadrootsMediaImportRequest()) + } + await #expect(throws: RadrootsCaptureIntakeError.unavailable("camera photo capture is unavailable")) { + _ = try await picker.captureMedia(RadrootsMediaCaptureRequest()) + } } -} #endif private func mediaPickerTestFileAccess() throws -> RadrootsAppleFileAccess { diff --git a/Tests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift b/Tests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift @@ -1,11 +1,10 @@ import CoreGraphics import Foundation import ImageIO +@testable import RadrootsKit import Testing import UniformTypeIdentifiers -@testable import RadrootsKit - @Test func appleMediaPreparationNormalizesAndCommitsStableFinalBytes() async throws { let roots = try mediaPreparationRoots() let sourceReference = RadrootsFileReference(scope: .cache, relativePath: "capture/source.jpg") @@ -39,15 +38,16 @@ import UniformTypeIdentifiers let prepared = try await preparer.prepareImage(RadrootsAppleImagePreparationRequest(source: .file(sourceReference))) let request = try await preparer.blossomUploadRequest( - preparedImage: prepared, remoteURL: URL(string: "https://blossom.radroots.org/upload")!, authorization: "Nostr signed-event", - identifier: RadrootsBackgroundTransferIdentifier("field.media.upload")) + preparedImage: prepared, remoteURL: #require(URL(string: "https://blossom.radroots.org/upload")), authorization: "Nostr signed-event", + identifier: RadrootsBackgroundTransferIdentifier("field.media.upload") + ) #expect(request.method == .put) #expect(request.operation == .upload(source: .stagedBlob(prepared.file))) #expect(request.headers["Authorization"] == "Nostr signed-event") #expect(request.headers["Content-Type"] == "image/png") #expect(request.metadata["sha256"] == prepared.sha256) - #expect(request.responsePolicy == (try .boundedJSON())) + #expect(try request.responsePolicy == .boundedJSON()) #expect(request.expectedSourceSHA256 == prepared.sha256) let preparedURL = try roots.stagedBlobURL(for: prepared.file) @@ -56,7 +56,8 @@ import UniformTypeIdentifiers try tampered.write(to: preparedURL, options: .atomic) await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest("prepared image no longer matches its commitment")) { _ = try await preparer.blossomUploadRequest( - preparedImage: prepared, remoteURL: URL(string: "https://blossom.radroots.org/upload")!, authorization: "Nostr signed-event") + preparedImage: prepared, remoteURL: #require(URL(string: "https://blossom.radroots.org/upload")), authorization: "Nostr signed-event" + ) } } @@ -81,7 +82,9 @@ private func writeOrientedImageWithMetadata(to url: URL) throws { let context = try #require( CGContext( data: nil, width: 2, height: 3, bitsPerComponent: 8, bytesPerRow: 8, space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + ) context.setFillColor(CGColor(red: 0.2, green: 0.7, blue: 0.3, alpha: 1)) context.fill(CGRect(x: 0, y: 0, width: 2, height: 3)) let image = try #require(context.makeImage()) @@ -89,7 +92,7 @@ private func writeOrientedImageWithMetadata(to url: URL) throws { let destination = try #require(CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil)) let properties: [CFString: Any] = [ kCGImagePropertyOrientation: 6, kCGImagePropertyGPSDictionary: [kCGImagePropertyGPSLatitude: 45.0], - kCGImageDestinationLossyCompressionQuality: 0.9 + kCGImageDestinationLossyCompressionQuality: 0.9, ] CGImageDestinationAddImage(destination, image, properties as CFDictionary) try #require(CGImageDestinationFinalize(destination)) @@ -97,9 +100,11 @@ private func writeOrientedImageWithMetadata(to url: URL) throws { private func mediaPreparationRoots() throws -> RadrootsAppleFileRoots { let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "radroots-media-preparation-\(UUID().uuidString)", isDirectory: true) + "radroots-media-preparation-\(UUID().uuidString)", isDirectory: true + ) return try RadrootsAppleFileRoots( appIdentifier: "org.radroots.tests", dataRoot: root.appendingPathComponent("data", isDirectory: true), cacheRoot: root.appendingPathComponent("cache", isDirectory: true), - temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)) + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) } diff --git a/Tests/RadrootsKitTests/RadrootsApplePermissionStatusTests.swift b/Tests/RadrootsKitTests/RadrootsApplePermissionStatusTests.swift @@ -1,21 +1,21 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing #if canImport(AVFoundation) -import AVFoundation + import AVFoundation #endif #if canImport(CoreLocation) -import CoreLocation + import CoreLocation #endif #if canImport(Photos) -import Photos + import Photos #endif #if canImport(UserNotifications) -import UserNotifications + import UserNotifications #endif @Test func applePermissionStatusProviderUsesAdaptersForEachPermissionKind() async throws { @@ -36,7 +36,7 @@ import UserNotifications .camera, .photos, .microphone, - .location + .location, ]) #expect(snapshots.map(\.kind) == [.notifications, .camera, .photos, .microphone, .location]) @@ -64,41 +64,41 @@ import UserNotifications } #if canImport(UserNotifications) -@Test func applePermissionStatusMapsNotificationStatuses() { - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.notDetermined) == .notDetermined) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.denied) == .denied) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.authorized) == .authorized) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.provisional) == .limited) -} + @Test func applePermissionStatusMapsNotificationStatuses() { + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.notDetermined) == .notDetermined) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.denied) == .denied) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.authorized) == .authorized) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.provisional) == .limited) + } #endif #if canImport(AVFoundation) -@Test func applePermissionStatusMapsCaptureStatuses() { - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.notDetermined) == .notDetermined) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.restricted) == .restricted) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.denied) == .denied) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.authorized) == .authorized) -} + @Test func applePermissionStatusMapsCaptureStatuses() { + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.notDetermined) == .notDetermined) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.restricted) == .restricted) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.denied) == .denied) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.authorized) == .authorized) + } #endif #if canImport(Photos) -@Test func applePermissionStatusMapsPhotoStatuses() { - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.notDetermined) == .notDetermined) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.restricted) == .restricted) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.denied) == .denied) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.authorized) == .authorized) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.limited) == .limited) -} + @Test func applePermissionStatusMapsPhotoStatuses() { + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.notDetermined) == .notDetermined) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.restricted) == .restricted) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.denied) == .denied) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.authorized) == .authorized) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.limited) == .limited) + } #endif #if canImport(CoreLocation) -@Test func applePermissionStatusMapsLocationStatuses() { - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.notDetermined) == .notDetermined) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.restricted) == .restricted) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.denied) == .denied) - #if os(iOS) - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.authorizedWhenInUse) == .authorized) - #endif - #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.authorizedAlways) == .authorized) -} + @Test func applePermissionStatusMapsLocationStatuses() { + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.notDetermined) == .notDetermined) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.restricted) == .restricted) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.denied) == .denied) + #if os(iOS) + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.authorizedWhenInUse) == .authorized) + #endif + #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.authorizedAlways) == .authorized) + } #endif diff --git a/Tests/RadrootsKitTests/RadrootsAppleUserPresenceTests.swift b/Tests/RadrootsKitTests/RadrootsAppleUserPresenceTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func appleUserPresenceReportsStatusThroughAdapter() async throws { let expectedStatus = RadrootsUserPresenceStatus( @@ -74,38 +74,56 @@ import Testing } #if canImport(LocalAuthentication) -import LocalAuthentication + import LocalAuthentication -@Test func appleUserPresenceMapsLocalAuthenticationPolicies() { - #expect( - RadrootsAppleUserPresenceAdapters.platformPolicy(.deviceOwnerAuthentication) == - LAPolicy.deviceOwnerAuthentication - ) - #expect( - RadrootsAppleUserPresenceAdapters.platformPolicy(.deviceOwnerAuthenticationWithBiometrics) == - LAPolicy.deviceOwnerAuthenticationWithBiometrics - ) -} + @Test func appleUserPresenceMapsLocalAuthenticationPolicies() { + #expect( + RadrootsAppleUserPresenceAdapters.platformPolicy(.deviceOwnerAuthentication) == + LAPolicy.deviceOwnerAuthentication + ) + #expect( + RadrootsAppleUserPresenceAdapters.platformPolicy(.deviceOwnerAuthenticationWithBiometrics) == + LAPolicy.deviceOwnerAuthenticationWithBiometrics + ) + } -@Test func appleUserPresenceMapsLocalAuthenticationErrors() { - assertUserPresenceError( - RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.userCancel)), - matches: { if case .userCancelled = $0 { true } else { false } } - ) - assertUserPresenceError( - RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.biometryNotAvailable)), - matches: { if case .unavailable = $0 { true } else { false } } - ) - assertUserPresenceError( - RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.authenticationFailed)), - matches: { if case .permissionDenied = $0 { true } else { false } } - ) -} + @Test func appleUserPresenceMapsLocalAuthenticationErrors() { + assertUserPresenceError( + RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.userCancel)), + matches: { + if case .userCancelled = $0 { + true + } else { + false + } + } + ) + assertUserPresenceError( + RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.biometryNotAvailable)), + matches: { + if case .unavailable = $0 { + true + } else { + false + } + } + ) + assertUserPresenceError( + RadrootsAppleUserPresenceAdapters.adapt(error: LAError(.authenticationFailed)), + matches: { + if case .permissionDenied = $0 { + true + } else { + false + } + } + ) + } -private func assertUserPresenceError( - _ error: RadrootsUserPresenceError, - matches: (RadrootsUserPresenceError) -> Bool -) { - #expect(matches(error)) -} + private func assertUserPresenceError( + _ error: RadrootsUserPresenceError, + matches: (RadrootsUserPresenceError) -> Bool + ) { + #expect(matches(error)) + } #endif diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTaskTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTaskTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func backgroundTaskIdentifierNormalizesAndRejectsUnsafeValues() throws { let identifier = try RadrootsBackgroundTaskIdentifier(" ORG.RADROOTS.FIELD-IOS.refresh ") diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift @@ -1,7 +1,6 @@ import Foundation -import Testing - @testable import RadrootsKit +import Testing @Test func backgroundTransferIdentifierNormalizesAndRejectsUnsafeValues() throws { let identifier = try RadrootsBackgroundTransferIdentifier(" FIELD-IOS.TRANSFER_1 ") @@ -13,7 +12,8 @@ import Testing } #expect( throws: RadrootsBackgroundTransferError.invalidRequest( - "background transfer identifier must use lowercase safe identifier characters") + "background transfer identifier must use lowercase safe identifier characters" + ) ) { _ = try RadrootsBackgroundTransferIdentifier("/escape") } #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components")) { _ = try RadrootsBackgroundTransferIdentifier("field..transfer") @@ -24,59 +24,69 @@ import Testing let destination = RadrootsBackgroundTransferLocalFile.file(RadrootsFileReference(scope: .cache, relativePath: "downloads/relay.json")) let request = try RadrootsBackgroundTransferRequest( identifier: RadrootsBackgroundTransferIdentifier("field.transfer.download"), - remoteURL: URL(string: "https://radroots.org/relay.json")!, method: .get, operation: .download(destination: destination), - headers: ["Accept": "application/json"], metadata: ["purpose": "diagnostics"]) + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, operation: .download(destination: destination), + headers: ["Accept": "application/json"], metadata: ["purpose": "diagnostics"] + ) #expect(request.method == .get) #expect(request.operation == .download(destination: destination)) #expect(request.headers["Accept"] == "application/json") - let longAuthorization = String(repeating: "a", count: 2_048) + let longAuthorization = String(repeating: "a", count: 2048) let authorized = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "https://radroots.org/relay.json")!, method: .get, operation: .download(destination: destination), - headers: ["Authorization": longAuthorization]) + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, operation: .download(destination: destination), + headers: ["Authorization": longAuthorization] + ) #expect(authorized.headers["Authorization"] == longAuthorization) #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use public HTTPS")) { _ = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "http://radroots.org/relay.json")!, method: .get, operation: .download(destination: destination)) + remoteURL: #require(URL(string: "http://radroots.org/relay.json")), method: .get, operation: .download(destination: destination) + ) } #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL is unsafe")) { _ = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "https://radroots.org/relay.json?token=secret")!, method: .get, - operation: .download(destination: destination)) + remoteURL: #require(URL(string: "https://radroots.org/relay.json?token=secret")), method: .get, + operation: .download(destination: destination) + ) } let simulatorRequest = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "http://127.0.0.1:21100/relay.json")!, method: .get, operation: .download(destination: destination), - networkPolicy: .simulatorLoopbackHTTP) + remoteURL: #require(URL(string: "http://127.0.0.1:21100/relay.json")), method: .get, operation: .download(destination: destination), + networkPolicy: .simulatorLoopbackHTTP + ) #expect(simulatorRequest.networkPolicy == .simulatorLoopbackHTTP) #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET")) { _ = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "https://radroots.org/relay.json")!, method: .post, operation: .download(destination: destination)) + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .post, operation: .download(destination: destination) + ) } #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer header value cannot contain control characters")) { _ = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "https://radroots.org/relay.json")!, method: .get, operation: .download(destination: destination), - headers: ["Accept": "application/json\ntext/plain"]) + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, operation: .download(destination: destination), + headers: ["Accept": "application/json\ntext/plain"] + ) } #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer header name is unsafe")) { _ = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "https://radroots.org/relay.json")!, method: .get, operation: .download(destination: destination), - headers: ["Content-Length": "4"]) + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, operation: .download(destination: destination), + headers: ["Content-Length": "4"] + ) } } @Test func backgroundTransferUploadRequiresUploadMethod() throws { - let source = RadrootsBackgroundTransferLocalFile.stagedBlob(try RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12)) + let source = try RadrootsBackgroundTransferLocalFile.stagedBlob(RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12)) let request = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "https://radroots.org/upload")!, method: .put, operation: .upload(source: source)) + remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .put, operation: .upload(source: source) + ) #expect(request.operation == .upload(source: source)) #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT")) { _ = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "https://radroots.org/upload")!, method: .get, operation: .upload(source: source)) + remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .get, operation: .upload(source: source) + ) } } @@ -84,7 +94,8 @@ import Testing let request = try testDownloadRequest(identifier: "field.transfer.snapshot") let progress = try RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10) let snapshot = try RadrootsBackgroundTransferSnapshot( - request: request, state: .running, progress: progress, errorMessage: " running ", updatedAt: Date(timeIntervalSince1970: 1)) + request: request, state: .running, progress: progress, errorMessage: " running ", updatedAt: Date(timeIntervalSince1970: 1) + ) #expect(snapshot.identifier == request.identifier) #expect(snapshot.state == .running) @@ -95,7 +106,8 @@ import Testing #expect( throws: RadrootsBackgroundTransferError.invalidRequest( - "background transfer expected byte count cannot be less than transferred bytes") + "background transfer expected byte count cannot be less than transferred bytes" + ) ) { _ = try RadrootsBackgroundTransferProgress(bytesTransferred: 10, totalBytesExpected: 5) } #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer updated date must be finite")) { _ = try RadrootsBackgroundTransferSnapshot(request: request, updatedAt: Date(timeIntervalSinceReferenceDate: .infinity)) @@ -106,11 +118,12 @@ import Testing let roots = try testBackgroundTransferRoots() let store = RadrootsAppleBackgroundTransferStore(roots: roots) let request = try RadrootsBackgroundTransferRequest( - identifier: RadrootsBackgroundTransferIdentifier("field.transfer.redacted"), remoteURL: URL(string: "https://radroots.org/upload")!, + identifier: RadrootsBackgroundTransferIdentifier("field.transfer.redacted"), remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .put, operation: .upload(source: .file(RadrootsFileReference(scope: .cache, relativePath: "upload.png"))), headers: ["Authorization": "Nostr secret-token"], - metadata: ["purpose": "blossom_upload", "sha256": String(repeating: "a", count: 64)]) - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: request)) + metadata: ["purpose": "blossom_upload", "sha256": String(repeating: "a", count: 64)] + ) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request)) let persistedURL = roots.dataRoot.appendingPathComponent("background_transfers/transfers.json") let persisted = try String(contentsOf: persistedURL, encoding: .utf8) @@ -185,9 +198,11 @@ import Testing let roots = try testBackgroundTransferRoots() let store = RadrootsAppleBackgroundTransferStore(roots: roots) let first = try RadrootsBackgroundTransferSnapshot( - request: testDownloadRequest(identifier: "field.transfer.b"), updatedAt: Date(timeIntervalSince1970: 2)) + request: testDownloadRequest(identifier: "field.transfer.b"), updatedAt: Date(timeIntervalSince1970: 2) + ) let second = try RadrootsBackgroundTransferSnapshot( - request: testDownloadRequest(identifier: "field.transfer.a"), state: .running, updatedAt: Date(timeIntervalSince1970: 3)) + request: testDownloadRequest(identifier: "field.transfer.a"), state: .running, updatedAt: Date(timeIntervalSince1970: 3) + ) try await store.saveSnapshot(first) try await store.saveSnapshot(second) @@ -207,10 +222,10 @@ import Testing let store = RadrootsAppleBackgroundTransferStore(roots: roots) try await withThrowingTaskGroup(of: Void.self) { group in - for index in 0..<32 { + for index in 0 ..< 32 { group.addTask { let request = try testDownloadRequest(identifier: "field.transfer.concurrent-\(index)") - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: request)) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request)) } } try await group.waitForAll() @@ -225,11 +240,12 @@ import Testing let body = Data(#"{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#.utf8) let request = try RadrootsBackgroundTransferRequest( identifier: RadrootsBackgroundTransferIdentifier("field.transfer.descriptor"), - remoteURL: URL(string: "https://blossom.radroots.org/upload")!, method: .put, + remoteURL: #require(URL(string: "https://blossom.radroots.org/upload")), method: .put, operation: .upload(source: .file(RadrootsFileReference(scope: .cache, relativePath: "prepared.png"))), - responsePolicy: .boundedJSON(maximumBodyBytes: 1_024)) + responsePolicy: .boundedJSON(maximumBodyBytes: 1024) + ) let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: "application/json", body: body) - try await store.saveSnapshot(try RadrootsBackgroundTransferSnapshot(request: request, state: .completed, response: response)) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .completed, response: response)) let recovered = try #require(try await RadrootsAppleBackgroundTransferStore(roots: roots).loadSnapshots().first) #expect(recovered.response?.body == body) @@ -257,14 +273,17 @@ import Testing private func testDownloadRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest { try RadrootsBackgroundTransferRequest( identifier: RadrootsBackgroundTransferIdentifier(identifier), remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, - method: .get, operation: .download(destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")))) + method: .get, operation: .download(destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))) + ) } private func testBackgroundTransferRoots() throws -> RadrootsAppleFileRoots { let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "radroots-background-transfer-\(UUID().uuidString)", isDirectory: true) + "radroots-background-transfer-\(UUID().uuidString)", isDirectory: true + ) return try RadrootsAppleFileRoots( appIdentifier: "org.radroots.tests", dataRoot: root.appendingPathComponent("data", isDirectory: true), cacheRoot: root.appendingPathComponent("cache", isDirectory: true), - temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)) + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) } diff --git a/Tests/RadrootsKitTests/RadrootsCaptureIntakeTests.swift b/Tests/RadrootsKitTests/RadrootsCaptureIntakeTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func mediaImportRequestNormalizesKindsAndSelectionLimit() throws { let request = try RadrootsMediaImportRequest( diff --git a/Tests/RadrootsKitTests/RadrootsDocumentInterchangeTests.swift b/Tests/RadrootsKitTests/RadrootsDocumentInterchangeTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func documentImportRequestNormalizesContentKinds() throws { let request = try RadrootsDocumentImportRequest( @@ -67,9 +67,9 @@ import Testing let request = try RadrootsShareRequest( items: [ .text(" public post "), - .url(URL(string: "https://radroots.org/posts/1")!), + .url(#require(URL(string: "https://radroots.org/posts/1"))), .file(file, suggestedFilename: " diagnostics.json ", mediaType: " Application/JSON ", sizeBytes: 24), - .stagedBlob(stagedBlob, suggestedFilename: " staged.json ") + .stagedBlob(stagedBlob, suggestedFilename: " staged.json "), ], subject: " Radroots " ) @@ -77,7 +77,7 @@ import Testing #expect(request.subject == "Radroots") #expect(request.items.count == 4) #expect(request.items[0] == .text("public post")) - #expect(request.items[1] == .url(URL(string: "https://radroots.org/posts/1")!)) + #expect(try request.items[1] == .url(#require(URL(string: "https://radroots.org/posts/1")))) #expect(request.items[2] == .file(file, suggestedFilename: "diagnostics.json", mediaType: "application/json", sizeBytes: 24)) #expect(request.items[3] == .stagedBlob(stagedBlob, suggestedFilename: "staged.json")) @@ -88,7 +88,7 @@ import Testing _ = try RadrootsShareRequest(items: [.text(" ")]) } #expect(throws: RadrootsDocumentInterchangeError.self) { - _ = try RadrootsShareRequest(items: [.url(URL(string: "file:///tmp/private.txt")!)]) + _ = try RadrootsShareRequest(items: [.url(#require(URL(string: "file:///tmp/private.txt")))]) } } diff --git a/Tests/RadrootsKitTests/RadrootsDocumentPresentationTests.swift b/Tests/RadrootsKitTests/RadrootsDocumentPresentationTests.swift @@ -1,7 +1,7 @@ import Foundation +@testable import RadrootsKit import Testing import UniformTypeIdentifiers -@testable import RadrootsKit @Test func documentPresentationMapsImportContentTypes() throws { let request = try RadrootsDocumentImportRequest( @@ -43,10 +43,10 @@ import UniformTypeIdentifiers #expect(textItem.text == "public post") #expect(textItem.subject == "Radroots") - let urlRequest = try RadrootsShareRequest(items: [.url(URL(string: "https://radroots.org/posts/1")!)]) + let urlRequest = try RadrootsShareRequest(items: [.url(#require(URL(string: "https://radroots.org/posts/1")))]) let urlItem = try RadrootsDocumentPresentationAdapter.transferItem(for: urlRequest) - #expect(urlItem.payload == .url(URL(string: "https://radroots.org/posts/1")!)) + #expect(try urlItem.payload == .url(#require(URL(string: "https://radroots.org/posts/1")))) #expect(urlItem.text == "https://radroots.org/posts/1") let file = RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json") @@ -71,7 +71,7 @@ import UniformTypeIdentifiers suggestedFilename: " diagnostics.json ", mediaType: " Application/JSON ", sizeBytes: UInt64(data.count) - ) + ), ], subject: " Radroots " ) @@ -124,7 +124,7 @@ import UniformTypeIdentifiers suggestedFilename: "private.txt", mediaType: "text/plain", sizeBytes: nil - ) + ), ] ) } @@ -139,7 +139,7 @@ import UniformTypeIdentifiers suggestedFilename: "selected_secret_hex.json", mediaType: "application/json", sizeBytes: nil - ) + ), ] ) } diff --git a/Tests/RadrootsKitTests/RadrootsExternalActionsTests.swift b/Tests/RadrootsKitTests/RadrootsExternalActionsTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func webDestinationAcceptsOnlyHttpsUrlsWithHosts() throws { let destination = try RadrootsExternalActionDestination.web(" https://radroots.org/field ") diff --git a/Tests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift b/Tests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift @@ -1,9 +1,8 @@ import Foundation +@testable import RadrootsKit import RadrootsKitTesting import Testing -@testable import RadrootsKit - private let identityTestNow: UInt64 = 1_800_000_000_000 private let aliceSecretHex = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5" private let alicePublicKeyHex = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" @@ -11,447 +10,449 @@ private let aliceNsec = "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h private let bobSecretHex = "59392e9068f66431b12f70218fb61281cb6b433d7f27c5abee1f1a3fe1a96ff8" @Test func identitySecretMaterialParsesHexAndNsecWithoutDebugDisclosure() throws { - let hex = try RadrootsIdentitySecretMaterial(importText: aliceSecretHex.uppercased()) - let nsec = try RadrootsIdentitySecretMaterial(importText: aliceNsec) - - #expect(hex.copyBytes() == nsec.copyBytes()) - #expect(hex.copyBytes().count == 32) - #expect(!String(reflecting: hex).contains(aliceSecretHex)) - #expect(throws: RadrootsIdentityCustodyError.invalidSecret) { - _ = try RadrootsIdentitySecretMaterial(importText: "nsec1invalid") - } + let hex = try RadrootsIdentitySecretMaterial(importText: aliceSecretHex.uppercased()) + let nsec = try RadrootsIdentitySecretMaterial(importText: aliceNsec) + + #expect(hex.copyBytes() == nsec.copyBytes()) + #expect(hex.copyBytes().count == 32) + #expect(!String(reflecting: hex).contains(aliceSecretHex)) + #expect(throws: RadrootsIdentityCustodyError.invalidSecret) { + _ = try RadrootsIdentitySecretMaterial(importText: "nsec1invalid") + } } @Test func identityLifecycleIsOneActiveOpaqueAndSignatureBound() async throws { - let fixture = try makeIdentityFixture() - #expect(await fixture.custody.snapshot().state == .absent) - - let imported = try await fixture.custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: aliceSecretHex), - label: " Alice " - ) - #expect(imported.state == .unlocked) - #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) - #expect(imported.identity?.label == "Alice") - let firstHandle = try #require(imported.signerHandle) - - await #expect(throws: RadrootsIdentityCustodyError.identityAlreadyExists) { - try await fixture.custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: bobSecretHex) + let fixture = try makeIdentityFixture() + #expect(await fixture.custody.snapshot().state == .absent) + + let imported = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex), + label: " Alice " ) - } - - let digest = Data(repeating: 0x42, count: 32) - let request = try RadrootsOpaqueSignRequest( - operationID: UUID().uuidString.lowercased(), - signerHandle: firstHandle, - publicKeyHex: alicePublicKeyHex, - digest: digest, - purpose: .nostrEvent, - deadlineUnixMilliseconds: identityTestNow + 1_000 - ) - let signature = try await fixture.custody.sign(request) - #expect(signature.operationID == request.operationID) - #expect(signature.signature.count == 64) - #expect( - RadrootsIdentityCryptography().verify( - signature: signature.signature, - digest: digest, - publicKeyHex: alicePublicKeyHex + #expect(imported.state == .unlocked) + #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) + #expect(imported.identity?.label == "Alice") + let firstHandle = try #require(imported.signerHandle) + + await #expect(throws: RadrootsIdentityCustodyError.identityAlreadyExists) { + try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: bobSecretHex) + ) + } + + let digest = Data(repeating: 0x42, count: 32) + let request = try RadrootsOpaqueSignRequest( + operationID: UUID().uuidString.lowercased(), + signerHandle: firstHandle, + publicKeyHex: alicePublicKeyHex, + digest: digest, + purpose: .nostrEvent, + deadlineUnixMilliseconds: identityTestNow + 1000 ) - ) - #expect(!String(reflecting: request).contains(digest.base64EncodedString())) - #expect(!String(reflecting: signature).contains(signature.signature.base64EncodedString())) - - await fixture.custody.lockIdentity() - #expect(await fixture.custody.snapshot().state == .locked) - let unlocked = try await fixture.custody.selectIdentity( - identityHandle: try #require(imported.identity?.identityHandle) - ) - #expect(unlocked.state == .unlocked) - #expect(unlocked.signerHandle != firstHandle) - await #expect(throws: RadrootsIdentityCustodyError.identityNotFound) { - try await fixture.custody.selectIdentity( - identityHandle: "rrid1_\(String(repeating: "0", count: 64))") - } - await #expect(throws: RadrootsIdentityCustodyError.staleSigner) { - try await fixture.custody.sign(request) - } + let signature = try await fixture.custody.sign(request) + #expect(signature.operationID == request.operationID) + #expect(signature.signature.count == 64) + #expect( + RadrootsIdentityCryptography().verify( + signature: signature.signature, + digest: digest, + publicKeyHex: alicePublicKeyHex + ) + ) + #expect(!String(reflecting: request).contains(digest.base64EncodedString())) + #expect(!String(reflecting: signature).contains(signature.signature.base64EncodedString())) + + await fixture.custody.lockIdentity() + #expect(await fixture.custody.snapshot().state == .locked) + let unlocked = try await fixture.custody.selectIdentity( + identityHandle: #require(imported.identity?.identityHandle) + ) + #expect(unlocked.state == .unlocked) + #expect(unlocked.signerHandle != firstHandle) + await #expect(throws: RadrootsIdentityCustodyError.identityNotFound) { + try await fixture.custody.selectIdentity( + identityHandle: "rrid1_\(String(repeating: "0", count: 64))" + ) + } + await #expect(throws: RadrootsIdentityCustodyError.staleSigner) { + try await fixture.custody.sign(request) + } } @Test func identitySigningRejectsTimeoutCancellationAndWrongBinding() async throws { - let fixture = try makeIdentityFixture() - let imported = try await fixture.custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: aliceSecretHex) - ) - let handle = try #require(imported.signerHandle) - let operationID = UUID().uuidString.lowercased() - let expired = try RadrootsOpaqueSignRequest( - operationID: operationID, - signerHandle: handle, - publicKeyHex: alicePublicKeyHex, - digest: Data(repeating: 1, count: 32), - purpose: .blossomUpload, - deadlineUnixMilliseconds: identityTestNow - 1 - ) - await #expect(throws: RadrootsIdentityCustodyError.timedOut) { - try await fixture.custody.sign(expired) - } - - try await fixture.custody.cancelSigning(operationID: operationID) - let cancelled = try RadrootsOpaqueSignRequest( - operationID: operationID, - signerHandle: handle, - publicKeyHex: alicePublicKeyHex, - digest: Data(repeating: 1, count: 32), - purpose: .blossomUpload, - deadlineUnixMilliseconds: identityTestNow + 1 - ) - await #expect(throws: RadrootsIdentityCustodyError.cancelled) { - try await fixture.custody.sign(cancelled) - } - - let wrongKey = String(repeating: "0", count: 64) - let wrongBinding = try RadrootsOpaqueSignRequest( - operationID: UUID().uuidString.lowercased(), - signerHandle: handle, - publicKeyHex: wrongKey, - digest: Data(repeating: 2, count: 32), - purpose: .nostrEvent, - deadlineUnixMilliseconds: identityTestNow + 1 - ) - await #expect(throws: RadrootsIdentityCustodyError.staleSigner) { - try await fixture.custody.sign(wrongBinding) - } + let fixture = try makeIdentityFixture() + let imported = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + let handle = try #require(imported.signerHandle) + let operationID = UUID().uuidString.lowercased() + let expired = try RadrootsOpaqueSignRequest( + operationID: operationID, + signerHandle: handle, + publicKeyHex: alicePublicKeyHex, + digest: Data(repeating: 1, count: 32), + purpose: .blossomUpload, + deadlineUnixMilliseconds: identityTestNow - 1 + ) + await #expect(throws: RadrootsIdentityCustodyError.timedOut) { + try await fixture.custody.sign(expired) + } + + try await fixture.custody.cancelSigning(operationID: operationID) + let cancelled = try RadrootsOpaqueSignRequest( + operationID: operationID, + signerHandle: handle, + publicKeyHex: alicePublicKeyHex, + digest: Data(repeating: 1, count: 32), + purpose: .blossomUpload, + deadlineUnixMilliseconds: identityTestNow + 1 + ) + await #expect(throws: RadrootsIdentityCustodyError.cancelled) { + try await fixture.custody.sign(cancelled) + } + + let wrongKey = String(repeating: "0", count: 64) + let wrongBinding = try RadrootsOpaqueSignRequest( + operationID: UUID().uuidString.lowercased(), + signerHandle: handle, + publicKeyHex: wrongKey, + digest: Data(repeating: 2, count: 32), + purpose: .nostrEvent, + deadlineUnixMilliseconds: identityTestNow + 1 + ) + await #expect(throws: RadrootsIdentityCustodyError.staleSigner) { + try await fixture.custody.sign(wrongBinding) + } } @Test func identityReplacementAndDeleteRecoverAfterMetadataFailures() async throws { - let fixture = try makeIdentityFixture() - fixture.metadata.failNext(.write, slot: .activeIdentity) - await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) { - try await fixture.custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: aliceSecretHex) - ) - } - #expect(await fixture.custody.snapshot().state == .recoveryRequired) - let recovered = try await fixture.custody.recover() - #expect(recovered.state == .locked) - #expect(recovered.identity?.publicKeyHex == alicePublicKeyHex) - - fixture.metadata.failNext(.delete, slot: .activeIdentity) - await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) { - try await fixture.custody.deleteIdentity() - } - #expect(await fixture.custody.snapshot().state == .recoveryRequired) - #expect(try await fixture.custody.recover().state == .absent) - #expect(fixture.secureStore.keys().isEmpty) + let fixture = try makeIdentityFixture() + fixture.metadata.failNext(.write, slot: .activeIdentity) + await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) { + try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + } + #expect(await fixture.custody.snapshot().state == .recoveryRequired) + let recovered = try await fixture.custody.recover() + #expect(recovered.state == .locked) + #expect(recovered.identity?.publicKeyHex == alicePublicKeyHex) + + fixture.metadata.failNext(.delete, slot: .activeIdentity) + await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) { + try await fixture.custody.deleteIdentity() + } + #expect(await fixture.custody.snapshot().state == .recoveryRequired) + #expect(try await fixture.custody.recover().state == .absent) + #expect(fixture.secureStore.keys().isEmpty) } @Test func corruptMetadataIsDistinctFromAbsenceAndCanBeRepaired() async throws { - let fixture = try makeIdentityFixture() - _ = try await fixture.custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: aliceSecretHex), - label: "Before" - ) - await fixture.custody.lockIdentity() - fixture.metadata.replaceRawData(Data("not-json".utf8), for: .activeIdentity) - - let corrupt = await fixture.custody.snapshot() - #expect(corrupt.state == .corrupt) - #expect(corrupt.recoveryCode == "identity.corrupt_metadata") - #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == Data("not-json".utf8)) - - let repaired = try await fixture.custody.repairCorruptMetadata(label: "Recovered") - #expect(repaired.state == .unlocked) - #expect(repaired.identity?.publicKeyHex == alicePublicKeyHex) - #expect(repaired.identity?.label == "Recovered") - #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == nil) + let fixture = try makeIdentityFixture() + _ = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex), + label: "Before" + ) + await fixture.custody.lockIdentity() + fixture.metadata.replaceRawData(Data("not-json".utf8), for: .activeIdentity) + + let corrupt = await fixture.custody.snapshot() + #expect(corrupt.state == .corrupt) + #expect(corrupt.recoveryCode == "identity.corrupt_metadata") + #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == Data("not-json".utf8)) + + let repaired = try await fixture.custody.repairCorruptMetadata(label: "Recovered") + #expect(repaired.state == .unlocked) + #expect(repaired.identity?.publicKeyHex == alicePublicKeyHex) + #expect(repaired.identity?.label == "Recovered") + #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == nil) } @Test func corruptTransactionJournalRequiresRecoveryWithoutClaimingAbsence() async throws { - let fixture = try makeIdentityFixture() - _ = try await fixture.custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: aliceSecretHex) - ) - await fixture.custody.lockIdentity() - fixture.metadata.replaceRawData(Data("not-a-journal".utf8), for: .transactionJournal) - - let snapshot = await fixture.custody.snapshot() - #expect(snapshot.state == .corrupt) - #expect(snapshot.identity == nil) - #expect(snapshot.recoveryCode == "identity.corrupt_metadata") - await #expect(throws: RadrootsIdentityCustodyError.corruptMetadata) { - try await fixture.custody.recover() - } + let fixture = try makeIdentityFixture() + _ = try await fixture.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + await fixture.custody.lockIdentity() + fixture.metadata.replaceRawData(Data("not-a-journal".utf8), for: .transactionJournal) + + let snapshot = await fixture.custody.snapshot() + #expect(snapshot.state == .corrupt) + #expect(snapshot.identity == nil) + #expect(snapshot.recoveryCode == "identity.corrupt_metadata") + await #expect(throws: RadrootsIdentityCustodyError.corruptMetadata) { + try await fixture.custody.recover() + } } @Test func identityCustodyRoundTripsAgainstAppleStorageAdapters() async throws { - let suffix = UUID().uuidString.lowercased() - let namespace = "native-storage-\(suffix)" - let servicePrefix = "org.radroots.tests.identity.\(suffix)" - let suiteName = "org.radroots.tests.identity.metadata.\(suffix)" - let keychain = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix) - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { - try? keychain.deleteNamespace(namespace) - defaults.removePersistentDomain(forName: suiteName) - } - let custody = try RadrootsIdentityCustody( - configuration: RadrootsIdentityCustodyConfiguration( - namespace: namespace, - secretPolicy: .secureLocalSecret - ), - secureStore: keychain, - metadataStore: RadrootsAppleIdentityMetadataStore( - namespace: namespace, - userDefaults: defaults - ), - userPresence: RadrootsFakeUserPresence(), - now: { identityTestNow } - ) - - let imported = try await custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: aliceSecretHex), - label: "Native" - ) - #expect(imported.state == .unlocked) - #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) - - await custody.lockIdentity() - #expect(await custody.snapshot().state == .locked) - #expect(try await custody.unlockIdentity().state == .unlocked) - #expect(try await custody.deleteIdentity().state == .absent) - #expect( - try keychain.contains( - RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") - ) == false) + let suffix = UUID().uuidString.lowercased() + let namespace = "native-storage-\(suffix)" + let servicePrefix = "org.radroots.tests.identity.\(suffix)" + let suiteName = "org.radroots.tests.identity.metadata.\(suffix)" + let keychain = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix) + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { + try? keychain.deleteNamespace(namespace) + defaults.removePersistentDomain(forName: suiteName) + } + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration( + namespace: namespace, + secretPolicy: .secureLocalSecret + ), + secureStore: keychain, + metadataStore: RadrootsAppleIdentityMetadataStore( + namespace: namespace, + userDefaults: defaults + ), + userPresence: RadrootsFakeUserPresence(), + now: { identityTestNow } + ) + + let imported = try await custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex), + label: "Native" + ) + #expect(imported.state == .unlocked) + #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) + + await custody.lockIdentity() + #expect(await custody.snapshot().state == .locked) + #expect(try await custody.unlockIdentity().state == .unlocked) + #expect(try await custody.deleteIdentity().state == .absent) + #expect( + try keychain.contains( + RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1") + ) == false + ) } @Test func protectedDataAndUserPresenceFailuresDoNotClaimIdentitySuccess() async throws { - let secureStore = RadrootsInMemorySecureStore() - let metadata = RadrootsInMemoryIdentityMetadataStore() - let presence = RadrootsFakeUserPresence(verificationOutcome: .success(false)) - let custody = try RadrootsIdentityCustody( - configuration: RadrootsIdentityCustodyConfiguration(namespace: "protected-test"), - secureStore: secureStore, - metadataStore: metadata, - userPresence: presence, - protectedData: RadrootsProtectedDataProvider { .available }, - now: { identityTestNow } - ) - await #expect(throws: RadrootsIdentityCustodyError.userPresenceRequired) { - try await custody.importIdentity(RadrootsIdentitySecretMaterial(importText: aliceSecretHex)) - } - #expect(await custody.snapshot().state == .absent) - - let unavailable = try RadrootsIdentityCustody( - configuration: RadrootsIdentityCustodyConfiguration(namespace: "locked-test"), - secureStore: RadrootsInMemorySecureStore(), - metadataStore: RadrootsInMemoryIdentityMetadataStore(), - userPresence: RadrootsFakeUserPresence(), - protectedData: RadrootsProtectedDataProvider { .locked }, - now: { identityTestNow } - ) - await #expect(throws: RadrootsIdentityCustodyError.protectedDataUnavailable) { - try await unavailable.createIdentity() - } - #expect(await unavailable.snapshot().state == .absent) + let secureStore = RadrootsInMemorySecureStore() + let metadata = RadrootsInMemoryIdentityMetadataStore() + let presence = RadrootsFakeUserPresence(verificationOutcome: .success(false)) + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: "protected-test"), + secureStore: secureStore, + metadataStore: metadata, + userPresence: presence, + protectedData: RadrootsProtectedDataProvider { .available }, + now: { identityTestNow } + ) + await #expect(throws: RadrootsIdentityCustodyError.userPresenceRequired) { + try await custody.importIdentity(RadrootsIdentitySecretMaterial(importText: aliceSecretHex)) + } + #expect(await custody.snapshot().state == .absent) + + let unavailable = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: "locked-test"), + secureStore: RadrootsInMemorySecureStore(), + metadataStore: RadrootsInMemoryIdentityMetadataStore(), + userPresence: RadrootsFakeUserPresence(), + protectedData: RadrootsProtectedDataProvider { .locked }, + now: { identityTestNow } + ) + await #expect(throws: RadrootsIdentityCustodyError.protectedDataUnavailable) { + try await unavailable.createIdentity() + } + #expect(await unavailable.snapshot().state == .absent) } @Test func encryptedPortabilityRoundTripsAcrossIndependentHostsAndRejectsTampering() async throws { - let source = try makeIdentityFixture(namespace: "portability-source") - _ = try await source.custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: aliceNsec), - label: "Portable" - ) - let passphrase = try RadrootsIdentityPassphrase("correct horse battery staple") - let envelope = try await source.custody.exportPortableIdentity(passphrase: passphrase) - let serialized = envelope.serializedRepresentation - let rendered = String(decoding: serialized, as: UTF8.self) - #expect(envelope.version == 1) - #expect(!rendered.contains(aliceSecretHex)) - #expect(!rendered.contains(aliceNsec)) - #expect(!String(reflecting: passphrase).contains("correct horse")) - - let destination = try makeIdentityFixture(namespace: "portability-destination") - let imported = try await destination.custody.importPortableIdentity( - envelope, - passphrase: passphrase - ) - #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) - #expect(imported.identity?.label == "Portable") - - let wrongDestination = try makeIdentityFixture(namespace: "portability-wrong") - await #expect(throws: RadrootsIdentityCustodyError.portabilityAuthenticationFailed) { - try await wrongDestination.custody.importPortableIdentity( - envelope, - passphrase: RadrootsIdentityPassphrase("incorrect but sufficiently long") + let source = try makeIdentityFixture(namespace: "portability-source") + _ = try await source.custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceNsec), + label: "Portable" + ) + let passphrase = try RadrootsIdentityPassphrase("correct horse battery staple") + let envelope = try await source.custody.exportPortableIdentity(passphrase: passphrase) + let serialized = envelope.serializedRepresentation + let rendered = String(decoding: serialized, as: UTF8.self) + #expect(envelope.version == 1) + #expect(!rendered.contains(aliceSecretHex)) + #expect(!rendered.contains(aliceNsec)) + #expect(!String(reflecting: passphrase).contains("correct horse")) + + let destination = try makeIdentityFixture(namespace: "portability-destination") + let imported = try await destination.custody.importPortableIdentity( + envelope, + passphrase: passphrase ) - } - - let unsupported = serialized.replacingOccurrences( - of: Data("\"version\":1".utf8), - with: Data("\"version\":2".utf8) - ) - #expect(throws: RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope) { - _ = try RadrootsIdentityPortabilityEnvelope(serializedRepresentation: unsupported) - } + #expect(imported.identity?.publicKeyHex == alicePublicKeyHex) + #expect(imported.identity?.label == "Portable") + + let wrongDestination = try makeIdentityFixture(namespace: "portability-wrong") + await #expect(throws: RadrootsIdentityCustodyError.portabilityAuthenticationFailed) { + try await wrongDestination.custody.importPortableIdentity( + envelope, + passphrase: RadrootsIdentityPassphrase("incorrect but sufficiently long") + ) + } + + let unsupported = serialized.replacingOccurrences( + of: Data("\"version\":1".utf8), + with: Data("\"version\":2".utf8) + ) + #expect(throws: RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope) { + _ = try RadrootsIdentityPortabilityEnvelope(serializedRepresentation: unsupported) + } } @Test func legacyIdentityMigrationIsIdempotentAndDeletesOnlyAfterCommit() async throws { - let fixture = try makeIdentityFixture(namespace: "legacy-test") - let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") - try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey) - - let migrated = try await fixture.custody.migrateLegacyIdentity(from: legacyKey, label: "Migrated") - #expect(migrated.identity?.publicKeyHex == alicePublicKeyHex) - #expect(try fixture.secureStore.get(legacyKey) == nil) - - try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey) - let replayed = try await fixture.custody.migrateLegacyIdentity(from: legacyKey) - #expect(replayed.identity?.publicKeyHex == alicePublicKeyHex) - #expect(try fixture.secureStore.get(legacyKey) == nil) + let fixture = try makeIdentityFixture(namespace: "legacy-test") + let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex") + try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey) + + let migrated = try await fixture.custody.migrateLegacyIdentity(from: legacyKey, label: "Migrated") + #expect(migrated.identity?.publicKeyHex == alicePublicKeyHex) + #expect(try fixture.secureStore.get(legacyKey) == nil) + + try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey) + let replayed = try await fixture.custody.migrateLegacyIdentity(from: legacyKey) + #expect(replayed.identity?.publicKeyHex == alicePublicKeyHex) + #expect(try fixture.secureStore.get(legacyKey) == nil) } @Test func opaqueSignerBridgeCancelsPendingWorkAndCompletesExactlyOnce() async throws { - let secureStore = RadrootsInMemorySecureStore() - let metadata = RadrootsInMemoryIdentityMetadataStore() - let presence = ControllableIdentityPresence() - let custody = try RadrootsIdentityCustody( - configuration: RadrootsIdentityCustodyConfiguration(namespace: "bridge-test"), - secureStore: secureStore, - metadataStore: metadata, - userPresence: presence, - now: { identityTestNow } - ) - let imported = try await custody.importIdentity( - RadrootsIdentitySecretMaterial(importText: aliceSecretHex) - ) - let signerHandle = try #require(imported.signerHandle) - await presence.suspendNextVerification() - let request = try RadrootsOpaqueSignRequest( - operationID: UUID().uuidString.lowercased(), - signerHandle: signerHandle, - publicKeyHex: alicePublicKeyHex, - digest: Data(repeating: 9, count: 32), - purpose: .nostrEvent, - deadlineUnixMilliseconds: identityTestNow + 1 - ) - let result = LockedIdentityResult() - let cancellation = RadrootsOpaqueSignerBridge(custody: custody).submit(request) { - result.record($0) - } - while await presence.pendingVerificationCount == 0 { - await Task.yield() - } - cancellation.cancel() - await presence.resumePending(verified: true) - while result.count == 0 { - await Task.yield() - } - #expect(result.count == 1) - guard case .failure(.cancelled) = result.value else { - Issue.record("expected one cancelled completion") - return - } + let secureStore = RadrootsInMemorySecureStore() + let metadata = RadrootsInMemoryIdentityMetadataStore() + let presence = ControllableIdentityPresence() + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: "bridge-test"), + secureStore: secureStore, + metadataStore: metadata, + userPresence: presence, + now: { identityTestNow } + ) + let imported = try await custody.importIdentity( + RadrootsIdentitySecretMaterial(importText: aliceSecretHex) + ) + let signerHandle = try #require(imported.signerHandle) + await presence.suspendNextVerification() + let request = try RadrootsOpaqueSignRequest( + operationID: UUID().uuidString.lowercased(), + signerHandle: signerHandle, + publicKeyHex: alicePublicKeyHex, + digest: Data(repeating: 9, count: 32), + purpose: .nostrEvent, + deadlineUnixMilliseconds: identityTestNow + 1 + ) + let result = LockedIdentityResult() + let cancellation = RadrootsOpaqueSignerBridge(custody: custody).submit(request) { + result.record($0) + } + while await presence.pendingVerificationCount == 0 { + await Task.yield() + } + cancellation.cancel() + await presence.resumePending(verified: true) + while result.count == 0 { + await Task.yield() + } + #expect(result.count == 1) + guard case .failure(.cancelled) = result.value else { + Issue.record("expected one cancelled completion") + return + } } private struct IdentityFixture { - let custody: RadrootsIdentityCustody - let secureStore: RadrootsInMemorySecureStore - let metadata: RadrootsInMemoryIdentityMetadataStore + let custody: RadrootsIdentityCustody + let secureStore: RadrootsInMemorySecureStore + let metadata: RadrootsInMemoryIdentityMetadataStore } private func makeIdentityFixture(namespace: String = UUID().uuidString.lowercased()) throws - -> IdentityFixture + -> IdentityFixture { - let secureStore = RadrootsInMemorySecureStore() - let metadata = RadrootsInMemoryIdentityMetadataStore() - let custody = try RadrootsIdentityCustody( - configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), - secureStore: secureStore, - metadataStore: metadata, - userPresence: RadrootsFakeUserPresence(), - now: { identityTestNow } - ) - return IdentityFixture(custody: custody, secureStore: secureStore, metadata: metadata) + let secureStore = RadrootsInMemorySecureStore() + let metadata = RadrootsInMemoryIdentityMetadataStore() + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), + secureStore: secureStore, + metadataStore: metadata, + userPresence: RadrootsFakeUserPresence(), + now: { identityTestNow } + ) + return IdentityFixture(custody: custody, secureStore: secureStore, metadata: metadata) } private actor ControllableIdentityPresence: RadrootsUserPresence { - private var suspendNext = false - private var pending: [CheckedContinuation<RadrootsUserPresenceResult, Never>] = [] - - func currentStatus() async throws -> RadrootsUserPresenceStatus { - RadrootsUserPresenceStatus( - support: .biometricsOrDeviceCredential, - biometryKind: .faceID, - canEvaluateDeviceCredential: true, - canEvaluateBiometrics: true - ) - } + private var suspendNext = false + private var pending: [CheckedContinuation<RadrootsUserPresenceResult, Never>] = [] + + func currentStatus() async throws -> RadrootsUserPresenceStatus { + RadrootsUserPresenceStatus( + support: .biometricsOrDeviceCredential, + biometryKind: .faceID, + canEvaluateDeviceCredential: true, + canEvaluateBiometrics: true + ) + } - func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { - guard suspendNext else { - return RadrootsUserPresenceResult(policy: request.policy, verified: true) + func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { + guard suspendNext else { + return RadrootsUserPresenceResult(policy: request.policy, verified: true) + } + suspendNext = false + return await withCheckedContinuation { continuation in + pending.append(continuation) + } } - suspendNext = false - return await withCheckedContinuation { continuation in - pending.append(continuation) + + func suspendNextVerification() { + suspendNext = true } - } - - func suspendNextVerification() { - suspendNext = true - } - - var pendingVerificationCount: Int { - pending.count - } - - func resumePending(verified: Bool) { - let continuations = pending - pending.removeAll() - for continuation in continuations { - continuation.resume( - returning: RadrootsUserPresenceResult( - policy: .deviceOwnerAuthentication, - verified: verified - ) - ) + + var pendingVerificationCount: Int { + pending.count + } + + func resumePending(verified: Bool) { + let continuations = pending + pending.removeAll() + for continuation in continuations { + continuation.resume( + returning: RadrootsUserPresenceResult( + policy: .deviceOwnerAuthentication, + verified: verified + ) + ) + } } - } } private final class LockedIdentityResult: @unchecked Sendable { - private let lock = NSLock() - private var results: [Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>] = [] - - var count: Int { - lock.lock() - defer { lock.unlock() } - return results.count - } - - var value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>? { - lock.lock() - defer { lock.unlock() } - return results.first - } - - func record(_ value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) { - lock.lock() - results.append(value) - lock.unlock() - } + private let lock = NSLock() + private var results: [Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>] = [] + + var count: Int { + lock.lock() + defer { lock.unlock() } + return results.count + } + + var value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>? { + lock.lock() + defer { lock.unlock() } + return results.first + } + + func record(_ value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) { + lock.lock() + results.append(value) + lock.unlock() + } } -extension Data { - fileprivate func replacingOccurrences(of needle: Data, with replacement: Data) -> Data { - guard let range = range(of: needle) else { - return self +private extension Data { + func replacingOccurrences(of needle: Data, with replacement: Data) -> Data { + guard let range = range(of: needle) else { + return self + } + var copy = self + copy.replaceSubrange(range, with: replacement) + return copy } - var copy = self - copy.replaceSubrange(range, with: replacement) - return copy - } } diff --git a/Tests/RadrootsKitTests/RadrootsPermissionLocationTests.swift b/Tests/RadrootsKitTests/RadrootsPermissionLocationTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func permissionSnapshotsPreserveKindStatusAndObservationTime() { let observedAt = Date(timeIntervalSince1970: 20) @@ -68,7 +68,7 @@ import Testing #expect(reading.horizontalAccuracyMeters == 5) #expect(try reading.age(relativeTo: Date(timeIntervalSince1970: 106)) == 6) #expect(try reading.isFresh(relativeTo: Date(timeIntervalSince1970: 106), maximumAgeSeconds: 10)) - #expect(!(try reading.isFresh(relativeTo: Date(timeIntervalSince1970: 120), maximumAgeSeconds: 10))) + #expect(try !(reading.isFresh(relativeTo: Date(timeIntervalSince1970: 120), maximumAgeSeconds: 10))) #expect(throws: RadrootsLocationServicesError.self) { _ = try RadrootsLocationReading( diff --git a/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift b/Tests/RadrootsKitTests/RadrootsSecureStoreTests.swift @@ -1,7 +1,7 @@ import Foundation +@testable import RadrootsKit import Security import Testing -@testable import RadrootsKit @Test func secureStoreKeyBuildsServiceName() throws { let key = RadrootsSecureStoreKey(namespace: "session", name: "token") diff --git a/Tests/RadrootsKitTests/RadrootsTelemetryTests.swift b/Tests/RadrootsKitTests/RadrootsTelemetryTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import RadrootsKit +import Testing @Test func telemetryEventNormalizesSafeIdentifiersAndFields() throws { let event = try RadrootsTelemetryEvent( @@ -9,9 +9,9 @@ import Testing level: .notice, message: " Startup began ", fields: [ - try .integer("configured_relay_count", 3), - try .bool("has_identity", true), - try .string("relay_light", "green") + .integer("configured_relay_count", 3), + .bool("has_identity", true), + .string("relay_light", "green"), ], occurredAt: Date(timeIntervalSince1970: 42) ) @@ -38,8 +38,8 @@ import Testing _ = try RadrootsTelemetryEvent( name: "field_ios.relay.status", fields: [ - try .integer("connected_count", 1), - try .integer("connected_count", 2) + .integer("connected_count", 1), + .integer("connected_count", 2), ] ) } @@ -54,11 +54,11 @@ import Testing level: .error, message: "failed with nsec1secretvalue", fields: [ - try .string("relay_error", "path /Users/person/container"), - try .string("selected_secret_key_name", "field identity"), - try .string("public_reason", "event id \(secretHex)"), - try .integer("absolute_path_count", 1), - try .stringList("relay_urls", ["wss://radroots.org", "nsec1relay"]) + .string("relay_error", "path /Users/person/container"), + .string("selected_secret_key_name", "field identity"), + .string("public_reason", "event id \(secretHex)"), + .integer("absolute_path_count", 1), + .stringList("relay_urls", ["wss://radroots.org", "nsec1relay"]), ] ) @@ -81,7 +81,7 @@ import Testing name: "field_ios.identity.import", message: "imported nsec1secret", fields: [ - try .string("identity_state", "imported") + .string("identity_state", "imported"), ] ) diff --git a/Tests/RadrootsKitTests/RadrootsUserPresenceTests.swift b/Tests/RadrootsKitTests/RadrootsUserPresenceTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing import RadrootsKit +import Testing @Test func userPresenceRequestNormalizesReason() throws { let request = try RadrootsUserPresenceRequest(