commit a4ba3f78160dd3d1ab7bada169b355bfeb699fb6
parent f04ab0620cb3c5a2a09ac4c34885602d14c23c79
Author: triesap <tyson@radroots.org>
Date: Wed, 9 Sep 2026 10:29:35 +0000
ios: reject stale scoped asynchronous results
- Bind store results and session commands to their current presentation generation.
- Preserve task-specific observer and media cleanup across scope replacement.
- Cover stale success, failure, edited forms and request revisions with 18 native regressions.
- Verify standalone Rust, Swift and artifact lanes while reducing existing lint debt.
Diffstat:
27 files changed, 1679 insertions(+), 715 deletions(-)
diff --git a/Package.swift b/Package.swift
@@ -70,10 +70,13 @@ let package = Package(
"Runtime/TeraUserMessageClassifier.swift",
"Runtime/TeraUserMessages.swift",
"State/TeraAddStore.swift",
+ "State/TeraAddPresentation.swift",
+ "State/TeraStoreObservation.swift",
"State/TeraConfigurationStore.swift",
"State/TeraIdentityStore.swift",
"State/TeraMediaStore.swift",
"State/TeraSessionStore.swift",
+ "State/TeraSessionBootstrap.swift",
"State/TeraSupportingStores.swift",
"State/TeraSettingsStore.swift",
"State/TeraTodayStore.swift",
diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj
@@ -7,14 +7,18 @@
objects = {
/* Begin PBXBuildFile section */
+ 095FBECA7268AA594F614EFA /* TeraScopeBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED6D3E35C3235B6068E1902 /* TeraScopeBackend.swift */; };
1D45B3D566A0A60063190854 /* TeraApp in Frameworks */ = {isa = PBXBuildFile; productRef = E666175A800272732A27CBEB /* TeraApp */; };
229C9AC991E67663DA53D339 /* TeraRuntimeBoundedTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A4FFC0023C620B68D0EC0ED /* TeraRuntimeBoundedTaskTests.swift */; };
2479571FA99A7962794999C4 /* TeraSupportingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0636AEDEB50DDDD328374A9 /* TeraSupportingStoreTests.swift */; };
+ 2AEAFDD0EECA3B7F799B1287 /* TeraScopedAddTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A756C16ACFABBD36C48DF62 /* TeraScopedAddTests.swift */; };
2C6DDB502DFA11C2CBCA49C4 /* TeraApp in Frameworks */ = {isa = PBXBuildFile; productRef = A1979630FE6BC343F0E59F24 /* TeraApp */; };
31CBA09815D972CCE187009F /* TeraSessionResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B870D20178E633610113 /* TeraSessionResourceTests.swift */; };
38EF7FA8874E82B28110A232 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6967B3EAC4CF1A410B26DD1A /* PrivacyInfo.xcprivacy */; };
39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */; };
4734A842C366608EC1BD31C5 /* TeraUserMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */; };
+ 53651EBAF42E8698717CE936 /* TeraScopedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */; };
+ 625CB9DF8B80A3F12458B49A /* TeraScopedObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75B60DBDD8C581D76E6F957 /* TeraScopedObservationTests.swift */; };
64D49ADFD3FDD54CBA509DC5 /* TeraTodayStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */; };
671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */; };
6B0BE8E92892EF60B8E996A5 /* TeraRuntimeResourceFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A51F9AF2E0803264E07FBB /* TeraRuntimeResourceFixtures.swift */; };
@@ -22,9 +26,11 @@
70417FB635F06CC9829FB38C /* TeraClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506546B0EC1792240C36F7 /* TeraClockTests.swift */; };
772A9B1AD8DE1D2E0969F285 /* TeraCalendarFFICompatibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B808E67E85CD36E49C38B6F /* TeraCalendarFFICompatibilityTests.swift */; };
79D06C6BF2B55C0C4104FB91 /* TeraSessionGenerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0836F5D5F71F9A54795C57 /* TeraSessionGenerationTests.swift */; };
+ 7DFBB14B53D57555AB0E0BF0 /* TeraScopeFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E9C876EA26B30C8AFE116F /* TeraScopeFixtures.swift */; };
81AF9DDA0E64DC4EEFCEF25D /* TeraRemoteQualificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */; };
852FF7514FF7F682A246FB47 /* TeraStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */; };
912C8743116D929BB6A02778 /* TeraRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB77C087F15C2F42CD959607 /* TeraRuntimeClientTests.swift */; };
+ 9384AAECB22885E2D05C54A8 /* TeraScopedMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */; };
9AD968F0B37C64B5AF8D9A68 /* TeraRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */; };
9B37084901EC52EB44969304 /* TeraRuntimeResourceLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */; };
A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */; };
@@ -41,6 +47,7 @@
E508A7061082B091B733D62B /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ACCE25979BA86C1EC5BFD6 /* App.swift */; };
EF7293C553BF1DB45EEEBD49 /* local-social-personas.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 27D9D40699A01FFB02F30B11 /* local-social-personas.v1.json */; };
F8C87782857DC930D47E6A90 /* TeraRuntimeResourceTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */; };
+ FBA9C3463CD1827392AF9599 /* TeraAddSchemaFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8ED2718D5E9E8980ADF3423 /* TeraAddSchemaFixtures.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -63,11 +70,13 @@
/* Begin PBXFileReference section */
02F322CF98F8BEB908B93215 /* TeraMutationAdmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMutationAdmissionTests.swift; sourceTree = "<group>"; };
0CDE3D76E1EDF909C19D0506 /* TeraUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TeraUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 11E9C876EA26B30C8AFE116F /* TeraScopeFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopeFixtures.swift; sourceTree = "<group>"; };
226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceLifecycleTests.swift; sourceTree = "<group>"; };
27D9D40699A01FFB02F30B11 /* local-social-personas.v1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "local-social-personas.v1.json"; sourceTree = "<group>"; };
2A4FFC0023C620B68D0EC0ED /* TeraRuntimeBoundedTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeBoundedTaskTests.swift; sourceTree = "<group>"; };
2A506546B0EC1792240C36F7 /* TeraClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraClockTests.swift; sourceTree = "<group>"; };
32CBDAE974FB8D7408108E69 /* TeraRemoteQualificationUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRemoteQualificationUITests.swift; sourceTree = "<group>"; };
+ 3A756C16ACFABBD36C48DF62 /* TeraScopedAddTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedAddTests.swift; sourceTree = "<group>"; };
3B808E67E85CD36E49C38B6F /* TeraCalendarFFICompatibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraCalendarFFICompatibilityTests.swift; sourceTree = "<group>"; };
4383B870D20178E633610113 /* TeraSessionResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraSessionResourceTests.swift; sourceTree = "<group>"; };
46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraUserMessagesTests.swift; sourceTree = "<group>"; };
@@ -85,6 +94,7 @@
93A3358AFC23C567DB9CB02F /* TeraErrorRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraErrorRecoveryTests.swift; sourceTree = "<group>"; };
98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraStateMigrationTests.swift; sourceTree = "<group>"; };
999BDCEF144A19F35D048D15 /* TeraTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TeraTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 9ED6D3E35C3235B6068E1902 /* TeraScopeBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopeBackend.swift; sourceTree = "<group>"; };
9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddStoreTests.swift; sourceTree = "<group>"; };
9F54FC4930051FC4611B37D3 /* tera */ = {isa = PBXFileReference; lastKnownFileType = folder; name = tera; path = .; sourceTree = SOURCE_ROOT; };
AB8A30A75EFD65EFD938D248 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -94,8 +104,12 @@
C1DBB5D9AC2FE8EA3AE68458 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceTaskTests.swift; sourceTree = "<group>"; };
C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraLifecycleTests.swift; sourceTree = "<group>"; };
+ CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedStoreTests.swift; sourceTree = "<group>"; };
+ D8ED2718D5E9E8980ADF3423 /* TeraAddSchemaFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddSchemaFixtures.swift; sourceTree = "<group>"; };
DE9E724A23014C6EA9042123 /* Tera.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Tera.app; sourceTree = BUILT_PRODUCTS_DIR; };
E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRemoteQualificationTests.swift; sourceTree = "<group>"; };
+ E75B60DBDD8C581D76E6F957 /* TeraScopedObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedObservationTests.swift; sourceTree = "<group>"; };
+ EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedMediaTests.swift; sourceTree = "<group>"; };
F0A51F9AF2E0803264E07FBB /* TeraRuntimeResourceFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceFixtures.swift; sourceTree = "<group>"; };
FB77C087F15C2F42CD959607 /* TeraRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeClientTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -160,6 +174,7 @@
50B89388753A1FCD1B2A4F38 /* TeraTests */ = {
isa = PBXGroup;
children = (
+ D8ED2718D5E9E8980ADF3423 /* TeraAddSchemaFixtures.swift */,
9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */,
61D8C0835D5A1D5C36D80020 /* TeraBackgroundUploadFixtures.swift */,
3B808E67E85CD36E49C38B6F /* TeraCalendarFFICompatibilityTests.swift */,
@@ -177,6 +192,12 @@
226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */,
C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */,
91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */,
+ 9ED6D3E35C3235B6068E1902 /* TeraScopeBackend.swift */,
+ 3A756C16ACFABBD36C48DF62 /* TeraScopedAddTests.swift */,
+ EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */,
+ E75B60DBDD8C581D76E6F957 /* TeraScopedObservationTests.swift */,
+ CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */,
+ 11E9C876EA26B30C8AFE116F /* TeraScopeFixtures.swift */,
7C0836F5D5F71F9A54795C57 /* TeraSessionGenerationTests.swift */,
4383B870D20178E633610113 /* TeraSessionResourceTests.swift */,
98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */,
@@ -391,6 +412,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ FBA9C3463CD1827392AF9599 /* TeraAddSchemaFixtures.swift in Sources */,
A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */,
6F53FF88A29F8DF93F074EB4 /* TeraBackgroundUploadFixtures.swift in Sources */,
772A9B1AD8DE1D2E0969F285 /* TeraCalendarFFICompatibilityTests.swift in Sources */,
@@ -408,6 +430,12 @@
9B37084901EC52EB44969304 /* TeraRuntimeResourceLifecycleTests.swift in Sources */,
F8C87782857DC930D47E6A90 /* TeraRuntimeResourceTaskTests.swift in Sources */,
39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */,
+ 095FBECA7268AA594F614EFA /* TeraScopeBackend.swift in Sources */,
+ 7DFBB14B53D57555AB0E0BF0 /* TeraScopeFixtures.swift in Sources */,
+ 2AEAFDD0EECA3B7F799B1287 /* TeraScopedAddTests.swift in Sources */,
+ 9384AAECB22885E2D05C54A8 /* TeraScopedMediaTests.swift in Sources */,
+ 625CB9DF8B80A3F12458B49A /* TeraScopedObservationTests.swift in Sources */,
+ 53651EBAF42E8698717CE936 /* TeraScopedStoreTests.swift in Sources */,
79D06C6BF2B55C0C4104FB91 /* TeraSessionGenerationTests.swift in Sources */,
31CBA09815D972CCE187009F /* TeraSessionResourceTests.swift in Sources */,
852FF7514FF7F682A246FB47 /* TeraStateMigrationTests.swift in Sources */,
diff --git a/Tera/App/TeraAppModel.swift b/Tera/App/TeraAppModel.swift
@@ -2,13 +2,14 @@ import Foundation
import RadrootsKit
@MainActor
-struct TeraProductStores {
+final class TeraProductStores {
let today: TeraTodayStore
let add: TeraAddStore
let search: TeraSearchStore
let me: TeraMeStore
let settings: TeraSettingsStore
let media: TeraMediaStore
+ private var generation = TeraSessionGeneration.initial
init(
runtimeClient: TeraRuntimeClient,
@@ -23,16 +24,26 @@ struct TeraProductStores {
}
func configure(snapshot: TeraRuntimeSnapshot) {
+ generation = generation.invalidated()
today.configure(snapshot: snapshot)
add.configure(snapshot: snapshot)
+ search.stop()
+ search.configure(context: today.selectedContext)
+ me.stop()
+ me.configure(context: today.selectedContext)
+ settings.configure(snapshot: snapshot)
+ media.configure(snapshot: snapshot)
}
func resume() async {
+ let requested = generation
await today.start()
+ guard generation == requested, generation.isActive, !Task.isCancelled else { return }
await add.start()
}
func suspend() {
+ generation = generation.invalidated()
today.stop()
add.suspend()
search.stop()
@@ -42,6 +53,7 @@ struct TeraProductStores {
}
func stop() {
+ generation = generation.invalidated()
today.stop()
add.stop()
search.stop()
@@ -62,7 +74,7 @@ final class TeraAppModel: ObservableObject {
private let sessionStore: TeraSessionStore?
private let bootstrapFailure: TeraRuntimeFailure?
private let lifecycleCoordinator: TeraLifecycleCoordinator
- private var generation: UInt64 = 0
+ private var generation = TeraSessionGeneration.initial
private var lifecycleRegistered = false
private var sessionOperationsInFlight = 0
private var resumePending = false
@@ -201,7 +213,7 @@ final class TeraAppModel: ObservableObject {
}
func suspend() async {
- generation &+= 1
+ generation = generation.invalidated()
resumePending = false
productStores?.suspend()
await sessionStore?.suspend()
@@ -229,8 +241,9 @@ final class TeraAppModel: ObservableObject {
_ operation: @escaping @Sendable (TeraSessionStore) async -> Phase
) async {
guard !isShellUITest else { return }
+ stopPresentationWork()
sessionOperationsInFlight += 1
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
if showsStarting {
phase = .starting
@@ -253,7 +266,7 @@ final class TeraAppModel: ObservableObject {
return
}
let result = await operation(sessionStore)
- guard generation == requestedGeneration else {
+ guard generation == requestedGeneration, generation.isActive, !Task.isCancelled else {
await finishSessionOperation()
return
}
diff --git a/Tera/State/TeraAddPresentation.swift b/Tera/State/TeraAddPresentation.swift
@@ -0,0 +1,69 @@
+import Foundation
+
+enum TeraAddPresentation {
+ static func sorted(_ drafts: [TeraDraftStatus]) -> [TeraDraftStatus] {
+ drafts.sorted {
+ if $0.updatedAtUnixMilliseconds == $1.updatedAtUnixMilliseconds {
+ return $0.id < $1.id
+ }
+ return $0.updatedAtUnixMilliseconds > $1.updatedAtUnixMilliseconds
+ }
+ }
+
+ static func newForm(
+ type: TeraAddCommandType,
+ identifier: @Sendable () -> String,
+ clock: TeraClock
+ ) -> TeraAddForm {
+ var form = TeraAddForm.empty(type)
+ if type == .createEvent || type == .createFoodAvailability {
+ let value = identifier()
+ if isValidIdentifier(value) {
+ form.identifier = value
+ }
+ }
+ if type == .createEvent {
+ guard let now = try? clock.unixSeconds() else {
+ return form
+ }
+ let start = now.addingReportingOverflow(3600).overflow ? now : now + 3600
+ let end = start.addingReportingOverflow(3600).overflow ? start : start + 3600
+ form.eventStartUnixSeconds = start
+ form.eventEndUnixSeconds = end
+ form.eventStartDate = eventDateFormatter.string(
+ from: Date(timeIntervalSince1970: TimeInterval(start))
+ )
+ form.eventEndDate = eventDateFormatter.string(
+ from: Date(timeIntervalSince1970: TimeInterval(end))
+ )
+ }
+ return form
+ }
+
+ static func isValidIdentifier(_ value: String) -> Bool {
+ value.utf8.count == 32
+ && value.utf8.allSatisfy { byte in
+ (byte >= 0x30 && byte <= 0x39) || (byte >= 0x61 && byte <= 0x66)
+ }
+ }
+
+ static let eventDateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.calendar = Calendar(identifier: .gregorian)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter
+ }()
+
+ static func message(for error: Error) -> String {
+ TeraUserMessages.text(for: error, fallback: .addOperationFailed)
+ }
+
+ static func failure(for error: Error) -> TeraRuntimeFailure? {
+ if case let TeraRuntimeClientError.add(failure) = error {
+ return failure
+ }
+ return error as? TeraRuntimeFailure
+ }
+}
diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift
@@ -28,12 +28,14 @@ final class TeraAddStore: ObservableObject {
private let observationDelay: @Sendable (UInt32) async throws -> Void
private let identifier: @Sendable () -> String
private let clock: TeraClock
- private var generation: UInt64 = 0
- private var operationGeneration: UInt64?
+ private var generation = TeraSessionGeneration.initial
+ private var operationGeneration: TeraSessionGeneration?
private var operationTask: Task<Void, Never>?
- private var observationTask: Task<Void, Never>?
- private var isStarted = false
- private var observationGeneration: UInt64 = 0
+ private let observation = TeraStoreObservation()
+ private var configuration: TeraPresentationConfiguration?
+ private var draftsGeneration = TeraSessionGeneration.initial
+ private var probeGeneration = TeraSessionGeneration.initial
+ private var blossomGeneration = TeraSessionGeneration.initial
private var revisionTarget: TeraRevisionTarget?
private var revisionOperationID: String?
private var activePublicKey: String?
@@ -54,17 +56,13 @@ final class TeraAddStore: ObservableObject {
self.identifier = identifier
self.clock = clock
self.observationDelay = observationDelay
- form = Self.newForm(
+ form = TeraAddPresentation.newForm(
type: initialType,
identifier: identifier,
clock: clock
)
}
- deinit {
- observationTask?.cancel()
- }
-
var selectedSchema: TeraAddSchema? {
schemas.first(where: { $0.commandType == form.commandType })
}
@@ -105,20 +103,38 @@ final class TeraAddStore: ObservableObject {
}
func configure(snapshot: TeraRuntimeSnapshot) {
+ let updated = TeraPresentationConfiguration(snapshot: snapshot)
+ if let configuration, configuration != updated {
+ stop()
+ schemas = []
+ drafts = []
+ activeDraft = nil
+ revisionTarget = nil
+ revisionOperationID = nil
+ form = TeraAddPresentation.newForm(type: form.commandType, identifier: identifier, clock: clock)
+ state = .idle
+ mediaSupport = .unavailable
+ message = nil
+ lastFailureCode = nil
+ }
+ blossomGeneration = blossomGeneration.invalidated()
+ configuration = updated
activePublicKey = snapshot.identity.publicKeyHex
blossomConfiguration = snapshot.blossomConfiguration
blossomEvidence = snapshot.blossomEvidence
}
func start() async {
- guard !isStarted else { return }
- isStarted = true
+ guard !observation.isActive, !Task.isCancelled else { return }
startObservation()
guard operationGeneration == nil else { return }
message = nil
state = .loading
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
+ draftsGeneration = draftsGeneration.invalidated()
+ let draftRequest = draftsGeneration
+ let serviceRequest = blossomGeneration
do {
async let schemaResult = runtimeClient.addSchemas()
async let draftResult = runtimeClient.draftHeads(limit: 100)
@@ -128,47 +144,49 @@ final class TeraAddStore: ObservableObject {
draftResult,
supportResult
)
- guard requestedGeneration == generation else { return }
- try await media?.reconcileBackgroundUploads(drafts: loadedDrafts)
- try Task.checkCancellation()
- guard requestedGeneration == generation else { return }
+ guard isCurrent(requestedGeneration) else { return }
+ if draftRequest == draftsGeneration {
+ try await media?.reconcileBackgroundUploads(drafts: loadedDrafts)
+ }
+ try ensureCurrent(requestedGeneration)
schemas = try TeraProductSurfaceContract.validate(schemas: loadedSchemas)
- drafts = Self.sorted(loadedDrafts)
- mediaSupport = loadedSupport
+ if draftRequest == draftsGeneration {
+ drafts = TeraAddPresentation.sorted(loadedDrafts)
+ }
+ if serviceRequest == blossomGeneration {
+ mediaSupport = loadedSupport
+ }
state = .ready
} catch {
- guard requestedGeneration == generation else { return }
- state = .failed(Self.message(for: error))
+ guard isCurrent(requestedGeneration) else { return }
+ state = .failed(TeraAddPresentation.message(for: error))
}
}
func stop() {
- generation &+= 1
+ generation = generation.invalidated()
operationGeneration = nil
+ probeGeneration = probeGeneration.invalidated()
+ isCheckingBlossom = false
operationTask?.cancel()
operationTask = nil
- isStarted = false
- observationGeneration &+= 1
- observationTask?.cancel()
- observationTask = nil
+ observation.stop()
observationState = .stopped
isWorking = false
}
func suspend() {
- isStarted = false
- observationGeneration &+= 1
- observationTask?.cancel()
- observationTask = nil
+ observation.stop()
observationState = .stopped
}
func selectType(_ type: TeraAddCommandType) {
guard !isWorking, isFormEditable, form.commandType != type else { return }
+ generation = generation.invalidated()
activeDraft = nil
revisionTarget = nil
revisionOperationID = nil
- form = Self.newForm(
+ form = TeraAddPresentation.newForm(
type: type,
identifier: identifier,
clock: clock
@@ -178,16 +196,17 @@ final class TeraAddStore: ObservableObject {
func updateForm<Value>(_ keyPath: WritableKeyPath<TeraAddForm, Value>, _ value: Value) {
guard isFormEditable else { return }
+ generation = generation.invalidated()
form[keyPath: keyPath] = value
}
func newDraft(type: TeraAddCommandType? = nil) {
guard !isWorking else { return }
- generation &+= 1
+ generation = generation.invalidated()
activeDraft = nil
revisionTarget = nil
revisionOperationID = nil
- form = Self.newForm(
+ form = TeraAddPresentation.newForm(
type: type ?? form.commandType,
identifier: identifier,
clock: clock
@@ -201,7 +220,7 @@ final class TeraAddStore: ObservableObject {
message = "This operation has no editable Add form."
return
}
- generation &+= 1
+ generation = generation.invalidated()
activeDraft = draft
revisionTarget = nil
revisionOperationID = draft.isRevision ? draft.id : nil
@@ -214,33 +233,50 @@ final class TeraAddStore: ObservableObject {
message = "Photo intake is unavailable."
return
}
- await perform {
+ await perform { requestedGeneration in
let remaining = self.mediaLimit - self.form.media.count
guard remaining > 0 else { return }
let imported = try await media.importImages(limit: remaining)
- try Task.checkCancellation()
+ try self.ensureCurrent(requestedGeneration)
self.form.media.append(contentsOf: imported.prefix(remaining))
self.message = "Photo prepared. Add descriptive text before publishing."
}
}
func checkPhotoService() async {
- guard !isCheckingBlossom else { return }
+ guard !isCheckingBlossom, !Task.isCancelled else { return }
guard blossomConfiguration != nil else {
mediaSupport = .unavailable
message = "No photo service is configured for the current network profile."
return
}
+ probeGeneration = probeGeneration.invalidated()
+ let probe = probeGeneration
+ blossomGeneration = blossomGeneration.invalidated()
+ let serviceRequest = blossomGeneration
+ let requestedGeneration = generation
isCheckingBlossom = true
- defer { isCheckingBlossom = false }
+ defer {
+ if probe == probeGeneration {
+ isCheckingBlossom = false
+ }
+ }
do {
- blossomEvidence = try await runtimeClient.probeBlossom()
- mediaSupport = try await loadMediaSupport()
+ let evidence = try await runtimeClient.probeBlossom()
+ try ensureCurrent(requestedGeneration)
+ let support = try await loadMediaSupport()
+ try ensureCurrent(requestedGeneration)
+ guard probe == probeGeneration, serviceRequest == blossomGeneration else { return }
+ blossomEvidence = evidence
+ mediaSupport = support
message = "Photo service is reachable."
} catch {
- await refreshBlossomSnapshot()
+ guard isCurrent(requestedGeneration), probe == probeGeneration,
+ serviceRequest == blossomGeneration else { return }
+ guard await refreshBlossomSnapshot(), isCurrent(requestedGeneration),
+ probe == probeGeneration else { return }
mediaSupport = .unavailable
- message = Self.message(for: error)
+ message = TeraAddPresentation.message(for: error)
}
}
@@ -249,9 +285,9 @@ final class TeraAddStore: ObservableObject {
message = "Camera intake is unavailable."
return
}
- await perform {
+ await perform { requestedGeneration in
let captured = try await media.captureImage()
- try Task.checkCancellation()
+ try self.ensureCurrent(requestedGeneration)
guard self.form.media.count < self.mediaLimit else { return }
self.form.media.append(captured)
self.message = "Photo prepared. Add descriptive text before publishing."
@@ -260,6 +296,7 @@ final class TeraAddStore: ObservableObject {
func removeMedia(id: String) {
guard isFormEditable else { return }
+ generation = generation.invalidated()
form.media.removeAll(where: { $0.id == id })
}
@@ -267,37 +304,40 @@ final class TeraAddStore: ObservableObject {
guard isFormEditable,
let index = form.media.firstIndex(where: { $0.id == id })
else { return }
+ generation = generation.invalidated()
form.media[index].alt = alt
}
func save() async {
- await perform {
- _ = try await self.saveCurrentForm()
+ await perform { requestedGeneration in
+ _ = try await self.saveCurrentForm(generation: requestedGeneration)
+ try self.ensureCurrent(requestedGeneration)
self.message = "Draft saved on this device."
}
}
func submit() async {
- await perform {
+ await perform { requestedGeneration in
var status: TeraDraftStatus =
if let active = self.activeDraft, !active.state.isEditable {
active
} else {
- try await self.saveCurrentForm()
+ try await self.saveCurrentForm(generation: requestedGeneration)
}
+ try self.ensureCurrent(requestedGeneration)
if !status.media.isEmpty,
status.media.contains(where: { $0.stage != .verified })
{
- status = try await self.uploadPendingMedia(status)
+ status = try await self.uploadPendingMedia(status, generation: requestedGeneration)
}
+ try self.ensureCurrent(requestedGeneration)
if status.isRevision {
let revision = try await self.runtimeClient.advanceRevision(
operationID: self.revisionOperationID ?? status.id
)
- try Task.checkCancellation()
- self.accept(revision)
+ try self.accept(revision, generation: requestedGeneration)
self.message = revision.honestSummary
return
}
@@ -308,10 +348,11 @@ final class TeraAddStore: ObservableObject {
id: status.id,
expectedRevision: status.revision
)
- self.accept(status)
+ try self.accept(status, generation: requestedGeneration)
} catch {
- if Self.failure(for: error)?.code == "writable_relay_unavailable" {
- self.accept(status)
+ try self.ensureCurrent(requestedGeneration)
+ if TeraAddPresentation.failure(for: error)?.code == "writable_relay_unavailable" {
+ try self.accept(status, generation: requestedGeneration)
self.message =
status.media.isEmpty
? "Draft saved. Configure a writable relay to publish."
@@ -322,27 +363,15 @@ final class TeraAddStore: ObservableObject {
}
}
- do {
- if status.state.canAdvance {
- status = try await self.runtimeClient.advanceDraft(
- id: status.id,
- expectedRevision: status.revision
- )
- self.accept(status)
- }
- self.message = status.honestSummary
- } catch {
- // Queueing is the commit point. A later retry must reuse this immutable snapshot.
- self.message = "Saved for retry. \(Self.message(for: error))"
- }
+ try await self.advanceSubmittedDraft(status, generation: requestedGeneration)
}
}
func retry(_ draft: TeraDraftStatus? = nil) async {
guard let draft = draft ?? activeDraft else { return }
- await perform {
+ await perform { requestedGeneration in
var current = try await self.runtimeClient.draftStatus(id: draft.id)
- try Task.checkCancellation()
+ try self.ensureCurrent(requestedGeneration)
if current.state == .draft || current.state == .mediaPreparing
|| current.state == .readyToSign
{
@@ -358,8 +387,7 @@ final class TeraAddStore: ObservableObject {
let revision = try await self.runtimeClient.advanceRevision(
operationID: current.id
)
- try Task.checkCancellation()
- self.accept(revision)
+ try self.accept(revision, generation: requestedGeneration)
self.message = revision.honestSummary
return
}
@@ -369,22 +397,21 @@ final class TeraAddStore: ObservableObject {
expectedRevision: current.revision
)
}
- self.accept(current)
+ try self.accept(current, generation: requestedGeneration)
self.message = current.honestSummary
}
}
func cancel(_ draft: TeraDraftStatus? = nil) async {
guard let draft = draft ?? activeDraft, draft.state.canCancel else { return }
- await perform {
+ await perform { requestedGeneration in
let current = try await self.runtimeClient.draftStatus(id: draft.id)
- try Task.checkCancellation()
+ try self.ensureCurrent(requestedGeneration)
if current.isRevision {
let cancelled = try await self.runtimeClient.cancelRevision(
operationID: current.id
)
- try Task.checkCancellation()
- self.accept(cancelled)
+ try self.accept(cancelled, generation: requestedGeneration)
self.message = cancelled.honestSummary
return
}
@@ -392,13 +419,13 @@ final class TeraAddStore: ObservableObject {
id: current.id,
expectedRevision: current.revision
)
- self.accept(cancelled)
+ try self.accept(cancelled, generation: requestedGeneration)
self.message = "Local work was cancelled. Any already-published relay effect is preserved."
}
}
func retractAndRevise(_ card: TeraTodayCard) async {
- await perform {
+ await perform { requestedGeneration in
guard let publicKey = self.activePublicKey, publicKey == card.authorPublicKey else {
throw TeraRuntimeFailure.local(
operation: "add.revise",
@@ -414,7 +441,7 @@ final class TeraAddStore: ObservableObject {
)
}
let source = try await self.runtimeClient.draftStatus(id: operationID)
- try Task.checkCancellation()
+ try self.ensureCurrent(requestedGeneration)
guard let sourceForm = source.form else {
throw TeraRuntimeFailure.local(
operation: "add.revise",
@@ -436,7 +463,7 @@ final class TeraAddStore: ObservableObject {
}
func retract(_ card: TeraTodayCard) async {
- await perform {
+ await perform { requestedGeneration in
guard let publicKey = self.activePublicKey, publicKey == card.authorPublicKey else {
throw TeraRuntimeFailure.local(
operation: "add.retract",
@@ -452,7 +479,7 @@ final class TeraAddStore: ObservableObject {
)
}
let draftID = self.identifier()
- guard Self.isValidIdentifier(draftID) else {
+ guard TeraAddPresentation.isValidIdentifier(draftID) else {
throw TeraRuntimeFailure.local(
operation: "add.retract",
code: "ios.add.identifier_invalid",
@@ -472,24 +499,44 @@ final class TeraAddStore: ObservableObject {
authoredAtUnixSeconds: self.clock.unixSeconds(),
persistedAtUnixMilliseconds: self.clock.unixMilliseconds()
)
- self.accept(status)
+ try self.accept(status, generation: requestedGeneration)
status = try await self.runtimeClient.queueAddIntent(
id: status.id,
expectedRevision: status.revision
)
- self.accept(status)
+ try self.accept(status, generation: requestedGeneration)
if status.state.canAdvance {
status = try await self.runtimeClient.advanceDraft(
id: status.id,
expectedRevision: status.revision
)
- self.accept(status)
+ try self.accept(status, generation: requestedGeneration)
}
self.message = status.honestSummary
}
}
- private func saveCurrentForm() async throws -> TeraDraftStatus {
+ private func advanceSubmittedDraft(
+ _ initial: TeraDraftStatus, generation requestedGeneration: TeraSessionGeneration
+ ) async throws {
+ var status = initial
+ do {
+ if status.state.canAdvance {
+ status = try await runtimeClient.advanceDraft(
+ id: status.id,
+ expectedRevision: status.revision
+ )
+ try accept(status, generation: requestedGeneration)
+ }
+ message = status.honestSummary
+ } catch {
+ try ensureCurrent(requestedGeneration)
+ // Queueing is the commit point. A later retry must reuse this immutable snapshot.
+ message = "Saved for retry. \(TeraAddPresentation.message(for: error))"
+ }
+ }
+
+ private func saveCurrentForm(generation requestedGeneration: TeraSessionGeneration) async throws -> TeraDraftStatus {
guard isFormEditable else {
throw TeraRuntimeFailure.local(
operation: "add.save",
@@ -499,16 +546,17 @@ final class TeraAddStore: ObservableObject {
}
let opened = try await openedMedia()
defer { opened.close() }
+ try ensureCurrent(requestedGeneration)
let input = TeraAddRuntimeInput(form: form, media: opened.handles)
if let target = revisionTarget {
let revision = try await runtimeClient.saveRevisionIntent(
target: target,
replacement: input
)
- try Task.checkCancellation()
+ try ensureCurrent(requestedGeneration)
revisionOperationID = revision.operationID
revisionTarget = nil
- accept(revision)
+ try accept(revision, generation: requestedGeneration)
return revision.replacement
}
let status = try await runtimeClient.saveAddIntent(
@@ -516,12 +564,13 @@ final class TeraAddStore: ObservableObject {
existingDraftID: activeDraft?.isRevision == true ? nil : activeDraft?.id,
expectedRevision: activeDraft?.isRevision == true ? nil : activeDraft?.revision
)
- try Task.checkCancellation()
- accept(status)
+ try accept(status, generation: requestedGeneration)
return status
}
- private func uploadPendingMedia(_ initial: TeraDraftStatus) async throws
+ private func uploadPendingMedia(
+ _ initial: TeraDraftStatus, generation requestedGeneration: TeraSessionGeneration
+ ) async throws
-> TeraDraftStatus
{
guard let media else {
@@ -535,6 +584,7 @@ final class TeraAddStore: ObservableObject {
guard let form = status.form else { return status }
let opened = try await openedMedia(form.media)
defer { opened.close() }
+ try ensureCurrent(requestedGeneration)
for mediaStatus in status.media where mediaStatus.stage != .verified {
guard let persisted = form.media.first(where: { $0.remoteURL == mediaStatus.url }),
let handle = opened.handles.first(where: {
@@ -553,43 +603,50 @@ final class TeraAddStore: ObservableObject {
media: handle
)
let job = try await runtimeClient.prepareAddMediaBackground(input: intent)
- try Task.checkCancellation()
- accept(job.draft)
+ try accept(job.draft, generation: requestedGeneration)
let receipt = try await media.uploadInBackground(job: job, media: persisted)
- try Task.checkCancellation()
- do {
- status = try await runtimeClient.completeAddMediaBackground(
- input: TeraNativeUploadCompletion(
- draftID: receipt.draftID,
- expectedRevision: receipt.expectedRevision,
- media: handle,
- statusCode: receipt.statusCode,
- responseMediaType: receipt.mediaType,
- responseContentEncoding: receipt.contentEncoding,
- responseBody: receipt.body
- )
- )
- } catch is CancellationError {
- // Rust completion may already be durable. Leave the receipt awaiting
- // verification so relaunch can reconcile the unknown outcome.
- throw CancellationError()
- } catch {
- if Self.failure(for: error)?.code == "ios.runtime.cancelled" {
- // The bounded runtime client cannot prove whether a cancelled FFI
- // completion became durable. Preserve the receipt for reconciliation.
- throw CancellationError()
- }
- try? await media.settleBackgroundUpload(identifier: receipt.identifier, accepted: false)
- throw error
- }
+ try ensureCurrent(requestedGeneration)
+ status = try await completeBackgroundUpload(receipt, handle: handle, media: media)
try await media.settleBackgroundUpload(identifier: receipt.identifier, accepted: true)
- try Task.checkCancellation()
- accept(status)
+ try accept(status, generation: requestedGeneration)
await refreshBlossomSnapshot()
+ try ensureCurrent(requestedGeneration)
}
return status
}
+ private func completeBackgroundUpload(
+ _ receipt: TeraAddBackgroundUploadReceipt,
+ handle: TeraPreparedMediaHandle,
+ media: any TeraAddMediaHandling
+ ) async throws -> TeraDraftStatus {
+ do {
+ return try await runtimeClient.completeAddMediaBackground(
+ input: TeraNativeUploadCompletion(
+ draftID: receipt.draftID,
+ expectedRevision: receipt.expectedRevision,
+ media: handle,
+ statusCode: receipt.statusCode,
+ responseMediaType: receipt.mediaType,
+ responseContentEncoding: receipt.contentEncoding,
+ responseBody: receipt.body
+ )
+ )
+ } catch is CancellationError {
+ // Rust completion may already be durable. Leave the receipt awaiting
+ // verification so relaunch can reconcile the unknown outcome.
+ throw CancellationError()
+ } catch {
+ if TeraAddPresentation.failure(for: error)?.code == "ios.runtime.cancelled" {
+ // The bounded runtime client cannot prove whether a cancelled FFI
+ // completion became durable. Preserve the receipt for reconciliation.
+ throw CancellationError()
+ }
+ try? await media.settleBackgroundUpload(identifier: receipt.identifier, accepted: false)
+ throw error
+ }
+ }
+
private func openedMedia(_ values: [TeraPreparedMedia]? = nil) async throws
-> TeraOpenedMedia
{
@@ -606,16 +663,21 @@ final class TeraAddStore: ObservableObject {
}
private func reloadDrafts() async {
+ let requestedGeneration = generation
+ draftsGeneration = draftsGeneration.invalidated()
+ let request = draftsGeneration
do {
let loaded = try await runtimeClient.draftHeads(limit: 100)
- drafts = Self.sorted(loaded)
- if let activeDraft,
- let current = loaded.first(where: { $0.id == activeDraft.id })
+ guard isCurrent(requestedGeneration), request == draftsGeneration else { return }
+ drafts = TeraAddPresentation.sorted(loaded)
+ if let activeDraft, let current = loaded.first(where: { $0.id == activeDraft.id }),
+ current.revision >= activeDraft.revision
{
self.activeDraft = current
}
} catch {
- message = Self.message(for: error)
+ guard isCurrent(requestedGeneration), request == draftsGeneration else { return }
+ message = TeraAddPresentation.message(for: error)
}
}
@@ -624,87 +686,76 @@ final class TeraAddStore: ObservableObject {
return try await media.support()
}
- private func refreshBlossomSnapshot() async {
- guard let snapshot = try? await runtimeClient.snapshot() else { return }
- blossomConfiguration = snapshot.blossomConfiguration
- blossomEvidence = snapshot.blossomEvidence
- }
-
- private func startObservation() {
- observationGeneration &+= 1
- let requestedGeneration = observationGeneration
- observationTask = Task { [weak self] in
- await self?.observe(generation: requestedGeneration)
+ @discardableResult
+ private func refreshBlossomSnapshot() async -> Bool {
+ let requestedGeneration = generation
+ blossomGeneration = blossomGeneration.invalidated()
+ let request = blossomGeneration
+ let snapshot = try? await runtimeClient.snapshot()
+ guard isCurrent(requestedGeneration), request == blossomGeneration else { return false }
+ if let snapshot, configuration == TeraPresentationConfiguration(snapshot: snapshot) {
+ blossomConfiguration = snapshot.blossomConfiguration
+ blossomEvidence = snapshot.blossomEvidence
}
+ return true
}
- private func observe(generation: UInt64) async {
- var attempt: UInt32 = 0
- while isStarted, observationGeneration == generation, !Task.isCancelled {
- observationState = .subscribing(attempt: attempt &+ 1)
- var failureMessage = TeraUserMessages.text(.runtimeObservationUnavailable)
- do {
- let changes = try await runtimeClient.changes(bufferCapacity: 16)
- observationState = .active
- for await change in changes {
- guard !Task.isCancelled else { break }
- attempt = 0
- if change.kind == .drafts || change.kind == .media {
- await reloadDrafts()
- }
- if change.kind == .media || change.kind == .settings {
- await refreshBlossomSnapshot()
- }
+ private func startObservation() {
+ observation.start(
+ client: runtimeClient, capacity: 16, delay: observationDelay,
+ state: { [weak self] in self?.observationState = $0 },
+ change: { [weak self] change in
+ guard let self else { return }
+ let requested = generation
+ if change.kind == .drafts || change.kind == .media {
+ await reloadDrafts()
+ }
+ guard isCurrent(requested) else { return }
+ if change.kind == .media || change.kind == .settings {
+ await refreshBlossomSnapshot()
}
- } catch {
- failureMessage = TeraUserMessages.text(
- for: error,
- fallback: .runtimeObservationUnavailable
- )
- }
- guard isStarted, observationGeneration == generation, !Task.isCancelled else { break }
- attempt = attempt == .max ? .max : attempt + 1
- observationState = .retrying(attempt: attempt, message: failureMessage)
- do {
- try await observationDelay(attempt)
- } catch {
- break
}
- }
- guard observationGeneration == generation else { return }
- observationTask = nil
- isStarted = false
- if case .retrying = observationState {
- return
- }
- observationState = .stopped
+ )
}
- private func accept(_ status: TeraDraftStatus) {
- guard operationGeneration == generation else { return }
+ private func isCurrent(_ requested: TeraSessionGeneration) -> Bool {
+ requested == generation && generation.isActive && !Task.isCancelled
+ }
+
+ private func ensureCurrent(_ requested: TeraSessionGeneration) throws {
+ guard isCurrent(requested) else { throw CancellationError() }
+ }
+
+ private func accept(
+ _ status: TeraDraftStatus, generation requested: TeraSessionGeneration
+ ) throws {
+ try ensureCurrent(requested)
+ draftsGeneration = draftsGeneration.invalidated()
activeDraft = status
if let form = status.form {
self.form = form
}
drafts.removeAll(where: { $0.id == status.id })
drafts.append(status)
- drafts = Self.sorted(drafts)
+ drafts = TeraAddPresentation.sorted(drafts)
}
- private func accept(_ status: TeraRevisionStatus) {
- guard operationGeneration == generation else { return }
+ private func accept(
+ _ status: TeraRevisionStatus, generation requested: TeraSessionGeneration
+ ) throws {
+ try ensureCurrent(requested)
revisionOperationID = status.operationID
- accept(status.replacement)
+ try accept(status.replacement, generation: requested)
if let retraction = status.retraction {
drafts.removeAll(where: { $0.id == retraction.id })
drafts.append(retraction)
- drafts = Self.sorted(drafts)
+ drafts = TeraAddPresentation.sorted(drafts)
}
}
- private func perform(_ operation: @escaping () async throws -> Void) async {
+ private func perform(_ operation: @escaping (TeraSessionGeneration) async throws -> Void) async {
guard operationTask == nil, !Task.isCancelled else { return }
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
operationGeneration = requestedGeneration
isWorking = true
@@ -719,92 +770,30 @@ final class TeraAddStore: ObservableObject {
}
private func execute(
- _ operation: @escaping () async throws -> Void,
- generation requestedGeneration: UInt64
+ _ operation: @escaping (TeraSessionGeneration) async throws -> Void,
+ generation requestedGeneration: TeraSessionGeneration
) async {
+ defer {
+ if operationGeneration == requestedGeneration {
+ isWorking = false
+ operationGeneration = nil
+ operationTask = nil
+ }
+ }
do {
- try await operation()
+ try ensureCurrent(requestedGeneration)
+ try await operation(requestedGeneration)
} catch is CancellationError {
- if requestedGeneration == generation {
+ if isCurrent(requestedGeneration) {
message = TeraUserMessages.text(.operationCancelled)
}
} catch {
- if requestedGeneration == generation {
+ if isCurrent(requestedGeneration) {
await refreshBlossomSnapshot()
- message = Self.message(for: error)
- lastFailureCode = Self.failure(for: error)?.code
+ guard isCurrent(requestedGeneration) else { return }
+ message = TeraAddPresentation.message(for: error)
+ lastFailureCode = TeraAddPresentation.failure(for: error)?.code
}
}
- if requestedGeneration == generation {
- isWorking = false
- operationGeneration = nil
- operationTask = nil
- }
- }
-
- private static func sorted(_ drafts: [TeraDraftStatus]) -> [TeraDraftStatus] {
- drafts.sorted {
- if $0.updatedAtUnixMilliseconds == $1.updatedAtUnixMilliseconds {
- return $0.id < $1.id
- }
- return $0.updatedAtUnixMilliseconds > $1.updatedAtUnixMilliseconds
- }
- }
-
- private static func newForm(
- type: TeraAddCommandType,
- identifier: @Sendable () -> String,
- clock: TeraClock
- ) -> TeraAddForm {
- var form = TeraAddForm.empty(type)
- if type == .createEvent || type == .createFoodAvailability {
- let value = identifier()
- if isValidIdentifier(value) {
- form.identifier = value
- }
- }
- if type == .createEvent {
- guard let now = try? clock.unixSeconds() else {
- return form
- }
- let start = now.addingReportingOverflow(3600).overflow ? now : now + 3600
- let end = start.addingReportingOverflow(3600).overflow ? start : start + 3600
- form.eventStartUnixSeconds = start
- form.eventEndUnixSeconds = end
- form.eventStartDate = eventDateFormatter.string(
- from: Date(timeIntervalSince1970: TimeInterval(start))
- )
- form.eventEndDate = eventDateFormatter.string(
- from: Date(timeIntervalSince1970: TimeInterval(end))
- )
- }
- return form
- }
-
- private static func isValidIdentifier(_ value: String) -> Bool {
- value.utf8.count == 32
- && value.utf8.allSatisfy { byte in
- (byte >= 0x30 && byte <= 0x39) || (byte >= 0x61 && byte <= 0x66)
- }
- }
-
- private static let eventDateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.timeZone = TimeZone(secondsFromGMT: 0)
- formatter.dateFormat = "yyyy-MM-dd"
- return formatter
- }()
-
- private static func message(for error: Error) -> String {
- TeraUserMessages.text(for: error, fallback: .addOperationFailed)
- }
-
- private static func failure(for error: Error) -> TeraRuntimeFailure? {
- if case let TeraRuntimeClientError.add(failure) = error {
- return failure
- }
- return error as? TeraRuntimeFailure
}
}
diff --git a/Tera/State/TeraMediaStore.swift b/Tera/State/TeraMediaStore.swift
@@ -25,10 +25,27 @@ enum TeraMediaPresentationState: Equatable {
@MainActor
final class TeraMediaStore: ObservableObject {
- @Published private var states: [String: TeraMediaPresentationState] = [:]
+ struct Request: Hashable {
+ let referenceID: String
+ let context: TeraLocalNetwork?
+ }
+
+ private struct Key: Hashable {
+ let account: String?
+ let context: TeraLocalNetwork
+ let reference: String
+ }
+
+ private struct Work {
+ let id: UUID
+ let task: Task<Void, Never>
+ }
+
+ @Published private var states: [Key: TeraMediaPresentationState] = [:]
private let runtimeClient: TeraRuntimeClient
- private var tasks: [String: Task<Void, Never>] = [:]
+ private var tasks: [Key: Work] = [:]
+ private var configuration: TeraPresentationConfiguration?
init(runtimeClient: TeraRuntimeClient) {
self.runtimeClient = runtimeClient
@@ -36,7 +53,7 @@ final class TeraMediaStore: ObservableObject {
deinit {
for task in tasks.values {
- task.cancel()
+ task.task.cancel()
}
}
@@ -45,7 +62,7 @@ final class TeraMediaStore: ObservableObject {
context: TeraLocalNetwork?
) -> TeraMediaPresentationState {
guard let context else { return .unavailable }
- if let state = states[Self.key(media: media, context: context)] {
+ if let state = states[key(media: media, context: context)] {
return state
}
switch media.verification {
@@ -57,7 +74,7 @@ final class TeraMediaStore: ObservableObject {
func load(media: TeraMediaReference, context: TeraLocalNetwork?) {
guard let context else { return }
- let key = Self.key(media: media, context: context)
+ let key = key(media: media, context: context)
guard states[key] == nil, tasks[key] == nil else { return }
switch media.verification {
case .pending:
@@ -84,8 +101,8 @@ final class TeraMediaStore: ObservableObject {
func retry(media: TeraMediaReference, context: TeraLocalNetwork?) {
guard let context else { return }
- let key = Self.key(media: media, context: context)
- tasks[key]?.cancel()
+ let key = key(media: media, context: context)
+ tasks[key]?.task.cancel()
tasks[key] = nil
states[key] = nil
start(key: key, context: context) { [runtimeClient] in
@@ -95,60 +112,63 @@ final class TeraMediaStore: ObservableObject {
func reset() {
for task in tasks.values {
- task.cancel()
+ task.task.cancel()
}
tasks.removeAll(keepingCapacity: false)
states.removeAll(keepingCapacity: false)
}
+ func configure(snapshot: TeraRuntimeSnapshot) {
+ let updated = TeraPresentationConfiguration(snapshot: snapshot)
+ guard configuration != updated else { return }
+ reset()
+ configuration = updated
+ }
+
private func start(
- key: String,
+ key: Key,
context: TeraLocalNetwork,
operation: @escaping @Sendable () async throws -> TeraVerifiedMediaArtifact?
) {
states[key] = .loading
- tasks[key] = Task { [weak self] in
- guard let self else { return }
+ let id = UUID()
+ let task = Task { [weak self] in
do {
- guard let artifact = try await operation() else {
- complete(key: key, state: .unavailable)
+ let artifact = try await operation()
+ guard let self, isCurrent(key: key, id: id) else { return }
+ guard let artifact else {
+ complete(key: key, id: id, state: .unavailable)
return
}
guard UIImage(data: artifact.bytes) != nil else {
- await invalidateCorrupt(artifact: artifact, context: context, key: key)
+ _ = try? await runtimeClient.invalidateMediaArtifact(
+ context: context, artifactID: artifact.artifactID
+ )
+ complete(key: key, id: id, state: .corrupt)
return
}
- complete(key: key, state: .ready(artifact))
+ complete(key: key, id: id, state: .ready(artifact))
} catch is CancellationError {
- complete(key: key, state: nil)
+ self?.complete(key: key, id: id, state: nil)
} catch {
- complete(key: key, state: Self.failureState(error))
+ self?.complete(key: key, id: id, state: Self.failureState(error))
}
}
+ tasks[key] = Work(id: id, task: task)
}
- private func invalidateCorrupt(
- artifact: TeraVerifiedMediaArtifact,
- context: TeraLocalNetwork,
- key: String
- ) async {
- _ = try? await runtimeClient.invalidateMediaArtifact(
- context: context,
- artifactID: artifact.artifactID
- )
- complete(key: key, state: .corrupt)
+ private func isCurrent(key: Key, id: UUID) -> Bool {
+ tasks[key]?.id == id && !Task.isCancelled
}
- private func complete(key: String, state: TeraMediaPresentationState?) {
+ private func complete(key: Key, id: UUID, state: TeraMediaPresentationState?) {
+ guard tasks[key]?.id == id else { return }
tasks[key] = nil
- states[key] = state
+ states[key] = Task.isCancelled ? nil : state
}
- private static func key(
- media: TeraMediaReference,
- context: TeraLocalNetwork
- ) -> String {
- "\(context.id)\u{0}\(context.generation)\u{0}\(media.referenceFingerprint)"
+ private func key(media: TeraMediaReference, context: TeraLocalNetwork) -> Key {
+ Key(account: configuration?.publicKey, context: context, reference: media.referenceFingerprint)
}
static func failureState(_ error: Error) -> TeraMediaPresentationState {
diff --git a/Tera/State/TeraSessionBootstrap.swift b/Tera/State/TeraSessionBootstrap.swift
@@ -0,0 +1,97 @@
+import Foundation
+import UIKit
+
+extension TeraSessionStore {
+ @MainActor
+ static func production(
+ bundle: Bundle = .main,
+ runtimeClient: TeraRuntimeClient = .production()
+ ) throws -> TeraSessionStore {
+ guard let bundleIdentifier = bundle.bundleIdentifier else {
+ throw TeraConfigurationError.missing("bundle_identifier")
+ }
+ let qualification = try TeraRemoteQualificationEnvironment.current()
+ #if DEBUG
+ let qualificationEvidenceStore = try TeraRemoteQualificationEvidence.prepare()
+ #else
+ let qualificationEvidenceStore: TeraRemoteQualificationEvidenceStore? = nil
+ #endif
+ let servicePrefix =
+ try qualification?.keychainServicePrefix
+ ?? requiredString(
+ "TERA_IOS_KEYCHAIN_SERVICE_PREFIX", bundle: bundle
+ )
+ let bootstrap = try TeraConfigurationBootstrap(
+ runtimeMode: qualification?.runtimeMode
+ ?? requiredString("TERA_IOS_RUNTIME_MODE", bundle: bundle),
+ relayURLs: qualification?.relayURLs
+ ?? array("TERA_IOS_NOSTR_RELAY_URLS", bundle: bundle),
+ blossomOrigins: qualification?.blossomOrigins
+ ?? array("TERA_IOS_BLOSSOM_ORIGINS", bundle: bundle),
+ keychainServicePrefix: servicePrefix,
+ bundleIdentifier: bundleIdentifier,
+ appMetadata: metadata(bundleIdentifier: bundleIdentifier, bundle: bundle)
+ )
+ let roots = try TeraRemoteQualificationEnvironment.applicationFileRoots(
+ appIdentifier: bundleIdentifier
+ )
+ let protectedData = TeraProtectedDataMonitor(
+ available: UIApplication.shared.isProtectedDataAvailable
+ )
+ return try TeraSessionStore(
+ configurationStore: TeraConfigurationStore(bootstrap: bootstrap, roots: roots),
+ identityStore: .production(
+ servicePrefix: servicePrefix,
+ protectedDataAvailable: { protectedData.isAvailable() },
+ qualification: qualification
+ ),
+ runtimeClient: runtimeClient,
+ roots: roots,
+ protectedData: protectedData,
+ automatesQualificationIdentity: qualification?.automatesIdentity == true,
+ qualificationEvidenceStore: qualificationEvidenceStore
+ )
+ }
+
+ @MainActor
+ private static func metadata(bundleIdentifier: String, bundle: Bundle) -> TeraRuntimeAppMetadata {
+ TeraRuntimeAppMetadata(
+ bundleIdentifier: bundleIdentifier,
+ version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ ?? "0",
+ buildNumber: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0",
+ buildSHA: normalizedOptional(
+ bundle.object(forInfoDictionaryKey: "GIT_SHA") as? String
+ )
+ )
+ }
+
+ @MainActor
+ private static func requiredString(_ key: String, bundle: Bundle) throws -> String {
+ guard let value = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else {
+ throw TeraConfigurationError.missing(key)
+ }
+ return value
+ }
+
+ @MainActor
+ private static func array(_ key: String, bundle: Bundle) -> [String] {
+ if let values = bundle.object(forInfoDictionaryKey: key) as? [String] {
+ return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+ guard let raw = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else {
+ return []
+ }
+ return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t"))
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+
+ @MainActor
+ private static func normalizedOptional(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty || trimmed == "unknown" ? nil : trimmed
+ }
+}
diff --git a/Tera/State/TeraSessionStore.swift b/Tera/State/TeraSessionStore.swift
@@ -1,6 +1,5 @@
import Foundation
import RadrootsKit
-import UIKit
final class TeraProtectedDataMonitor: @unchecked Sendable {
private let lock = NSLock()
@@ -66,65 +65,6 @@ actor TeraSessionStore {
self.qualificationEvidenceStore = qualificationEvidenceStore
}
- @MainActor
- static func production(
- bundle: Bundle = .main,
- runtimeClient: TeraRuntimeClient = .production()
- ) throws -> TeraSessionStore {
- guard let bundleIdentifier = bundle.bundleIdentifier else {
- throw TeraConfigurationError.missing("bundle_identifier")
- }
- let qualification = try TeraRemoteQualificationEnvironment.current()
- #if DEBUG
- let qualificationEvidenceStore = try TeraRemoteQualificationEvidence.prepare()
- #else
- let qualificationEvidenceStore: TeraRemoteQualificationEvidenceStore? = nil
- #endif
- let servicePrefix =
- try qualification?.keychainServicePrefix
- ?? requiredString(
- "TERA_IOS_KEYCHAIN_SERVICE_PREFIX", bundle: bundle
- )
- let bootstrap = try TeraConfigurationBootstrap(
- runtimeMode: qualification?.runtimeMode
- ?? requiredString("TERA_IOS_RUNTIME_MODE", bundle: bundle),
- relayURLs: qualification?.relayURLs
- ?? array("TERA_IOS_NOSTR_RELAY_URLS", bundle: bundle),
- blossomOrigins: qualification?.blossomOrigins
- ?? array("TERA_IOS_BLOSSOM_ORIGINS", bundle: bundle),
- keychainServicePrefix: servicePrefix,
- bundleIdentifier: bundleIdentifier,
- appMetadata: TeraRuntimeAppMetadata(
- bundleIdentifier: bundleIdentifier,
- version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
- ?? "0",
- buildNumber: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0",
- buildSHA: normalizedOptional(
- bundle.object(forInfoDictionaryKey: "GIT_SHA") as? String
- )
- )
- )
- let roots = try TeraRemoteQualificationEnvironment.applicationFileRoots(
- appIdentifier: bundleIdentifier
- )
- let protectedData = TeraProtectedDataMonitor(
- available: UIApplication.shared.isProtectedDataAvailable
- )
- return try TeraSessionStore(
- configurationStore: TeraConfigurationStore(bootstrap: bootstrap, roots: roots),
- identityStore: .production(
- servicePrefix: servicePrefix,
- protectedDataAvailable: { protectedData.isAvailable() },
- qualification: qualification
- ),
- runtimeClient: runtimeClient,
- roots: roots,
- protectedData: protectedData,
- automatesQualificationIdentity: qualification?.automatesIdentity == true,
- qualificationEvidenceStore: qualificationEvidenceStore
- )
- }
-
func updateProtectedDataAvailability(_ available: Bool) {
protectedData.update(available: available)
}
@@ -147,10 +87,12 @@ actor TeraSessionStore {
phase = .starting
do {
let identity = try await identityStore.loadAndMigrate()
+ try ensureCurrent(requestedGeneration)
guard identity.state == .unlocked else {
return await start()
}
let configuration = try await configurationStore.load()
+ try ensureCurrent(requestedGeneration)
phase = try await startRuntime(
configuration: configuration,
identity: identity,
@@ -173,8 +115,9 @@ actor TeraSessionStore {
func suspend() async {
generation = generation.invalidated()
+ let requestedGeneration = generation
await runtimeClient.suspend()
- if case .starting = phase {
+ if generation == requestedGeneration, case .starting = phase {
phase = .stopped
}
}
@@ -205,27 +148,8 @@ actor TeraSessionStore {
case .corrupt:
phase = .corruptIdentity(identity)
case .unlocked:
- let configuration = try await configurationStore.load()
- if configuration.activationState == .reconfigurationRequired,
- !acceptingReconfiguration
- {
- phase = .configurationReconfigurationRequired(
- TeraConfigurationReconfigurationRequirement(
- generation: configuration.generation,
- previousBlossomConfigFingerprint: configuration
- .previousBlossomConfigFingerprint
- )
- )
- return phase
- }
- phase = try await startRuntime(
- configuration: configuration,
- identity: identity,
- generation: requestedGeneration.requireActive(),
- forceReconfiguration: configuration.activationState
- == .reconfigurationRequired,
- adoptBootstrapSettings: configuration.activationState
- == .reconfigurationRequired
+ phase = try await startUnlocked(
+ identity, acceptingReconfiguration: acceptingReconfiguration, generation: requestedGeneration
)
}
} catch TeraRuntimeClientError.superseded {
@@ -246,32 +170,52 @@ actor TeraSessionStore {
return phase
}
- func createIdentity(label: String? = nil) async -> TeraSessionPhase {
- do {
- _ = try await identityStore.create(label: label)
- } catch {
- return failIdentityOperation(error)
+ private func startUnlocked(
+ _ identity: TeraAppIdentity, acceptingReconfiguration: Bool,
+ generation requestedGeneration: TeraSessionGeneration
+ ) async throws -> TeraSessionPhase {
+ let configuration = try await configurationStore.load()
+ try ensureCurrent(requestedGeneration)
+ if configuration.activationState == .reconfigurationRequired,
+ !acceptingReconfiguration
+ {
+ return .configurationReconfigurationRequired(
+ TeraConfigurationReconfigurationRequirement(
+ generation: configuration.generation,
+ previousBlossomConfigFingerprint: configuration
+ .previousBlossomConfigFingerprint
+ )
+ )
}
- return await start()
+ return try await startRuntime(
+ configuration: configuration,
+ identity: identity,
+ generation: requestedGeneration.requireActive(),
+ forceReconfiguration: configuration.activationState
+ == .reconfigurationRequired,
+ adoptBootstrapSettings: configuration.activationState
+ == .reconfigurationRequired
+ )
+ }
+
+ func createIdentity(label: String? = nil) async -> TeraSessionPhase {
+ await runIdentityOperation { try await self.identityStore.create(label: label) }
}
func importIdentity(
_ material: RadrootsIdentitySecretMaterial,
label: String? = nil
) async -> TeraSessionPhase {
- do {
- _ = try await identityStore.importIdentity(material, label: label)
- } catch {
- return failIdentityOperation(error)
- }
- return await start()
+ await runIdentityOperation { try await self.identityStore.importIdentity(material, label: label) }
}
func lockIdentity() async -> TeraSessionPhase {
generation = generation.invalidated()
+ let requestedGeneration = generation
if case .running = phase,
let settings = try? await runtimeClient.mobileSettings()
{
+ guard isCurrent(requestedGeneration) else { return phase }
_ = try? await runtimeClient.applyIdentityCommand(
expectedRevision: settings.revision,
command: TeraIdentityCommand(
@@ -282,41 +226,56 @@ actor TeraSessionStore {
)
)
}
+ guard isCurrent(requestedGeneration) else { return phase }
_ = try? await runtimeClient.stop()
+ guard isCurrent(requestedGeneration) else { return phase }
await identityStore.lock()
+ guard isCurrent(requestedGeneration) else { return phase }
let identity = await identityStore.snapshot()
+ guard isCurrent(requestedGeneration) else { return phase }
phase = .identityLocked(identity)
return phase
}
func unlockIdentity() async -> TeraSessionPhase {
- do {
- _ = try await identityStore.unlock()
- } catch {
- return failIdentityOperation(error)
- }
- return await start()
+ await runIdentityOperation { try await self.identityStore.unlock() }
}
func recoverIdentity() async -> TeraSessionPhase {
+ await runIdentityOperation { try await self.identityStore.recover() }
+ }
+
+ private func runIdentityOperation(
+ _ operation: () async throws -> TeraAppIdentity
+ ) async -> TeraSessionPhase {
+ generation = generation.invalidated()
+ let requestedGeneration = generation
do {
- _ = try await identityStore.recover()
+ try ensureCurrent(requestedGeneration)
+ _ = try await operation()
+ try ensureCurrent(requestedGeneration)
+ return await start()
} catch {
+ guard generation == requestedGeneration, !Task.isCancelled else { return phase }
return failIdentityOperation(error)
}
- return await start()
}
func stop() async -> TeraSessionPhase {
generation = generation.invalidated()
+ let requestedGeneration = generation
do {
_ = try await runtimeClient.stop()
+ try ensureCurrent(requestedGeneration)
await identityStore.lock()
+ try ensureCurrent(requestedGeneration)
try qualificationEvidenceStore?.cleanup()
phase = .stopped
} catch let TeraRuntimeClientError.shutdown(failure) {
+ guard isCurrent(requestedGeneration) else { return phase }
phase = .failed(failure)
} catch {
+ guard isCurrent(requestedGeneration) else { return phase }
phase = .failed(
.local(
operation: "session.stop",
@@ -346,6 +305,7 @@ actor TeraSessionStore {
protectedDataAvailability: protectedData.isAvailable() ? .available : .unavailable
)
let sourceGeneration = try await configurationStore.sourceGeneration()
+ try ensureCurrent(requestedGeneration)
let signer = try await identityStore.signer(for: identity)
guard generation == requestedGeneration else { throw TeraRuntimeClientError.superseded }
let launchConfiguration = TeraRuntimeLaunchConfiguration(
@@ -371,7 +331,8 @@ actor TeraSessionStore {
guard generation == requestedGeneration else {
throw TeraRuntimeClientError.superseded
}
- try await reconcileIdentity(identity)
+ try await reconcileIdentity(identity, generation: requestedGeneration)
+ try ensureCurrent(requestedGeneration)
if configuration.activationState == .reconfigurationRequired,
adoptBootstrapSettings
{
@@ -385,13 +346,16 @@ actor TeraSessionStore {
return .running(snapshot)
}
- private func reconcileIdentity(_ identity: TeraAppIdentity) async throws {
+ private func reconcileIdentity(
+ _ identity: TeraAppIdentity, generation requestedGeneration: TeraSessionGeneration
+ ) async throws {
guard let identityID = identity.identityHandle,
let publicKeyHex = identity.publicKeyHex
else {
throw TeraIdentityStoreError.unavailable
}
var settings = try await runtimeClient.mobileSettings()
+ try ensureCurrent(requestedGeneration)
if let pending = settings.identity.pendingImportOperationID {
settings = try await runtimeClient.applyIdentityCommand(
expectedRevision: settings.revision,
@@ -402,7 +366,29 @@ actor TeraSessionStore {
publicKeyHex: nil
)
).settings
+ try ensureCurrent(requestedGeneration)
}
+ settings = try await adoptIdentity(
+ identityID: identityID, publicKeyHex: publicKeyHex, settings: settings, generation: requestedGeneration
+ )
+ try ensureCurrent(requestedGeneration)
+ _ = try await runtimeClient.applyIdentityCommand(
+ expectedRevision: settings.revision,
+ command: TeraIdentityCommand(
+ kind: .unlock,
+ operationID: nil,
+ identityID: nil,
+ publicKeyHex: nil
+ )
+ )
+ try ensureCurrent(requestedGeneration)
+ }
+
+ private func adoptIdentity(
+ identityID: String, publicKeyHex: String, settings initial: TeraMobileSettings,
+ generation requestedGeneration: TeraSessionGeneration
+ ) async throws -> TeraMobileSettings {
+ var settings = initial
if let existing = settings.identity.identities.first(where: {
$0.publicKeyHex == publicKeyHex
}) {
@@ -416,6 +402,7 @@ actor TeraSessionStore {
publicKeyHex: nil
)
).settings
+ try ensureCurrent(requestedGeneration)
}
} else {
let operationID = UUID().uuidString.lowercased()
@@ -428,6 +415,7 @@ actor TeraSessionStore {
publicKeyHex: nil
)
).settings
+ try ensureCurrent(requestedGeneration)
settings = try await runtimeClient.applyIdentityCommand(
expectedRevision: settings.revision,
command: TeraIdentityCommand(
@@ -437,16 +425,17 @@ actor TeraSessionStore {
publicKeyHex: publicKeyHex
)
).settings
+ try ensureCurrent(requestedGeneration)
}
- _ = try await runtimeClient.applyIdentityCommand(
- expectedRevision: settings.revision,
- command: TeraIdentityCommand(
- kind: .unlock,
- operationID: nil,
- identityID: nil,
- publicKeyHex: nil
- )
- )
+ return settings
+ }
+
+ private func isCurrent(_ requested: TeraSessionGeneration) -> Bool {
+ generation == requested && generation.isActive && !Task.isCancelled
+ }
+
+ private func ensureCurrent(_ requested: TeraSessionGeneration) throws {
+ guard isCurrent(requested) else { throw TeraRuntimeClientError.superseded }
}
private func failIdentityOperation(_ error: Error) -> TeraSessionPhase {
@@ -460,33 +449,4 @@ actor TeraSessionStore {
)
return phase
}
-
- @MainActor
- private static func requiredString(_ key: String, bundle: Bundle) throws -> String {
- guard let value = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else {
- throw TeraConfigurationError.missing(key)
- }
- return value
- }
-
- @MainActor
- private static func array(_ key: String, bundle: Bundle) -> [String] {
- if let values = bundle.object(forInfoDictionaryKey: key) as? [String] {
- return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- }
- guard let raw = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else {
- return []
- }
- return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t"))
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- }
-
- @MainActor
- private static func normalizedOptional(_ value: String?) -> String? {
- guard let value else { return nil }
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty || trimmed == "unknown" ? nil : trimmed
- }
}
diff --git a/Tera/State/TeraSettingsStore.swift b/Tera/State/TeraSettingsStore.swift
@@ -25,16 +25,17 @@ final class TeraSettingsStore: ObservableObject {
@Published var profileBot = false
private let runtimeClient: TeraRuntimeClient
- private var generation: UInt64 = 0
+ private var generation = TeraSessionGeneration.initial
/// Presentation invalidation cannot release an active mutation's admission.
private var mutationInProgress = false
+ private var configuration: TeraPresentationConfiguration?
init(runtimeClient: TeraRuntimeClient) {
self.runtimeClient = runtimeClient
}
func load(profile: TeraProfileSummary?) async {
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
isWorking = true
defer {
@@ -44,7 +45,7 @@ final class TeraSettingsStore: ObservableObject {
}
do {
let loaded = try await runtimeClient.mobileSettings()
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
apply(loaded)
if let profile {
profileName = profile.name ?? ""
@@ -55,7 +56,7 @@ final class TeraSettingsStore: ObservableObject {
message = nil
failureCode = nil
} catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
record(error)
}
}
@@ -71,7 +72,7 @@ final class TeraSettingsStore: ObservableObject {
func saveSettings() async -> Bool {
guard let settings, reserveMutation() else { return false }
defer { mutationInProgress = false }
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
isWorking = true
defer {
@@ -99,7 +100,7 @@ final class TeraSettingsStore: ObservableObject {
mediaCacheArtifacts: UInt32(max(mediaCacheArtifacts, 1))
)
)
- guard requestedGeneration == generation, !Task.isCancelled else { return false }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return false }
apply(transition.settings)
let effects = [
transition.runtimeRestartRequired ? "runtime restart" : nil,
@@ -112,7 +113,7 @@ final class TeraSettingsStore: ObservableObject {
failureCode = nil
return transition.runtimeRestartRequired
} catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return false }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return false }
record(error)
return false
}
@@ -121,7 +122,7 @@ final class TeraSettingsStore: ObservableObject {
func saveProfile() async {
guard reserveMutation() else { return }
defer { mutationInProgress = false }
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
isWorking = true
defer {
@@ -141,12 +142,12 @@ final class TeraSettingsStore: ObservableObject {
bot: profileBot
)
)
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
profileStatus = status
message = "Profile update saved to the durable outbox."
failureCode = nil
} catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
record(error)
}
}
@@ -169,16 +170,42 @@ final class TeraSettingsStore: ObservableObject {
}
func stop() {
- generation &+= 1
+ generation = generation.invalidated()
isWorking = false
}
+ func configure(snapshot: TeraRuntimeSnapshot) {
+ let updated = TeraPresentationConfiguration(snapshot: snapshot)
+ guard configuration != updated else { return }
+ configuration = updated
+ stop()
+ settings = nil
+ profileStatus = nil
+ message = nil
+ failureCode = nil
+ networkEnvironment = .publicNetwork
+ relays = []
+ blossomAuthority = .publicWebPKI
+ blossomPrimaryOrigin = ""
+ blossomFallbackOrigins = ""
+ allowCellularDownloads = true
+ allowCellularUploads = true
+ allowBackgroundTransfers = true
+ mediaCacheMegabytes = 256
+ mediaCacheArtifacts = 1024
+ profileName = ""
+ profileDisplayName = ""
+ profileAbout = ""
+ profileNip05 = ""
+ profileBot = false
+ }
+
private func runProfileOperation(
_ operation: @escaping () async throws -> TeraProfileStatus
) async {
guard reserveMutation() else { return }
defer { mutationInProgress = false }
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
isWorking = true
defer {
@@ -188,12 +215,12 @@ final class TeraSettingsStore: ObservableObject {
}
do {
let status = try await operation()
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
profileStatus = status
message = status.honestSummary
failureCode = nil
} catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
record(error)
}
}
diff --git a/Tera/State/TeraStoreObservation.swift b/Tera/State/TeraStoreObservation.swift
@@ -0,0 +1,85 @@
+import Foundation
+
+/// Stable configuration inputs, excluding changing service observation evidence.
+struct TeraPresentationConfiguration: Equatable {
+ let publicKey: String
+ let context: TeraLocalNetwork
+ let blossom: TeraBlossomConfigurationStatus?
+
+ init(snapshot: TeraRuntimeSnapshot) {
+ publicKey = snapshot.identity.publicKeyHex
+ context = .defaultContext(snapshot: snapshot)
+ blossom = snapshot.blossomConfiguration
+ }
+}
+
+/// Each store owns one observer. An old subscription or waiter cannot update or
+/// clear a replacement observer, including while subscription creation is pending.
+@MainActor
+final class TeraStoreObservation {
+ private var generation = TeraSessionGeneration.initial
+ private var task: Task<Void, Never>?
+
+ var isActive: Bool {
+ task != nil
+ }
+
+ deinit { task?.cancel() }
+
+ func stop() {
+ generation = generation.invalidated()
+ task?.cancel()
+ task = nil
+ }
+
+ func start(
+ client: TeraRuntimeClient,
+ capacity: Int,
+ delay: @escaping @Sendable (UInt32) async throws -> Void,
+ state: @escaping @MainActor (TeraRuntimeObservationState) -> Void,
+ change: @escaping @MainActor (TeraRuntimeChange) async -> Void
+ ) {
+ guard task == nil, generation.isActive else { return }
+ generation = generation.invalidated()
+ guard generation.isActive else { return }
+ let requested = generation
+ task = Task { [weak self] in
+ var attempt: UInt32 = 0
+ while self?.isCurrent(requested) == true {
+ state(.subscribing(attempt: attempt == .max ? .max : attempt + 1))
+ var message = TeraUserMessages.text(.runtimeObservationUnavailable)
+ do {
+ let changes = try await client.changes(bufferCapacity: capacity)
+ guard self?.isCurrent(requested) == true else { break }
+ state(.active)
+ for await value in changes {
+ guard self?.isCurrent(requested) == true else { break }
+ attempt = 0
+ await change(value)
+ }
+ } catch {
+ message = TeraUserMessages.text(for: error, fallback: .runtimeObservationUnavailable)
+ }
+ guard self?.isCurrent(requested) == true else { break }
+ attempt = attempt == .max ? .max : attempt + 1
+ state(.retrying(attempt: attempt, message: message))
+ do { try await delay(attempt) } catch { break }
+ }
+ self?.finish(requested, state: state)
+ }
+ }
+
+ private func finish(
+ _ requested: TeraSessionGeneration, state: @MainActor (TeraRuntimeObservationState) -> Void
+ ) {
+ guard generation == requested else { return }
+ task = nil
+ if Task.isCancelled {
+ state(.stopped)
+ }
+ }
+
+ private func isCurrent(_ requested: TeraSessionGeneration) -> Bool {
+ generation == requested && generation.isActive && !Task.isCancelled
+ }
+}
diff --git a/Tera/State/TeraSupportingStores.swift b/Tera/State/TeraSupportingStores.swift
@@ -17,7 +17,7 @@ final class TeraSearchStore: ObservableObject {
private let runtimeClient: TeraRuntimeClient
private let clock: TeraClock
private var context: TeraLocalNetwork?
- private var generation: UInt64 = 0
+ private var generation = TeraSessionGeneration.initial
init(
runtimeClient: TeraRuntimeClient,
@@ -29,19 +29,18 @@ final class TeraSearchStore: ObservableObject {
func configure(context: TeraLocalNetwork?) {
guard self.context != context else { return }
- generation &+= 1
+ generation = generation.invalidated()
self.context = context
+ query = ""
results = []
state = .idle
}
func updateQuery(_ value: String) {
+ generation = generation.invalidated()
query = String(value.prefix(256))
- if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- generation &+= 1
- results = []
- state = .idle
- }
+ results = []
+ state = .idle
}
func search() async {
@@ -60,7 +59,7 @@ final class TeraSearchStore: ObservableObject {
return
}
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
state = .loading
do {
@@ -70,18 +69,20 @@ final class TeraSearchStore: ObservableObject {
limit: 50,
asOfUnixSeconds: clock.unixSeconds()
)
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
results = Self.unique(loaded)
state = results.isEmpty ? .empty : .loaded
} catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
results = []
state = .failed(Self.message(for: error))
}
}
func stop() {
- generation &+= 1
+ generation = generation.invalidated()
+ query = ""
+ results = []
state = .idle
}
@@ -105,10 +106,8 @@ final class TeraMeStore: ObservableObject {
private let clock: TeraClock
private let observationDelay: @Sendable (UInt32) async throws -> Void
private var context: TeraLocalNetwork?
- private var generation: UInt64 = 0
- private var observationTask: Task<Void, Never>?
- private var isStarted = false
- private var observationGeneration: UInt64 = 0
+ private var generation = TeraSessionGeneration.initial
+ private let observation = TeraStoreObservation()
init(
runtimeClient: TeraRuntimeClient,
@@ -121,27 +120,20 @@ final class TeraMeStore: ObservableObject {
self.observationDelay = observationDelay
}
- deinit {
- observationTask?.cancel()
- }
-
func configure(context: TeraLocalNetwork?) {
guard self.context != context else { return }
- generation &+= 1
+ generation = generation.invalidated()
self.context = context
snapshot = nil
state = .idle
+ if observation.isActive {
+ observation.stop()
+ startObservation()
+ }
}
func start() async {
- if !isStarted {
- isStarted = true
- observationGeneration &+= 1
- let requestedGeneration = observationGeneration
- observationTask = Task { [weak self] in
- await self?.observe(generation: requestedGeneration)
- }
- }
+ startObservation()
await reload()
}
@@ -151,7 +143,7 @@ final class TeraMeStore: ObservableObject {
state = .failed("Choose a local network before loading your profile.")
return
}
- generation &+= 1
+ generation = generation.invalidated()
let requestedGeneration = generation
if snapshot == nil {
state = .loading
@@ -161,68 +153,36 @@ final class TeraMeStore: ObservableObject {
context: context,
asOfUnixSeconds: clock.unixSeconds()
)
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
snapshot = loaded
state = loaded.cards.isEmpty && loaded.profile == nil ? .empty : .loaded
} catch {
- guard requestedGeneration == generation, !Task.isCancelled else { return }
+ guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
state = .failed(Self.message(for: error))
}
}
func stop() {
- generation &+= 1
- isStarted = false
- observationGeneration &+= 1
- observationTask?.cancel()
- observationTask = nil
+ generation = generation.invalidated()
+ observation.stop()
observationState = .stopped
+ snapshot = nil
+ state = .idle
}
- private func observe(generation: UInt64) async {
- var attempt: UInt32 = 0
- while isStarted, observationGeneration == generation, !Task.isCancelled {
- observationState = .subscribing(attempt: attempt &+ 1)
- var failureMessage = TeraUserMessages.text(.runtimeObservationUnavailable)
- do {
- let changes = try await runtimeClient.changes(bufferCapacity: 8)
- observationState = .active
- for await change in changes {
- guard !Task.isCancelled else { break }
- attempt = 0
- switch change.kind {
- case .today, .identity, .profile, .media, .drafts:
- await reload()
- case .initial, .settings, .relay, .lifecycle:
- continue
- }
+ private func startObservation() {
+ observation.start(
+ client: runtimeClient, capacity: 8, delay: observationDelay,
+ state: { [weak self] in self?.observationState = $0 },
+ change: { [weak self] change in
+ switch change.kind {
+ case .today, .identity, .profile, .media, .drafts:
+ await self?.reload()
+ case .initial, .settings, .relay, .lifecycle:
+ break
}
- } catch {
- failureMessage = TeraUserMessages.text(
- for: error,
- fallback: .runtimeObservationUnavailable
- )
}
- guard isStarted, observationGeneration == generation, !Task.isCancelled else { break }
- attempt = attempt == .max ? .max : attempt + 1
- observationState = .retrying(attempt: attempt, message: failureMessage)
- do {
- try await observationDelay(attempt)
- } catch {
- break
- }
- }
- observationDidFinish(generation: generation)
- }
-
- private func observationDidFinish(generation: UInt64) {
- guard observationGeneration == generation else { return }
- observationTask = nil
- isStarted = false
- if case .retrying = observationState {
- return
- }
- observationState = .stopped
+ )
}
private static func message(for error: Error) -> String {
diff --git a/Tera/State/TeraTodayStore.swift b/Tera/State/TeraTodayStore.swift
@@ -24,11 +24,10 @@ final class TeraTodayStore: ObservableObject {
private let observationDelay: @Sendable (UInt32) async throws -> Void
private var frozenAsOfUnixSeconds: UInt64?
private var nextCursor: String?
- private var requestGeneration: UInt64 = 0
- private var observationTask: Task<Void, Never>?
+ private var requestGeneration = TeraSessionGeneration.initial
+ private let observation = TeraStoreObservation()
+ private var configuration: TeraPresentationConfiguration?
private var reloadTask: Task<Void, Never>?
- private var isStarted = false
- private var observationGeneration: UInt64 = 0
init(
runtimeClient: TeraRuntimeClient,
@@ -54,7 +53,6 @@ final class TeraTodayStore: ObservableObject {
}
deinit {
- observationTask?.cancel()
reloadTask?.cancel()
}
@@ -67,62 +65,75 @@ final class TeraTodayStore: ObservableObject {
}
func configure(snapshot: TeraRuntimeSnapshot) {
- guard contexts.isEmpty else { return }
- let context = TeraLocalNetwork.defaultContext(snapshot: snapshot)
- contexts = [context]
- selectedContextID = context.id
+ let updated = TeraPresentationConfiguration(snapshot: snapshot)
+ guard configuration != updated else { return }
+ let previous = configuration
+ let reload = observation.isActive
+ invalidatePresentation()
+ configuration = updated
+ if previous != nil || contexts.isEmpty {
+ contexts = [updated.context]
+ selectedContextID = updated.context.id
+ }
+ if reload {
+ scheduleReload()
+ }
}
func start() async {
- guard !isStarted else { return }
- isStarted = true
- observationGeneration &+= 1
- let generation = observationGeneration
- observationTask = Task { [weak self] in
- await self?.observe(generation: generation)
- }
+ guard !observation.isActive, !Task.isCancelled else { return }
+ startObservation()
await reload()
}
func stop() {
- isStarted = false
- observationGeneration &+= 1
- requestGeneration &+= 1
- observationTask?.cancel()
- observationTask = nil
+ observation.stop()
observationState = .stopped
+ requestGeneration = requestGeneration.invalidated()
reloadTask?.cancel()
reloadTask = nil
isLoadingNextPage = false
}
+ private func invalidatePresentation() {
+ requestGeneration = requestGeneration.invalidated()
+ reloadTask?.cancel()
+ reloadTask = nil
+ cards = []
+ frozenAsOfUnixSeconds = nil
+ nextCursor = nil
+ isLoadingNextPage = false
+ state = .idle
+ if observation.isActive {
+ observation.stop()
+ startObservation()
+ }
+ }
+
func selectContext(id: String) {
guard id != selectedContextID,
contexts.contains(where: { $0.id == id })
else {
return
}
+ invalidatePresentation()
selectedContextID = id
- requestGeneration &+= 1
- reloadTask?.cancel()
- reloadTask = Task { [weak self] in
- await self?.reload()
- }
+ scheduleReload()
}
func replaceContexts(_ updatedContexts: [TeraLocalNetwork], selectedID: String?) {
let updatedContexts = Self.unique(updatedContexts)
- guard !updatedContexts.isEmpty else { return }
+ invalidatePresentation()
contexts = updatedContexts
selectedContextID =
selectedID.flatMap { requested in
updatedContexts.contains(where: { $0.id == requested }) ? requested : nil
} ?? updatedContexts.first?.id
- requestGeneration &+= 1
- reloadTask?.cancel()
- reloadTask = Task { [weak self] in
- await self?.reload()
- }
+ scheduleReload()
+ }
+
+ private func scheduleReload() {
+ reloadTask = Task { [weak self] in await self?.reload() }
}
func reload(
@@ -135,7 +146,7 @@ final class TeraTodayStore: ObservableObject {
return
}
- requestGeneration &+= 1
+ requestGeneration = requestGeneration.invalidated()
let generation = requestGeneration
frozenAsOfUnixSeconds = nil
nextCursor = nil
@@ -157,6 +168,7 @@ final class TeraTodayStore: ObservableObject {
}
}
+ guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
do {
let asOf = try clock.unixSeconds()
let page = try await runtimeClient.todayPage(
@@ -166,13 +178,13 @@ final class TeraTodayStore: ObservableObject {
asOfUnixSeconds: asOf
)
)
- guard generation == requestGeneration, !Task.isCancelled else { return }
+ guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
frozenAsOfUnixSeconds = page.asOfUnixSeconds
nextCursor = page.nextCursor
cards = Self.unique(page.items)
state = refreshFailure.map(Self.failureState) ?? (cards.isEmpty ? .empty : .loaded)
} catch {
- guard generation == requestGeneration, !Task.isCancelled else { return }
+ guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
state = Self.failureState(error)
}
}
@@ -196,7 +208,7 @@ final class TeraTodayStore: ObservableObject {
let page = try await runtimeClient.todayPage(
request: .after(context: context, limit: pageSize, cursor: cursor)
)
- guard generation == requestGeneration, !Task.isCancelled else { return }
+ guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
guard frozenAsOfUnixSeconds == nil || frozenAsOfUnixSeconds == page.asOfUnixSeconds else {
state = .failed(message: "Today changed while loading. Refresh to continue.")
return
@@ -211,7 +223,7 @@ final class TeraTodayStore: ObservableObject {
state = cards.isEmpty ? .empty : .loaded
}
} catch {
- guard generation == requestGeneration, !Task.isCancelled else { return }
+ guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
state = Self.failureState(error)
}
}
@@ -221,50 +233,19 @@ final class TeraTodayStore: ObservableObject {
return contexts.filter { identifiers.insert($0.id).inserted }
}
- private func observe(generation: UInt64) async {
- var attempt: UInt32 = 0
- while isStarted, observationGeneration == generation, !Task.isCancelled {
- observationState = .subscribing(attempt: attempt &+ 1)
- var failureMessage = TeraUserMessages.text(.runtimeObservationUnavailable)
- do {
- let changes = try await runtimeClient.changes(bufferCapacity: 16)
- observationState = .active
- for await change in changes {
- guard !Task.isCancelled else { break }
- attempt = 0
- switch change.kind {
- case .today, .drafts, .media, .identity, .profile:
- await reload(refreshProjection: false)
- case .initial, .settings, .relay, .lifecycle:
- continue
- }
+ private func startObservation() {
+ observation.start(
+ client: runtimeClient, capacity: 16, delay: observationDelay,
+ state: { [weak self] in self?.observationState = $0 },
+ change: { [weak self] change in
+ switch change.kind {
+ case .today, .drafts, .media, .identity, .profile:
+ await self?.reload(refreshProjection: false)
+ case .initial, .settings, .relay, .lifecycle:
+ break
}
- } catch {
- failureMessage = TeraUserMessages.text(
- for: error,
- fallback: .runtimeObservationUnavailable
- )
}
- guard isStarted, observationGeneration == generation, !Task.isCancelled else { break }
- attempt = attempt == .max ? .max : attempt + 1
- observationState = .retrying(attempt: attempt, message: failureMessage)
- do {
- try await observationDelay(attempt)
- } catch {
- break
- }
- }
- observationDidFinish(generation: generation)
- }
-
- private func observationDidFinish(generation: UInt64) {
- guard observationGeneration == generation else { return }
- observationTask = nil
- isStarted = false
- if case .retrying = observationState {
- return
- }
- observationState = .stopped
+ )
}
private static func unique(_ cards: [TeraTodayCard]) -> [TeraTodayCard] {
diff --git a/Tera/Views/TeraSupportingViews.swift b/Tera/Views/TeraSupportingViews.swift
@@ -45,7 +45,7 @@ struct TeraSearchSheet: View {
}
}
}
- .task { store.configure(context: context) }
+ .task(id: context) { store.configure(context: context) }
.accessibilityIdentifier("radroots.support.search.sheet")
}
@@ -132,7 +132,7 @@ struct TeraMeSheet: View {
.sheet(isPresented: $showsDrafts) {
TeraDraftsSheet(store: addStore)
}
- .task {
+ .task(id: context) {
store.configure(context: context)
await store.start()
}
diff --git a/Tera/Views/TeraTodayView.swift b/Tera/Views/TeraTodayView.swift
@@ -401,7 +401,7 @@ struct TeraLocalMediaContent: View {
)
}
}
- .task(id: media.id) {
+ .task(id: TeraMediaStore.Request(referenceID: media.id, context: context)) {
store.load(media: media, context: context)
}
.accessibilityElement(children: .combine)
diff --git a/TeraTests/TeraAddSchemaFixtures.swift b/TeraTests/TeraAddSchemaFixtures.swift
@@ -0,0 +1,112 @@
+@testable import TeraApp
+
+enum TeraAddSchemaFixtures {
+ static func schemas() -> [TeraAddSchema] {
+ [update, photoUpdate, ask, event, foodAvailability]
+ }
+
+ private static let field = { @Sendable
+ (
+ id: String,
+ label: String,
+ kind: TeraAddFieldKind,
+ required: Bool,
+ choices: [String],
+ maxBytes: UInt64?,
+ maxItems: UInt16?
+ ) in
+ TeraAddField(
+ schemaVersion: 1,
+ id: id,
+ label: label,
+ kind: kind,
+ required: required,
+ choices: choices,
+ maxBytes: maxBytes,
+ maxItems: maxItems
+ )
+ }
+
+ private static let text = { @Sendable
+ (id: String, label: String, kind: TeraAddFieldKind, required: Bool, maximum: UInt64?) in
+ field(id, label, kind, required, [], maximum, nil)
+ }
+
+ private static let media = { @Sendable (required: Bool, maximum: UInt16) in
+ field("media", "Photos", .media, required, [], 10 * 1024 * 1024, maximum)
+ }
+
+ private static var update: TeraAddSchema {
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createUpdate,
+ label: "Update",
+ fields: [text("content", "Update", .multilineText, true, 65535)]
+ )
+ }
+
+ private static var photoUpdate: TeraAddSchema {
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createPhotoUpdate,
+ label: "Photo update",
+ fields: [
+ text("content", "Update", .multilineText, true, 65535),
+ media(true, 20),
+ ]
+ )
+ }
+
+ private static var ask: TeraAddSchema {
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createAsk,
+ label: "Ask",
+ fields: [
+ text("content", "Question", .multilineText, true, 65535),
+ media(false, 20),
+ ]
+ )
+ }
+
+ private static var event: TeraAddSchema {
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createEvent,
+ label: "Event",
+ fields: [
+ text("identifier", "Identifier", .text, true, 256),
+ text("title", "Title", .text, true, 256),
+ text("content", "Description", .multilineText, false, 65535),
+ text("event_start", "Starts", .dateTime, true, nil),
+ text("event_end", "Ends", .dateTime, false, nil),
+ text("location", "Location", .location, false, 256),
+ media(false, 1),
+ ]
+ )
+ }
+
+ private static var foodAvailability: TeraAddSchema {
+ TeraAddSchema(
+ schemaVersion: 1,
+ commandType: .createFoodAvailability,
+ label: "Food availability",
+ fields: [
+ text("identifier", "Identifier", .text, true, 256),
+ text("title", "Food", .text, true, 256),
+ text("summary", "Summary", .text, true, 256),
+ text("content", "Details", .multilineText, true, 65535),
+ text("location", "Pickup location", .location, true, 256),
+ text("price_amount", "Price", .decimal, true, 64),
+ field("currency", "Currency", .choice, true, [], 3, nil),
+ field(
+ "unit", "Unit", .choice, true,
+ ["g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket"],
+ nil, nil
+ ),
+ text("quantity", "Available quantity", .decimal, false, 64),
+ media(false, 20),
+ ]
+ )
+ }
+}
diff --git a/TeraTests/TeraAddStoreTests.swift b/TeraTests/TeraAddStoreTests.swift
@@ -286,7 +286,7 @@ final class TeraAddStoreTests: XCTestCase {
XCTAssertNil(unicodeIdentifierStore.form.identifier)
_ = try await validClient.stop()
- let exact = AddBackend.schemas()
+ let exact = TeraAddSchemaFixtures.schemas()
var missingField = exact
let food = exact[4]
missingField[4] = TeraAddSchema(
@@ -867,7 +867,7 @@ private actor AddBackend: TeraRuntimeBackend {
includeWritableRelay: Bool = true,
delayedPhase: AddDelayPhase? = nil,
delayAfterBackgroundCompletion: Bool = false,
- schemas: [TeraAddSchema] = AddBackend.schemas()
+ schemas: [TeraAddSchema] = TeraAddSchemaFixtures.schemas()
) {
self.advanceOffline = advanceOffline
self.savePause = savePause
@@ -1376,100 +1376,6 @@ private actor AddSubscriptionToken: TeraRuntimeSubscriptionToken {
func cancel() {}
}
-private extension AddBackend {
- nonisolated static func schemas() -> [TeraAddSchema] {
- let field = {
- (
- id: String,
- label: String,
- kind: TeraAddFieldKind,
- required: Bool,
- choices: [String],
- maxBytes: UInt64?,
- maxItems: UInt16?
- ) in
- TeraAddField(
- schemaVersion: 1,
- id: id,
- label: label,
- kind: kind,
- required: required,
- choices: choices,
- maxBytes: maxBytes,
- maxItems: maxItems
- )
- }
- let text = {
- (id: String, label: String, kind: TeraAddFieldKind, required: Bool, maximum: UInt64?) in
- field(id, label, kind, required, [], maximum, nil)
- }
- let media = { (required: Bool, maximum: UInt16) in
- field("media", "Photos", .media, required, [], 10 * 1024 * 1024, maximum)
- }
- return [
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createUpdate,
- label: "Update",
- fields: [text("content", "Update", .multilineText, true, 65535)]
- ),
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createPhotoUpdate,
- label: "Photo update",
- fields: [
- text("content", "Update", .multilineText, true, 65535),
- media(true, 20),
- ]
- ),
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createAsk,
- label: "Ask",
- fields: [
- text("content", "Question", .multilineText, true, 65535),
- media(false, 20),
- ]
- ),
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createEvent,
- label: "Event",
- fields: [
- text("identifier", "Identifier", .text, true, 256),
- text("title", "Title", .text, true, 256),
- text("content", "Description", .multilineText, false, 65535),
- text("event_start", "Starts", .dateTime, true, nil),
- text("event_end", "Ends", .dateTime, false, nil),
- text("location", "Location", .location, false, 256),
- media(false, 1),
- ]
- ),
- TeraAddSchema(
- schemaVersion: 1,
- commandType: .createFoodAvailability,
- label: "Food availability",
- fields: [
- text("identifier", "Identifier", .text, true, 256),
- text("title", "Food", .text, true, 256),
- text("summary", "Summary", .text, true, 256),
- text("content", "Details", .multilineText, true, 65535),
- text("location", "Pickup location", .location, true, 256),
- text("price_amount", "Price", .decimal, true, 64),
- field("currency", "Currency", .choice, true, [], 3, nil),
- field(
- "unit", "Unit", .choice, true,
- ["g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket"],
- nil, nil
- ),
- text("quantity", "Available quantity", .decimal, false, 64),
- media(false, 20),
- ]
- ),
- ]
- }
-}
-
extension TeraAddStoreTests {
@MainActor
func testMutationAdmissionKeepsOneAddOperationBeforeItsBackendWait() async throws {
diff --git a/TeraTests/TeraRuntimeResourceFixtures.swift b/TeraTests/TeraRuntimeResourceFixtures.swift
@@ -54,6 +54,7 @@ actor ResourceTestBackend: TeraRuntimeBackend {
private var shutdownFailure: TeraRuntimeFailure?
private var shutdownCompleted = false
private(set) var profileMutations = 0
+ private(set) var identityCommands = 0
private var receive: (@Sendable (TeraRuntimeChange) async -> Void)?
init(publicKeyHex: String) {
@@ -176,7 +177,8 @@ actor ResourceTestBackend: TeraRuntimeBackend {
func applyIdentityCommand(
expectedRevision _: UInt64, command _: TeraIdentityCommand
) -> TeraSettingsTransition {
- TeraSettingsTransition(
+ identityCommands += 1
+ return TeraSettingsTransition(
settings: settings(), runtimeRestartRequired: false,
outboxRequeueRequired: false, mediaCacheInvalidationRequired: false
)
diff --git a/TeraTests/TeraScopeBackend.swift b/TeraTests/TeraScopeBackend.swift
@@ -0,0 +1,150 @@
+import Foundation
+@testable import TeraApp
+
+actor TeraScopeBackend: TeraRuntimeBackend {
+ enum Call: Hashable { case snapshot, drafts, save, probe, page, refresh, search, me, subscribe, media, invalidate }
+ struct Pending {
+ let pause: ResourceTestPause
+ let fails: Bool
+ }
+
+ private(set) var value = TeraScopeFixtures.snapshot()
+ private var pending: [Call: [Pending]] = [:]
+ private(set) var counts: [Call: Int] = [:]
+ private var receivers: [@Sendable (TeraRuntimeChange) async -> Void] = []
+ private(set) var tokens: [ResourceTestToken] = []
+ private(set) var lastMeContext: TeraLocalNetwork?
+ private var drafts: [TeraDraftStatus] = [TeraScopeFixtures.draft("old")]
+ private var media: TeraVerifiedMediaArtifact
+
+ init() throws {
+ media = try TeraScopeFixtures.artifact("a")
+ }
+
+ func pause(_ call: Call, fails: Bool = false) -> ResourceTestPause {
+ let pause = ResourceTestPause()
+ pending[call, default: []].append(Pending(pause: pause, fails: fails))
+ return pause
+ }
+
+ func configure(_ value: TeraRuntimeSnapshot) {
+ self.value = value
+ }
+
+ func setDrafts(_ values: [TeraDraftStatus]) {
+ drafts = values
+ }
+
+ func setMedia(_ value: TeraVerifiedMediaArtifact) {
+ media = value
+ }
+
+ private func wait(_ call: Call) async throws {
+ counts[call, default: 0] += 1
+ guard var queue = pending[call], !queue.isEmpty else { return }
+ let next = queue.removeFirst()
+ pending[call] = queue
+ await next.pause.wait()
+ if next.fails {
+ throw TeraScopeFixtures.failure()
+ }
+ }
+
+ func snapshot() async throws -> TeraRuntimeSnapshot {
+ let snapshot = value
+ try await wait(.snapshot)
+ return snapshot
+ }
+
+ func addSchemas() -> [TeraAddSchema] {
+ TeraAddSchemaFixtures.schemas()
+ }
+
+ func draftHeads(limit _: UInt16) async throws -> [TeraDraftStatus] {
+ let result = drafts
+ try await wait(.drafts)
+ return result
+ }
+
+ func saveAddIntent(
+ input: TeraAddRuntimeInput, existingDraftID _: String?, expectedRevision: UInt64?
+ ) async throws -> TeraDraftStatus {
+ let result = TeraScopeFixtures.draft(input.form.content, revision: (expectedRevision ?? 0) + 1)
+ try await wait(.save)
+ drafts = [result]
+ return result
+ }
+
+ func probeBlossom() async throws -> TeraBlossomEvidence {
+ let fingerprint = value.blossomConfiguration?.configFingerprint ?? ""
+ try await wait(.probe)
+ return TeraBlossomEvidence(
+ schemaVersion: 2, origin: "http://127.0.0.1:3000", configFingerprint: fingerprint,
+ state: "reachable", lastSuccessfulState: "probe", transportSecurity: "loopback_plaintext",
+ observedAtUnixMilliseconds: 1, httpStatus: 200, errorCode: nil, serverErrorCode: nil,
+ errorPhase: nil, retryable: false, possibleOrphan: false, attempts: 1
+ )
+ }
+
+ func todayPage(request: TeraTodayPageRequest) async throws -> TeraTodayPage {
+ let result = TeraTodayPage(
+ asOfUnixSeconds: 1, items: [TeraScopeFixtures.card(request.context.relayURLs.first ?? "none")], nextCursor: nil
+ )
+ try await wait(.page)
+ return result
+ }
+
+ func refreshToday(
+ context _: TeraLocalNetwork, nowUnixSeconds _: UInt64, update: TeraTodayProjectionUpdate
+ ) async throws -> TeraTodayRefreshReceipt {
+ try await wait(.refresh)
+ return TeraTodayRefreshReceipt(
+ update: update, sourceEvents: 0, visibleCards: 0, profiles: 0, threadEntries: 0, contentGeneration: 1, changed: false
+ )
+ }
+
+ func search(
+ context _: TeraLocalNetwork, query: String, limit _: UInt16, asOfUnixSeconds _: UInt64
+ ) async throws -> [TeraSearchResult] {
+ try await wait(.search)
+ return [TeraSearchResult(type: .card, id: query, card: TeraScopeFixtures.card(query), profile: nil)]
+ }
+
+ func me(context: TeraLocalNetwork, asOfUnixSeconds _: UInt64) async throws -> TeraMeSnapshot {
+ lastMeContext = context
+ let result = TeraMeSnapshot(publicKey: value.identity.publicKeyHex, profile: nil, cards: [TeraScopeFixtures.card("old")])
+ try await wait(.me)
+ return result
+ }
+
+ func retrieveMedia(context _: TeraLocalNetwork, reference _: TeraMediaReference) async throws -> TeraVerifiedMediaArtifact {
+ let result = media
+ try await wait(.media)
+ return result
+ }
+
+ func invalidateMediaArtifact(context _: TeraLocalNetwork, artifactID _: String) async throws -> Bool {
+ try await wait(.invalidate)
+ return true
+ }
+
+ func subscribe(
+ bufferCapacity _: Int, receive: @escaping @Sendable (TeraRuntimeChange) async -> Void
+ ) async throws -> any TeraRuntimeSubscriptionToken {
+ let token = ResourceTestToken()
+ receivers.append(receive)
+ tokens.append(token)
+ try await wait(.subscribe)
+ return token
+ }
+
+ func emit(_ kind: TeraRuntimeChangeKind) async {
+ for receive in receivers {
+ await receive(TeraRuntimeChange(schemaVersion: 1, generation: TeraProjectionRevision(rawValue: 1), kind: kind, entityID: nil))
+ }
+ }
+
+ func shutdown() -> TeraRuntimeShutdownReceipt {
+ TeraRuntimeShutdownReceipt(state: "closed", alreadyClosed: false)
+ }
+}
diff --git a/TeraTests/TeraScopeFixtures.swift b/TeraTests/TeraScopeFixtures.swift
@@ -0,0 +1,99 @@
+import Foundation
+@testable import TeraApp
+import XCTest
+
+enum TeraScopeFixtures {
+ static func snapshot(
+ account: String = "a", relay: String = "first", evidence: TeraBlossomEvidence? = nil
+ ) -> TeraRuntimeSnapshot {
+ TeraRuntimeSnapshot(
+ identity: TeraRuntimeIdentity(publicKeyHex: String(repeating: account, count: 64), hostSignerConfigured: true),
+ relay: TeraRelayStatus(
+ profile: "simulator", state: "configured", readAvailability: "unobserved", writeAvailability: "unobserved",
+ relays: [TeraRelayEndpointStatus(
+ url: "wss://\(relay).example", access: .readWrite, readState: "unobserved", writeState: "unobserved",
+ readLastAttemptUnixMilliseconds: nil, writeLastAttemptUnixMilliseconds: nil,
+ readNextAttemptUnixMilliseconds: nil, writeNextAttemptUnixMilliseconds: nil
+ )]
+ ),
+ blossomConfiguration: TeraBlossomConfigurationStatus(
+ schemaVersion: 1, hostKind: "simulator", endpointAuthority: "loopback_development",
+ primaryOrigin: "http://127.0.0.1:3000", fallbackOrigins: [], configFingerprint: relay
+ ),
+ blossomEvidence: evidence, crateName: "tera_ffi", crateVersion: "0.1.0-alpha", isClosed: false
+ )
+ }
+
+ static func evidence(observedAt: UInt64) -> TeraBlossomEvidence {
+ TeraBlossomEvidence(
+ schemaVersion: 2, origin: "http://127.0.0.1:3000", configFingerprint: "first",
+ state: "reachable", lastSuccessfulState: "probe", transportSecurity: "loopback_plaintext",
+ observedAtUnixMilliseconds: observedAt, httpStatus: 200, errorCode: nil, serverErrorCode: nil,
+ errorPhase: nil, retryable: false, possibleOrphan: false, attempts: 1
+ )
+ }
+
+ static func card(_ id: String) -> TeraTodayCard {
+ TeraTodayCard(
+ id: id, type: .update, sourceEventID: id, sourceAddress: nil,
+ authorPublicKey: String(repeating: "a", count: 64), contractID: "test.update",
+ title: nil, content: id, authoredAtUnixSeconds: 1, effectiveAtUnixSeconds: 1,
+ eventStartUnixSeconds: nil, eventEndUnixSeconds: nil, location: nil,
+ priceAmount: nil, priceCurrency: nil, priceUnit: nil, quantity: nil, foodSummary: nil,
+ foodPublishedAtUnixSeconds: nil, foodStatus: nil, contextRank: 1, inclusionReason: "local",
+ media: [], lifecycle: .active, rankDigest: nil, authorProfile: nil, thread: [],
+ localOperationID: nil, localOperationState: nil
+ )
+ }
+
+ static func draft(_ text: String, revision: UInt64 = 1) -> TeraDraftStatus {
+ var form = TeraAddForm.empty(.createUpdate)
+ form.content = text
+ return TeraDraftStatus(
+ id: "draft", revision: revision, authorPublicKey: String(repeating: "a", count: 64),
+ kind: .add, commandType: .createUpdate, form: form, state: .draft,
+ cardID: "card", operationID: nil, createdAtUnixMilliseconds: 1,
+ updatedAtUnixMilliseconds: revision, media: [], settlement: nil, isRevision: false
+ )
+ }
+
+ static func reference() -> TeraMediaReference {
+ TeraMediaReference(
+ referenceFingerprint: String(repeating: "e", count: 64), url: "https://media.example/image.png",
+ sha256: String(repeating: "b", count: 64), mediaType: "image/png", width: 1, height: 1,
+ byteSize: 68, alt: "Test pixel", verification: .unavailable
+ )
+ }
+
+ static func artifact(_ id: String, corrupt: Bool = false) throws -> TeraVerifiedMediaArtifact {
+ let pixel = Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jWZkAAAAASUVORK5CYII=") ?? Data()
+ let bytes = corrupt ? Data([0, 1, 2]) : pixel
+ return try XCTUnwrap(TeraVerifiedMediaArtifact(
+ artifactID: String(repeating: id, count: 64), bytes: bytes,
+ byteSize: UInt64(bytes.count), mediaType: "image/png", width: 1, height: 1
+ ))
+ }
+
+ static func failure() -> TeraRuntimeFailure {
+ .local(operation: "test.scope", code: "test.scope.failed", safeMessage: "A controlled request failed.")
+ }
+
+ @MainActor
+ static func eventually(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async {
+ let deadline = ContinuousClock.now.advanced(by: .seconds(5))
+ while !condition(), ContinuousClock.now < deadline {
+ await Task.yield()
+ }
+ XCTAssertTrue(condition(), file: file, line: line)
+ }
+
+ @MainActor
+ static func client(_ backend: TeraScopeBackend) async throws -> TeraRuntimeClient {
+ let configuration = TeraRuntimeClientTests().makeConfiguration(generation: "51")
+ let client = TeraRuntimeClient { _ in
+ await TeraRuntimeBackendStart(backend: backend, snapshot: backend.value)
+ }
+ _ = try await client.start(configuration: configuration)
+ return client
+ }
+}
diff --git a/TeraTests/TeraScopedAddTests.swift b/TeraTests/TeraScopedAddTests.swift
@@ -0,0 +1,199 @@
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraScopedAddTests: XCTestCase {
+ func testServiceProbeCompletionCannotChangeReplacementScopeOrClearItsProbe() async throws {
+ for fails in [false, true] {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ let first = await backend.pause(.probe, fails: fails)
+ let old = Task { await store.checkPhotoService() }
+ await first.entered.wait()
+ let updated = TeraScopeFixtures.snapshot(account: "b", relay: "second")
+ await backend.configure(updated)
+ store.configure(snapshot: updated)
+ let second = await backend.pause(.probe)
+ let current = Task { await store.checkPhotoService() }
+ await second.entered.wait()
+ await first.resume.open()
+ await old.value
+ XCTAssertTrue(store.isCheckingBlossom)
+ XCTAssertNil(store.blossomEvidence)
+ XCTAssertNil(store.message)
+ await second.resume.open()
+ await current.value
+ XCTAssertEqual(store.blossomEvidence?.configFingerprint, "second")
+ XCTAssertFalse(store.isCheckingBlossom)
+ _ = try await client.stop()
+ }
+ }
+
+ func testLateDraftStartupCannotPopulateAReplacementAccount() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ let pause = await backend.pause(.drafts)
+ let old = Task { await store.start() }
+ await pause.entered.wait()
+ store.configure(snapshot: TeraScopeFixtures.snapshot(account: "b"))
+ await backend.setDrafts([TeraScopeFixtures.draft("new")])
+ await store.start()
+ await pause.resume.open()
+ await old.value
+ XCTAssertEqual(store.drafts.first?.form?.content, "new")
+ XCTAssertEqual(store.state, .ready)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testFormEditDuringSaveKeepsTheNewFormAndRetainsDurableOldReceipt() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ await store.start()
+ store.updateForm(\.content, "saved version")
+ let pause = await backend.pause(.save)
+ let old = Task { await store.save() }
+ await pause.entered.wait()
+ store.updateForm(\.content, "new edit")
+ await pause.resume.open()
+ await old.value
+ XCTAssertEqual(store.form.content, "new edit")
+ XCTAssertNil(store.activeDraft)
+ XCTAssertNil(store.message)
+ XCTAssertFalse(store.isWorking)
+ let durable = try await client.draftHeads(limit: 100)
+ XCTAssertEqual(durable.first?.form?.content, "saved version")
+ await store.save()
+ XCTAssertEqual(store.activeDraft?.form?.content, "new edit")
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testLateFailureSnapshotCannotWriteItsMessageOrClearANewerOperation() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ await store.start()
+ let save = await backend.pause(.save, fails: true)
+ let old = Task { await store.save() }
+ await save.entered.wait()
+ let snapshot = await backend.pause(.snapshot)
+ await save.resume.open()
+ await snapshot.entered.wait()
+ let updated = TeraScopeFixtures.snapshot(account: "b")
+ await backend.configure(updated)
+ store.configure(snapshot: updated)
+ await store.start()
+ store.updateForm(\.content, "new account")
+ let next = await backend.pause(.save)
+ let current = Task { await store.save() }
+ await next.entered.wait()
+ await snapshot.resume.open()
+ await old.value
+ XCTAssertTrue(store.isWorking)
+ XCTAssertNil(store.message)
+ XCTAssertNil(store.lastFailureCode)
+ await next.resume.open()
+ await current.value
+ XCTAssertEqual(store.activeDraft?.form?.content, "new account")
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testOldDraftObserverCannotOverwriteOrReportFailureInNewAccount() async throws {
+ for fails in [false, true] {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ await store.start()
+ await TeraScopeFixtures.eventually { store.observationState == .active }
+ let tokens = await backend.tokens
+ let oldToken = try XCTUnwrap(tokens.first)
+ let pause = await backend.pause(.drafts, fails: fails)
+ await backend.emit(.drafts)
+ await pause.entered.wait()
+ store.configure(snapshot: TeraScopeFixtures.snapshot(account: "b"))
+ await backend.setDrafts([TeraScopeFixtures.draft("new account")])
+ await store.start()
+ await pause.resume.open()
+ await oldToken.cancelled.wait()
+ XCTAssertEqual(store.drafts.first?.form?.content, "new account")
+ XCTAssertNil(store.message)
+ store.stop()
+ _ = try await client.stop()
+ }
+ }
+
+ func testLateStartupInventoryCannotOverwriteANewerObserverSnapshot() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ let pause = await backend.pause(.drafts)
+ let startup = Task { await store.start() }
+ await pause.entered.wait()
+ await TeraScopeFixtures.eventually { store.observationState == .active }
+ await backend.setDrafts([TeraScopeFixtures.draft("new revision", revision: 2)])
+ await backend.emit(.drafts)
+ await TeraScopeFixtures.eventually { store.drafts.first?.revision == 2 }
+ await pause.resume.open()
+ await startup.value
+ XCTAssertEqual(store.drafts.first?.revision, 2)
+ XCTAssertEqual(store.state, .ready)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testLateProbeCannotOverwriteNewerServiceSnapshotInTheSameScope() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ await store.start()
+ await TeraScopeFixtures.eventually { store.observationState == .active }
+ let pause = await backend.pause(.probe)
+ let old = Task { await store.checkPhotoService() }
+ await pause.entered.wait()
+ let evidence = TeraScopeFixtures.evidence(observedAt: 2)
+ await backend.configure(TeraScopeFixtures.snapshot(evidence: evidence))
+ await backend.emit(.settings)
+ await TeraScopeFixtures.eventually { store.blossomEvidence == evidence }
+ await pause.resume.open()
+ await old.value
+ XCTAssertEqual(store.blossomEvidence, evidence)
+ XCTAssertFalse(store.isCheckingBlossom)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testLateFailureSnapshotCannotOverwriteEditedFormMessage() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ await store.start()
+ let save = await backend.pause(.save, fails: true)
+ let old = Task { await store.save() }
+ await save.entered.wait()
+ let snapshot = await backend.pause(.snapshot)
+ await save.resume.open()
+ await snapshot.entered.wait()
+ store.updateForm(\.content, "New edit while error recovery waits")
+ await snapshot.resume.open()
+ await old.value
+ XCTAssertNil(store.message)
+ XCTAssertNil(store.lastFailureCode)
+ XCTAssertEqual(store.form.content, "New edit while error recovery waits")
+ XCTAssertFalse(store.isWorking)
+ store.stop()
+ _ = try await client.stop()
+ }
+}
diff --git a/TeraTests/TeraScopedMediaTests.swift b/TeraTests/TeraScopedMediaTests.swift
@@ -0,0 +1,78 @@
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraScopedMediaTests: XCTestCase {
+ func testCancelledMediaOwnerCannotClearAReplacementTaskOrAdmitDuplicateWork() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraMediaStore(runtimeClient: client)
+ let reference = TeraScopeFixtures.reference()
+ let context = TeraLocalNetwork.defaultContext(snapshot: TeraScopeFixtures.snapshot())
+ let first = await backend.pause(.media)
+ store.load(media: reference, context: context)
+ await first.entered.wait()
+ let second = await backend.pause(.media)
+ let expected = try TeraScopeFixtures.artifact("b")
+ await backend.setMedia(expected)
+ store.retry(media: reference, context: context)
+ await second.entered.wait()
+ await first.resume.open()
+ // A completed actor hop gives the cancelled owner a chance to finish while
+ // the replacement is held at its explicit backend gate.
+ await TeraScopeFixtures.eventually { store.state(for: reference, context: context) == .loading }
+ store.load(media: reference, context: context)
+ await second.resume.open()
+ await TeraScopeFixtures.eventually { store.state(for: reference, context: context) == .ready(expected) }
+ let calls = await backend.counts[.media]
+ XCTAssertEqual(calls, 2)
+ store.reset()
+ _ = try await client.stop()
+ }
+
+ func testAccountResetCannotBeRepopulatedByLateMediaAndSameContextCanReload() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraMediaStore(runtimeClient: client)
+ let reference = TeraScopeFixtures.reference()
+ let first = TeraScopeFixtures.snapshot()
+ let context = TeraLocalNetwork.defaultContext(snapshot: first)
+ store.configure(snapshot: first)
+ let pause = await backend.pause(.media)
+ store.load(media: reference, context: context)
+ await pause.entered.wait()
+ store.configure(snapshot: TeraScopeFixtures.snapshot(account: "b"))
+ await pause.resume.open()
+ let current = try TeraScopeFixtures.artifact("b")
+ await backend.setMedia(current)
+ store.load(media: reference, context: context)
+ await TeraScopeFixtures.eventually { store.state(for: reference, context: context) == .ready(current) }
+ let calls = await backend.counts[.media]
+ XCTAssertEqual(calls, 2)
+ store.reset()
+ _ = try await client.stop()
+ }
+
+ func testLateCorruptInvalidationCannotReplaceNewVerifiedMedia() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraMediaStore(runtimeClient: client)
+ let reference = TeraScopeFixtures.reference()
+ let context = TeraLocalNetwork.defaultContext(snapshot: TeraScopeFixtures.snapshot())
+ try await backend.setMedia(TeraScopeFixtures.artifact("a", corrupt: true))
+ let pause = await backend.pause(.invalidate)
+ store.load(media: reference, context: context)
+ await pause.entered.wait()
+ let current = try TeraScopeFixtures.artifact("b")
+ await backend.setMedia(current)
+ store.retry(media: reference, context: context)
+ await TeraScopeFixtures.eventually { store.state(for: reference, context: context) == .ready(current) }
+ await pause.resume.open()
+ store.load(media: reference, context: context)
+ let calls = await backend.counts[.media]
+ XCTAssertEqual(calls, 2)
+ XCTAssertEqual(store.state(for: reference, context: context), .ready(current))
+ store.reset()
+ _ = try await client.stop()
+ }
+}
diff --git a/TeraTests/TeraScopedObservationTests.swift b/TeraTests/TeraScopedObservationTests.swift
@@ -0,0 +1,56 @@
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraScopedObservationTests: XCTestCase {
+ func testCancelledLateSubscriptionCannotClearOrUpdateReplacementObserver() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraTodayStore(runtimeClient: client, clock: .fixed(unixSeconds: 1))
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ let first = await backend.pause(.subscribe)
+ await store.start()
+ await first.entered.wait()
+ store.stop()
+ let second = await backend.pause(.subscribe)
+ await store.start()
+ await second.entered.wait()
+ await first.resume.open()
+ let tokens = await backend.tokens
+ await tokens[0].cancelled.wait()
+ XCTAssertEqual(store.observationState, .subscribing(attempt: 1))
+ await second.resume.open()
+ await TeraScopeFixtures.eventually { store.observationState == .active }
+ let attempts = await backend.counts[.subscribe]
+ await store.start()
+ let after = await backend.counts[.subscribe]
+ XCTAssertEqual(attempts, after)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testFailedObservationDelayClearsItsOwnerSoStartResubscribes() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let observation = TeraStoreObservation()
+ var state = TeraRuntimeObservationState.inactive
+ let pause = await backend.pause(.subscribe, fails: true)
+ observation.start(
+ client: client, capacity: 8, delay: { _ in throw CancellationError() },
+ state: { state = $0 }, change: { _ in }
+ )
+ await pause.entered.wait()
+ await pause.resume.open()
+ await TeraScopeFixtures.eventually { !observation.isActive }
+ guard case .retrying = state else { return XCTFail("The failed retry remains visible") }
+ observation.start(
+ client: client, capacity: 8, delay: { _ in throw CancellationError() },
+ state: { state = $0 }, change: { _ in }
+ )
+ await TeraScopeFixtures.eventually { state == .active }
+ let count = await backend.counts[.subscribe]
+ XCTAssertEqual(count, 2)
+ observation.stop()
+ _ = try await client.stop()
+ }
+}
diff --git a/TeraTests/TeraScopedStoreTests.swift b/TeraTests/TeraScopedStoreTests.swift
@@ -0,0 +1,117 @@
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraScopedStoreTests: XCTestCase {
+ func testTodayReconfigurationClearsOldCardsSynchronouslyAndRejectsLatePages() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraTodayStore(runtimeClient: client, clock: .fixed(unixSeconds: 1))
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ await store.reload(refreshProjection: false)
+ XCTAssertEqual(store.cards.map(\.id), ["wss://first.example"])
+ let pause = await backend.pause(.page)
+ let old = Task { await store.reload(refreshProjection: false) }
+ await pause.entered.wait()
+ store.configure(snapshot: TeraScopeFixtures.snapshot(account: "b", relay: "second"))
+ XCTAssertTrue(store.cards.isEmpty)
+ XCTAssertEqual(store.selectedContext?.relayURLs, ["wss://second.example"])
+ await store.reload(refreshProjection: false)
+ await pause.resume.open()
+ await old.value
+ XCTAssertEqual(store.cards.map(\.id), ["wss://second.example"])
+ XCTAssertEqual(store.state, .loaded)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testLateRefreshFailureDoesNotIssueAnOldScopePageOrChangeNewState() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraTodayStore(runtimeClient: client, clock: .fixed(unixSeconds: 1))
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ let pause = await backend.pause(.refresh, fails: true)
+ let old = Task { await store.reload() }
+ await pause.entered.wait()
+ store.configure(snapshot: TeraScopeFixtures.snapshot(relay: "second"))
+ await store.reload(refreshProjection: false)
+ let count = await backend.counts[.page]
+ await pause.resume.open()
+ await old.value
+ let after = await backend.counts[.page]
+ XCTAssertEqual(after, count)
+ XCTAssertEqual(store.cards.map(\.id), ["wss://second.example"])
+ XCTAssertEqual(store.state, .loaded)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testSearchQueryEditsRejectLateSuccessAndFailureWithoutAnotherSearch() async throws {
+ for fails in [false, true] {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraSearchStore(runtimeClient: client, clock: .fixed(unixSeconds: 1))
+ store.configure(context: .defaultContext(snapshot: TeraScopeFixtures.snapshot()))
+ store.updateQuery("old")
+ let pause = await backend.pause(.search, fails: fails)
+ let old = Task { await store.search() }
+ await pause.entered.wait()
+ store.updateQuery("new")
+ await pause.resume.open()
+ await old.value
+ XCTAssertEqual(store.query, "new")
+ XCTAssertTrue(store.results.isEmpty)
+ XCTAssertEqual(store.state, .idle)
+ _ = try await client.stop()
+ }
+ }
+
+ func testAccountReconfigurationClearsSupportingStateAndRejectsLateMe() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let stores = TeraProductStores(runtimeClient: client)
+ let first = TeraScopeFixtures.snapshot()
+ stores.configure(snapshot: first)
+ stores.me.configure(context: .defaultContext(snapshot: first))
+ stores.search.configure(context: .defaultContext(snapshot: first))
+ stores.search.updateQuery("old")
+ await stores.search.search()
+ let pause = await backend.pause(.me)
+ let old = Task { await stores.me.reload() }
+ await pause.entered.wait()
+ stores.settings.profileName = "Old account"
+ let updated = TeraScopeFixtures.snapshot(account: "b", relay: "second")
+ await backend.configure(updated)
+ stores.configure(snapshot: updated)
+ XCTAssertTrue(stores.search.results.isEmpty)
+ XCTAssertEqual(stores.settings.profileName, "")
+ await pause.resume.open()
+ await old.value
+ XCTAssertNil(stores.me.snapshot)
+ XCTAssertEqual(stores.me.state, .idle)
+ await stores.me.reload()
+ let requested = await backend.lastMeContext
+ XCTAssertEqual(requested, stores.today.selectedContext)
+ XCTAssertEqual(stores.me.snapshot?.publicKey, updated.identity.publicKeyHex)
+ stores.stop()
+ _ = try await client.stop()
+ }
+
+ func testStoppedProductResumeCannotStartAddAfterLateTodayCompletion() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let stores = TeraProductStores(runtimeClient: client)
+ stores.configure(snapshot: TeraScopeFixtures.snapshot())
+ let pause = await backend.pause(.page)
+ let old = Task { await stores.resume() }
+ await pause.entered.wait()
+ stores.stop()
+ await pause.resume.open()
+ await old.value
+ XCTAssertEqual(stores.add.state, .idle)
+ XCTAssertEqual(stores.add.observationState, .stopped)
+ let calls = await backend.counts[.drafts, default: 0]
+ XCTAssertEqual(calls, 0)
+ _ = try await client.stop()
+ }
+}
diff --git a/TeraTests/TeraSessionResourceTests.swift b/TeraTests/TeraSessionResourceTests.swift
@@ -15,9 +15,12 @@ final class TeraSessionResourceTests: XCTestCase {
await pause.entered.wait()
let current = await fixture.session.start()
guard case .running = current else { return XCTFail("New session must adopt the shared runtime") }
+ let commands = await fixture.backend.identityCommands
await pause.resume.open()
_ = await old.value
try await assertRunning(fixture, phase: current)
+ let after = await fixture.backend.identityCommands
+ XCTAssertEqual(after, commands, "Superseded reconciliation cannot issue identity commands in the live session")
_ = await fixture.session.stop()
}
@@ -30,9 +33,12 @@ final class TeraSessionResourceTests: XCTestCase {
await pause.entered.wait()
let current = await fixture.session.start()
guard case .running = current else { return XCTFail("New session must adopt the shared runtime") }
+ let commands = await fixture.backend.identityCommands
await pause.resume.open()
_ = await old.value
try await assertRunning(fixture, phase: current)
+ let after = await fixture.backend.identityCommands
+ XCTAssertEqual(after, commands, "Superseded reconciliation cannot issue identity commands in the live session")
_ = await fixture.session.stop()
}
diff --git a/project.yml b/project.yml
@@ -59,6 +59,13 @@ targets:
- path: TeraTests/TeraRootShellTests.swift
- path: TeraTests/TeraStateMigrationTests.swift
- path: TeraTests/TeraTodayStoreTests.swift
+ - path: TeraTests/TeraAddSchemaFixtures.swift
+ - path: TeraTests/TeraScopeFixtures.swift
+ - path: TeraTests/TeraScopeBackend.swift
+ - path: TeraTests/TeraScopedStoreTests.swift
+ - path: TeraTests/TeraScopedAddTests.swift
+ - path: TeraTests/TeraScopedMediaTests.swift
+ - path: TeraTests/TeraScopedObservationTests.swift
- path: TeraTests/TeraMediaStoreTests.swift
- path: TeraTests/TeraAddStoreTests.swift
- path: TeraTests/TeraBackgroundUploadFixtures.swift
diff --git a/release/provenance.json b/release/provenance.json
@@ -6,7 +6,7 @@
"info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef",
"privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12",
"sbom_sha256": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410",
- "xcode_project_sha256": "fbfb6c2a9c966901e6f6fbe267e34a3f429c7a5512c99d411947c330777c43ad"
+ "xcode_project_sha256": "5be5c4ec76a400ce7241c58d3d5fb79a252dfc4d69e0616ecc9a6092e55bc7ed"
},
"disposition": "unsigned",
"platforms": [
diff --git a/test-fixtures/swiftlint-maintainability-baseline.v1.json b/test-fixtures/swiftlint-maintainability-baseline.v1.json
@@ -1 +1 @@
-[{"violation":{"severity":"error","location":{"line":71,"file":"Tera\/App\/TeraAppModel.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines"},"text":" init("},{"violation":{"severity":"error","location":{"line":73,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines"},"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {"},{"violation":{"severity":"error","location":{"line":480,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 480"},"text":"#endif"},{"violation":{"severity":"error","location":{"line":134,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":15},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 545 lines"},"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"severity":"error","location":{"line":1696,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1696"},"text":"}"},{"violation":{"severity":"error","location":{"line":291,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 790 lines"},"text":"actor TeraRuntimeClient {"},{"violation":{"severity":"error","location":{"line":709,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"severity":"error","location":{"line":1163,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1163"},"text":"}"},{"violation":{"severity":"error","location":{"line":1234,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1234"},"text":"}"},{"violation":{"severity":"error","location":{"line":11,"file":"Tera\/State\/TeraAddStore.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 741 lines"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"severity":"error","location":{"line":280,"file":"Tera\/State\/TeraAddStore.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines"},"text":" func submit() async {"},{"violation":{"severity":"error","location":{"line":438,"file":"Tera\/State\/TeraAddStore.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines"},"text":" func retract(_ card: TeraTodayCard) async {"},{"violation":{"severity":"error","location":{"line":524,"file":"Tera\/State\/TeraAddStore.swift","character":11},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 60 lines"},"text":" private func uploadPendingMedia(_ initial: TeraDraftStatus) async throws"},{"violation":{"severity":"error","location":{"line":810,"file":"Tera\/State\/TeraAddStore.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 810"},"text":"}"},{"violation":{"severity":"error","location":{"line":78,"file":"Tera\/State\/TeraConfigurationStore.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines"},"text":"actor TeraConfigurationStore {"},{"violation":{"severity":"error","location":{"line":148,"file":"Tera\/State\/TeraConfigurationStore.swift","character":5},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"severity":"error","location":{"line":759,"file":"Tera\/State\/TeraConfigurationStore.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 759"},"text":"}"},{"violation":{"severity":"error","location":{"line":40,"file":"Tera\/State\/TeraSessionStore.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 430 lines"},"text":"actor TeraSessionStore {"},{"violation":{"severity":"error","location":{"line":70,"file":"Tera\/State\/TeraSessionStore.swift","character":10},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines"},"text":" static func production("},{"violation":{"severity":"error","location":{"line":182,"file":"Tera\/State\/TeraSessionStore.swift","character":11},"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 14"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"severity":"error","location":{"line":182,"file":"Tera\/State\/TeraSessionStore.swift","character":11},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 64 lines"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"severity":"error","location":{"line":388,"file":"Tera\/State\/TeraSessionStore.swift","character":11},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines"},"text":" private func reconcileIdentity(_ identity: TeraAppIdentity) async throws {"},{"violation":{"severity":"error","location":{"line":492,"file":"Tera\/State\/TeraSessionStore.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 492"},"text":"}"},{"violation":{"severity":"error","location":{"line":4,"file":"Tera\/Views\/TeraAddView.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 454 lines"},"text":"struct TeraAddView: View {"},{"violation":{"severity":"error","location":{"line":560,"file":"Tera\/Views\/TeraAddView.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 560"},"text":"}"},{"violation":{"severity":"error","location":{"line":365,"file":"Tera\/Views\/TeraSupportingViews.swift","character":1},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines"},"text":"struct TeraSettingsView: View {"},{"violation":{"severity":"error","location":{"line":629,"file":"Tera\/Views\/TeraSupportingViews.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 629"},"text":"}"},{"violation":{"severity":"error","location":{"line":493,"file":"Tera\/Views\/TeraTodayView.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 493"},"text":"}"},{"violation":{"severity":"error","location":{"line":6,"file":"TeraTests\/TeraAddStoreTests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 665 lines"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":847,"file":"TeraTests\/TeraAddStoreTests.swift","character":9},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 492 lines"},"text":"private actor AddBackend: TeraRuntimeBackend {"},{"violation":{"severity":"error","location":{"line":1283,"file":"TeraTests\/TeraAddStoreTests.swift","character":11},"ruleIdentifier":"function_parameter_count","ruleDescription":"Number of function parameters should be low.","ruleName":"Function Parameter Count","reason":"Function should have 5 parameters or less: it currently has 8"},"text":" private func makeStatus("},{"violation":{"severity":"error","location":{"line":1380,"file":"TeraTests\/TeraAddStoreTests.swift","character":22},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 89 lines"},"text":" nonisolated static func schemas() -> [TeraAddSchema] {"},{"violation":{"severity":"error","location":{"line":1528,"file":"TeraTests\/TeraAddStoreTests.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 1528"},"text":"}"},{"violation":{"severity":"error","location":{"line":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":45,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"severity":"error","location":{"line":212,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"severity":"error","location":{"line":4,"file":"TeraTests\/TeraRuntimeClientTests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 332 lines"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":6,"file":"TeraTests\/TeraStateMigrationTests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":7,"file":"TeraTests\/TeraStateMigrationTests.swift","character":5},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"severity":"error","location":{"line":622,"file":"TeraTests\/TeraStateMigrationTests.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 622"},"text":"}"},{"violation":{"severity":"error","location":{"line":207,"file":"TeraTests\/TeraSupportingStoreTests.swift","character":9},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 253 lines"},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"severity":"error","location":{"line":487,"file":"TeraTests\/TeraSupportingStoreTests.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 400 lines or less: currently contains 487"},"text":"}"},{"violation":{"severity":"error","location":{"line":4,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":7},"ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"severity":"error","location":{"line":62,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"severity":"error","location":{"line":174,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"severity":"error","location":{"line":247,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"severity":"error","location":{"line":573,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 18"},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"severity":"error","location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","reason":"Function should have complexity 10 or less; currently complexity is 11"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"severity":"error","location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"severity":"error","location":{"line":1736,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":10},"ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines"},"text":" static func environment("},{"violation":{"severity":"error","location":{"line":2212,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":1},"ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","reason":"File should contain 1000 lines or less: currently contains 2212"},"text":"}"}]
-\ No newline at end of file
+[{"violation":{"reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":83,"character":3,"file":"Tera\/App\/TeraAppModel.swift"}},"text":" init("},{"violation":{"reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":73,"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift"}},"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 480","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":480,"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift"}},"text":"#endif"},{"violation":{"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 545 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":134,"character":15,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift"}},"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"reason":"File should contain 1000 lines or less: currently contains 1696","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":1696,"character":1,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift"}},"text":"}"},{"violation":{"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 790 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":291,"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift"}},"text":"actor TeraRuntimeClient {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":709,"character":3,"file":"Tera\/Runtime\/TeraRuntimeClient.swift"}},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"reason":"File should contain 1000 lines or less: currently contains 1163","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":1163,"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift"}},"text":"}"},{"violation":{"reason":"File should contain 1000 lines or less: currently contains 1234","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":1234,"character":1,"file":"Tera\/Runtime\/TeraRuntimeModels.swift"}},"text":"}"},{"violation":{"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 734 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":11,"character":7,"file":"Tera\/State\/TeraAddStore.swift"}},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":465,"character":3,"file":"Tera\/State\/TeraAddStore.swift"}},"text":" func retract(_ card: TeraTodayCard) async {"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 799","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":799,"character":1,"file":"Tera\/State\/TeraAddStore.swift"}},"text":"}"},{"violation":{"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":78,"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift"}},"text":"actor TeraConfigurationStore {"},{"violation":{"reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":148,"character":5,"file":"Tera\/State\/TeraConfigurationStore.swift"}},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 759","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":759,"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift"}},"text":"}"},{"violation":{"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":39,"character":1,"file":"Tera\/State\/TeraSessionStore.swift"}},"text":"actor TeraSessionStore {"},{"violation":{"reason":"Function should have complexity 10 or less; currently complexity is 13","ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","location":{"line":125,"character":11,"file":"Tera\/State\/TeraSessionStore.swift"}},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 452","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":452,"character":1,"file":"Tera\/State\/TeraSessionStore.swift"}},"text":"}"},{"violation":{"reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 454 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":4,"character":1,"file":"Tera\/Views\/TeraAddView.swift"}},"text":"struct TeraAddView: View {"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 560","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":560,"character":1,"file":"Tera\/Views\/TeraAddView.swift"}},"text":"}"},{"violation":{"reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":365,"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift"}},"text":"struct TeraSettingsView: View {"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 629","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":629,"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift"}},"text":"}"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 493","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":493,"character":1,"file":"Tera\/Views\/TeraTodayView.swift"}},"text":"}"},{"violation":{"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 665 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":6,"character":7,"file":"TeraTests\/TeraAddStoreTests.swift"}},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 492 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":847,"character":9,"file":"TeraTests\/TeraAddStoreTests.swift"}},"text":"private actor AddBackend: TeraRuntimeBackend {"},{"violation":{"reason":"Function should have 5 parameters or less: it currently has 8","ruleName":"Function Parameter Count","ruleIdentifier":"function_parameter_count","ruleDescription":"Number of function parameters should be low.","severity":"error","location":{"line":1283,"character":11,"file":"TeraTests\/TeraAddStoreTests.swift"}},"text":" private func makeStatus("},{"violation":{"reason":"File should contain 1000 lines or less: currently contains 1434","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":1434,"character":1,"file":"TeraTests\/TeraAddStoreTests.swift"}},"text":"}"},{"violation":{"reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":7,"character":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift"}},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":45,"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift"}},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":212,"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift"}},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 332 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":4,"character":7,"file":"TeraTests\/TeraRuntimeClientTests.swift"}},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":6,"character":7,"file":"TeraTests\/TeraStateMigrationTests.swift"}},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":7,"character":5,"file":"TeraTests\/TeraStateMigrationTests.swift"}},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 622","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":622,"character":1,"file":"TeraTests\/TeraStateMigrationTests.swift"}},"text":"}"},{"violation":{"reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 253 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":207,"character":9,"file":"TeraTests\/TeraSupportingStoreTests.swift"}},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"reason":"File should contain 400 lines or less: currently contains 487","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":487,"character":1,"file":"TeraTests\/TeraSupportingStoreTests.swift"}},"text":"}"},{"violation":{"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","severity":"error","location":{"line":4,"character":7,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":62,"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":174,"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":247,"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"reason":"Function should have complexity 10 or less; currently complexity is 18","ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","location":{"line":573,"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"reason":"Function should have complexity 10 or less; currently complexity is 11","ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","severity":"error","location":{"line":767,"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":767,"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","severity":"error","location":{"line":1736,"character":10,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":" static func environment("},{"violation":{"reason":"File should contain 1000 lines or less: currently contains 2212","ruleName":"File Length","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","severity":"error","location":{"line":2212,"character":1,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift"}},"text":"}"}]
+\ No newline at end of file