field_ios

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

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 }