field_ios

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

TeraSettingsStore.swift (11631B)


      1 import Foundation
      2 
      3 @MainActor
      4 final class TeraSettingsStore: ObservableObject {
      5     @Published private(set) var settings: TeraMobileSettings?
      6     @Published private(set) var profileStatus: TeraProfileStatus?
      7     @Published private(set) var isWorking = false
      8     @Published private(set) var message: String?
      9     @Published private(set) var failureCode: String?
     10 
     11     @Published var networkEnvironment: TeraSettingsNetworkEnvironment = .publicNetwork
     12     @Published var relays: [TeraRelayPreference] = []
     13     @Published var blossomAuthority: TeraBlossomAuthorityPreference = .publicWebPKI
     14     @Published var blossomPrimaryOrigin = ""
     15     @Published var blossomFallbackOrigins = ""
     16     @Published var allowCellularDownloads = true
     17     @Published var allowCellularUploads = true
     18     @Published var allowBackgroundTransfers = true
     19     @Published var mediaCacheMegabytes = 256
     20     @Published var mediaCacheArtifacts = 1024
     21     @Published var profileName = ""
     22     @Published var profileDisplayName = ""
     23     @Published var profileAbout = ""
     24     @Published var profileNip05 = ""
     25     @Published var profileBot = false
     26 
     27     private let runtimeClient: TeraRuntimeClient
     28     private var generation = TeraSessionGeneration.initial
     29     /// Presentation invalidation cannot release an active mutation's admission.
     30     private var mutationInProgress = false
     31     private var configuration: TeraPresentationConfiguration?
     32 
     33     init(runtimeClient: TeraRuntimeClient) {
     34         self.runtimeClient = runtimeClient
     35     }
     36 
     37     func load(profile: TeraProfileSummary?) async {
     38         generation = generation.invalidated()
     39         let requestedGeneration = generation
     40         isWorking = true
     41         defer {
     42             if requestedGeneration == generation {
     43                 isWorking = false
     44             }
     45         }
     46         do {
     47             let loaded = try await runtimeClient.mobileSettings()
     48             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
     49             apply(loaded)
     50             if let profile {
     51                 profileName = profile.name ?? ""
     52                 profileDisplayName = profile.displayName ?? ""
     53                 profileAbout = profile.about ?? ""
     54                 profileNip05 = profile.nip05 ?? ""
     55             }
     56             message = nil
     57             failureCode = nil
     58         } catch {
     59             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
     60             record(error)
     61         }
     62     }
     63 
     64     func addRelay() {
     65         relays.append(TeraRelayPreference(url: "", access: .readWrite))
     66     }
     67 
     68     func removeRelays(at offsets: IndexSet) {
     69         relays.remove(atOffsets: offsets)
     70     }
     71 
     72     func saveSettings() async -> Bool {
     73         guard let settings, reserveMutation() else { return false }
     74         defer { mutationInProgress = false }
     75         generation = generation.invalidated()
     76         let requestedGeneration = generation
     77         isWorking = true
     78         defer {
     79             if requestedGeneration == generation {
     80                 isWorking = false
     81             }
     82         }
     83         let fallbacks = blossomFallbackOrigins
     84             .components(separatedBy: CharacterSet(charactersIn: ",;\n\r"))
     85             .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
     86             .filter { !$0.isEmpty }
     87         do {
     88             let (bytes, artifacts) = try TeraSettingsCapacityInput.convert(megabytes: mediaCacheMegabytes, artifacts: mediaCacheArtifacts)
     89             let transition = try await runtimeClient.replaceMobileSettings(
     90                 input: TeraReplaceSettings(
     91                   expectedRevision: settings.revision,
     92                   networkEnvironment: networkEnvironment,
     93                   relays: relays,
     94                   blossomAuthority: blossomAuthority,
     95                   blossomPrimaryOrigin: blossomPrimaryOrigin,
     96                   blossomFallbackOrigins: fallbacks,
     97                   allowCellularDownloads: allowCellularDownloads,
     98                   allowCellularUploads: allowCellularUploads,
     99                   allowBackgroundTransfers: allowBackgroundTransfers,
    100                   mediaCacheBytes: bytes,
    101                   mediaCacheArtifacts: artifacts
    102                 )
    103             )
    104             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return false }
    105             apply(transition.settings)
    106             let effects = [
    107               transition.runtimeRestartRequired ? "runtime restart" : nil,
    108               transition.outboxRequeueRequired ? "outbox requeue" : nil,
    109               transition.mediaCacheInvalidationRequired ? "media cache refresh" : nil,
    110             ].compactMap(\.self)
    111             message = effects.isEmpty
    112                 ? "Settings saved."
    113                 : "Settings saved; required changes: \(effects.joined(separator: ", "))."
    114             failureCode = nil
    115             return transition.runtimeRestartRequired
    116         } catch {
    117             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return false }
    118             record(error)
    119             return false
    120         }
    121     }
    122 
    123     func saveProfile() async {
    124         guard reserveMutation() else { return }
    125         defer { mutationInProgress = false }
    126         generation = generation.invalidated()
    127         let requestedGeneration = generation
    128         isWorking = true
    129         defer {
    130             if requestedGeneration == generation {
    131                 isWorking = false
    132             }
    133         }
    134         do {
    135             let status = try await runtimeClient.saveProfileMetadata(
    136                 input: TeraProfileMetadataInput(
    137                   name: profileName,
    138                   displayName: optional(profileDisplayName),
    139                   about: optional(profileAbout),
    140                   picture: nil,
    141                   banner: nil,
    142                   nip05: optional(profileNip05),
    143                   bot: profileBot
    144                 )
    145             )
    146             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
    147             profileStatus = status
    148             message = "Profile update saved to the durable outbox."
    149             failureCode = nil
    150         } catch {
    151             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
    152             record(error)
    153         }
    154     }
    155 
    156     func advanceProfile() async {
    157         guard let profileStatus else { return }
    158         await runProfileOperation {
    159             try await self.runtimeClient.advanceProfile(operationID: profileStatus.id)
    160         }
    161     }
    162 
    163     func cancelProfile() async {
    164         guard let profileStatus else { return }
    165         await runProfileOperation {
    166             try await self.runtimeClient.cancelProfile(
    167               operationID: profileStatus.id,
    168               expectedRevision: profileStatus.revision
    169             )
    170         }
    171     }
    172 
    173     func stop() {
    174         generation = generation.invalidated()
    175         isWorking = false
    176     }
    177 
    178     func configure(snapshot: TeraRuntimeSnapshot) {
    179         let updated = TeraPresentationConfiguration(snapshot: snapshot)
    180         guard configuration != updated else { return }
    181         configuration = updated
    182         stop()
    183         settings = nil
    184         profileStatus = nil
    185         message = nil
    186         failureCode = nil
    187         networkEnvironment = .publicNetwork
    188         relays = []
    189         blossomAuthority = .publicWebPKI
    190         blossomPrimaryOrigin = ""
    191         blossomFallbackOrigins = ""
    192         allowCellularDownloads = true
    193         allowCellularUploads = true
    194         allowBackgroundTransfers = true
    195         mediaCacheMegabytes = 256
    196         mediaCacheArtifacts = 1024
    197         profileName = ""
    198         profileDisplayName = ""
    199         profileAbout = ""
    200         profileNip05 = ""
    201         profileBot = false
    202     }
    203 
    204     private func runProfileOperation(
    205         _ operation: @escaping () async throws -> TeraProfileStatus
    206     ) async {
    207         guard reserveMutation() else { return }
    208         defer { mutationInProgress = false }
    209         generation = generation.invalidated()
    210         let requestedGeneration = generation
    211         isWorking = true
    212         defer {
    213             if requestedGeneration == generation {
    214                 isWorking = false
    215             }
    216         }
    217         do {
    218             let status = try await operation()
    219             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
    220             profileStatus = status
    221             message = status.honestSummary
    222             failureCode = nil
    223         } catch {
    224             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
    225             record(error)
    226         }
    227     }
    228 
    229     private func reserveMutation() -> Bool {
    230         guard !mutationInProgress, !Task.isCancelled else { return false }
    231         mutationInProgress = true
    232         return true
    233     }
    234 
    235     private func apply(_ loaded: TeraMobileSettings) {
    236         settings = loaded
    237         networkEnvironment = loaded.networkEnvironment
    238         relays = loaded.relays
    239         blossomAuthority = loaded.blossomAuthority
    240         blossomPrimaryOrigin = loaded.blossomPrimaryOrigin
    241         blossomFallbackOrigins = loaded.blossomFallbackOrigins.joined(separator: "\n")
    242         allowCellularDownloads = loaded.allowCellularDownloads
    243         allowCellularUploads = loaded.allowCellularUploads
    244         allowBackgroundTransfers = loaded.allowBackgroundTransfers
    245         mediaCacheMegabytes = Int(loaded.mediaCacheBytes / 1_048_576)
    246         mediaCacheArtifacts = Int(loaded.mediaCacheArtifacts)
    247     }
    248 
    249     private func record(_ error: Error) {
    250         if case let TeraRuntimeClientError.support(failure) = error {
    251             failureCode = failure.code
    252         } else {
    253             failureCode = "ios.settings.operation_failed"
    254         }
    255         message = TeraUserMessages.text(for: error, fallback: .settingsOperationFailed)
    256     }
    257 
    258     private func optional(_ value: String) -> String? {
    259         let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
    260         return normalized.isEmpty ? nil : normalized
    261     }
    262 }
    263 
    264 private enum TeraSettingsCapacityInput: Error {
    265     case invalid
    266     static func convert(megabytes: Int, artifacts: Int) throws -> (UInt64, UInt32) {
    267         guard let megabytes = UInt64(exactly: megabytes), let artifacts = UInt32(exactly: artifacts) else { throw invalid }
    268         let (bytes, overflow) = megabytes.multipliedReportingOverflow(by: 1_048_576)
    269         guard !overflow else { throw Self.invalid }
    270         return (bytes, artifacts)
    271     }
    272 }
    273 
    274 extension TeraSettingsStore {
    275     func cleanupMediaCache(context: TeraLocalNetwork) async {
    276         guard reserveMutation() else { return }
    277         defer { mutationInProgress = false }
    278         generation = generation.invalidated()
    279         let requestedGeneration = generation
    280         isWorking = true
    281         defer {
    282           if requestedGeneration == generation {
    283             isWorking = false
    284           }
    285         }
    286         do {
    287             let result = try await runtimeClient.cleanupMediaCache(context: context)
    288             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
    289             message = "Cleared \(result.invalidatedEntries) cache entries; \(result.remainingEntries) remain in this context. \(result.retainedCandidates) shared or unverified file candidates were retained. Cached photos can reload; pending work is preserved."
    290             failureCode = nil
    291         } catch {
    292             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
    293             record(error)
    294         }
    295     }
    296 }