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:
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