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 }