TeraAppModel.swift (10300B)
1 import Foundation 2 import RadrootsKit 3 4 @MainActor 5 final class TeraAppModel: ObservableObject { 6 typealias Phase = TeraSessionPhase 7 8 @Published private(set) var phase: Phase = .starting 9 let productStores: TeraProductStores? 10 let diagnosticsStore: TeraDiagnosticsStore 11 12 private let sessionStore: TeraSessionStore? 13 private let bootstrapFailure: TeraRuntimeFailure? 14 private let lifecycleCoordinator: TeraLifecycleCoordinator 15 private var generation = TeraSessionGeneration.initial 16 private var lifecycleRegistered = false 17 private var sessionOperationsInFlight = 0 18 private var resumePending = false 19 private let isShellUITest: Bool 20 21 init( 22 sessionStore: TeraSessionStore? = nil, 23 runtimeClient: TeraRuntimeClient? = nil, 24 lifecycleCoordinator requestedLifecycleCoordinator: TeraLifecycleCoordinator? = nil 25 ) { 26 let productionServices = Bundle.main.bundleIdentifier.flatMap { 27 try? TeraLifecycleCoordinator.productionServices(bundleIdentifier: $0) 28 } 29 let lifecycleCoordinator = 30 requestedLifecycleCoordinator 31 ?? productionServices?.coordinator 32 ?? TeraLifecycleCoordinator.disabled() 33 let backgroundTransfer = 34 requestedLifecycleCoordinator == nil 35 ? productionServices?.backgroundTransfer : nil 36 self.lifecycleCoordinator = lifecycleCoordinator 37 diagnosticsStore = TeraDiagnosticsStore(coordinator: lifecycleCoordinator) 38 #if DEBUG 39 if ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_SHELL"] == "1" { 40 self.sessionStore = nil 41 productStores = nil 42 bootstrapFailure = nil 43 isShellUITest = true 44 phase = .running(.shellUITest) 45 return 46 } 47 #endif 48 isShellUITest = false 49 if let sessionStore { 50 self.sessionStore = sessionStore 51 productStores = runtimeClient.map { TeraProductStores(runtimeClient: $0) } 52 bootstrapFailure = nil 53 } else { 54 do { 55 let runtimeClient = runtimeClient ?? .production() 56 let sessionStore = try TeraSessionStore.production( 57 runtimeClient: runtimeClient 58 ) 59 let mediaCoordinator = try Self.productionMediaCoordinator( 60 transfer: backgroundTransfer 61 ) 62 self.sessionStore = sessionStore 63 productStores = TeraProductStores( 64 runtimeClient: runtimeClient, 65 addMedia: mediaCoordinator 66 ) 67 bootstrapFailure = nil 68 } catch { 69 self.sessionStore = nil 70 productStores = nil 71 bootstrapFailure = .local( 72 operation: "app.bootstrap", 73 code: "ios.app.configuration_invalid", 74 safeMessage: TeraUserMessages.text(for: error, fallback: .configurationInvalid) 75 ) 76 } 77 } 78 } 79 80 func start() async { 81 await ensureLifecycleRegistration() 82 await lifecycleCoordinator.record("ios.lifecycle.start_requested") 83 await run(name: "start", showsStarting: true) { store in await store.start() } 84 } 85 86 func retry() async { 87 await start() 88 } 89 90 func createIdentity() async { 91 await run(name: "identity_create", showsStarting: true) { store in 92 await store.createIdentity() 93 } 94 } 95 96 func importIdentity(_ material: RadrootsIdentitySecretMaterial) async { 97 await run(name: "identity_import", showsStarting: true) { store in 98 await store.importIdentity(material) 99 } 100 } 101 102 func lockIdentity() async { 103 stopPresentationWork() 104 await run(name: "identity_lock") { store in await store.lockIdentity() } 105 } 106 107 func unlockIdentity() async { 108 await run(name: "identity_unlock", showsStarting: true) { store in 109 await store.unlockIdentity() 110 } 111 } 112 113 func recoverIdentity() async { 114 await run(name: "identity_recover", showsStarting: true) { store in 115 await store.recoverIdentity() 116 } 117 } 118 119 func applyConfigurationReconfiguration() async { 120 await run(name: "configuration_reconfigure", showsStarting: true) { store in 121 await store.applyConfigurationReconfiguration() 122 } 123 } 124 125 func applySettingsReconfiguration() async { 126 stopPresentationWork() 127 await run(name: "settings_reconfigure", showsStarting: true) { store in 128 await store.applySettingsReconfiguration() 129 } 130 } 131 132 func stop() async { 133 stopPresentationWork() 134 await run(name: "stop") { store in await store.stop() } 135 } 136 137 func resume() async { 138 await ensureLifecycleRegistration() 139 guard sessionOperationsInFlight == 0 else { 140 resumePending = true 141 await lifecycleCoordinator.record("ios.lifecycle.resume_coalesced") 142 return 143 } 144 resumePending = false 145 if case .running = phase { 146 await productStores?.resume() 147 await lifecycleCoordinator.record("ios.lifecycle.active") 148 } else { 149 await start() 150 } 151 } 152 153 func suspend() async { 154 generation = generation.invalidated() 155 resumePending = false 156 productStores?.suspend() 157 await sessionStore?.suspend() 158 await lifecycleCoordinator.record("ios.lifecycle.background") 159 } 160 161 func updateProtectedDataAvailability(_ available: Bool) async { 162 await sessionStore?.updateProtectedDataAvailability(available) 163 await lifecycleCoordinator.record( 164 available 165 ? "ios.lifecycle.protected_data_available" 166 : "ios.lifecycle.protected_data_unavailable" 167 ) 168 if available { 169 await start() 170 } else { 171 await stop() 172 await start() 173 } 174 } 175 176 private func run( 177 name: String, 178 showsStarting: Bool = false, 179 retainsCompletedEffect: Bool = false, 180 _ operation: @escaping @Sendable (TeraSessionStore) async -> Phase 181 ) async { 182 guard !isShellUITest else { return } 183 stopPresentationWork() 184 sessionOperationsInFlight += 1 185 generation = generation.invalidated() 186 let requestedGeneration = generation 187 if showsStarting { 188 phase = .starting 189 } 190 guard let sessionStore else { 191 phase = .failed( 192 bootstrapFailure 193 ?? .local( 194 operation: "app.bootstrap", 195 code: "ios.app.bootstrap_failed", 196 safeMessage: "Tera could not start." 197 ) 198 ) 199 await lifecycleCoordinator.record( 200 "ios.lifecycle.operation_failed", 201 level: .error, 202 fields: ["operation": name, "code": "bootstrap_failed"] 203 ) 204 await finishSessionOperation() 205 return 206 } 207 let result = await operation(sessionStore) 208 guard generation == requestedGeneration, generation.isActive, 209 retainsCompletedEffect || !Task.isCancelled 210 else { 211 await finishSessionOperation() 212 return 213 } 214 if case let .running(snapshot) = result { 215 productStores?.configure(snapshot: snapshot) 216 } 217 phase = result 218 if case .running = result { 219 productStores?.start() 220 } 221 await lifecycleCoordinator.record( 222 "ios.lifecycle.operation_completed", 223 level: Self.isFailure(result) ? .warning : .info, 224 fields: ["operation": name, "phase": Self.phaseCode(result)] 225 ) 226 await finishSessionOperation() 227 } 228 229 private func finishSessionOperation() async { 230 sessionOperationsInFlight -= 1 231 guard sessionOperationsInFlight == 0, resumePending else { return } 232 resumePending = false 233 await resume() 234 } 235 236 private func stopPresentationWork() { 237 productStores?.stop() 238 } 239 240 private static func isFailure(_ phase: Phase) -> Bool { 241 if case .failed = phase { 242 return true 243 } 244 return false 245 } 246 247 private static func phaseCode(_ phase: Phase) -> String { 248 switch phase { 249 case .starting: "starting" 250 case .identityRequired: "identity_required" 251 case .identityLocked: "identity_locked" 252 case .protectedDataUnavailable: "protected_data_unavailable" 253 case .recoveryRequired: "recovery_required" 254 case .corruptIdentity: "corrupt_identity" 255 case .configurationReconfigurationRequired: "configuration_reconfiguration_required" 256 case .running: "running" 257 case .stopped: "stopped" 258 case .failed: "failed" 259 } 260 } 261 262 private static func productionMediaCoordinator( 263 bundle: Bundle = .main, 264 transfer: (any RadrootsBackgroundTransfer)? 265 ) throws -> TeraAddMediaCoordinator { 266 guard let bundleIdentifier = bundle.bundleIdentifier else { 267 throw TeraConfigurationError.missing("bundle_identifier") 268 } 269 guard let transfer else { 270 throw TeraConfigurationError.missing("background_transfer") 271 } 272 return try TeraAddMediaCoordinator.production( 273 bundleIdentifier: bundleIdentifier, 274 transfer: transfer 275 ) 276 } 277 } 278 279 #if DEBUG 280 extension TeraRuntimeSnapshot { 281 fileprivate static let shellUITest = TeraRuntimeSnapshot( 282 identity: TeraRuntimeIdentity( 283 publicKeyHex: String(repeating: "ab", count: 32), 284 hostSignerConfigured: true 285 ), 286 relay: TeraRelayStatus( 287 profile: "simulator", 288 state: "configured", 289 readAvailability: "unobserved", 290 writeAvailability: "unobserved", 291 relays: [] 292 ), 293 blossomConfiguration: nil, 294 blossomEvidence: nil, 295 crateName: "tera_ffi", 296 crateVersion: "0.1.0-alpha", 297 isClosed: false 298 ) 299 } 300 #endif 301 302 extension TeraAppModel { 303 @discardableResult 304 func shutdown() async -> Bool { 305 await lifecycleCoordinator.record("ios.lifecycle.shutdown_requested", level: .notice) 306 await stop() 307 await TeraBackgroundEventRouter.shared.detachAndCompletePending() 308 if case .stopped = phase { 309 return true 310 } 311 return isShellUITest 312 } 313 314 private func ensureLifecycleRegistration() async { 315 guard !lifecycleRegistered else { return } 316 guard await lifecycleCoordinator.attachBackgroundEvents() else { return } 317 guard !lifecycleRegistered else { return } 318 lifecycleRegistered = true 319 await TeraLifecycleBridge.shared.register { @Sendable [weak self] in 320 await self?.shutdown() ?? true 321 } 322 } 323 } 324 325 extension TeraAppModel { 326 func removeSigningKey(author: String, requests: [TeraKeyRemovalRequest]) async { 327 await run(name: "identity_key_removal", retainsCompletedEffect: true) { store in 328 await store.removeSigningKey(author: author, requests: requests) 329 } 330 } 331 332 func keyRemovalPage(author: String, cursor: String?) async throws -> TeraLegacyDraftPage { 333 guard let sessionStore else { throw TeraIdentityStoreError.unavailable } 334 return try await sessionStore.keyRemovalPage(author: author, cursor: cursor) 335 } 336 }