commit 3d5215d146a05fff52cb1f6c238de1741fe84b6f
parent 46cd51708df7a001f3582a04474ed60d84698864
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 17:30:18 -0700
field-ios: add Nostr-only background maintenance
- refresh Nostr relay status only when identity is unlocked
- inspect AppleKit transfer snapshots without remote upload behavior
- sweep expired AppleKit staged blobs during maintenance
- cancel scheduled background tasks during destructive identity reset
Diffstat:
3 files changed, 222 insertions(+), 31 deletions(-)
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -115,6 +115,11 @@ public final class AppState: ObservableObject {
let metadataStore = try FieldIdentityPublicMetadataStore.configured()
let appBundleIdentifier = try bundleIdentifier()
let resetLocalStateRequested = BuildConfig.bool(.resetLocalState) == true
+ let backgroundExecution = try FieldBackgroundExecution.configured(
+ bundleIdentifier: appBundleIdentifier,
+ telemetry: telemetry
+ )
+ self.backgroundExecution = backgroundExecution
try FieldFileAccessUITestProbe.seedDestructiveResetSentinelIfRequested(
bundleIdentifier: appBundleIdentifier,
resetLocalStateRequested: resetLocalStateRequested
@@ -122,6 +127,7 @@ public final class AppState: ObservableObject {
secureIdentityStore = secureStore
identityMetadataStore = metadataStore
if resetLocalStateRequested {
+ await backgroundExecution.cancelAll()
try FieldLocalState.resetFileRoots(bundleIdentifier: appBundleIdentifier)
try secureStore.deleteSelectedSecret()
metadataStore.delete()
@@ -133,11 +139,6 @@ public final class AppState: ObservableObject {
}
let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier)
self.captureIntake = captureIntake
- let backgroundExecution = try FieldBackgroundExecution.configured(
- bundleIdentifier: appBundleIdentifier,
- telemetry: telemetry
- )
- self.backgroundExecution = backgroundExecution
try await backgroundExecution.start()
await refreshRuntimeState(using: service)
if runtimeIdentityReady && !isLocked {
@@ -193,7 +194,8 @@ public final class AppState: ObservableObject {
public func appDidEnterBackground() {
Task {
- try? await backgroundExecution?.schedulePermittedTasks(reason: "background")
+ _ = try? await backgroundExecution?.schedulePermittedTasks(reason: "background")
+ await backgroundExecution?.performMaintenance(reason: "background")
}
}
@@ -269,6 +271,8 @@ public final class AppState: ObservableObject {
let service = try requireRuntimeService()
do {
try await requireUserPresence(for: .deleteIdentity)
+ await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false)
+ await backgroundExecution?.cancelAll()
try secureIdentityStoreOrConfigured().deleteSelectedSecret()
try identityMetadataStoreOrConfigured().delete()
try await resetRuntimeIdentityState(using: service)
@@ -598,6 +602,10 @@ public final class AppState: ObservableObject {
relayLastError = error.localizedDescription
}
await refreshRelayStatus(using: service)
+ await backgroundExecution?.updateRuntimeState(
+ service: service,
+ identityUnlocked: runtimeIdentityReady && !isLocked
+ )
}
private func refreshRelayStatus(using service: FieldRuntimeService) async {
@@ -722,6 +730,7 @@ public final class AppState: ObservableObject {
}
hasKey = storedIdentityAvailable
await refreshRelayStatus(using: service)
+ await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false)
}
private func apply(storedIdentity metadata: FieldIdentityPublicMetadata) {
diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift
@@ -31,18 +31,24 @@ struct FieldBackgroundExecutionHandlers: Sendable {
}
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
@@ -50,6 +56,7 @@ actor FieldBackgroundExecution {
self.identifiers = identifiers
self.scheduler = scheduler
self.transfer = transfer
+ self.roots = roots
self.telemetry = telemetry
self.now = now
self.registerHandlers = registerHandlers
@@ -60,6 +67,7 @@ actor FieldBackgroundExecution {
telemetry: FieldTelemetry
) throws -> FieldBackgroundExecution {
let identifiers = try FieldBackgroundTaskIdentifiers(bundleIdentifier: bundleIdentifier)
+ let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier)
if uiTestWasRequested {
let scheduler = RadrootsFakeBackgroundTaskScheduler()
let transfer = RadrootsFakeBackgroundTransfer()
@@ -67,12 +75,12 @@ actor FieldBackgroundExecution {
identifiers: identifiers,
scheduler: scheduler,
transfer: transfer,
+ roots: roots,
telemetry: telemetry,
registerHandlers: { _ in }
)
}
let scheduler = RadrootsAppleBackgroundTaskScheduler()
- let roots = try FieldLocalState.roots(bundleIdentifier: bundleIdentifier)
let transfer = try RadrootsAppleBackgroundTransfer(
roots: roots,
sessionIdentifier: identifiers.transferSessionIdentifier
@@ -81,6 +89,7 @@ actor FieldBackgroundExecution {
identifiers: identifiers,
scheduler: scheduler,
transfer: transfer,
+ roots: roots,
telemetry: telemetry,
registerHandlers: { handlers in
_ = try await scheduler.register(
@@ -105,8 +114,12 @@ actor FieldBackgroundExecution {
if !hasRegisteredHandlers {
try await registerHandlers(
FieldBackgroundExecutionHandlers(
- refresh: { true },
- processing: { true }
+ refresh: { [weak self] in
+ await self?.performMaintenance(reason: "refresh_task") ?? false
+ },
+ processing: { [weak self] in
+ await self?.performMaintenance(reason: "processing_task") ?? false
+ }
)
)
hasRegisteredHandlers = true
@@ -115,24 +128,38 @@ actor FieldBackgroundExecution {
_ = 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] {
- 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
+ do {
+ 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 {
@@ -162,6 +189,109 @@ actor FieldBackgroundExecution {
}
}
+ @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 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 {
+ try await runtimeService.nostrSetDefaultRelays(try RelaySettings.relays())
+ try await runtimeService.nostrConnectIfKeyPresent()
+ let status = await runtimeService.nostrConnectionStatus()
+ telemetry.backgroundExecution(
+ operation: "relay_refresh",
+ outcome: "success",
+ relayConnectedCount: status.connected,
+ relayConnectingCount: status.connecting,
+ identityUnlocked: true,
+ reason: reason
+ )
+ return true
+ } catch {
+ telemetry.backgroundExecution(
+ operation: "relay_refresh",
+ outcome: FieldTelemetry.backgroundExecutionOutcome(for: error),
+ identityUnlocked: true,
+ reason: reason
+ )
+ return false
+ }
+ }
+
private static var uiTestWasRequested: Bool {
let environment = ProcessInfo.processInfo.environment
let arguments = ProcessInfo.processInfo.arguments
diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift
@@ -225,16 +225,50 @@ final class FieldTelemetry: @unchecked Sendable {
operation: String,
outcome: String,
taskCount: Int? = nil,
+ stagedBlobCount: Int? = nil,
+ transferCount: Int? = nil,
+ relayConnectedCount: UInt32? = nil,
+ relayConnectingCount: UInt32? = 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 relayConnectedCount,
+ let field = try? RadrootsTelemetryField.integer("relay_connected_count", Int64(relayConnectedCount)) {
+ fields.append(field)
+ }
+ if let relayConnectingCount,
+ let field = try? RadrootsTelemetryField.integer("relay_connecting_count", Int64(relayConnectingCount)) {
+ 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: outcome == "success" ? .info : .warning,
- fields: [
- try? .string("outcome", outcome),
- taskCount.map { try? .integer("task_count", $0) } ?? nil,
- reason.map { try? .string("reason", $0) } ?? nil
- ].compactMap { $0 }
+ level: expectedOutcome ? .info : .warning,
+ fields: fields
)
}
@@ -382,6 +416,24 @@ final class FieldTelemetry: @unchecked Sendable {
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"
+ }
default:
return "failure"
}