field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit f112a7c07d44ca85cdb7eb3a929663ed7b4d1796
parent af31b0dbb33e7d03a052ca3d4318ab4ba8746972
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 03:06:56 +0000

feat(ios): close lifecycle and diagnostics

- preserve durable operations across scene and protected-data transitions
- complete bounded background callbacks and privacy-safe diagnostic exports
- remove dormant Field runtime and unused privacy/configuration surfaces
- normalize generated XCFramework metadata and cover lifecycle races

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 42+++++++++++++++++++-----------------------
MRadroots/App/App.swift | 2++
MRadroots/App/RadrootsAppDelegate.swift | 25++++++++++---------------
MRadroots/App/RadrootsAppModel.swift | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MRadroots/App/RadrootsProvider.swift | 23++++++++++++++++++++---
MRadroots/Config/Base.xcconfig | 6------
MRadroots/Config/Debug.xcconfig | 5-----
MRadroots/Info.plist | 32+-------------------------------
DRadroots/Resources/Localizations/en.lproj/Localizable.strings | 4----
DRadroots/Runtime/FieldBackgroundExecution.swift | 438-------------------------------------------------------------------------------
DRadroots/Runtime/FieldBackgroundExecutionUITestProbe.swift | 116-------------------------------------------------------------------------------
DRadroots/Runtime/FieldBackgroundURLSessionEvents.swift | 65-----------------------------------------------------------------
DRadroots/Runtime/FieldCaptureIntake.swift | 441-------------------------------------------------------------------------------
DRadroots/Runtime/FieldDocumentInterchange.swift | 197-------------------------------------------------------------------------------
DRadroots/Runtime/FieldDocumentInterchangeUITestProbe.swift | 129-------------------------------------------------------------------------------
DRadroots/Runtime/FieldExternalActions.swift | 102-------------------------------------------------------------------------------
DRadroots/Runtime/FieldFileAccessUITestProbe.swift | 110-------------------------------------------------------------------------------
DRadroots/Runtime/FieldIdentityImportFailureUITestProbe.swift | 56--------------------------------------------------------
DRadroots/Runtime/FieldIdentityPolicyUITestProbe.swift | 35-----------------------------------
DRadroots/Runtime/FieldLocationCheckIn.swift | 180-------------------------------------------------------------------------------
DRadroots/Runtime/FieldTelemetry.swift | 510-------------------------------------------------------------------------------
DRadroots/Runtime/FieldTelemetryUITestProbe.swift | 90-------------------------------------------------------------------------------
DRadroots/Runtime/FieldUITestSupport.swift | 284-------------------------------------------------------------------------------
DRadroots/Runtime/FieldUserPresenceGate.swift | 150-------------------------------------------------------------------------------
DRadroots/Runtime/LoggingSettings.swift | 43-------------------------------------------
ARadroots/Runtime/RadrootsLifecycleCoordinator.swift | 392+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DRadroots/Shared/Components/CopyButton.swift | 40----------------------------------------
DRadroots/Shared/Components/CopyRow.swift | 26--------------------------
DRadroots/Shared/Components/SectionWideButton.swift | 45---------------------------------------------
DRadroots/Shared/Extensions/Bundle+Build.swift | 7-------
DRadroots/Shared/Extensions/View+Nav.swift | 13-------------
DRadroots/Shared/Localisation/Strings.swift | 8--------
DRadroots/Shared/Modifiers/ToastModifier.swift | 41-----------------------------------------
MRadroots/State/RadrootsAddStore.swift | 38+++++++++++++++++++++++++-------------
MRadroots/State/RadrootsIdentityStore.swift | 4++--
MRadroots/State/RadrootsSessionStore.swift | 41++++++++++++++++++++++++++++++++---------
MRadroots/Views/RadrootsSupportingViews.swift | 20++++++++++++++++++++
MRadrootsFFI/Makefile | 6++++++
MRadrootsTests/RadrootsAddStoreTests.swift | 21+++++++++++++++++++++
ARadrootsTests/RadrootsLifecycleTests.swift | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 3+++
41 files changed, 835 insertions(+), 3243 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0486BFE6223D0FF0ACFA4249 /* RadrootsAddStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E3E5B1D1A0E1A32A83100 /* RadrootsAddStore.swift */; }; 27B7FB991398E2BE7034C72E /* RadrootsAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C296DF1E35100FE59C04D3 /* RadrootsAddStoreTests.swift */; }; 27F796571E92A6589E0111E3 /* RadrootsKitBindings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; }; + 3233513A881D056514142457 /* RadrootsAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3D78A3EDB345DB46C8E547 /* RadrootsAppDelegate.swift */; }; 3BCAFE2B1A96BBE5D741D8FC /* RadrootsSupportingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B94CEAFD958CE92D6B9265 /* RadrootsSupportingStoreTests.swift */; }; 3C16EC0B109F76EEE8D3031A /* RadrootsSupportingStores.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478127DF070D6065A282C44A /* RadrootsSupportingStores.swift */; }; 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */; }; @@ -20,9 +21,9 @@ 5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */; }; 69326B7DA05C115D0055AD13 /* RadrootsKitBindings.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */; }; - 7527378BC2D91420121C1C9C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F432D5450B21DFADF99FACD1 /* Localizable.strings */; }; 75275CDCE16FB99248793AB4 /* RadrootsSupportingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29DD502141AA86FD5C948618 /* RadrootsSupportingViews.swift */; }; 75C044403AF08F7230DE9485 /* RadrootsRootShellUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF484F988B3D1D3D81880388 /* RadrootsRootShellUITests.swift */; }; + 7A1131CC1E9308706ECE4B7B /* RadrootsLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DAAFF0BC944ECC04D0BDF41 /* RadrootsLifecycleCoordinator.swift */; }; 7CF6D05AC3F7C8CAD56E3A91 /* RadrootsRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */; }; 86DF51D41F92E1A695D83205 /* RadrootsTodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78F57CD1A43D46EE8CB35591 /* RadrootsTodayView.swift */; }; AA7D80A73CD6C22ED5080781 /* RadrootsKitBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879C89400666972D90996AE5 /* RadrootsKitBindings.swift */; }; @@ -31,6 +32,7 @@ C356F2A068722087ED91256C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 298DA81B0A000E307098C840 /* Assets.xcassets */; }; CA827A410369AC55B0FEE305 /* RuntimeStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9469790A906080D90F8735CB /* RuntimeStatusView.swift */; }; CAD7B38817DC65AAF19576F7 /* RadrootsFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */; }; + CD88BB6222151E0B8242F84A /* RadrootsLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D95CB1F4A964D87D2256DA9C /* RadrootsLifecycleTests.swift */; }; CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */; }; D03C81CCF4FBC6D7D5634D35 /* RadrootsConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */; }; E236CCF8D8F8AC12AF59932E /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397ED29DF8E9BF73B4BBFDEC /* App.swift */; }; @@ -98,11 +100,11 @@ 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsProvider.swift; sourceTree = "<group>"; }; 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClient.swift; sourceTree = "<group>"; }; 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsGeneratedRuntimeBackend.swift; sourceTree = "<group>"; }; - 67A31CC210B73EC072BD3542 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; 7098C419BAC4E8E058A2B3C2 /* RadrootsAddMediaCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAddMediaCoordinator.swift; sourceTree = "<group>"; }; 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsSessionStore.swift; sourceTree = "<group>"; }; 78F57CD1A43D46EE8CB35591 /* RadrootsTodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsTodayView.swift; sourceTree = "<group>"; }; 879C89400666972D90996AE5 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; }; + 8DAAFF0BC944ECC04D0BDF41 /* RadrootsLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsLifecycleCoordinator.swift; sourceTree = "<group>"; }; 93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9469790A906080D90F8735CB /* RuntimeStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeStatusView.swift; sourceTree = "<group>"; }; B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRootShellTests.swift; sourceTree = "<group>"; }; @@ -114,8 +116,10 @@ CF529B919921D63157110E44 /* RadrootsRootShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRootShell.swift; sourceTree = "<group>"; }; D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClientTests.swift; sourceTree = "<group>"; }; D91770F919A06DB39186281B /* RadrootsTodayStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsTodayStoreTests.swift; sourceTree = "<group>"; }; + D95CB1F4A964D87D2256DA9C /* RadrootsLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsLifecycleTests.swift; sourceTree = "<group>"; }; DA2E3E5B1D1A0E1A32A83100 /* RadrootsAddStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAddStore.swift; sourceTree = "<group>"; }; DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsConfigurationStore.swift; sourceTree = "<group>"; }; + EE3D78A3EDB345DB46C8E547 /* RadrootsAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAppDelegate.swift; sourceTree = "<group>"; }; FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = RadrootsKitBindings.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -144,6 +148,7 @@ isa = PBXGroup; children = ( 42C296DF1E35100FE59C04D3 /* RadrootsAddStoreTests.swift */, + D95CB1F4A964D87D2256DA9C /* RadrootsLifecycleTests.swift */, B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */, D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */, C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */, @@ -179,6 +184,7 @@ children = ( 7098C419BAC4E8E058A2B3C2 /* RadrootsAddMediaCoordinator.swift */, 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */, + 8DAAFF0BC944ECC04D0BDF41 /* RadrootsLifecycleCoordinator.swift */, 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */, 3DDB24124BB8917DBB49EC2C /* RadrootsRuntimeModels.swift */, ); @@ -190,7 +196,6 @@ isa = PBXGroup; children = ( 298DA81B0A000E307098C840 /* Assets.xcassets */, - CF740CDF6131C939A001727E /* Localizations */, ); name = Resources; path = Radroots/Resources; @@ -223,6 +228,7 @@ children = ( 397ED29DF8E9BF73B4BBFDEC /* App.swift */, 4E0D9C4E6CAE584304521D08 /* AppEntry.swift */, + EE3D78A3EDB345DB46C8E547 /* RadrootsAppDelegate.swift */, 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */, 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */, CF529B919921D63157110E44 /* RadrootsRootShell.swift */, @@ -248,14 +254,6 @@ ); sourceTree = "<group>"; }; - CF740CDF6131C939A001727E /* Localizations */ = { - isa = PBXGroup; - children = ( - F432D5450B21DFADF99FACD1 /* Localizable.strings */, - ); - path = Localizations; - sourceTree = "<group>"; - }; D17ED36BEBE689794F31C2BB /* Generated */ = { isa = PBXGroup; children = ( @@ -285,6 +283,13 @@ path = RadrootsUITests; sourceTree = "<group>"; }; + "TEMP_C9716F76-7D47-4886-AA36-A6BB8236F43C" /* Localizations */ = { + isa = PBXGroup; + children = ( + ); + path = Localizations; + sourceTree = "<group>"; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -410,7 +415,6 @@ buildActionMask = 2147483647; files = ( C356F2A068722087ED91256C /* Assets.xcassets in Resources */, - 7527378BC2D91420121C1C9C /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -460,6 +464,7 @@ buildActionMask = 2147483647; files = ( 27B7FB991398E2BE7034C72E /* RadrootsAddStoreTests.swift in Sources */, + CD88BB6222151E0B8242F84A /* RadrootsLifecycleTests.swift in Sources */, 7CF6D05AC3F7C8CAD56E3A91 /* RadrootsRootShellTests.swift in Sources */, CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */, 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */, @@ -477,10 +482,12 @@ F9EA6E6935307271FF920B7D /* RadrootsAddMediaCoordinator.swift in Sources */, 0486BFE6223D0FF0ACFA4249 /* RadrootsAddStore.swift in Sources */, BB932CEEA2713588E14AC3ED /* RadrootsAddView.swift in Sources */, + 3233513A881D056514142457 /* RadrootsAppDelegate.swift in Sources */, 74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */, D03C81CCF4FBC6D7D5634D35 /* RadrootsConfigurationStore.swift in Sources */, 5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */, F6F36A06BD77BCFA64FC3541 /* RadrootsIdentityStore.swift in Sources */, + 7A1131CC1E9308706ECE4B7B /* RadrootsLifecycleCoordinator.swift in Sources */, 47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */, 509FF909EFFD7FD9BAEC537C /* RadrootsRootShell.swift in Sources */, F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */, @@ -514,17 +521,6 @@ }; /* End PBXTargetDependency section */ -/* Begin PBXVariantGroup section */ - F432D5450B21DFADF99FACD1 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - 67A31CC210B73EC072BD3542 /* en */, - ); - name = Localizable.strings; - sourceTree = "<group>"; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ 065194E2B92BBDACF3D27647 /* Release */ = { isa = XCBuildConfiguration; diff --git a/Radroots/App/App.swift b/Radroots/App/App.swift @@ -2,6 +2,8 @@ import SwiftUI @main struct RadrootsApp: App { + @UIApplicationDelegateAdaptor(RadrootsAppDelegate.self) private var appDelegate + var body: some Scene { WindowGroup { RadrootsProvider { diff --git a/Radroots/App/RadrootsAppDelegate.swift b/Radroots/App/RadrootsAppDelegate.swift @@ -2,27 +2,22 @@ import UIKit final class RadrootsAppDelegate: NSObject, UIApplicationDelegate { func application( - _ application: UIApplication, + _: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void ) { - let completion = FieldBackgroundURLSessionCompletion(completionHandler) + let completion = RadrootsCompletionOnce(completionHandler) Task { - await FieldBackgroundURLSessionEvents.shared.handleEvents(identifier: identifier) { - completion.complete() - } + await RadrootsBackgroundEventRouter.shared.handle( + identifier: identifier, + completion: completion + ) } } -} - -private final class FieldBackgroundURLSessionCompletion: @unchecked Sendable { - private let completionHandler: () -> Void - - init(_ completionHandler: @escaping () -> Void) { - self.completionHandler = completionHandler - } - func complete() { - completionHandler() + func applicationWillTerminate(_: UIApplication) { + Task { + await RadrootsLifecycleBridge.shared.requestShutdown() + } } } diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -1,4 +1,5 @@ import Foundation +import RadrootsKit @MainActor final class RadrootsAppModel: ObservableObject { @@ -9,16 +10,27 @@ final class RadrootsAppModel: ObservableObject { private(set) var addStore: RadrootsAddStore? private(set) var searchStore: RadrootsSearchStore? private(set) var meStore: RadrootsMeStore? + let diagnosticsStore: RadrootsDiagnosticsStore private let sessionStore: RadrootsSessionStore? private let bootstrapFailure: RadrootsRuntimeFailure? + private let lifecycleCoordinator: RadrootsLifecycleCoordinator private var generation: UInt64 = 0 + private var lifecycleRegistered = false private let isShellUITest: Bool init( sessionStore: RadrootsSessionStore? = nil, - runtimeClient: RadrootsRuntimeClient? = nil + runtimeClient: RadrootsRuntimeClient? = nil, + lifecycleCoordinator requestedLifecycleCoordinator: RadrootsLifecycleCoordinator? = nil ) { + let lifecycleCoordinator = requestedLifecycleCoordinator + ?? Bundle.main.bundleIdentifier.flatMap { + try? RadrootsLifecycleCoordinator.production(bundleIdentifier: $0) + } + ?? RadrootsLifecycleCoordinator.disabled() + self.lifecycleCoordinator = lifecycleCoordinator + diagnosticsStore = RadrootsDiagnosticsStore(coordinator: lifecycleCoordinator) #if DEBUG if ProcessInfo.processInfo.environment["RADROOTS_IOS_UI_TEST_SHELL"] == "1" { self.sessionStore = nil @@ -79,7 +91,9 @@ final class RadrootsAppModel: ObservableObject { } func start() async { - await run { store in await store.start() } + await ensureLifecycleRegistration() + await lifecycleCoordinator.record("ios.lifecycle.start_requested") + await run(name: "start", showsStarting: true) { store in await store.start() } } func retry() async { @@ -87,27 +101,79 @@ final class RadrootsAppModel: ObservableObject { } func createIdentity() async { - await run { store in await store.createIdentity() } + await run(name: "identity_create", showsStarting: true) { store in + await store.createIdentity() + } } func unlockIdentity() async { - await run { store in await store.unlockIdentity() } + await run(name: "identity_unlock", showsStarting: true) { store in + await store.unlockIdentity() + } } func recoverIdentity() async { - await run { store in await store.recoverIdentity() } + await run(name: "identity_recover", showsStarting: true) { store in + await store.recoverIdentity() + } } func stop() async { - await run { store in await store.stop() } + stopPresentationWork() + await run(name: "stop") { store in await store.stop() } + } + + func resume() async { + await ensureLifecycleRegistration() + if case .running = phase { + await todayStore?.start() + await addStore?.start() + await lifecycleCoordinator.record("ios.lifecycle.active") + } else { + await start() + } + } + + func suspend() async { + todayStore?.stop() + addStore?.suspend() + searchStore?.stop() + meStore?.stop() + await lifecycleCoordinator.record("ios.lifecycle.background") + } + + func updateProtectedDataAvailability(_ available: Bool) async { + await sessionStore?.updateProtectedDataAvailability(available) + await lifecycleCoordinator.record( + available + ? "ios.lifecycle.protected_data_available" + : "ios.lifecycle.protected_data_unavailable" + ) + if available { + await start() + } else { + await stop() + await start() + } + } + + func shutdown() async { + await lifecycleCoordinator.record("ios.lifecycle.shutdown_requested", level: .notice) + await stop() + await RadrootsBackgroundEventRouter.shared.detachAndCompletePending() } private func run( + name: String, + showsStarting: Bool = false, _ operation: @escaping @Sendable (RadrootsSessionStore) async -> Phase ) async { guard !isShellUITest else { return } generation &+= 1 let requestedGeneration = generation + if showsStarting { + phase = .starting + } guard let sessionStore else { phase = .failed( bootstrapFailure ?? .local( @@ -116,6 +182,11 @@ final class RadrootsAppModel: ObservableObject { safeMessage: "Radroots could not start." ) ) + await lifecycleCoordinator.record( + "ios.lifecycle.operation_failed", + level: .error, + fields: ["operation": name, "code": "bootstrap_failed"] + ) return } let result = await operation(sessionStore) @@ -125,6 +196,48 @@ final class RadrootsAppModel: ObservableObject { addStore?.configure(snapshot: snapshot) } phase = result + await lifecycleCoordinator.record( + "ios.lifecycle.operation_completed", + level: Self.isFailure(result) ? .warning : .info, + fields: ["operation": name, "phase": Self.phaseCode(result)] + ) + } + + private func ensureLifecycleRegistration() async { + guard !lifecycleRegistered else { return } + lifecycleRegistered = true + await lifecycleCoordinator.attachBackgroundEvents() + await RadrootsLifecycleBridge.shared.register { @Sendable [weak self] in + await self?.shutdown() + } + } + + private func stopPresentationWork() { + todayStore?.stop() + addStore?.stop() + searchStore?.stop() + meStore?.stop() + } + + private static func isFailure(_ phase: Phase) -> Bool { + if case .failed = phase { + return true + } + return false + } + + private static func phaseCode(_ phase: Phase) -> String { + switch phase { + case .starting: "starting" + case .identityRequired: "identity_required" + case .identityLocked: "identity_locked" + case .protectedDataUnavailable: "protected_data_unavailable" + case .recoveryRequired: "recovery_required" + case .corruptIdentity: "corrupt_identity" + case .running: "running" + case .stopped: "stopped" + case .failed: "failed" + } } private static func productionMediaCoordinator(bundle: Bundle = .main) -> RadrootsAddMediaCoordinator? { diff --git a/Radroots/App/RadrootsProvider.swift b/Radroots/App/RadrootsProvider.swift @@ -1,4 +1,6 @@ +import Combine import SwiftUI +import UIKit struct RadrootsProvider<Content: View>: View { @Environment(\.scenePhase) private var scenePhase @@ -16,13 +18,28 @@ struct RadrootsProvider<Content: View>: View { var body: some View { content() .environmentObject(appModel) - .task { await appModel.start() } + .environmentObject(appModel.diagnosticsStore) + .task { await appModel.resume() } .onChange(of: scenePhase) { _, phase in if phase == .active { - Task { await appModel.start() } + Task { await appModel.resume() } } else if phase == .background { - Task { await appModel.stop() } + Task { await appModel.suspend() } } } + .onReceive( + NotificationCenter.default.publisher( + for: UIApplication.protectedDataDidBecomeAvailableNotification + ) + ) { _ in + Task { await appModel.updateProtectedDataAvailability(true) } + } + .onReceive( + NotificationCenter.default.publisher( + for: UIApplication.protectedDataWillBecomeUnavailableNotification + ) + ) { _ in + Task { await appModel.updateProtectedDataAvailability(false) } + } } } diff --git a/Radroots/Config/Base.xcconfig b/Radroots/Config/Base.xcconfig @@ -2,12 +2,6 @@ #include "../radroots.xcconfig" RADROOTS_FIELD_IOS_RUNTIME_MODE = production -RADROOTS_FIELD_IOS_LOGGING_STDOUT = false -RADROOTS_FIELD_IOS_LOGGING_FILTER = info -RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = true -RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios.log RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = wss:$(SLASH)$(SLASH)radroots.org RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios -RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY = user_presence_local -RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false diff --git a/Radroots/Config/Debug.xcconfig b/Radroots/Config/Debug.xcconfig @@ -2,13 +2,8 @@ RADROOTS_FIELD_IOS_RUNTIME_MODE = localhost-dev PRODUCT_BUNDLE_IDENTIFIER = dev.local.radroots -RADROOTS_FIELD_IOS_LOGGING_STDOUT = true -RADROOTS_FIELD_IOS_LOGGING_FILTER = debug -RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = false -RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios-debug.log RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = ws:$(SLASH)$(SLASH)127.0.0.1:8080 RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = http:$(SLASH)$(SLASH)127.0.0.1:3000 RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios.local -RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false #include? "../radroots.local.xcconfig" diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -20,24 +20,12 @@ <string>0.1.0-alpha</string> <key>CFBundleVersion</key> <string>1</string> - <key>BGTaskSchedulerPermittedIdentifiers</key> - <array> - <string>$(PRODUCT_BUNDLE_IDENTIFIER).background.refresh</string> - <string>$(PRODUCT_BUNDLE_IDENTIFIER).background.processing</string> - </array> - <key>NSLocationWhenInUseUsageDescription</key> - <string>Radroots uses your location only when you tap Location Check-in.</string> <key>NSCameraUsageDescription</key> <string>Radroots uses the camera only when you choose to add a farm photo.</string> <key>NSPhotoLibraryUsageDescription</key> <string>Radroots accesses only the photos you choose for a farm update, ask, event, or food post.</string> <key>NSFaceIDUsageDescription</key> - <string>Radroots uses Face ID only to unlock, save, or delete the local Nostr identity stored on this iPhone.</string> - <key>LSApplicationQueriesSchemes</key> - <array> - <string>nostr</string> - </array> - + <string>Radroots uses Face ID only to unlock the local Nostr identity or approve a post or media upload you choose to sign.</string> <key>GIT_SHA</key> <string>$(GIT_SHA)</string> @@ -64,12 +52,6 @@ <key>UIImageName</key> <string>RadrootsSplashLogomark</string> </dict> - <key>UIBackgroundModes</key> - <array> - <string>fetch</string> - <string>processing</string> - </array> - <key>UISupportedInterfaceOrientations</key> <array> <string>UIInterfaceOrientationPortrait</string> @@ -86,23 +68,11 @@ <key>RADROOTS_FIELD_IOS_RUNTIME_MODE</key> <string>$(RADROOTS_FIELD_IOS_RUNTIME_MODE)</string> - <key>RADROOTS_FIELD_IOS_LOGGING_STDOUT</key> - <string>$(RADROOTS_FIELD_IOS_LOGGING_STDOUT)</string> - <key>RADROOTS_FIELD_IOS_LOGGING_FILTER</key> - <string>$(RADROOTS_FIELD_IOS_LOGGING_FILTER)</string> - <key>RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED</key> - <string>$(RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED)</string> - <key>RADROOTS_FIELD_IOS_LOGGING_FILE_NAME</key> - <string>$(RADROOTS_FIELD_IOS_LOGGING_FILE_NAME)</string> <key>RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS</key> <string>$(RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS)</string> <key>RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX</key> <string>$(RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX)</string> <key>RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS</key> <string>$(RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS)</string> - <key>RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY</key> - <string>$(RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY)</string> - <key>RADROOTS_FIELD_IOS_RESET_LOCAL_STATE</key> - <string>$(RADROOTS_FIELD_IOS_RESET_LOCAL_STATE)</string> </dict> </plist> diff --git a/Radroots/Resources/Localizations/en.lproj/Localizable.strings b/Radroots/Resources/Localizations/en.lproj/Localizable.strings @@ -1,4 +0,0 @@ -"app_name" = "Radroots"; -"settings" = "Settings"; -"setup_greeting_header" = "Welcome to Radroots!"; -"setup_greeting_header_sub" = "Your device will be configured by the setup wizard"; diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift @@ -1,438 +0,0 @@ -import Foundation -import RadrootsKit - -struct FieldBackgroundTaskIdentifiers: Equatable, Sendable { - let refresh: RadrootsBackgroundTaskIdentifier - let processing: RadrootsBackgroundTaskIdentifier - let transferSessionIdentifier: String - - init(bundleIdentifier: String) throws { - let normalized = try FieldBackgroundTaskIdentifiers.normalizedBundleIdentifier(bundleIdentifier) - self.refresh = try RadrootsBackgroundTaskIdentifier("\(normalized).background.refresh") - self.processing = try RadrootsBackgroundTaskIdentifier("\(normalized).background.processing") - self.transferSessionIdentifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier( - "\(normalized).background.transfer" - ) - } - - private static func normalizedBundleIdentifier(_ value: String) throws -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { - throw FieldLocalStateError.missingBundleIdentifier - } - return trimmed - } -} - -struct FieldBackgroundExecutionHandlers: Sendable { - let refresh: @Sendable () async -> Bool - let processing: @Sendable () async -> Bool -} - -actor FieldBackgroundExecution { - private static let stagedBlobRetention: TimeInterval = 24 * 60 * 60 - - private let identifiers: FieldBackgroundTaskIdentifiers - private let scheduler: any RadrootsBackgroundTaskScheduler - private let transfer: any RadrootsBackgroundTransfer - private let roots: RadrootsAppleFileRoots - private let telemetry: FieldTelemetry - private let now: @Sendable () -> Date - private let registerHandlers: @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void - private var runtimeService: FieldRuntimeService? - private var identityUnlocked = false - private var hasRegisteredHandlers = false - - init( - identifiers: FieldBackgroundTaskIdentifiers, - scheduler: any RadrootsBackgroundTaskScheduler, - transfer: any RadrootsBackgroundTransfer, - roots: RadrootsAppleFileRoots, - telemetry: FieldTelemetry, - now: @escaping @Sendable () -> Date = Date.init, - registerHandlers: @escaping @Sendable (FieldBackgroundExecutionHandlers) async throws -> Void - ) { - self.identifiers = identifiers - self.scheduler = scheduler - self.transfer = transfer - self.roots = roots - self.telemetry = telemetry - self.now = now - self.registerHandlers = registerHandlers - } - - static func configured( - bundleIdentifier: String, - telemetry: FieldTelemetry - ) throws -> FieldBackgroundExecution { - let identifiers = try FieldBackgroundTaskIdentifiers(bundleIdentifier: bundleIdentifier) - let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier) - #if DEBUG - if FieldUITestHarness.isRequested { - let scheduler = FieldUITestBackgroundTaskScheduler() - let transfer = FieldUITestBackgroundTransfer() - let now: @Sendable () -> Date - if FieldBackgroundExecutionUITestProbe.isRequested { - now = { Date.distantFuture } - } else { - now = Date.init - } - return FieldBackgroundExecution( - identifiers: identifiers, - scheduler: scheduler, - transfer: transfer, - roots: roots, - telemetry: telemetry, - now: now, - registerHandlers: { _ in } - ) - } - #endif - let scheduler = RadrootsAppleBackgroundTaskScheduler() - let transfer = try RadrootsAppleBackgroundTransfer( - roots: roots, - sessionIdentifier: identifiers.transferSessionIdentifier - ) - return FieldBackgroundExecution( - identifiers: identifiers, - scheduler: scheduler, - transfer: transfer, - roots: roots, - telemetry: telemetry, - registerHandlers: { handlers in - let refreshRegistered = try await scheduler.register( - RadrootsAppleBackgroundTaskRegistration( - identifier: identifiers.refresh, - kind: .appRefresh, - handler: handlers.refresh - ) - ) - let processingRegistered = try await scheduler.register( - RadrootsAppleBackgroundTaskRegistration( - identifier: identifiers.processing, - kind: .processing, - handler: handlers.processing - ) - ) - guard refreshRegistered && processingRegistered else { - throw RadrootsBackgroundTaskError.schedulerFailure( - "background task handler registration was rejected" - ) - } - } - ) - } - - func start() async throws { - if !hasRegisteredHandlers { - do { - try await registerHandlers( - FieldBackgroundExecutionHandlers( - refresh: { [weak self] in - await self?.performMaintenance(reason: "refresh_task") ?? false - }, - processing: { [weak self] in - await self?.performMaintenance(reason: "processing_task") ?? false - } - ) - ) - hasRegisteredHandlers = true - telemetry.backgroundExecution(operation: "handler_registration", outcome: "success", taskCount: 2) - } catch { - hasRegisteredHandlers = false - telemetry.backgroundExecution( - operation: "handler_registration", - outcome: FieldTelemetry.backgroundExecutionOutcome(for: error) - ) - throw error - } - } - _ = try await schedulePermittedTasks(reason: "startup") - } - - func updateRuntimeState(service: FieldRuntimeService?, identityUnlocked: Bool) { - self.runtimeService = service - self.identityUnlocked = identityUnlocked - } - - @discardableResult - func schedulePermittedTasks(reason: String) async throws -> [RadrootsBackgroundTaskSnapshot] { - do { - guard hasRegisteredHandlers else { - throw RadrootsBackgroundTaskError.schedulerFailure( - "background task handlers are not registered" - ) - } - let refresh = try RadrootsBackgroundTaskRequest( - identifier: identifiers.refresh, - kind: .appRefresh, - earliestBeginDate: now().addingTimeInterval(15 * 60) - ) - let processing = try RadrootsBackgroundTaskRequest( - identifier: identifiers.processing, - kind: .processing, - earliestBeginDate: now().addingTimeInterval(60 * 60) - ) - let snapshots = [ - try await scheduler.submit(refresh), - try await scheduler.submit(processing) - ] - telemetry.backgroundExecution(operation: "schedule", outcome: "success", taskCount: snapshots.count, reason: reason) - return snapshots - } catch { - telemetry.backgroundExecution( - operation: "schedule", - outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), - reason: reason - ) - throw error - } - } - - func cancelAll() async { - do { - try await scheduler.cancelAll() - telemetry.backgroundExecution(operation: "cancel_all", outcome: "success") - } catch { - telemetry.backgroundExecution(operation: "cancel_all", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) - } - } - - func pendingTaskSnapshots() async -> [RadrootsBackgroundTaskSnapshot] { - do { - return try await scheduler.pendingTasks() - } catch { - telemetry.backgroundExecution(operation: "pending_tasks", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) - return [] - } - } - - func transferSnapshots() async -> [RadrootsBackgroundTransferSnapshot] { - do { - return try await transfer.snapshots() - } catch { - telemetry.backgroundExecution(operation: "transfer_snapshots", outcome: FieldTelemetry.backgroundExecutionOutcome(for: error)) - return [] - } - } - - func handleEventsForBackgroundURLSession( - identifier: String, - completionHandler: @escaping @Sendable () -> Void - ) async { - await transfer.handleEventsForBackgroundURLSession( - identifier: identifier, - completionHandler: completionHandler - ) - telemetry.backgroundExecution(operation: "background_url_session_events", outcome: "success") - } - - func uiTestProbeValue() async -> String? { - guard FieldBackgroundExecutionUITestProbe.isRequested else { - return nil - } - do { - return try await buildUITestProbeValue() - } catch { - return FieldBackgroundExecutionUITestProbe.failureValue( - outcome: FieldTelemetry.backgroundExecutionOutcome(for: error) - ) - } - } - - @discardableResult - func performMaintenance(reason: String) async -> Bool { - let transferCount = await inspectTransferSnapshots(reason: reason) - let sweptCount = sweepExpiredStagedBlobs(reason: reason) - let relaySucceeded = await refreshRelaysIfAllowed(reason: reason) - let succeeded = transferCount != nil && sweptCount != nil && relaySucceeded - telemetry.backgroundExecution( - operation: "maintenance", - outcome: succeeded ? "success" : "partial_failure", - stagedBlobCount: sweptCount, - transferCount: transferCount, - identityUnlocked: identityUnlocked, - reason: reason - ) - return succeeded - } - - private func buildUITestProbeValue() async throws -> String { - let registered = hasRegisteredHandlers - let scheduledTaskCount = await fakeSubmittedRequestCount() - let pendingBeforeMaintenance = try await scheduler.pendingTasks().count - let transferSnapshotCount = try await seedUITestTransferSnapshot() - let stagedBlobRemoved = try await seedUITestStagedBlobAndRunMaintenance() - let pendingBeforeCancel = try await scheduler.pendingTasks().count - await cancelAll() - let pendingAfterCancel = try await scheduler.pendingTasks().count - let cancellationObserved = await fakeCancelAllCount() > 0 - try? await Task.sleep(nanoseconds: 100_000_000) - let events = await telemetry.recordedEventsForUITest() - return FieldBackgroundExecutionUITestProbe.value( - registered: registered, - scheduledTaskCount: scheduledTaskCount, - pendingBeforeMaintenance: pendingBeforeMaintenance, - pendingBeforeCancel: pendingBeforeCancel, - pendingAfterCancel: pendingAfterCancel, - cancellationObserved: cancellationObserved, - stagedBlobRemoved: stagedBlobRemoved, - transferSnapshotCount: transferSnapshotCount, - events: events - ) - } - - private func seedUITestTransferSnapshot() async throws -> Int { - #if DEBUG - guard let fakeTransfer = transfer as? FieldUITestBackgroundTransfer else { - return try await transfer.snapshots().count - } - let request = try RadrootsBackgroundTransferRequest( - remoteURL: URL(string: "https://radroots.org/field-ios-background-probe")!, - method: .get, - operation: .download( - destination: .file( - RadrootsFileReference( - scope: .cache, - relativePath: "ui_tests/background_execution/probe-download.bin" - ) - ) - ), - metadata: ["purpose": "background_execution_probe"] - ) - _ = try await fakeTransfer.enqueue(request) - return try await fakeTransfer.snapshots().count - #else - return try await transfer.snapshots().count - #endif - } - - private func seedUITestStagedBlobAndRunMaintenance() async throws -> Bool { - let fileAccess = RadrootsAppleFileAccess(roots: roots) - let blob = try fileAccess.stageBlob( - Data("field-ios-background-probe".utf8), - mediaType: "text/plain", - filenameHint: "background-probe.txt" - ) - _ = await performMaintenance(reason: "ui_test_probe") - do { - _ = try fileAccess.readStagedBlob(blob) - return false - } catch RadrootsAppleFileError.notFound { - return true - } catch { - throw error - } - } - - private func fakeSubmittedRequestCount() async -> Int { - #if DEBUG - guard let fakeScheduler = scheduler as? FieldUITestBackgroundTaskScheduler else { - return (try? await scheduler.pendingTasks().count) ?? 0 - } - return await fakeScheduler.submittedRequestCount - #else - return (try? await scheduler.pendingTasks().count) ?? 0 - #endif - } - - private func fakeCancelAllCount() async -> Int { - #if DEBUG - guard let fakeScheduler = scheduler as? FieldUITestBackgroundTaskScheduler else { - return 0 - } - return await fakeScheduler.cancelAllCount - #else - return 0 - #endif - } - - private func inspectTransferSnapshots(reason: String) async -> Int? { - do { - let snapshots = try await transfer.snapshots() - telemetry.backgroundExecution( - operation: "transfer_inspect", - outcome: "success", - transferCount: snapshots.count, - reason: reason - ) - return snapshots.count - } catch { - telemetry.backgroundExecution( - operation: "transfer_inspect", - outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), - reason: reason - ) - return nil - } - } - - private func sweepExpiredStagedBlobs(reason: String) -> Int? { - do { - let fileAccess = RadrootsAppleFileAccess(roots: roots) - let swept = try fileAccess.sweepStagedBlobs( - olderThan: now().addingTimeInterval(-Self.stagedBlobRetention) - ) - telemetry.backgroundExecution( - operation: "staged_blob_sweep", - outcome: "success", - stagedBlobCount: swept.count, - reason: reason - ) - return swept.count - } catch { - telemetry.backgroundExecution( - operation: "staged_blob_sweep", - outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), - reason: reason - ) - return nil - } - } - - private func refreshRelaysIfAllowed(reason: String) async -> Bool { - guard identityUnlocked else { - telemetry.backgroundExecution( - operation: "relay_refresh", - outcome: "skipped_locked", - identityUnlocked: false, - reason: reason - ) - return true - } - guard let runtimeService else { - telemetry.backgroundExecution( - operation: "relay_refresh", - outcome: "skipped_runtime_unavailable", - identityUnlocked: true, - reason: reason - ) - return true - } - do { - let relaySettings = try RelaySettings.effectiveSnapshot(bundleIdentifier: roots.appIdentifier) - try await runtimeService.nostrSetDefaultRelays(relaySettings.relays) - try await runtimeService.nostrConnectIfKeyPresent() - let status = try await runtimeService.nostrConnectionStatus() - telemetry.backgroundExecution( - operation: "relay_refresh", - outcome: "success", - relayConfigured: status.configured, - relaySourceAvailable: status.sourceAvailable, - relaySinkAvailable: status.sinkAvailable, - identityUnlocked: true, - reason: reason - ) - return true - } catch { - telemetry.backgroundExecution( - operation: "relay_refresh", - outcome: FieldTelemetry.backgroundExecutionOutcome(for: error), - identityUnlocked: true, - reason: reason - ) - return false - } - } - -} diff --git a/Radroots/Runtime/FieldBackgroundExecutionUITestProbe.swift b/Radroots/Runtime/FieldBackgroundExecutionUITestProbe.swift @@ -1,116 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldBackgroundExecutionUITestProbe { - private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_BACKGROUND_EXECUTION_PROBE" - private static let redactionPolicy = RadrootsTelemetryRedactionPolicy.default - - static var isRequested: Bool { - ProcessInfo.processInfo.environment[enabledKey] == "true" - } - - static func value( - registered: Bool, - scheduledTaskCount: Int, - pendingBeforeMaintenance: Int, - pendingBeforeCancel: Int, - pendingAfterCancel: Int, - cancellationObserved: Bool, - stagedBlobRemoved: Bool, - transferSnapshotCount: Int, - events: [RadrootsTelemetryEvent] - ) -> String { - let eventNames = Set(events.map(\.name)) - let values = events.flatMap(stringValues) - return [ - "registered=\(registered)", - "scheduled_task_count=\(scheduledTaskCount)", - "schedule_observed=\(scheduledTaskCount >= 2)", - "pending_before_maintenance=\(pendingBeforeMaintenance)", - "pending_before_cancel=\(pendingBeforeCancel)", - "pending_after_cancel=\(pendingAfterCancel)", - "cancellation_observed=\(cancellationObserved)", - "staged_blob_removed=\(stagedBlobRemoved)", - "transfer_snapshot_count=\(transferSnapshotCount)", - "background_register_seen=\(eventNames.contains("field_ios.background_execution.handler_registration"))", - "background_schedule_seen=\(eventNames.contains("field_ios.background_execution.schedule"))", - "background_cancel_all_seen=\(eventNames.contains("field_ios.background_execution.cancel_all"))", - "background_transfer_inspect_seen=\(eventNames.contains("field_ios.background_execution.transfer_inspect"))", - "background_staged_blob_sweep_seen=\(eventNames.contains("field_ios.background_execution.staged_blob_sweep"))", - "background_relay_refresh_seen=\(eventNames.contains("field_ios.background_execution.relay_refresh"))", - "background_maintenance_seen=\(eventNames.contains("field_ios.background_execution.maintenance"))", - "unsafe_values_present=\(values.contains(where: containsUnsafeValue))", - "relay_url_values_present=\(values.contains(where: containsRelayURL))", - "secret_like_values_present=\(values.contains(where: containsSecretLikeValue))", - "path_like_values_present=\(values.contains(where: containsPathLikeValue))", - "npub_values_present=\(values.contains(where: containsNpubValue))" - ].joined(separator: ";") - } - - static func failureValue(outcome: String) -> String { - [ - "registered=false", - "scheduled_task_count=0", - "schedule_observed=false", - "pending_before_maintenance=0", - "pending_before_cancel=0", - "pending_after_cancel=0", - "cancellation_observed=false", - "staged_blob_removed=false", - "transfer_snapshot_count=0", - "background_register_seen=false", - "background_schedule_seen=false", - "background_cancel_all_seen=false", - "background_transfer_inspect_seen=false", - "background_staged_blob_sweep_seen=false", - "background_relay_refresh_seen=false", - "background_maintenance_seen=false", - "probe_failure_outcome=\(outcome)", - "unsafe_values_present=false", - "relay_url_values_present=false", - "secret_like_values_present=false", - "path_like_values_present=false", - "npub_values_present=false" - ].joined(separator: ";") - } - - private static func stringValues(from event: RadrootsTelemetryEvent) -> [String] { - var values = event.message.map { [$0] } ?? [] - values.append(contentsOf: event.fields.map { field in - field.value.renderedValue - }) - return values - } - - private static func containsUnsafeValue(_ value: String) -> Bool { - redactionPolicy.containsUnsafeValue(value) - || containsRelayURL(value) - || containsSecretLikeValue(value) - || containsPathLikeValue(value) - || containsNpubValue(value) - } - - private static func containsRelayURL(_ value: String) -> Bool { - let normalized = value.lowercased() - return normalized.contains("ws://") || normalized.contains("wss://") - } - - private static func containsSecretLikeValue(_ value: String) -> Bool { - let normalized = value.lowercased() - return normalized.contains("nsec") - || normalized.range(of: "[a-f0-9]{64}", options: .regularExpression) != nil - } - - private static func containsPathLikeValue(_ value: String) -> Bool { - let normalized = value.lowercased() - return normalized.contains("/users/") - || normalized.contains("/private/var/") - || normalized.contains("/var/mobile/containers/") - || normalized.contains("/var/folders/") - || normalized.contains("file:///") - } - - private static func containsNpubValue(_ value: String) -> Bool { - value.lowercased().contains("npub") - } -} diff --git a/Radroots/Runtime/FieldBackgroundURLSessionEvents.swift b/Radroots/Runtime/FieldBackgroundURLSessionEvents.swift @@ -1,65 +0,0 @@ -import Foundation - -actor FieldBackgroundURLSessionEvents { - static let shared = FieldBackgroundURLSessionEvents() - - private var backgroundExecution: FieldBackgroundExecution? - private var pendingEvents: [FieldPendingBackgroundURLSessionEvent] - private var completesImmediately: Bool - - private init() { - self.pendingEvents = [] - self.completesImmediately = false - } - - func attach(_ backgroundExecution: FieldBackgroundExecution) async { - self.backgroundExecution = backgroundExecution - completesImmediately = false - let events = pendingEvents - pendingEvents = [] - for event in events { - await backgroundExecution.handleEventsForBackgroundURLSession( - identifier: event.identifier, - completionHandler: event.completionHandler - ) - } - } - - func handleEvents( - identifier: String, - completionHandler: @escaping @Sendable () -> Void - ) async { - guard !completesImmediately else { - completionHandler() - return - } - guard let backgroundExecution else { - pendingEvents.append( - FieldPendingBackgroundURLSessionEvent( - identifier: identifier, - completionHandler: completionHandler - ) - ) - return - } - await backgroundExecution.handleEventsForBackgroundURLSession( - identifier: identifier, - completionHandler: completionHandler - ) - } - - func completePendingAfterStartupFailure() { - backgroundExecution = nil - completesImmediately = true - let events = pendingEvents - pendingEvents = [] - for event in events { - event.completionHandler() - } - } -} - -private struct FieldPendingBackgroundURLSessionEvent: Sendable { - let identifier: String - let completionHandler: @Sendable () -> Void -} diff --git a/Radroots/Runtime/FieldCaptureIntake.swift b/Radroots/Runtime/FieldCaptureIntake.swift @@ -1,441 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldCaptureIntakeError: LocalizedError { - case serviceNotReady - case missingFileScope(String) - - var errorDescription: String? { - switch self { - case .serviceNotReady: - "Capture intake is not ready. Please retry." - case .missingFileScope(let value): - "Unsupported capture file scope: \(value)." - } - } -} - -public enum FieldCaptureRecordSource: String, Codable, Equatable, Sendable { - case libraryImport - case cameraCapture - case documentScan - - var displayName: String { - switch self { - case .libraryImport: - "Imported photo" - case .cameraCapture: - "Camera photo" - case .documentScan: - "Scanned document" - } - } -} - -public enum FieldCaptureRecordKind: String, Codable, Equatable, Sendable { - case image - case pdf -} - -public enum FieldCaptureFileScope: String, Codable, Equatable, Sendable { - case data - case cache - case temporary - case logs - - init(_ scope: RadrootsFileScope) { - switch scope { - case .data: - self = .data - case .cache: - self = .cache - case .temporary: - self = .temporary - case .logs: - self = .logs - } - } - - var fileScope: RadrootsFileScope { - switch self { - case .data: - .data - case .cache: - .cache - case .temporary: - .temporary - case .logs: - .logs - } - } -} - -public struct FieldCaptureRecord: Identifiable, Codable, Equatable, Sendable { - public let id: UUID - let source: FieldCaptureRecordSource - let kind: FieldCaptureRecordKind - let fileScope: FieldCaptureFileScope - let fileRelativePath: String - let mediaType: String - let suggestedFilename: String - let sizeBytes: UInt64 - let pixelWidth: UInt32? - let pixelHeight: UInt32? - let pageCount: UInt16? - let capturedAt: Date - - var file: RadrootsFileReference { - RadrootsFileReference(scope: fileScope.fileScope, relativePath: fileRelativePath) - } - - var summary: String { - if let pageCount { - return "\(source.displayName) - \(pageCount) pages - \(suggestedFilename)" - } - if let pixelWidth, let pixelHeight { - return "\(source.displayName) - \(pixelWidth)x\(pixelHeight) - \(suggestedFilename)" - } - return "\(source.displayName) - \(suggestedFilename)" - } -} - -public struct FieldCaptureSupportState: Equatable, Sendable { - var photoImportAvailable: Bool - var cameraPhotoAvailable: Bool - var documentScannerAvailable: Bool - - static let unavailable = FieldCaptureSupportState( - photoImportAvailable: false, - cameraPhotoAvailable: false, - documentScannerAvailable: false - ) -} - -public enum FieldCaptureIntakeOperation: Equatable, Sendable { - case idle - case refreshing - case importingPhoto - case capturingPhoto - case scanningDocument -} - -public struct FieldCaptureIntakeState: Equatable, Sendable { - var support: FieldCaptureSupportState - var records: [FieldCaptureRecord] - var operation: FieldCaptureIntakeOperation - var lastError: String? - var recoveryAction: FieldExternalActionRecovery? - - static let idle = FieldCaptureIntakeState( - support: .unavailable, - records: [], - operation: .idle, - lastError: nil, - recoveryAction: nil - ) - - var latestRecord: FieldCaptureRecord? { - records.sorted { left, right in - left.capturedAt > right.capturedAt - }.first - } -} - -final class FieldCaptureIntake: @unchecked Sendable { - private let mediaPicker: any RadrootsMediaPicker - private let documentScanner: any RadrootsDocumentScanner - private let fileAccess: RadrootsAppleFileAccess - private let recordsFile = RadrootsFileReference( - scope: .data, - relativePath: "capture_intake/records.json" - ) - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - init( - fileAccess: RadrootsAppleFileAccess, - mediaPicker: any RadrootsMediaPicker, - documentScanner: any RadrootsDocumentScanner - ) { - self.fileAccess = fileAccess - self.mediaPicker = mediaPicker - self.documentScanner = documentScanner - self.encoder = JSONEncoder() - self.encoder.dateEncodingStrategy = .iso8601 - self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - self.decoder = JSONDecoder() - self.decoder.dateDecodingStrategy = .iso8601 - } - - static func configured(bundleIdentifier: String) throws -> FieldCaptureIntake { - let fileAccess = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier) - #if DEBUG - if FieldUITestHarness.isRequested { - return try uiTestConfigured(fileAccess: fileAccess) - } - #endif - return FieldCaptureIntake( - fileAccess: fileAccess, - mediaPicker: RadrootsAppleMediaPicker(fileAccess: fileAccess), - documentScanner: RadrootsAppleDocumentScanner(fileAccess: fileAccess) - ) - } - - func loadRecords() throws -> [FieldCaptureRecord] { - do { - let result = try fileAccess.read(recordsFile, mode: .inline) - guard case .inline(let data) = result else { - return [] - } - return try decoder.decode([FieldCaptureRecord].self, from: data) - } catch let error as RadrootsAppleFileError { - if case .notFound = error { - return [] - } - throw error - } - } - - func support() async throws -> FieldCaptureSupportState { - let mediaSupport = try await mediaPicker.currentSupport() - let scannerSupport = try await documentScanner.currentSupport() - return FieldCaptureSupportState( - photoImportAvailable: mediaSupport.importAvailable && mediaSupport.supportedImportKinds.contains(.image), - cameraPhotoAvailable: mediaSupport.cameraCaptureAvailable && mediaSupport.supportedCaptureKinds.contains(.image), - documentScannerAvailable: scannerSupport.interactiveScanAvailable && scannerSupport.supportedOutputKinds.contains(.pdf) - ) - } - - func importPhoto(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { - let result = try await mediaPicker.importMedia( - try RadrootsMediaImportRequest( - allowedMediaKinds: [.image], - selectionLimit: 1, - destinationScope: .data - ) - ) - return try append(result.items.map(record(from:)), to: records) - } - - func capturePhoto(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { - let result = try await mediaPicker.captureMedia( - try RadrootsMediaCaptureRequest(mediaKind: .image, destinationScope: .data) - ) - return try append([record(from: result.item)], to: records) - } - - func scanDocument(records: [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] { - let result = try await documentScanner.scanDocument( - RadrootsDocumentScanRequest(outputKind: .pdf, destinationScope: .data) - ) - return try append([record(from: result)], to: records) - } - - private func append(_ newRecords: [FieldCaptureRecord], to records: [FieldCaptureRecord]) throws -> [FieldCaptureRecord] { - let updated = records + newRecords - try save(updated) - return updated - } - - private func save(_ records: [FieldCaptureRecord]) throws { - try fileAccess.write(.inline(encoder.encode(records)), to: recordsFile) - } - - private func record(from asset: RadrootsMediaAsset) -> FieldCaptureRecord { - FieldCaptureRecord( - id: UUID(), - source: asset.source == .cameraCapture ? .cameraCapture : .libraryImport, - kind: .image, - fileScope: FieldCaptureFileScope(asset.file.scope), - fileRelativePath: asset.file.relativePath, - mediaType: asset.mediaType, - suggestedFilename: asset.suggestedFilename, - sizeBytes: asset.sizeBytes, - pixelWidth: asset.pixelWidth, - pixelHeight: asset.pixelHeight, - pageCount: nil, - capturedAt: asset.capturedAt - ) - } - - private func record(from document: RadrootsScannedDocument) -> FieldCaptureRecord { - FieldCaptureRecord( - id: UUID(), - source: .documentScan, - kind: .pdf, - fileScope: FieldCaptureFileScope(document.file.scope), - fileRelativePath: document.file.relativePath, - mediaType: document.mediaType, - suggestedFilename: document.suggestedFilename, - sizeBytes: document.sizeBytes, - pixelWidth: nil, - pixelHeight: nil, - pageCount: document.pageCount, - capturedAt: document.capturedAt - ) - } - - #if DEBUG - private static func uiTestConfigured(fileAccess: RadrootsAppleFileAccess) throws -> FieldCaptureIntake { - let importedAsset = try uiTestMediaAsset( - fileAccess: fileAccess, - source: .libraryImport, - relativePath: "capture_intake/ui_tests/imported_photo.jpg", - filename: "imported-field-photo.jpg", - bytes: "radroots imported field photo".data(using: .utf8) ?? Data(), - capturedAt: Date(timeIntervalSinceReferenceDate: 1_000) - ) - let capturedAsset = try uiTestMediaAsset( - fileAccess: fileAccess, - source: .cameraCapture, - relativePath: "capture_intake/ui_tests/camera_photo.jpg", - filename: "camera-field-photo.jpg", - bytes: "radroots camera field photo".data(using: .utf8) ?? Data(), - capturedAt: Date(timeIntervalSinceReferenceDate: 2_000) - ) - let scannedDocument = try uiTestScannedDocument(fileAccess: fileAccess) - let importOutcome = try uiTestMediaImportOutcome(success: RadrootsMediaImportResult(items: [importedAsset])) - let captureOutcome = uiTestMediaCaptureOutcome(success: RadrootsMediaCaptureResult(item: capturedAsset)) - let scannerOutcome = uiTestDocumentScannerOutcome(success: scannedDocument) - let mediaSupport = try RadrootsMediaPickerSupport( - importAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_IMPORT_OUTCOME").isUnavailable, - cameraCaptureAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_CAMERA_OUTCOME").isUnavailable, - supportedImportKinds: [.image], - supportedCaptureKinds: [.image], - multipleSelectionSupported: false - ) - let scannerSupport = try RadrootsDocumentScannerSupport( - interactiveScanAvailable: !uiTestOutcome("RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_SCANNER_OUTCOME").isUnavailable, - multiPageSupported: true, - supportedOutputKinds: [.pdf] - ) - return FieldCaptureIntake( - fileAccess: fileAccess, - mediaPicker: FieldUITestMediaPicker( - support: mediaSupport, - importOutcome: importOutcome, - captureOutcome: captureOutcome - ), - documentScanner: FieldUITestDocumentScanner( - support: scannerSupport, - scanOutcome: scannerOutcome - ) - ) - } - - private static func uiTestMediaAsset( - fileAccess: RadrootsAppleFileAccess, - source: RadrootsMediaSource, - relativePath: String, - filename: String, - bytes: Data, - capturedAt: Date - ) throws -> RadrootsMediaAsset { - let file = RadrootsFileReference(scope: .data, relativePath: relativePath) - try fileAccess.write(.inline(bytes), to: file) - return try RadrootsMediaAsset( - source: source, - kind: .image, - file: file, - mediaType: "image/jpeg", - suggestedFilename: filename, - sizeBytes: UInt64(bytes.count), - pixelWidth: 1200, - pixelHeight: 900, - capturedAt: capturedAt - ) - } - - private static func uiTestScannedDocument(fileAccess: RadrootsAppleFileAccess) throws -> RadrootsScannedDocument { - let bytes = Data("%PDF-1.7\n% radroots field scan\n".utf8) - let file = RadrootsFileReference( - scope: .data, - relativePath: "capture_intake/ui_tests/scanned_document.pdf" - ) - try fileAccess.write(.inline(bytes), to: file) - return try RadrootsScannedDocument( - file: file, - outputKind: .pdf, - suggestedFilename: "field-scan.pdf", - mediaType: "application/pdf", - pageCount: 2, - sizeBytes: UInt64(bytes.count), - capturedAt: Date(timeIntervalSinceReferenceDate: 3_000) - ) - } - - private static func uiTestMediaImportOutcome( - success: RadrootsMediaImportResult - ) throws -> Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError> { - try uiTestResult( - key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_IMPORT_OUTCOME", - success: success - ) - } - - private static func uiTestMediaCaptureOutcome( - success: RadrootsMediaCaptureResult - ) -> Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError> { - do { - return try uiTestResult( - key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_CAMERA_OUTCOME", - success: success - ) - } catch { - return .failure(.permanentFailure(error.fieldRuntimeMessage)) - } - } - - private static func uiTestDocumentScannerOutcome( - success: RadrootsScannedDocument - ) -> Result<RadrootsScannedDocument, RadrootsCaptureIntakeError> { - do { - return try uiTestResult( - key: "RADROOTS_FIELD_IOS_UI_TEST_CAPTURE_SCANNER_OUTCOME", - success: success - ) - } catch { - return .failure(.permanentFailure(error.fieldRuntimeMessage)) - } - } - - private static func uiTestResult<T>( - key: String, - success: T - ) throws -> Result<T, RadrootsCaptureIntakeError> { - let outcome = uiTestOutcome(key) - switch outcome { - case .success: - return .success(success) - case .cancelled: - return .failure(.userCancelled("Capture was cancelled.")) - case .denied: - return .failure(.permissionDenied("Capture permission is denied.")) - case .unavailable: - return .failure(.unavailable("Capture is unavailable.")) - case .transientFailure: - return .failure(.transientFailure("Capture failed. Please retry.")) - } - } - - private static func uiTestOutcome(_ key: String) -> FieldCaptureUITestOutcome { - FieldCaptureUITestOutcome( - rawValue: FieldUITestHarness.string(key) ?? "" - ) ?? .success - } - #endif -} - -#if DEBUG -private enum FieldCaptureUITestOutcome: String { - case success - case cancelled - case denied - case unavailable - case transientFailure = "transient_failure" - - var isUnavailable: Bool { - self == .unavailable - } -} -#endif diff --git a/Radroots/Runtime/FieldDocumentInterchange.swift b/Radroots/Runtime/FieldDocumentInterchange.swift @@ -1,197 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldDocumentInterchangeError: LocalizedError, Equatable { - case emptyRelayConfig - case invalidRelayURL(String) - case invalidRelayConfigDocument - - var errorDescription: String? { - switch self { - case .emptyRelayConfig: - "Relay config must include at least one Nostr relay." - case .invalidRelayURL(let value): - "Invalid Nostr relay URL: \(value)." - case .invalidRelayConfigDocument: - "Relay config document is not valid JSON." - } - } -} - -struct FieldRelayStatusDocument: Encodable, Equatable { - let configuredRelays: [String] - let configured: Bool - let sourceAvailable: Bool - let sinkAvailable: Bool - let lastError: String? -} - -struct FieldRelayConfigDocument: Codable, Equatable { - static let format = "radroots_field_ios_relay_config_v1" - - let format: String - let relays: [String] - - init(relays: [String]) throws { - self.format = Self.format - self.relays = try FieldDocumentInterchange.validatedRelays(relays) - } -} - -struct FieldDiagnosticsDocument: Encodable, Equatable { - let format: String - let runtime: JSONValue - let relay: FieldRelayStatusDocument -} - -enum JSONValue: Encodable, Equatable { - case object([String: JSONValue]) - case array([JSONValue]) - case string(String) - case number(Double) - case bool(Bool) - case null - - init(jsonObject: Any) { - switch jsonObject { - case let object as [String: Any]: - self = .object(object.mapValues(JSONValue.init(jsonObject:))) - case let array as [Any]: - self = .array(array.map(JSONValue.init(jsonObject:))) - case let string as String: - self = .string(string) - case let number as NSNumber: - if CFGetTypeID(number) == CFBooleanGetTypeID() { - self = .bool(number.boolValue) - } else { - self = .number(number.doubleValue) - } - case _ as NSNull: - self = .null - default: - self = .string(String(describing: jsonObject)) - } - } - - func encode(to encoder: Encoder) throws { - switch self { - case .object(let object): - try object.encode(to: encoder) - case .array(let array): - try array.encode(to: encoder) - case .string(let string): - try string.encode(to: encoder) - case .number(let number): - try number.encode(to: encoder) - case .bool(let bool): - try bool.encode(to: encoder) - case .null: - var container = encoder.singleValueContainer() - try container.encodeNil() - } - } -} - -final class FieldDocumentInterchange { - private let fileAccess: RadrootsAppleFileAccess - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - init(bundleIdentifier: String) throws { - self.fileAccess = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier) - self.encoder = JSONEncoder() - self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - self.decoder = JSONDecoder() - } - - func prepareDiagnosticsExport( - infoJSONString: String, - relays: [String], - configured: Bool, - sourceAvailable: Bool, - sinkAvailable: Bool, - lastError: String? - ) throws -> RadrootsPreparedExportDocument { - let document = FieldDiagnosticsDocument( - format: "radroots_field_ios_diagnostics_v2", - runtime: Self.jsonValue(from: infoJSONString), - relay: FieldRelayStatusDocument( - configuredRelays: try Self.validatedRelays(relays), - configured: configured, - sourceAvailable: sourceAvailable, - sinkAvailable: sinkAvailable, - lastError: lastError - ) - ) - return try prepareJSONExport(data: encoder.encode(document), filename: "radroots-diagnostics.json") - } - - func prepareRelayConfigExport(relays: [String]) throws -> RadrootsPreparedExportDocument { - let document = try FieldRelayConfigDocument(relays: relays) - return try prepareJSONExport(data: encoder.encode(document), filename: "radroots-relays.json") - } - - func importedRelayConfig(from importedDocument: RadrootsImportedDocument) throws -> [String] { - try importedRelayConfig(from: importedDocument.file) - } - - func importedRelayConfig(from file: RadrootsFileReference) throws -> [String] { - let result = try fileAccess.read(file, mode: .inline) - guard case .inline(let data) = result else { - throw FieldDocumentInterchangeError.invalidRelayConfigDocument - } - let document = try decoder.decode(FieldRelayConfigDocument.self, from: data) - guard document.format == FieldRelayConfigDocument.format else { - throw FieldDocumentInterchangeError.invalidRelayConfigDocument - } - return try Self.validatedRelays(document.relays) - } - - func publicPostShareRequest(content: String) throws -> RadrootsShareRequest { - try RadrootsShareRequest(items: [.text(content)], subject: "Radroots") - } - - static func validatedRelays(_ relays: [String]) throws -> [String] { - var seen = Set<String>() - var normalized: [String] = [] - for relay in relays { - let trimmed = relay.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - continue - } - guard let components = URLComponents(string: trimmed), - let scheme = components.scheme?.lowercased(), - scheme == "ws" || scheme == "wss", - components.host != nil, - trimmed.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { - throw FieldDocumentInterchangeError.invalidRelayURL(relay) - } - let key = trimmed.lowercased() - if seen.insert(key).inserted { - normalized.append(trimmed) - } - } - guard !normalized.isEmpty else { - throw FieldDocumentInterchangeError.emptyRelayConfig - } - return normalized - } - - private func prepareJSONExport(data: Data, filename: String) throws -> RadrootsPreparedExportDocument { - try fileAccess.prepareExport( - RadrootsExportDocumentRequest( - source: .inlineData(data), - suggestedFilename: filename, - mediaType: "application/json" - ) - ) - } - - private static func jsonValue(from string: String) -> JSONValue { - guard let data = string.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) else { - return .string(string) - } - return JSONValue(jsonObject: object) - } -} diff --git a/Radroots/Runtime/FieldDocumentInterchangeUITestProbe.swift b/Radroots/Runtime/FieldDocumentInterchangeUITestProbe.swift @@ -1,129 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldDocumentInterchangeUITestProbe { - private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_DOCUMENT_INTERCHANGE_PROBE" - private static let importFixture = RadrootsFileReference( - scope: .temporary, - relativePath: "ui_tests/document_interchange/relay_import.json" - ) - private static let invalidImportFixture = RadrootsFileReference( - scope: .temporary, - relativePath: "ui_tests/document_interchange/invalid_relay_import.json" - ) - - static var isRequested: Bool { - ProcessInfo.processInfo.environment[enabledKey] == "true" - } - - static func relayImportDocument(bundleIdentifier: String) throws -> RadrootsImportedDocument? { - guard isRequested else { - return nil - } - let data = relayImportFixtureData() - try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).write( - .inline(data), - to: importFixture - ) - return try RadrootsImportedDocument( - file: importFixture, - originalURL: nil, - suggestedFilename: "radroots-relays.json", - mediaType: "application/json", - sizeBytes: UInt64(data.count) - ) - } - - static func startupValue( - bundleIdentifier: String, - infoJSONString: String, - relays: [String], - configured: Bool, - sourceAvailable: Bool, - sinkAvailable: Bool, - lastError: String? - ) throws -> String? { - guard isRequested else { - return nil - } - let interchange = try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier) - let fileAccess = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier) - let diagnosticsExport = try interchange.prepareDiagnosticsExport( - infoJSONString: infoJSONString, - relays: relays, - configured: configured, - sourceAvailable: sourceAvailable, - sinkAvailable: sinkAvailable, - lastError: lastError - ) - let diagnosticsFileExists = try fileAccess.preparedExportExists(diagnosticsExport) - try fileAccess.releasePreparedExport(diagnosticsExport) - let diagnosticsReleaseRemovedFile = !(try fileAccess.preparedExportExists(diagnosticsExport)) - let relayExport = try interchange.prepareRelayConfigExport(relays: relays) - let relayExportFileExists = try fileAccess.preparedExportExists(relayExport) - try fileAccess.releasePreparedExport(relayExport) - try fileAccess.write( - .inline(relayImportFixtureData()), - to: importFixture - ) - let importedRelays = try interchange.importedRelayConfig(from: importFixture) - try fileAccess.write( - .inline(invalidRelayImportFixtureData()), - to: invalidImportFixture - ) - let rejectedInvalidImport = invalidImportWasRejected(interchange) - let shareRequest = try interchange.publicPostShareRequest(content: " public field update ") - let shareTextTrimmed = shareRequest.items == [.text("public field update")] - return [ - "diagnostics_filename=\(diagnosticsExport.suggestedFilename)", - "diagnostics_media_type=\(diagnosticsExport.mediaType ?? "")", - "diagnostics_file_exists=\(diagnosticsFileExists)", - "diagnostics_release_removed_file=\(diagnosticsReleaseRemovedFile)", - "relay_export_filename=\(relayExport.suggestedFilename)", - "relay_export_media_type=\(relayExport.mediaType ?? "")", - "relay_export_file_exists=\(relayExportFileExists)", - "relay_import_count=\(importedRelays.count)", - "relay_import_contains_production=\(importedRelays.contains("wss://radroots.org"))", - "relay_import_rejected_invalid=\(rejectedInvalidImport)", - "share_subject=\(shareRequest.subject ?? "")", - "share_text_trimmed=\(shareTextTrimmed)" - ].joined(separator: ";") - } - - private static func invalidImportWasRejected(_ interchange: FieldDocumentInterchange) -> Bool { - do { - _ = try interchange.importedRelayConfig(from: invalidImportFixture) - return false - } catch { - return true - } - } - - private static func relayImportFixtureData() -> Data { - Data( - """ - { - "format": "radroots_field_ios_relay_config_v1", - "relays": [ - "wss://radroots.org", - "ws://127.0.0.1:8080", - "wss://radroots.org" - ] - } - """.utf8 - ) - } - - private static func invalidRelayImportFixtureData() -> Data { - Data( - """ - { - "format": "radroots_field_ios_relay_config_v1", - "relays": [ - "https://radroots.org" - ] - } - """.utf8 - ) - } -} diff --git a/Radroots/Runtime/FieldExternalActions.swift b/Radroots/Runtime/FieldExternalActions.swift @@ -1,102 +0,0 @@ -import Foundation -import RadrootsKit - -public enum FieldExternalActionRecovery: String, Equatable, Sendable { - case appSettings -} - -public struct FieldExternalActionRequestRecord: Equatable, Sendable { - public let kind: RadrootsExternalActionDestinationKind - public let urlString: String? - - init(destination: RadrootsExternalActionDestination) { - self.kind = destination.kind - self.urlString = destination.url?.absoluteString - } - - public var statusText: String { - switch kind { - case .appSettings: - "Requested app settings" - case .web: - "Requested web link" - case .nostr: - "Requested Nostr profile" - case .appleMaps: - "Requested Apple Maps" - } - } -} - -final class FieldExternalActions: Sendable { - private let actions: any RadrootsExternalActions - - init(actions: any RadrootsExternalActions) { - self.actions = actions - } - - static func configured() -> FieldExternalActions { - #if DEBUG - if FieldUITestHarness.isRequested { - return FieldExternalActions(actions: uiTestExternalActions()) - } - #endif - return FieldExternalActions(actions: RadrootsAppleExternalActions()) - } - - func canOpenPublicNostrProfile(npub: String) async -> Bool { - guard let destination = try? publicNostrProfileDestination(npub: npub) else { - return false - } - return await actions.canOpen(destination).canOpen - } - - func openAppSettings() async throws -> FieldExternalActionRequestRecord { - let destination = RadrootsExternalActionDestination.appSettings - try await actions.open(RadrootsExternalActionRequest(destination: destination)) - return FieldExternalActionRequestRecord(destination: destination) - } - - func openPublicNostrProfile(npub: String) async throws -> FieldExternalActionRequestRecord { - let destination = try publicNostrProfileDestination(npub: npub) - try await actions.open(RadrootsExternalActionRequest(destination: destination)) - return FieldExternalActionRequestRecord(destination: destination) - } - - private func publicNostrProfileDestination(npub: String) throws -> RadrootsExternalActionDestination { - try RadrootsExternalActionDestination.nostr("nostr:\(npub)") - } - - #if DEBUG - private static func uiTestExternalActions() -> FieldUITestExternalActions { - FieldUITestExternalActions( - defaultCanOpen: uiTestCanOpen, - openOutcome: uiTestOpenOutcome - ) - } - - private static var uiTestCanOpen: Bool { - if FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_NOSTR_CAN_OPEN") != nil { - return FieldUITestHarness.bool("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_NOSTR_CAN_OPEN", default: true) - } - if FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_CAN_OPEN") != nil { - return FieldUITestHarness.bool("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_CAN_OPEN", default: true) - } - return true - } - - private static var uiTestOpenOutcome: Result<Void, RadrootsExternalActionError> { - let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_OPEN_OUTCOME")?.lowercased() - switch raw { - case nil, "", "success": - return .success(()) - case "unavailable": - return .failure(.unavailable("external actions are unavailable in this UI test")) - case "transient_failure": - return .failure(.transientFailure("external action failed in this UI test")) - default: - return .failure(.blockedByPolicy("unsupported UI-test external action outcome")) - } - } - #endif -} diff --git a/Radroots/Runtime/FieldFileAccessUITestProbe.swift b/Radroots/Runtime/FieldFileAccessUITestProbe.swift @@ -1,110 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldFileAccessUITestProbe { - private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_FILE_ACCESS_PROBE" - private static let destructiveResetSentinel = RadrootsFileReference( - scope: .data, - relativePath: "ui_tests/file_access/destructive_reset_sentinel.txt" - ) - private static let identityBoundarySentinel = RadrootsFileReference( - scope: .data, - relativePath: "ui_tests/file_access/identity_boundary_sentinel.txt" - ) - - static var isRequested: Bool { - ProcessInfo.processInfo.environment[enabledKey] == "true" - } - - static func seedDestructiveResetSentinelIfRequested( - bundleIdentifier: String, - resetLocalStateRequested: Bool - ) throws { - guard isRequested && resetLocalStateRequested else { - return - } - try access(bundleIdentifier: bundleIdentifier).write( - .inline(Data("destructive-reset-sentinel".utf8)), - to: destructiveResetSentinel - ) - } - - static func startupValue( - bundleIdentifier: String, - resetLocalStateRequested: Bool, - loggingFileEnabled: Bool, - loggingFileName: String - ) throws -> String? { - guard isRequested else { - return nil - } - let fileAccess = try access(bundleIdentifier: bundleIdentifier) - try fileAccess.write( - .inline(Data("identity-boundary-sentinel".utf8)), - to: identityBoundarySentinel - ) - return try value( - bundleIdentifier: bundleIdentifier, - resetLocalStateRequested: resetLocalStateRequested, - identityResetObserved: false, - loggingFileEnabled: loggingFileEnabled, - loggingFileName: loggingFileName - ) - } - - static func identityResetValue( - bundleIdentifier: String, - loggingFileEnabled: Bool, - loggingFileName: String - ) throws -> String? { - guard isRequested else { - return nil - } - return try value( - bundleIdentifier: bundleIdentifier, - resetLocalStateRequested: false, - identityResetObserved: true, - loggingFileEnabled: loggingFileEnabled, - loggingFileName: loggingFileName - ) - } - - private static func value( - bundleIdentifier: String, - resetLocalStateRequested: Bool, - identityResetObserved: Bool, - loggingFileEnabled: Bool, - loggingFileName: String - ) throws -> String { - let fileAccess = try access(bundleIdentifier: bundleIdentifier) - let resetSentinelExists = try exists(destructiveResetSentinel, using: fileAccess) - let identitySentinelExists = try exists(identityBoundarySentinel, using: fileAccess) - let logFileURL = try FieldLocalState.logFileURL(bundleIdentifier: bundleIdentifier, fileName: loggingFileName) - let logsRoot = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier).root(for: .logs) - let logURLUnderLogsRoot = logFileURL.path.hasPrefix(logsRoot.path + "/") - let destructiveResetRemovedSentinel = resetLocalStateRequested ? !resetSentinelExists : true - return [ - "destructive_reset_removed_sentinel=\(destructiveResetRemovedSentinel)", - "identity_boundary_sentinel_exists=\(identitySentinelExists)", - "identity_reset_observed=\(identityResetObserved)", - "logging_file_enabled=\(loggingFileEnabled)", - "log_url_under_logs_root=\(logURLUnderLogsRoot)" - ].joined(separator: ";") - } - - private static func exists( - _ file: RadrootsFileReference, - using fileAccess: RadrootsAppleFileAccess - ) throws -> Bool { - do { - _ = try fileAccess.read(file, mode: .inline) - return true - } catch RadrootsAppleFileError.notFound(_) { - return false - } - } - - private static func access(bundleIdentifier: String) throws -> RadrootsAppleFileAccess { - try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier) - } -} diff --git a/Radroots/Runtime/FieldIdentityImportFailureUITestProbe.swift b/Radroots/Runtime/FieldIdentityImportFailureUITestProbe.swift @@ -1,56 +0,0 @@ -#if DEBUG -import Foundation - -enum FieldIdentityImportFailureUITestProbe { - private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_IDENTITY_IMPORT_FAILURE_PROBE" - private static let candidateSecretKey = "RADROOTS_FIELD_IOS_UI_TEST_IDENTITY_IMPORT_FAILURE_SECRET" - private static let defaultCandidateSecret = "0000000000000000000000000000000000000000000000000000000000000002" - - static var isRequested: Bool { - FieldUITestHarness.string(enabledKey) == "true" - } - - static func value( - secureStore: FieldSecureIdentityStore, - service: FieldRuntimeService - ) async -> String? { - guard isRequested else { - return nil - } - let previousSecret = try? secureStore.loadSelectedSecretHex() - let candidateSecret = FieldUITestHarness.string(candidateSecretKey) ?? defaultCandidateSecret - var importFailed = false - var errorContainsForcedRestore = false - - do { - _ = try await secureStore.importSecret( - candidateSecret, - label: "UI Test Import Failure", - using: service - ) - } catch { - importFailed = true - errorContainsForcedRestore = error.localizedDescription.contains("Forced identity import restore failure") - } - - let selectedSecretAfterImport = try? secureStore.loadSelectedSecretHex() - var runtimePreviousRestored = false - if previousSecret != nil, - let restored = try? await secureStore.restoreStoredIdentity( - label: "Radroots Field", - using: service - ) { - runtimePreviousRestored = restored.isSelected - } - - return [ - "previous_secret_present=\(previousSecret != nil)", - "import_failed=\(importFailed)", - "error_contains_forced_restore=\(errorContainsForcedRestore)", - "selected_secret_preserved=\(selectedSecretAfterImport == previousSecret)", - "candidate_secret_selected=\(selectedSecretAfterImport == candidateSecret)", - "runtime_previous_restored=\(runtimePreviousRestored)" - ].joined(separator: ";") - } -} -#endif diff --git a/Radroots/Runtime/FieldIdentityPolicyUITestProbe.swift b/Radroots/Runtime/FieldIdentityPolicyUITestProbe.swift @@ -1,35 +0,0 @@ -#if DEBUG -import Foundation -import RadrootsKit - -enum FieldIdentityPolicyUITestProbe { - private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_IDENTITY_POLICY_PROBE" - - static var isRequested: Bool { - FieldUITestHarness.string(enabledKey) == "true" - } - - static func value() throws -> String? { - guard isRequested else { - return nil - } - let configuredPolicy = try FieldSecureIdentityStore.configuredAccessPolicy() - let storePolicy = configuredPolicy.storePolicy - return [ - "configured_policy=\(configuredPolicy.rawValue)", - "store_policy_accessibility=\(accessibilityValue(storePolicy.accessibility))", - "store_policy_device_local_only=\(storePolicy.deviceLocalOnly)", - "store_policy_user_presence_required=\(storePolicy.userPresenceRequired)" - ].joined(separator: ";") - } - - private static func accessibilityValue(_ accessibility: RadrootsSecretAccessibility) -> String { - switch accessibility { - case .whenUnlocked: - "when_unlocked" - case .afterFirstUnlock: - "after_first_unlock" - } - } -} -#endif diff --git a/Radroots/Runtime/FieldLocationCheckIn.swift b/Radroots/Runtime/FieldLocationCheckIn.swift @@ -1,180 +0,0 @@ -import Foundation -import RadrootsKit - -public struct FieldLocationCheckInReading: Equatable, Sendable { - public let latitude: Double - public let longitude: Double - public let horizontalAccuracyMeters: Double - public let capturedAt: Date - - init(reading: RadrootsLocationReading) { - self.latitude = reading.coordinate.latitude - self.longitude = reading.coordinate.longitude - self.horizontalAccuracyMeters = reading.horizontalAccuracyMeters - self.capturedAt = reading.capturedAt - } - - public var coordinateSummary: String { - String(format: "%.4f, %.4f", latitude, longitude) - } - - public var accuracySummary: String { - String(format: "within %.0f m", horizontalAccuracyMeters) - } -} - -public enum FieldLocationCheckInState: Equatable, Sendable { - case idle(RadrootsLocationServicesAvailability) - case checking(RadrootsLocationServicesAvailability) - case checkedIn(FieldLocationCheckInReading) - case failed(RadrootsLocationServicesAvailability?, String) - - public var availability: RadrootsLocationServicesAvailability? { - switch self { - case .idle(let availability): - availability - case .checking(let availability): - availability - case .checkedIn: - nil - case .failed(let availability, _): - availability - } - } -} - -public struct FieldLocationCheckIn: Sendable { - private let locationServices: any RadrootsLocationServices - private let request: RadrootsCurrentLocationRequest - - public init( - locationServices: any RadrootsLocationServices = RadrootsAppleLocationServices(), - request: RadrootsCurrentLocationRequest? = nil - ) { - self.locationServices = locationServices - self.request = request ?? Self.defaultRequest() - } - - static func configured() -> Self { - guard let mode = FieldLocationCheckInUITestMode.current else { - return Self() - } - return Self(locationServices: FieldLocationCheckInUITestLocationServices(mode: mode)) - } - - public func status() async -> FieldLocationCheckInState { - .idle(await locationServices.currentAvailability()) - } - - public func checkIn() async -> FieldLocationCheckInState { - let availability = await locationServices.currentAvailability() - guard availability.locationServicesEnabled else { - return .failed(availability, "Location Services are disabled.") - } - do { - if availability.authorization == .notDetermined { - let authorization = try await locationServices.requestWhenInUseAuthorization() - guard authorization == .authorizedWhenInUse || authorization == .authorizedAlways else { - return .failed( - RadrootsLocationServicesAvailability( - locationServicesEnabled: true, - authorization: authorization - ), - "Location permission was not granted." - ) - } - } - let result = try await locationServices.currentLocation(request) - return .checkedIn(FieldLocationCheckInReading(reading: result.reading)) - } catch RadrootsLocationServicesError.permissionDenied(let message) { - return .failed(availability, message) - } catch RadrootsLocationServicesError.unavailable(let message) { - return .failed(availability, message) - } catch RadrootsLocationServicesError.timeout(let message) { - return .failed(availability, message) - } catch RadrootsLocationServicesError.cancelled(let message) { - return .failed(availability, message) - } catch { - return .failed(availability, error.fieldRuntimeMessage) - } - } - - private static func defaultRequest() -> RadrootsCurrentLocationRequest { - do { - return try RadrootsCurrentLocationRequest( - timeoutSeconds: 10, - desiredAccuracyMeters: 100, - maximumCachedReadingAgeSeconds: 30 - ) - } catch { - preconditionFailure("invalid default location check-in request") - } - } -} - -private enum FieldLocationCheckInUITestMode: String { - case success - case denied - case unavailable - case timeout - - static var current: Self? { - guard ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" else { - return nil - } - guard let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_LOCATION_MODE"] else { - return nil - } - return Self(rawValue: raw) - } -} - -private actor FieldLocationCheckInUITestLocationServices: RadrootsLocationServices { - private let mode: FieldLocationCheckInUITestMode - - init(mode: FieldLocationCheckInUITestMode) { - self.mode = mode - } - - func currentAvailability() async -> RadrootsLocationServicesAvailability { - switch mode { - case .success: - RadrootsLocationServicesAvailability(locationServicesEnabled: true, authorization: .authorizedWhenInUse) - case .denied: - RadrootsLocationServicesAvailability(locationServicesEnabled: true, authorization: .denied) - case .unavailable: - RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable) - case .timeout: - RadrootsLocationServicesAvailability(locationServicesEnabled: true, authorization: .authorizedWhenInUse) - } - } - - func requestWhenInUseAuthorization() async throws -> RadrootsLocationAuthorization { - switch mode { - case .success, .timeout: - .authorizedWhenInUse - case .denied: - throw RadrootsLocationServicesError.permissionDenied("location permission is denied") - case .unavailable: - throw RadrootsLocationServicesError.unavailable("location services are unavailable") - } - } - - func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult { - switch mode { - case .success: - let reading = try RadrootsLocationReading( - coordinate: RadrootsLocationCoordinate(latitude: 49.2827, longitude: -123.1207), - horizontalAccuracyMeters: 12, - capturedAt: Date() - ) - return try RadrootsCurrentLocationResult(reading: reading, authorization: .authorizedWhenInUse) - case .denied: - throw RadrootsLocationServicesError.permissionDenied("location permission is denied") - case .unavailable: - throw RadrootsLocationServicesError.unavailable("location services are unavailable") - case .timeout: - throw RadrootsLocationServicesError.timeout("current location request timed out") - } - } -} diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift @@ -1,510 +0,0 @@ -import Foundation -import RadrootsKit - -final class FieldTelemetry: @unchecked Sendable { - static let shared = FieldTelemetry.configured() - - private let sink: any RadrootsTelemetry - private let minimumLevel: RadrootsTelemetryLevel - private let recordedEventsProvider: (@Sendable () async -> [RadrootsTelemetryEvent])? - - init( - sink: any RadrootsTelemetry, - minimumLevel: RadrootsTelemetryLevel = .info, - recordedEventsProvider: (@Sendable () async -> [RadrootsTelemetryEvent])? = nil - ) { - self.sink = sink - self.minimumLevel = minimumLevel - self.recordedEventsProvider = recordedEventsProvider - } - - static func configured( - bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "dev.local.radroots", - loggingSettings: LoggingSettings = .load() - ) -> FieldTelemetry { - let minimumLevel = telemetryMinimumLevel(from: loggingSettings.level) - let appleTelemetry = RadrootsAppleLoggerTelemetry(subsystem: bundleIdentifier) - #if DEBUG - if FieldUITestHarness.isRequested { - let recorder = FieldUITestRecordingTelemetry() - return FieldTelemetry( - sink: RadrootsMultiplexTelemetry([appleTelemetry, recorder]), - minimumLevel: minimumLevel, - recordedEventsProvider: { await recorder.recordedEvents } - ) - } - #endif - return FieldTelemetry(sink: appleTelemetry, minimumLevel: minimumLevel) - } - - func record( - name: String, - category: String = "field_ios", - level: RadrootsTelemetryLevel = .info, - message: String? = nil, - fields: [RadrootsTelemetryField] = [] - ) { - Task { - await recordAsync( - name: name, - category: category, - level: level, - message: message, - fields: fields - ) - } - } - - func recordAsync( - name: String, - category: String = "field_ios", - level: RadrootsTelemetryLevel = .info, - message: String? = nil, - fields: [RadrootsTelemetryField] = [] - ) async { - guard level >= minimumLevel else { - return - } - guard let event = try? RadrootsTelemetryEvent( - name: name, - category: category, - level: level, - message: message, - fields: fields - ) else { - return - } - await sink.record(event) - } - - func runtimeLoggingInitialized(settings: LoggingSettings) { - record( - name: "field_ios.runtime.logging_initialized", - level: .info, - fields: [ - try? .bool("stdout_enabled", settings.stdout), - try? .bool("file_enabled", settings.fileEnabled), - try? .string("logging_filter", settings.level ?? "unset"), - ].compactMap { $0 } - ) - } - - func appStartupBegan() { - record(name: "field_ios.startup.begin", level: .notice) - } - - func appStartupSucceeded( - storedIdentityAvailable: Bool, - runtimeIdentityReady: Bool, - locked: Bool - ) { - record( - name: "field_ios.startup.success", - level: .notice, - fields: [ - try? .bool("stored_identity_available", storedIdentityAvailable), - try? .bool("runtime_identity_ready", runtimeIdentityReady), - try? .bool("identity_locked", locked) - ].compactMap { $0 } - ) - } - - func appStartupFailed(_ error: Error) { - record( - name: "field_ios.startup.failure", - level: .error, - fields: [ - try? .string("outcome", Self.outcome(for: error)) - ].compactMap { $0 } - ) - } - - func relayStatusChanged( - configured: Bool, - sourceAvailable: Bool, - sinkAvailable: Bool, - configuredRelayCount: Int, - light: String - ) { - record( - name: "field_ios.relay.status_changed", - level: light == "red" ? .warning : .info, - fields: [ - try? .bool("configured", configured), - try? .bool("source_available", sourceAvailable), - try? .bool("sink_available", sinkAvailable), - try? .integer("configured_relay_count", configuredRelayCount), - try? .string("relay_light", light) - ].compactMap { $0 } - ) - } - - func identityCustody(action: String, outcome: String) { - record( - name: "field_ios.identity_custody.\(action)", - level: outcome == "success" ? .info : .warning, - fields: [ - try? .string("outcome", outcome) - ].compactMap { $0 } - ) - } - - func userPresence(action: FieldUserPresenceAction, outcome: String) { - record( - name: "field_ios.user_presence.\(action.telemetryName)", - level: outcome == "success" ? .info : .warning, - fields: [ - try? .string("outcome", outcome) - ].compactMap { $0 } - ) - } - - func captureSupportRefreshed( - support: FieldCaptureSupportState, - recordCount: Int, - outcome: String - ) { - record( - name: "field_ios.capture.support_refreshed", - level: outcome == "success" ? .info : .warning, - fields: [ - try? .string("outcome", outcome), - try? .bool("photo_import_available", support.photoImportAvailable), - try? .bool("camera_photo_available", support.cameraPhotoAvailable), - try? .bool("document_scanner_available", support.documentScannerAvailable), - try? .integer("record_count", recordCount) - ].compactMap { $0 } - ) - } - - func captureOperation( - operation: FieldCaptureIntakeOperation, - outcome: String, - recordCount: Int, - recoveryAction: FieldExternalActionRecovery? - ) { - record( - name: "field_ios.capture.\(operation.telemetryName)", - level: outcome == "success" ? .info : .warning, - fields: [ - try? .string("outcome", outcome), - try? .integer("record_count", recordCount), - recoveryAction.map { try? .string("recovery_action", $0.rawValue) } ?? nil - ].compactMap { $0 } - ) - } - - func documentInterchange(operation: String, outcome: String, relayCount: Int? = nil) { - record( - name: "field_ios.document_interchange.\(operation)", - level: outcome == "success" ? .info : .warning, - fields: [ - try? .string("outcome", outcome), - relayCount.map { try? .integer("relay_count", $0) } ?? nil - ].compactMap { $0 } - ) - } - - func externalAction( - operation: String, - kind: RadrootsExternalActionDestinationKind?, - outcome: String - ) { - record( - name: "field_ios.external_action.\(operation)", - level: outcome == "success" ? .info : .warning, - fields: [ - try? .string("outcome", outcome), - kind.map { try? .string("destination_kind", $0.rawValue) } ?? nil - ].compactMap { $0 } - ) - } - - func backgroundExecution( - operation: String, - outcome: String, - taskCount: Int? = nil, - stagedBlobCount: Int? = nil, - transferCount: Int? = nil, - relayConfigured: Bool? = nil, - relaySourceAvailable: Bool? = nil, - relaySinkAvailable: Bool? = nil, - identityUnlocked: Bool? = nil, - reason: String? = nil - ) { - let expectedOutcome = outcome == "success" || outcome.hasPrefix("skipped") - var fields: [RadrootsTelemetryField] = [] - if let field = try? RadrootsTelemetryField.string("outcome", outcome) { - fields.append(field) - } - if let taskCount, - let field = try? RadrootsTelemetryField.integer("task_count", taskCount) { - fields.append(field) - } - if let stagedBlobCount, - let field = try? RadrootsTelemetryField.integer("staged_blob_count", stagedBlobCount) { - fields.append(field) - } - if let transferCount, - let field = try? RadrootsTelemetryField.integer("transfer_count", transferCount) { - fields.append(field) - } - if let relayConfigured, - let field = try? RadrootsTelemetryField.bool("relay_configured", relayConfigured) { - fields.append(field) - } - if let relaySourceAvailable, - let field = try? RadrootsTelemetryField.bool("relay_source_available", relaySourceAvailable) { - fields.append(field) - } - if let relaySinkAvailable, - let field = try? RadrootsTelemetryField.bool("relay_sink_available", relaySinkAvailable) { - fields.append(field) - } - if let identityUnlocked, - let field = try? RadrootsTelemetryField.bool("identity_unlocked", identityUnlocked) { - fields.append(field) - } - if let reason, - let field = try? RadrootsTelemetryField.string("reason", reason) { - fields.append(field) - } - record( - name: "field_ios.background_execution.\(operation)", - level: expectedOutcome ? .info : .warning, - fields: fields - ) - } - - func recordedEventsForUITest() async -> [RadrootsTelemetryEvent] { - guard let recordedEventsProvider else { - return [] - } - return await recordedEventsProvider() - } - - private static func telemetryMinimumLevel(from filter: String?) -> RadrootsTelemetryLevel { - guard let filter else { - return .info - } - let lowered = filter.lowercased() - if lowered.contains("trace") { - return .trace - } - if lowered.contains("debug") { - return .debug - } - if lowered.contains("info") { - return .info - } - if lowered.contains("notice") { - return .notice - } - if lowered.contains("warn") { - return .warning - } - if lowered.contains("error") { - return .error - } - if lowered.contains("critical") || lowered.contains("fault") { - return .critical - } - return .info - } - - private static func outcome(for error: Error) -> String { - if let error = error as? FieldAppRuntimeError { - switch error { - case .forcedStartupFailure: - return "forced_failure" - case .runtimeNotReady: - return "runtime_not_ready" - } - } - if let error = error as? RelaySettingsError { - switch error { - case .noRelaysConfigured: - return "relay_config_missing" - case .invalidRelayURL: - return "invalid_relay_url" - case .invalidStoredRelaySettings: - return "invalid_relay_settings" - } - } - switch error { - case FieldUserPresenceGateError.notVerified: - return "unverified" - case is FieldRuntimeLoggingError: - return "logging_initialization_failed" - case let error as RadrootsUserPresenceError: - return userPresenceOutcome(for: error) - case let error as RadrootsCaptureIntakeError: - return captureOutcome(for: error) - case let error as RadrootsExternalActionError: - return externalActionOutcome(for: error) - case let error as FieldDocumentInterchangeError: - return documentInterchangeOutcome(for: error) - default: - return "failure" - } - } - - static func userPresenceOutcome(for error: Error) -> String { - if let error = error as? FieldUserPresenceGateError { - switch error { - case .notVerified: - return "unverified" - } - } - guard let error = error as? RadrootsUserPresenceError else { - return "failure" - } - switch error { - case .userCancelled: - return "cancelled" - case .permissionDenied: - return "denied" - case .unavailable: - return "unavailable" - case .timeout: - return "timeout" - case .transientFailure: - return "transient_failure" - case .permanentFailure: - return "permanent_failure" - case .invalidRequest: - return "invalid_request" - } - } - - static func captureOutcome(for error: Error) -> String { - guard let error = error as? RadrootsCaptureIntakeError else { - return "failure" - } - switch error { - case .userCancelled: - return "cancelled" - case .permissionDenied: - return "denied" - case .unavailable: - return "unavailable" - case .transientFailure: - return "transient_failure" - case .permanentFailure: - return "permanent_failure" - case .invalidRequest: - return "invalid_request" - } - } - - static func backgroundExecutionOutcome(for error: Error) -> String { - switch error { - case let error as RadrootsBackgroundTaskError: - switch error { - case .invalidRequest: - return "invalid_request" - case .unavailable: - return "unavailable" - case .schedulerFailure: - return "scheduler_failure" - } - case let error as RadrootsBackgroundTransferError: - switch error { - case .invalidRequest: - return "invalid_request" - case .unavailable: - return "unavailable" - case .transferFailure: - return "transfer_failure" - case .persistenceFailure: - return "persistence_failure" - } - case let error as RadrootsAppleFileError: - switch error { - case .invalidRequest: - return "invalid_request" - case .notFound: - return "not_found" - case .permissionDenied: - return "permission_denied" - case .transientFailure: - return "transient_failure" - case .permanentFailure: - return "permanent_failure" - } - case let error as RelaySettingsError: - switch error { - case .noRelaysConfigured: - return "relay_config_missing" - case .invalidRelayURL: - return "invalid_relay_url" - case .invalidStoredRelaySettings: - return "invalid_relay_settings" - } - default: - return "failure" - } - } - - static func externalActionOutcome(for error: Error) -> String { - guard let error = error as? RadrootsExternalActionError else { - return "failure" - } - switch error { - case .invalidRequest: - return "invalid_request" - case .blockedByPolicy: - return "blocked_by_policy" - case .unavailable: - return "unavailable" - case .transientFailure: - return "transient_failure" - case .permanentFailure: - return "permanent_failure" - } - } - - static func documentInterchangeOutcome(for error: Error) -> String { - guard let error = error as? FieldDocumentInterchangeError else { - return "failure" - } - switch error { - case .emptyRelayConfig: - return "empty_relay_config" - case .invalidRelayURL: - return "invalid_relay_url" - case .invalidRelayConfigDocument: - return "invalid_relay_config_document" - } - } -} - -extension FieldUserPresenceAction { - var telemetryName: String { - switch self { - case .unlockIdentity: - return "unlock_identity" - case .saveIdentity: - return "save_identity" - case .deleteIdentity: - return "delete_identity" - } - } -} - -extension FieldCaptureIntakeOperation { - var telemetryName: String { - switch self { - case .idle: - return "idle" - case .refreshing: - return "support_refresh" - case .importingPhoto: - return "import_photo" - case .capturingPhoto: - return "capture_photo" - case .scanningDocument: - return "scan_document" - } - } -} diff --git a/Radroots/Runtime/FieldTelemetryUITestProbe.swift b/Radroots/Runtime/FieldTelemetryUITestProbe.swift @@ -1,90 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldTelemetryUITestProbe { - private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_TELEMETRY_PROBE" - private static let redactionPolicy = RadrootsTelemetryRedactionPolicy.default - - static var isRequested: Bool { - ProcessInfo.processInfo.environment[enabledKey] == "true" - } - - static func value(recordedBy telemetry: FieldTelemetry) async -> String? { - guard isRequested else { - return nil - } - let events = await telemetry.recordedEventsForUITest() - return value(events: events) - } - - private static func value(events: [RadrootsTelemetryEvent]) -> String { - let eventNames = Set(events.map(\.name)) - let fieldKeys = Set(events.flatMap { event in - event.fields.map(\.key) - }) - let values = events.flatMap(stringValues) - return [ - "event_count=\(events.count)", - "event_names=\(eventNames.sorted().joined(separator: ","))", - "field_keys=\(fieldKeys.sorted().joined(separator: ","))", - "runtime_logging_seen=\(eventNames.contains("field_ios.runtime.logging_initialized"))", - "startup_success_seen=\(eventNames.contains("field_ios.startup.success"))", - "relay_status_seen=\(eventNames.contains("field_ios.relay.status_changed"))", - "identity_create_seen=\(eventNames.contains("field_ios.identity_custody.create"))", - "user_presence_save_identity_seen=\(eventNames.contains("field_ios.user_presence.save_identity"))", - "document_diagnostics_export_seen=\(eventNames.contains("field_ios.document_interchange.diagnostics_export"))", - "document_relay_config_export_seen=\(eventNames.contains("field_ios.document_interchange.relay_config_export"))", - "document_relay_config_import_seen=\(eventNames.contains("field_ios.document_interchange.relay_config_import"))", - "document_public_share_prepare_seen=\(eventNames.contains("field_ios.document_interchange.public_share_prepare"))", - "capture_support_refreshed_seen=\(eventNames.contains("field_ios.capture.support_refreshed"))", - "capture_import_photo_seen=\(eventNames.contains("field_ios.capture.import_photo"))", - "capture_scan_document_seen=\(eventNames.contains("field_ios.capture.scan_document"))", - "external_action_open_seen=\(eventNames.contains("field_ios.external_action.open"))", - "unsafe_values_present=\(values.contains(where: containsUnsafeValue))", - "relay_url_values_present=\(values.contains(where: containsRelayURL))", - "secret_like_values_present=\(values.contains(where: containsSecretLikeValue))", - "path_like_values_present=\(values.contains(where: containsPathLikeValue))", - "npub_values_present=\(values.contains(where: containsNpubValue))" - ].joined(separator: ";") - } - - private static func stringValues(from event: RadrootsTelemetryEvent) -> [String] { - var values = event.message.map { [$0] } ?? [] - values.append(contentsOf: event.fields.map { field in - field.value.renderedValue - }) - return values - } - - private static func containsUnsafeValue(_ value: String) -> Bool { - redactionPolicy.containsUnsafeValue(value) - || containsRelayURL(value) - || containsSecretLikeValue(value) - || containsPathLikeValue(value) - || containsNpubValue(value) - } - - private static func containsRelayURL(_ value: String) -> Bool { - let normalized = value.lowercased() - return normalized.contains("ws://") || normalized.contains("wss://") - } - - private static func containsSecretLikeValue(_ value: String) -> Bool { - let normalized = value.lowercased() - return normalized.contains("nsec") - || normalized.range(of: "[a-f0-9]{64}", options: .regularExpression) != nil - } - - private static func containsPathLikeValue(_ value: String) -> Bool { - let normalized = value.lowercased() - return normalized.contains("/users/") - || normalized.contains("/private/var/") - || normalized.contains("/var/mobile/containers/") - || normalized.contains("/var/folders/") - || normalized.contains("file:///") - } - - private static func containsNpubValue(_ value: String) -> Bool { - value.lowercased().contains("npub") - } -} diff --git a/Radroots/Runtime/FieldUITestSupport.swift b/Radroots/Runtime/FieldUITestSupport.swift @@ -1,284 +0,0 @@ -import Foundation -import RadrootsKit - -#if DEBUG -enum FieldUITestHarness { - static var isRequested: Bool { - let environment = ProcessInfo.processInfo.environment - let arguments = ProcessInfo.processInfo.arguments - return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" || - arguments.contains("--radroots-field-ios-ui-test") - } - - static func bool(_ key: String, default defaultValue: Bool) -> Bool { - guard let raw = ProcessInfo.processInfo.environment[key] else { - return defaultValue - } - switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "1", "true", "yes": - return true - case "0", "false", "no": - return false - default: - return defaultValue - } - } - - static func string(_ key: String) -> String? { - ProcessInfo.processInfo.environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines) - } -} - -actor FieldUITestBackgroundTaskScheduler: RadrootsBackgroundTaskScheduler { - private var pendingTaskSnapshots: [RadrootsBackgroundTaskIdentifier: RadrootsBackgroundTaskSnapshot] - private var submittedRequestsValue: [RadrootsBackgroundTaskRequest] - private var cancelAllCountValue: Int - private let submittedAt: Date - - init(submittedAt: Date = Date(timeIntervalSince1970: 0)) { - self.pendingTaskSnapshots = [:] - self.submittedRequestsValue = [] - self.cancelAllCountValue = 0 - self.submittedAt = submittedAt - } - - func submit(_ request: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot { - submittedRequestsValue.append(request) - let snapshot = try RadrootsBackgroundTaskSnapshot(request: request, submittedAt: submittedAt) - pendingTaskSnapshots[request.identifier] = snapshot - return snapshot - } - - func cancel(_ identifier: RadrootsBackgroundTaskIdentifier) async throws { - pendingTaskSnapshots.removeValue(forKey: identifier) - } - - func cancelAll() async throws { - cancelAllCountValue += 1 - pendingTaskSnapshots.removeAll() - } - - func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] { - pendingTaskSnapshots.values.sorted { lhs, rhs in - lhs.identifier < rhs.identifier - } - } - - var submittedRequestCount: Int { - submittedRequestsValue.count - } - - var cancelAllCount: Int { - cancelAllCountValue - } -} - -actor FieldUITestBackgroundTransferStore: RadrootsBackgroundTransferStore { - private var snapshotsByIdentifier: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] = [:] - - func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - snapshotsByIdentifier.values.sorted { left, right in - left.identifier < right.identifier - } - } - - func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { - snapshotsByIdentifier[snapshot.identifier] = snapshot - } - - func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { - snapshotsByIdentifier.removeValue(forKey: identifier) - } - - func removeAllSnapshots() async throws { - snapshotsByIdentifier.removeAll() - } -} - -actor FieldUITestBackgroundTransfer: RadrootsBackgroundTransfer { - private let store: any RadrootsBackgroundTransferStore - private let updatedAt: Date - - init( - store: any RadrootsBackgroundTransferStore = FieldUITestBackgroundTransferStore(), - updatedAt: Date = Date(timeIntervalSince1970: 0) - ) { - self.store = store - self.updatedAt = updatedAt - } - - func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: request, - state: .queued, - updatedAt: updatedAt - ) - try await store.saveSnapshot(snapshot) - return RadrootsBackgroundTransferHandle(request: request) - } - - func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { - if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) { - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .cancelled, - progress: existing.progress, - updatedAt: updatedAt - ) - try await store.saveSnapshot(snapshot) - } - } - - func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { - try await store.loadSnapshots().first { $0.identifier == identifier } - } - - func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - try await store.loadSnapshots() - } - - func handleEventsForBackgroundURLSession( - identifier: String, - completionHandler: @escaping @Sendable () -> Void - ) async { - completionHandler() - } -} - -actor FieldUITestMediaPicker: RadrootsMediaPicker { - private let support: RadrootsMediaPickerSupport - private let importOutcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError> - private let captureOutcome: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError> - - init( - support: RadrootsMediaPickerSupport, - importOutcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>, - captureOutcome: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError> - ) { - self.support = support - self.importOutcome = importOutcome - self.captureOutcome = captureOutcome - } - - func currentSupport() async throws -> RadrootsMediaPickerSupport { - support - } - - func importMedia(_ request: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult { - switch importOutcome { - case .success(let result): - return result - case .failure(let error): - throw error - } - } - - func captureMedia(_ request: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult { - switch captureOutcome { - case .success(let result): - return result - case .failure(let error): - throw error - } - } -} - -actor FieldUITestDocumentScanner: RadrootsDocumentScanner { - private let support: RadrootsDocumentScannerSupport - private let scanOutcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError> - - init( - support: RadrootsDocumentScannerSupport, - scanOutcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError> - ) { - self.support = support - self.scanOutcome = scanOutcome - } - - func currentSupport() async throws -> RadrootsDocumentScannerSupport { - support - } - - func scanDocument(_ request: RadrootsDocumentScanRequest) async throws -> RadrootsScannedDocument { - switch scanOutcome { - case .success(let result): - return result - case .failure(let error): - throw error - } - } -} - -actor FieldUITestExternalActions: RadrootsExternalActions { - private let defaultCanOpen: Bool - private let openOutcome: Result<Void, RadrootsExternalActionError> - - init( - defaultCanOpen: Bool = true, - openOutcome: Result<Void, RadrootsExternalActionError> = .success(()) - ) { - self.defaultCanOpen = defaultCanOpen - self.openOutcome = openOutcome - } - - func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability { - RadrootsExternalActionCapability(destination: destination, canOpen: defaultCanOpen) - } - - func open(_ request: RadrootsExternalActionRequest) async throws { - switch openOutcome { - case .success: - return - case .failure(let error): - throw error - } - } -} - -actor FieldUITestUserPresence: RadrootsUserPresence { - private let statusValue: RadrootsUserPresenceStatus - private let outcomes: [Result<Bool, RadrootsUserPresenceError>] - private var requestCount: Int - - init( - status: RadrootsUserPresenceStatus = RadrootsUserPresenceStatus( - support: .biometricsOrDeviceCredential, - biometryKind: .faceID, - canEvaluateDeviceCredential: true, - canEvaluateBiometrics: true - ), - outcomes: [Result<Bool, RadrootsUserPresenceError>] = [.success(true)] - ) { - self.statusValue = status - self.outcomes = outcomes.isEmpty ? [.success(true)] : outcomes - self.requestCount = 0 - } - - func currentStatus() async throws -> RadrootsUserPresenceStatus { - statusValue - } - - func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { - let outcome = outcomes[min(requestCount, outcomes.count - 1)] - requestCount += 1 - switch outcome { - case .success(let verified): - return RadrootsUserPresenceResult(policy: request.policy, verified: verified) - case .failure(let error): - throw error - } - } -} - -actor FieldUITestRecordingTelemetry: RadrootsTelemetry { - private var recordedEventsValue: [RadrootsTelemetryEvent] = [] - - func record(_ event: RadrootsTelemetryEvent) async { - recordedEventsValue.append(event) - } - - var recordedEvents: [RadrootsTelemetryEvent] { - recordedEventsValue - } -} -#endif diff --git a/Radroots/Runtime/FieldUserPresenceGate.swift b/Radroots/Runtime/FieldUserPresenceGate.swift @@ -1,150 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldUserPresenceAction: Equatable, Sendable { - case unlockIdentity - case saveIdentity - case deleteIdentity - - var reason: String { - switch self { - case .unlockIdentity: - "Unlock your local Nostr identity." - case .saveIdentity: - "Save your local Nostr identity on this iPhone." - case .deleteIdentity: - "Delete your local Nostr identity from this iPhone." - } - } - - var verifiedStatusText: String { - switch self { - case .unlockIdentity: - "Verified user presence to unlock identity." - case .saveIdentity: - "Verified user presence to save identity." - case .deleteIdentity: - "Verified user presence to delete identity." - } - } -} - -struct FieldUserPresenceRequestRecord: Equatable, Sendable { - let action: FieldUserPresenceAction - let statusText: String -} - -enum FieldUserPresenceGateError: LocalizedError, Equatable { - case notVerified - - var errorDescription: String? { - switch self { - case .notVerified: - "User presence was not verified." - } - } -} - -final class FieldUserPresenceGate: Sendable { - private let userPresence: any RadrootsUserPresence - - init(userPresence: any RadrootsUserPresence) { - self.userPresence = userPresence - } - - static func configured() -> FieldUserPresenceGate { - #if DEBUG - if FieldUITestHarness.isRequested { - return FieldUserPresenceGate(userPresence: uiTestUserPresence()) - } - #endif - return FieldUserPresenceGate(userPresence: RadrootsAppleUserPresence()) - } - - func requirePresence(for action: FieldUserPresenceAction) async throws -> FieldUserPresenceRequestRecord { - let request = try RadrootsUserPresenceRequest(reason: action.reason) - let result = try await userPresence.verify(request) - guard result.verified else { - throw FieldUserPresenceGateError.notVerified - } - return FieldUserPresenceRequestRecord(action: action, statusText: action.verifiedStatusText) - } - - #if DEBUG - private static func uiTestUserPresence() -> any RadrootsUserPresence { - let outcomes = uiTestOutcomes() - let status = uiTestStatus() - return FieldUITestUserPresence(status: status, outcomes: outcomes.map(\.result)) - } - - private static func uiTestStatus() -> RadrootsUserPresenceStatus { - let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_STATUS")?.lowercased() - switch raw { - case "unavailable": - return .unavailable - case "device_credential": - return RadrootsUserPresenceStatus( - support: .deviceCredential, - biometryKind: .none, - canEvaluateDeviceCredential: true, - canEvaluateBiometrics: false - ) - case nil, "", "available", "biometrics": - return RadrootsUserPresenceStatus( - support: .biometricsOrDeviceCredential, - biometryKind: .faceID, - canEvaluateDeviceCredential: true, - canEvaluateBiometrics: true - ) - default: - return .unavailable - } - } - - private static func uiTestOutcomes() -> [FieldUserPresenceUITestOutcome] { - let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_USER_PRESENCE_OUTCOME") ?? "" - let parts = raw - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .filter { !$0.isEmpty } - if parts.isEmpty { - return [.success] - } - return parts.map { FieldUserPresenceUITestOutcome(rawValue: $0) ?? .denied } - } - #endif -} - -#if DEBUG -private enum FieldUserPresenceUITestOutcome: String { - case success - case unverified - case cancelled - case denied - case unavailable - case timeout - case transientFailure = "transient_failure" - case permanentFailure = "permanent_failure" - - var result: Result<Bool, RadrootsUserPresenceError> { - switch self { - case .success: - .success(true) - case .unverified: - .success(false) - case .cancelled: - .failure(.userCancelled("User presence was cancelled.")) - case .denied: - .failure(.permissionDenied("User presence permission is denied.")) - case .unavailable: - .failure(.unavailable("User presence is unavailable.")) - case .timeout: - .failure(.timeout("User presence timed out.")) - case .transientFailure: - .failure(.transientFailure("User presence failed. Please retry.")) - case .permanentFailure: - .failure(.permanentFailure("User presence failed.")) - } - } -} -#endif diff --git a/Radroots/Runtime/LoggingSettings.swift b/Radroots/Runtime/LoggingSettings.swift @@ -1,43 +0,0 @@ -import Foundation - -enum FieldRuntimeLoggingError: LocalizedError { - case initializationFailed(String) - - var errorDescription: String? { - switch self { - case .initializationFailed(let message): - "Runtime logging initialization failed: \(message)" - } - } -} - -struct LoggingSettings: Equatable { - var stdout: Bool - var fileEnabled: Bool - var fileName: String - var level: String? - - static func load() -> LoggingSettings { - let stdout = BuildConfig.bool(.loggingStdout) ?? true - let fileEnabled = BuildConfig.bool(.loggingFileEnabled) ?? false - let fileName = BuildConfig.string(.loggingFileName) ?? "field-ios.log" - let level = BuildConfig.string(.loggingFilter) - return LoggingSettings(stdout: stdout, fileEnabled: fileEnabled, fileName: fileName, level: level) - } - - func apply(bundleIdentifier: String) throws { - if let level { - setenv("RUST_LOG", level, 1) - } - if fileEnabled { - let logFileURL = try FieldLocalState.logFileURL(bundleIdentifier: bundleIdentifier, fileName: fileName) - try initLogging( - dir: logFileURL.deletingLastPathComponent().path, - fileName: logFileURL.lastPathComponent, - isStdout: stdout - ) - } else { - try initLogging(dir: nil, fileName: fileName, isStdout: stdout) - } - } -} diff --git a/Radroots/Runtime/RadrootsLifecycleCoordinator.swift b/Radroots/Runtime/RadrootsLifecycleCoordinator.swift @@ -0,0 +1,392 @@ +import Combine +import Foundation +import RadrootsKit + +final class RadrootsCompletionOnce: @unchecked Sendable { + private let lock = NSLock() + private var completion: (() -> Void)? + + init(_ completion: @escaping () -> Void) { + self.completion = completion + } + + func complete() { + let action = lock.withLock { + let action = completion + completion = nil + return action + } + action?() + } +} + +actor RadrootsBackgroundEventRouter { + typealias Handler = @Sendable ( + _ identifier: String, + _ completion: @escaping @Sendable () -> Void + ) async -> Void + + static let shared = RadrootsBackgroundEventRouter() + private static let maximumPendingEvents = 8 + private static let pendingTimeoutNanoseconds: UInt64 = 15_000_000_000 + + private struct PendingEvent: Sendable { + let token: UUID + let identifier: String + let completion: RadrootsCompletionOnce + let timeout: Task<Void, Never> + } + + private var expectedIdentifier: String? + private var handler: Handler? + private var pending: [PendingEvent] = [] + + func attach(identifier: String, handler: @escaping Handler) async { + expectedIdentifier = identifier + self.handler = handler + let events = pending + pending.removeAll(keepingCapacity: true) + for event in events { + event.timeout.cancel() + guard event.identifier == identifier else { + event.completion.complete() + continue + } + await handler(event.identifier) { + event.completion.complete() + } + } + } + + func handle(identifier: String, completion: RadrootsCompletionOnce) async { + if let expectedIdentifier, identifier != expectedIdentifier { + completion.complete() + return + } + if let handler { + await handler(identifier) { + completion.complete() + } + return + } + + while pending.count >= Self.maximumPendingEvents { + let oldest = pending.removeFirst() + oldest.timeout.cancel() + oldest.completion.complete() + } + let token = UUID() + let timeout = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: Self.pendingTimeoutNanoseconds) + } catch { + return + } + await self?.expire(token: token) + } + pending.append( + PendingEvent( + token: token, + identifier: identifier, + completion: completion, + timeout: timeout + ) + ) + } + + func detachAndCompletePending() { + handler = nil + expectedIdentifier = nil + let events = pending + pending.removeAll() + for event in events { + event.timeout.cancel() + event.completion.complete() + } + } + + private func expire(token: UUID) { + guard let index = pending.firstIndex(where: { $0.token == token }) else { return } + let event = pending.remove(at: index) + event.completion.complete() + } +} + +actor RadrootsLifecycleBridge { + static let shared = RadrootsLifecycleBridge() + + private var shutdown: (@Sendable () async -> Void)? + + func register(shutdown: @escaping @Sendable () async -> Void) { + self.shutdown = shutdown + } + + func requestShutdown() async { + let action = shutdown + shutdown = nil + await action?() + await RadrootsBackgroundEventRouter.shared.detachAndCompletePending() + } +} + +struct RadrootsDiagnosticRecord: Codable, Sendable, Equatable { + let name: String + let category: String + let level: String + let fields: [String: String] + let occurredAtUnixMilliseconds: Int64 +} + +actor RadrootsDiagnosticsBuffer: RadrootsTelemetry { + private let capacity: Int + private let policy = RadrootsTelemetryRedactionPolicy.default + private var events: [RadrootsTelemetryEvent] = [] + + init(capacity: Int = 128) { + self.capacity = min(max(capacity, 16), 256) + } + + func record(_ event: RadrootsTelemetryEvent) { + events.append(policy.redacted(event)) + if events.count > capacity { + events.removeFirst(events.count - capacity) + } + } + + func records() -> [RadrootsDiagnosticRecord] { + events.map { event in + RadrootsDiagnosticRecord( + name: event.name, + category: event.category, + level: event.level.rawValue, + fields: Dictionary(uniqueKeysWithValues: event.fields.map { field in + (field.key, field.value.renderedValue) + }), + occurredAtUnixMilliseconds: Int64(event.occurredAt.timeIntervalSince1970 * 1000) + ) + } + } +} + +private struct RadrootsDiagnosticsDocument: Codable, Sendable { + let schema: String + let appVersion: String + let appBuild: String + let runtimeCrate: String + let runtimeVersion: String + let runtimePhase: String + let relayProfile: String? + let relayState: String? + let relayCount: Int + let records: [RadrootsDiagnosticRecord] +} + +actor RadrootsLifecycleCoordinator { + private static let maximumExportBytes = 256 * 1024 + + private let telemetry: any RadrootsTelemetry + private let buffer: RadrootsDiagnosticsBuffer + private let fileAccess: RadrootsAppleFileAccess? + private let transfer: (any RadrootsBackgroundTransfer)? + private let transferIdentifier: String? + private var backgroundEventsAttached = false + + init( + telemetry: any RadrootsTelemetry, + buffer: RadrootsDiagnosticsBuffer, + fileAccess: RadrootsAppleFileAccess?, + transfer: (any RadrootsBackgroundTransfer)?, + transferIdentifier: String? + ) { + self.telemetry = telemetry + self.buffer = buffer + self.fileAccess = fileAccess + self.transfer = transfer + self.transferIdentifier = transferIdentifier + } + + static func production(bundleIdentifier: String) throws -> RadrootsLifecycleCoordinator { + let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) + let fileAccess = RadrootsAppleFileAccess(roots: roots) + let buffer = RadrootsDiagnosticsBuffer() + let logger = RadrootsAppleLoggerTelemetry(subsystem: bundleIdentifier) + let telemetry = RadrootsMultiplexTelemetry([ + logger, + RadrootsRedactingTelemetry(sink: buffer), + ]) + let identifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier( + "\(bundleIdentifier.lowercased()).background.transfer" + ) + let transfer = try RadrootsAppleBackgroundTransfer( + roots: roots, + sessionIdentifier: identifier + ) + return RadrootsLifecycleCoordinator( + telemetry: telemetry, + buffer: buffer, + fileAccess: fileAccess, + transfer: transfer, + transferIdentifier: identifier + ) + } + + static func disabled() -> RadrootsLifecycleCoordinator { + let buffer = RadrootsDiagnosticsBuffer() + return RadrootsLifecycleCoordinator( + telemetry: RadrootsRedactingTelemetry(sink: buffer), + buffer: buffer, + fileAccess: nil, + transfer: nil, + transferIdentifier: nil + ) + } + + static func testing(roots: RadrootsAppleFileRoots, capacity: Int = 128) -> RadrootsLifecycleCoordinator { + let buffer = RadrootsDiagnosticsBuffer(capacity: capacity) + return RadrootsLifecycleCoordinator( + telemetry: RadrootsRedactingTelemetry(sink: buffer), + buffer: buffer, + fileAccess: RadrootsAppleFileAccess(roots: roots), + transfer: nil, + transferIdentifier: nil + ) + } + + func attachBackgroundEvents() async { + guard !backgroundEventsAttached, + let transfer, + let transferIdentifier + else { + return + } + backgroundEventsAttached = true + await RadrootsBackgroundEventRouter.shared.attach(identifier: transferIdentifier) { + identifier, + completion in + await transfer.handleEventsForBackgroundURLSession( + identifier: identifier, + completionHandler: completion + ) + } + } + + func record( + _ name: String, + level: RadrootsTelemetryLevel = .info, + fields: [String: String] = [:] + ) async { + let values = fields.sorted(by: { $0.key < $1.key }).compactMap { key, value in + try? RadrootsTelemetryField.string(key, value) + } + guard let event = try? RadrootsTelemetryEvent( + name: name, + category: "ios_lifecycle", + level: level, + fields: values + ) else { + return + } + await telemetry.record(event) + } + + func prepareDiagnostics( + snapshot: RadrootsRuntimeSnapshot, + appVersion: String, + appBuild: String, + phase: String + ) async throws -> RadrootsPreparedExportDocument { + guard let fileAccess else { + throw RadrootsRuntimeFailure.local( + operation: "diagnostics.prepare", + code: "ios.diagnostics.unavailable", + safeMessage: "Diagnostics export is unavailable." + ) + } + let records = await buffer.records() + let document = RadrootsDiagnosticsDocument( + schema: "radroots.ios.diagnostics.v1", + appVersion: appVersion, + appBuild: appBuild, + runtimeCrate: snapshot.crateName, + runtimeVersion: snapshot.crateVersion, + runtimePhase: phase, + relayProfile: snapshot.relay?.profile, + relayState: snapshot.relay?.state, + relayCount: snapshot.relay?.relays.count ?? 0, + records: records + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(document) + guard data.count <= Self.maximumExportBytes else { + throw RadrootsRuntimeFailure.local( + operation: "diagnostics.prepare", + code: "ios.diagnostics.too_large", + safeMessage: "The bounded diagnostics report could not be prepared." + ) + } + return try fileAccess.prepareExport( + RadrootsExportDocumentRequest( + source: .inlineData(data), + suggestedFilename: "radroots-diagnostics.json", + mediaType: "application/json", + sizeBytes: UInt64(data.count) + ) + ) + } + + func releaseDiagnostics(_ export: RadrootsPreparedExportDocument) { + guard let fileAccess else { return } + try? fileAccess.releasePreparedExport(export) + } +} + +@MainActor +final class RadrootsDiagnosticsStore: ObservableObject { + @Published var preparedExport: RadrootsPreparedExportDocument? + @Published private(set) var isPreparing = false + @Published private(set) var message: String? + + private let coordinator: RadrootsLifecycleCoordinator + private var activeExport: RadrootsPreparedExportDocument? + + init(coordinator: RadrootsLifecycleCoordinator) { + self.coordinator = coordinator + } + + func prepare(snapshot: RadrootsRuntimeSnapshot, bundle: Bundle = .main) async { + guard !isPreparing, preparedExport == nil, activeExport == nil else { return } + isPreparing = true + message = nil + defer { isPreparing = false } + do { + let export = try await coordinator.prepareDiagnostics( + snapshot: snapshot, + appVersion: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0", + appBuild: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0", + phase: "running" + ) + activeExport = export + preparedExport = export + } catch { + activeExport = nil + preparedExport = nil + message = (error as? LocalizedError)?.errorDescription + ?? "Diagnostics export could not be prepared." + } + } + + func completeExport(_ result: Result<RadrootsExportDocumentResult, Error>) { + let export = activeExport + activeExport = nil + preparedExport = nil + switch result { + case .success: + message = "Diagnostics exported." + case .failure: + message = "Diagnostics export was cancelled or failed." + } + guard let export else { return } + Task { await coordinator.releaseDiagnostics(export) } + } +} diff --git a/Radroots/Shared/Components/CopyButton.swift b/Radroots/Shared/Components/CopyButton.swift @@ -1,40 +0,0 @@ -import SwiftUI - -public struct CopyButton: View { - private let value: String - private let onCopied: (() -> Void)? - @State private var copied = false - - public init(value: String, onCopied: (() -> Void)? = nil) { - self.value = value - self.onCopied = onCopied - } - - public var body: some View { - Button { - Task { @MainActor in - UIPasteboard.general.string = value - } - let gen = UINotificationFeedbackGenerator() - gen.notificationOccurred(.success) - withAnimation(.easeInOut(duration: 0.12)) { copied = true } - onCopied?() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - withAnimation(.easeInOut(duration: 0.12)) { copied = false } - } - } label: { - ZStack { - Image(systemName: "doc.on.doc") - .opacity(copied ? 0 : 1) - Image(systemName: "checkmark.circle.fill") - .opacity(copied ? 1 : 0) - } - .frame(width: 24, height: 24) - .font(.system(size: 17, weight: .semibold)) - } - .buttonStyle(.plain) - .foregroundStyle(copied ? .green : .accentColor) - .contentTransition(.opacity) - .accessibilityLabel(copied ? "Copied" : "Copy") - } -} diff --git a/Radroots/Shared/Components/CopyRow.swift b/Radroots/Shared/Components/CopyRow.swift @@ -1,26 +0,0 @@ -import SwiftUI - -public struct CopyRow: View { - private let title: String - private let value: String - private let onCopied: (() -> Void)? - - public init(title: String, value: String, onCopied: (() -> Void)? = nil) { - self.title = title - self.value = value - self.onCopied = onCopied - } - - public var body: some View { - LabeledContent(title) { - HStack(spacing: 6) { - Text(value) - .font(.callout.monospaced()) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - CopyButton(value: value, onCopied: onCopied) - } - } - } -} diff --git a/Radroots/Shared/Components/SectionWideButton.swift b/Radroots/Shared/Components/SectionWideButton.swift @@ -1,45 +0,0 @@ -import SwiftUI - -public struct SectionWideButton: View { - private let title: String - private let enabled: Bool - private let isProminent: Bool - private let action: () -> Void - - public init( - _ title: String, - enabled: Bool = true, - isProminent: Bool = false, - action: @escaping () -> Void - ) { - self.title = title - self.enabled = enabled - self.isProminent = isProminent - self.action = action - } - - public var body: some View { - Button(action: action) { - Text(title) - .font(.headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - } - .buttonStyle(.plain) - .foregroundStyle(foregroundStyle) - .disabled(!enabled) - .listRowBackground(backgroundStyle) - .animation(.easeInOut(duration: 0.15), value: isProminent) - .accessibilityAddTraits(.isButton) - } - - private var backgroundStyle: Color { - guard enabled else { return Color.secondary.opacity(0.25) } - return isProminent ? .accentColor : Color.secondary.opacity(0.15) - } - - private var foregroundStyle: Color { - guard enabled else { return .secondary } - return isProminent ? .white : .primary - } -} diff --git a/Radroots/Shared/Extensions/Bundle+Build.swift b/Radroots/Shared/Extensions/Bundle+Build.swift @@ -1,7 +0,0 @@ -import Foundation - -extension Bundle { - var buildSHA: String? { object(forInfoDictionaryKey: "GIT_SHA") as? String } - var version: String? { object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String } - var buildNumber: String? { object(forInfoDictionaryKey: "CFBundleVersion") as? String } -} diff --git a/Radroots/Shared/Extensions/View+Nav.swift b/Radroots/Shared/Extensions/View+Nav.swift @@ -1,13 +0,0 @@ -import SwiftUI - -extension View { - func inlineNavigationTitle(_ title: LocalizedStringKey) -> some View { - navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - } - - func inlineNavigationTitle(_ title: String) -> some View { - navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/Radroots/Shared/Localisation/Strings.swift b/Radroots/Shared/Localisation/Strings.swift @@ -1,8 +0,0 @@ -import SwiftUI - -enum Ls { - static var appName: LocalizedStringKey { "app_name" } - static var settings: LocalizedStringKey { "settings" } - static var setupGreetingHeader: LocalizedStringKey { "setup_greeting_header" } - static var setupGreetingHeaderSub: LocalizedStringKey { "setup_greeting_header_sub" } -} diff --git a/Radroots/Shared/Modifiers/ToastModifier.swift b/Radroots/Shared/Modifiers/ToastModifier.swift @@ -1,41 +0,0 @@ -import SwiftUI - -struct ToastModifier<Overlay: View>: ViewModifier { - @Binding var isPresented: Bool - let autoDismiss: Double - let overlay: () -> Overlay - - func body(content: Content) -> some View { - ZStack { - content - if isPresented { - overlay() - .transition(.opacity.combined(with: .scale(scale: 0.98))) - .zIndex(1) - .onAppear { - guard autoDismiss > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + autoDismiss) { - withAnimation { isPresented = false } - } - } - } - } - .animation(.easeInOut(duration: 0.15), value: isPresented) - } -} - -extension View { - func toast<Overlay: View>( - isPresented: Binding<Bool>, - autoDismiss: Double = 1.2, - @ViewBuilder overlay: @escaping () -> Overlay - ) -> some View { - modifier( - ToastModifier( - isPresented: isPresented, - autoDismiss: autoDismiss, - overlay: overlay - ) - ) - } -} diff --git a/Radroots/State/RadrootsAddStore.swift b/Radroots/State/RadrootsAddStore.swift @@ -70,22 +70,12 @@ final class RadrootsAddStore: ObservableObject { func start() async { guard !isStarted else { return } isStarted = true + startObservation() + guard operationGeneration == nil else { return } + message = nil state = .loading generation &+= 1 let requestedGeneration = generation - observationTask = Task { [weak self, runtimeClient] in - do { - let changes = try await runtimeClient.changes(bufferCapacity: 16) - for await change in changes { - guard !Task.isCancelled else { break } - if change.kind == .drafts || change.kind == .media { - await self?.reloadDrafts() - } - } - } catch { - // Durable draft operations remain available without observation. - } - } do { async let schemaResult = runtimeClient.addSchemas() async let draftResult = runtimeClient.draftHeads(limit: 100) @@ -114,6 +104,12 @@ final class RadrootsAddStore: ObservableObject { isWorking = false } + func suspend() { + isStarted = false + observationTask?.cancel() + observationTask = nil + } + func selectType(_ type: RadrootsAddCommandType) { guard !isWorking, isFormEditable, form.commandType != type else { return } activeDraft = nil @@ -407,6 +403,22 @@ final class RadrootsAddStore: ObservableObject { return try await media.support() } + private func startObservation() { + observationTask = Task { [weak self, runtimeClient] in + do { + let changes = try await runtimeClient.changes(bufferCapacity: 16) + for await change in changes { + guard !Task.isCancelled else { break } + if change.kind == .drafts || change.kind == .media { + await self?.reloadDrafts() + } + } + } catch { + // Durable draft operations remain available without observation. + } + } + } + private func accept(_ status: RadrootsDraftStatus) { guard operationGeneration == nil || operationGeneration == generation else { return } activeDraft = status diff --git a/Radroots/State/RadrootsIdentityStore.swift b/Radroots/State/RadrootsIdentityStore.swift @@ -79,7 +79,7 @@ actor RadrootsIdentityStore { @MainActor static func production( servicePrefix: String, - protectedDataAvailable: Bool + protectedDataAvailable: @escaping @Sendable () -> Bool ) throws -> RadrootsIdentityStore { let namespace = "radroots_identity_v1" let secureStore = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix) @@ -92,7 +92,7 @@ actor RadrootsIdentityStore { ), userPresence: RadrootsAppleUserPresence(), protectedData: RadrootsProtectedDataProvider { - protectedDataAvailable ? .available : .unavailable + protectedDataAvailable() ? .available : .unavailable } ) return RadrootsIdentityStore( diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -2,6 +2,23 @@ import Foundation import RadrootsKit import UIKit +final class RadrootsProtectedDataMonitor: @unchecked Sendable { + private let lock = NSLock() + private var available: Bool + + init(available: Bool) { + self.available = available + } + + func update(available: Bool) { + lock.withLock { self.available = available } + } + + func isAvailable() -> Bool { + lock.withLock { available } + } +} + enum RadrootsSessionPhase: Sendable, Equatable { case starting case identityRequired @@ -19,7 +36,7 @@ actor RadrootsSessionStore { private let identityStore: RadrootsIdentityStore private let runtimeClient: RadrootsRuntimeClient private let roots: RadrootsAppleFileRoots - private let protectedDataAvailable: Bool + private let protectedData: RadrootsProtectedDataMonitor private var generation: UInt64 = 0 private var phase: RadrootsSessionPhase = .starting @@ -28,13 +45,13 @@ actor RadrootsSessionStore { identityStore: RadrootsIdentityStore, runtimeClient: RadrootsRuntimeClient, roots: RadrootsAppleFileRoots, - protectedDataAvailable: Bool + protectedData: RadrootsProtectedDataMonitor ) { self.configurationStore = configurationStore self.identityStore = identityStore self.runtimeClient = runtimeClient self.roots = roots - self.protectedDataAvailable = protectedDataAvailable + self.protectedData = protectedData } @MainActor @@ -65,19 +82,25 @@ actor RadrootsSessionStore { ) ) let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) - let protectedDataAvailable = UIApplication.shared.isProtectedDataAvailable + let protectedData = RadrootsProtectedDataMonitor( + available: UIApplication.shared.isProtectedDataAvailable + ) return try RadrootsSessionStore( configurationStore: RadrootsConfigurationStore(bootstrap: bootstrap, roots: roots), identityStore: .production( servicePrefix: servicePrefix, - protectedDataAvailable: protectedDataAvailable + protectedDataAvailable: { protectedData.isAvailable() } ), runtimeClient: runtimeClient, roots: roots, - protectedDataAvailable: protectedDataAvailable + protectedData: protectedData ) } + func updateProtectedDataAvailability(_ available: Bool) { + protectedData.update(available: available) + } + func currentPhase() -> RadrootsSessionPhase { phase } @@ -87,7 +110,6 @@ actor RadrootsSessionStore { let requestedGeneration = generation phase = .starting do { - let configuration = try await configurationStore.load() let identity = try await identityStore.loadAndMigrate() guard generation == requestedGeneration else { throw RadrootsRuntimeClientError.superseded @@ -104,6 +126,7 @@ actor RadrootsSessionStore { case .corrupt: phase = .corruptIdentity(identity) case .unlocked: + let configuration = try await configurationStore.load() phase = try await startRuntime( configuration: configuration, identity: identity, @@ -206,7 +229,7 @@ actor RadrootsSessionStore { let mobileStore = try RadrootsAppleMobileStore.prepare( roots: roots, publicKeyHex: publicKeyHex, - protectedDataAvailability: protectedDataAvailable ? .available : .unavailable + protectedDataAvailability: protectedData.isAvailable() ? .available : .unavailable ) let sourceGeneration = try await configurationStore.sourceGeneration() let signer = try await identityStore.signer(for: identity) @@ -216,7 +239,7 @@ actor RadrootsSessionStore { publicKeyHex: publicKeyHex, sourceGenerationHex: sourceGeneration.generationHex, sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds, - protectedData: protectedDataAvailable ? .available : .unavailable, + protectedData: protectedData.isAvailable() ? .available : .unavailable, networkProfile: configuration.profile.runtimeValue, writableRelays: configuration.writableRelays, blossomOrigins: configuration.blossomOrigins, diff --git a/Radroots/Views/RadrootsSupportingViews.swift b/Radroots/Views/RadrootsSupportingViews.swift @@ -1,3 +1,4 @@ +import RadrootsKit import SwiftUI struct RadrootsSearchSheet: View { @@ -308,6 +309,7 @@ struct RadrootsSettingsView: View { let snapshot: RadrootsRuntimeSnapshot @ObservedObject var todayStore: RadrootsTodayStore @ObservedObject var addStore: RadrootsAddStore + @EnvironmentObject private var diagnosticsStore: RadrootsDiagnosticsStore var body: some View { List { @@ -342,8 +344,26 @@ struct RadrootsSettingsView: View { LabeledContent("Version", value: snapshot.crateVersion) LabeledContent("State", value: snapshot.isClosed ? "Closed" : "Running") } + Section { + if let message = diagnosticsStore.message { + Text(message) + .foregroundStyle(.secondary) + } + Button("Prepare diagnostics export") { + Task { await diagnosticsStore.prepare(snapshot: snapshot) } + } + .disabled(diagnosticsStore.isPreparing) + .accessibilityIdentifier("radroots.settings.diagnostics") + } header: { + Text("Privacy-safe diagnostics") + } footer: { + Text("Exports contain bounded lifecycle codes and runtime status only. Posts, keys, credentials, endpoint URLs, and local paths are excluded.") + } } .navigationTitle("Settings") + .radrootsDocumentExporter(preparedExport: $diagnosticsStore.preparedExport) { result in + diagnosticsStore.completeExport(result) + } .accessibilityIdentifier("radroots.support.settings.view") } diff --git a/RadrootsFFI/Makefile b/RadrootsFFI/Makefile @@ -147,6 +147,12 @@ package: generate -library $(LIB_DEVICE) -headers $(HEADERS_DIR) \ -library $(LIB_SIMULATOR) -headers $(HEADERS_DIR) \ -output $(ARTIFACTS_DIR)/$(FFI_OUTPUT_NAME).xcframework + plutil -convert json -o $(OUT_DIR)/xcframework-info.json \ + $(ARTIFACTS_DIR)/$(FFI_OUTPUT_NAME).xcframework/Info.plist + jq -S '.AvailableLibraries |= sort_by(.LibraryIdentifier)' \ + $(OUT_DIR)/xcframework-info.json > $(OUT_DIR)/xcframework-info.sorted.json + plutil -convert xml1 -o $(ARTIFACTS_DIR)/$(FFI_OUTPUT_NAME).xcframework/Info.plist \ + $(OUT_DIR)/xcframework-info.sorted.json install: package mkdir -p $(GENERATED_SWIFT_DIR) diff --git a/RadrootsTests/RadrootsAddStoreTests.swift b/RadrootsTests/RadrootsAddStoreTests.swift @@ -95,6 +95,27 @@ final class RadrootsAddStoreTests: XCTestCase { } @MainActor + func testSuspendAndResumePreserveLateDurableDraftCompletion() async throws { + let backend = AddBackend(saveDelayNanoseconds: 50_000_000) + let client = try await Self.startedClient(backend) + let store = RadrootsAddStore(runtimeClient: client, now: { 1_800_000_000 }) + await store.configure(snapshot: backend.snapshot()) + await store.start() + store.updateForm(\.content, "Background draft") + + let save = Task { await store.save() } + try await Task.sleep(nanoseconds: 2_000_000) + store.suspend() + await store.start() + await save.value + + XCTAssertEqual(store.activeDraft?.form?.content, "Background draft") + XCTAssertEqual(store.message, "Draft saved on this device.") + XCTAssertFalse(store.isWorking) + _ = try await client.stop() + } + + @MainActor private func configure(_ store: RadrootsAddStore, type: RadrootsAddCommandType) { switch type { case .createUpdate: diff --git a/RadrootsTests/RadrootsLifecycleTests.swift b/RadrootsTests/RadrootsLifecycleTests.swift @@ -0,0 +1,163 @@ +import Foundation +@testable import Radroots +import RadrootsKit +import XCTest + +final class RadrootsLifecycleTests: XCTestCase { + func testBackgroundEventsCompleteExactlyOnceBeforeAndAfterAttachment() async { + let router = RadrootsBackgroundEventRouter() + let first = CompletionProbe() + + await router.handle( + identifier: "org.radroots.tests.background.transfer", + completion: RadrootsCompletionOnce { first.increment() } + ) + XCTAssertEqual(first.value(), 0) + + await router.attach(identifier: "org.radroots.tests.background.transfer") { _, completion in + completion() + completion() + } + XCTAssertEqual(first.value(), 1) + + let mismatched = CompletionProbe() + await router.handle( + identifier: "org.radroots.tests.background.other", + completion: RadrootsCompletionOnce { mismatched.increment() } + ) + XCTAssertEqual(mismatched.value(), 1) + } + + func testBackgroundEventQueueIsBoundedAndShutdownCompletesPendingEvents() async { + let router = RadrootsBackgroundEventRouter() + let probes = (0 ..< 12).map { _ in CompletionProbe() } + + for probe in probes { + await router.handle( + identifier: "org.radroots.tests.background.transfer", + completion: RadrootsCompletionOnce { probe.increment() } + ) + } + + XCTAssertEqual(probes.filter { $0.value() == 1 }.count, 4) + await router.detachAndCompletePending() + XCTAssertTrue(probes.allSatisfy { $0.value() == 1 }) + } + + func testLifecycleBridgeRunsRegisteredShutdownOnlyOnce() async { + let bridge = RadrootsLifecycleBridge() + let shutdowns = CompletionProbe() + await bridge.register { shutdowns.increment() } + + await bridge.requestShutdown() + await bridge.requestShutdown() + + XCTAssertEqual(shutdowns.value(), 1) + } + + func testProtectedDataMonitorReflectsTransitions() { + let monitor = RadrootsProtectedDataMonitor(available: false) + XCTAssertFalse(monitor.isAvailable()) + + monitor.update(available: true) + + XCTAssertTrue(monitor.isAvailable()) + } + + func testDiagnosticsAreBoundedAndExcludeSensitiveRuntimeValues() async throws { + let roots = try makeRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let coordinator = RadrootsLifecycleCoordinator.testing(roots: roots, capacity: 16) + let secret = "nsec1diagnosticcanary" + let privateKey = String(repeating: "ab", count: 32) + let privatePath = "/Users/private/radroots/private.json" + + for index in 0 ..< 40 { + await coordinator.record( + "ios.lifecycle.test_event", + fields: [ + "attempt": "\(index)", + "content": secret, + "private_key": privateKey, + "source": privatePath, + ] + ) + } + let export = try await coordinator.prepareDiagnostics( + snapshot: makeSnapshot(privateKey: privateKey), + appVersion: "0.1.0-alpha", + appBuild: "1", + phase: "running" + ) + let data = try Data(contentsOf: export.fileURL) + let text = try XCTUnwrap(String(data: data, encoding: .utf8)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let records = try XCTUnwrap(object["records"] as? [[String: Any]]) + + XCTAssertEqual(object["schema"] as? String, "radroots.ios.diagnostics.v1") + XCTAssertEqual(records.count, 16) + XCTAssertLessThanOrEqual(data.count, 256 * 1024) + XCTAssertTrue(text.contains("[redacted]")) + XCTAssertFalse(text.contains(secret)) + XCTAssertFalse(text.contains(privateKey)) + XCTAssertFalse(text.contains(privatePath)) + XCTAssertFalse(text.contains("password@example.test")) + + await coordinator.releaseDiagnostics(export) + XCTAssertFalse(FileManager.default.fileExists(atPath: export.fileURL.path)) + } + + private func makeRoots() throws -> RadrootsAppleFileRoots { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-ios-lifecycle-tests-\(UUID().uuidString)", isDirectory: true) + return try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: base.appendingPathComponent("data", isDirectory: true), + cacheRoot: base.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: base.appendingPathComponent("temporary", isDirectory: true) + ) + } + + private func makeSnapshot(privateKey: String) -> RadrootsRuntimeSnapshot { + RadrootsRuntimeSnapshot( + identity: RadrootsRuntimeIdentity( + publicKeyHex: privateKey, + hostSignerConfigured: true + ), + relay: RadrootsRelayStatus( + profile: "simulator", + state: "configured", + readAvailability: "available", + writeAvailability: "available", + relays: [ + RadrootsRelayEndpointStatus( + url: "wss://user:password@example.test", + access: "read_write", + readState: "available", + writeState: "available", + readLastAttemptUnixMilliseconds: nil, + writeLastAttemptUnixMilliseconds: nil, + readNextAttemptUnixMilliseconds: nil, + writeNextAttemptUnixMilliseconds: nil + ), + ] + ), + crateName: "radroots_mobile_ffi", + crateVersion: "0.1.0-alpha", + isClosed: false + ) + } +} + +private final class CompletionProbe: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + lock.withLock { count += 1 } + } + + func value() -> Int { + lock.withLock { count } + } +} diff --git a/project.yml b/project.yml @@ -36,12 +36,14 @@ targets: deploymentTarget: "18.0" sources: - path: Radroots/App/App.swift + - path: Radroots/App/RadrootsAppDelegate.swift - path: Radroots/App/AppEntry.swift - path: Radroots/App/RadrootsAppModel.swift - path: Radroots/App/RadrootsProvider.swift - path: Radroots/App/RadrootsRootShell.swift - path: Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift - path: Radroots/Runtime/RadrootsAddMediaCoordinator.swift + - path: Radroots/Runtime/RadrootsLifecycleCoordinator.swift - path: Radroots/Runtime/RadrootsRuntimeClient.swift - path: Radroots/Runtime/RadrootsRuntimeModels.swift - path: Radroots/State/RadrootsConfigurationStore.swift @@ -84,6 +86,7 @@ targets: - path: RadrootsTests/RadrootsTodayStoreTests.swift - path: RadrootsTests/RadrootsAddStoreTests.swift - path: RadrootsTests/RadrootsSupportingStoreTests.swift + - path: RadrootsTests/RadrootsLifecycleTests.swift settings: base: "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64