field_ios

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

commit 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:
MPackage.swift | 3+++
MTera.xcodeproj/project.pbxproj | 28++++++++++++++++++++++++++++
MTera/App/TeraAppModel.swift | 23++++++++++++++++++-----
ATera/State/TeraAddPresentation.swift | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTera/State/TeraAddStore.swift | 509+++++++++++++++++++++++++++++++++++++++----------------------------------------
MTera/State/TeraMediaStore.swift | 88++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
ATera/State/TeraSessionBootstrap.swift | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTera/State/TeraSessionStore.swift | 244+++++++++++++++++++++++++++++++++----------------------------------------------
MTera/State/TeraSettingsStore.swift | 55+++++++++++++++++++++++++++++++++++++++++--------------
ATera/State/TeraStoreObservation.swift | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTera/State/TeraSupportingStores.swift | 116++++++++++++++++++++++++++-----------------------------------------------------
MTera/State/TeraTodayStore.swift | 139++++++++++++++++++++++++++++++++++---------------------------------------------
MTera/Views/TeraSupportingViews.swift | 4++--
MTera/Views/TeraTodayView.swift | 2+-
ATeraTests/TeraAddSchemaFixtures.swift | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraAddStoreTests.swift | 98++-----------------------------------------------------------------------------
MTeraTests/TeraRuntimeResourceFixtures.swift | 4+++-
ATeraTests/TeraScopeBackend.swift | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraScopeFixtures.swift | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraScopedAddTests.swift | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraScopedMediaTests.swift | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraScopedObservationTests.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraScopedStoreTests.swift | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraSessionResourceTests.swift | 6++++++
Mproject.yml | 7+++++++
Mrelease/provenance.json | 2+-
Mtest-fixtures/swiftlint-maintainability-baseline.v1.json | 4++--
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