ConfigManager.swift (17001B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2026 Bohdan, Volodymyr Potuzhnyi 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 import Foundation 18 import Combine 19 20 private let kBackendApiVersion = "20:0:8" 21 private let kMerchantUrl = "merchantUrl" 22 private let kAccessToken = "accessToken" 23 private let kSavePassword = "savePassword" 24 private let kInitialOrderScreen = "initialOrderScreen" 25 private let kCachedRuntimeConfig = "cachedRuntimeConfig" 26 27 struct LibtoolVersion { 28 let current: Int 29 let revision: Int 30 let age: Int 31 32 static func parse(_ v: String) -> LibtoolVersion? { 33 let parts = v.split(separator: ":") 34 guard parts.count == 3, 35 let c = Int(parts[0]), let r = Int(parts[1]), let a = Int(parts[2]) else { return nil } 36 return LibtoolVersion(current: c, revision: r, age: a) 37 } 38 39 func isCompatible(with other: LibtoolVersion) -> Bool { 40 current - age <= other.current && current >= other.current - other.age 41 } 42 } 43 44 enum ConfigUpdateResult: Equatable { 45 case success(currency: String) 46 case error(String) 47 } 48 49 enum InitialOrderScreen: String, CaseIterable { 50 case amountEntry = "amountEntry" 51 case inventory = "inventory" 52 53 var destination: PosDestination { 54 switch self { 55 case .amountEntry: return .amountEntry 56 case .inventory: return .order 57 } 58 } 59 } 60 61 @MainActor 62 protocol ConfigurationReceiver: AnyObject { 63 func onConfigurationReceived(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String? 64 func onInventoryUpdated(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String? 65 } 66 67 extension ConfigurationReceiver { 68 func onInventoryUpdated(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String? { 69 return onConfigurationReceived(posConfig: posConfig, currency: currency, currencySpec: currencySpec) 70 } 71 } 72 73 private struct CachedRuntimeConfig: Codable { 74 let posConfig: PosConfig 75 let merchantConfig: MerchantConfig 76 let currency: String 77 let currencySpec: CurrencySpecification? 78 } 79 80 @MainActor 81 class ConfigManager: ObservableObject { 82 @Published var merchantUrl: String 83 @Published var accessToken: String 84 @Published var savePassword: Bool 85 86 @Published var merchantConfig: MerchantConfig? 87 @Published var currency: String? 88 @Published var currencySpec: CurrencySpecification? 89 @Published var backendVersion: String? 90 @Published var configResult: ConfigUpdateResult? 91 @Published var isLoading = false 92 @Published var sessionExpired = false 93 @Published var initialOrderScreen: InitialOrderScreen 94 95 let api: MerchantApi 96 private let defaults = UserDefaults.standard 97 private var configurationReceivers: [ConfigurationReceiver] = [] 98 private var inventoryRefreshTask: Task<Void, Never>? 99 private var cachedRuntimeConfig: CachedRuntimeConfig? 100 101 var isConfigured: Bool { 102 merchantConfig != nil && currency != nil 103 } 104 105 var isValid: Bool { 106 !merchantUrl.isEmpty && !accessToken.isEmpty 107 } 108 109 var hasPassword: Bool { 110 !accessToken.isEmpty 111 } 112 113 init(api: MerchantApi) { 114 self.api = api 115 self.merchantUrl = defaults.string(forKey: kMerchantUrl) ?? "" 116 self.accessToken = defaults.string(forKey: kAccessToken) ?? "" 117 self.savePassword = defaults.object(forKey: kSavePassword) != nil ? defaults.bool(forKey: kSavePassword) : true 118 self.initialOrderScreen = InitialOrderScreen(rawValue: defaults.string(forKey: kInitialOrderScreen) ?? "") ?? .amountEntry 119 restoreCachedRuntimeConfig() 120 } 121 122 func addConfigurationReceiver(_ receiver: ConfigurationReceiver) { 123 configurationReceivers.append(receiver) 124 if let cached = cachedRuntimeConfig { 125 _ = receiver.onConfigurationReceived(posConfig: cached.posConfig, currency: cached.currency, currencySpec: cached.currencySpec) 126 } 127 } 128 129 func setInitialOrderScreen(_ screen: InitialOrderScreen) { 130 initialOrderScreen = screen 131 defaults.set(screen.rawValue, forKey: kInitialOrderScreen) 132 } 133 134 func fetchConfig(config: (url: String, token: String), save: Bool) { 135 fetchConfigInternal(url: config.url, token: config.token, save: save, inventoryOnly: false, silent: false) 136 } 137 138 func fetchConfig(url: String, token: String, save: Bool) { 139 fetchConfigInternal(url: url, token: token, save: save, inventoryOnly: false, silent: false) 140 } 141 142 func reloadConfig() { 143 fetchConfigInternal(url: merchantUrl, token: accessToken, save: true, inventoryOnly: true, silent: false) 144 } 145 146 func refreshConfigInBackground() { 147 guard isValid, hasPassword else { return } 148 fetchConfigInternal(url: merchantUrl, token: accessToken, save: false, inventoryOnly: false, silent: true) 149 } 150 151 func refreshInventory() { 152 inventoryRefreshTask?.cancel() 153 inventoryRefreshTask = Task { 154 try? await Task.sleep(nanoseconds: 350_000_000) 155 guard !Task.isCancelled else { return } 156 await MainActor.run { 157 self.fetchConfigInternal(url: self.merchantUrl, token: self.accessToken, save: false, inventoryOnly: true, silent: true) 158 } 159 } 160 } 161 162 private func fetchConfigInternal(url: String, token: String, save: Bool, inventoryOnly: Bool, silent: Bool) { 163 guard !url.isEmpty, !token.isEmpty else { return } 164 if !silent { 165 isLoading = true 166 configResult = nil 167 } 168 169 Task { 170 do { 171 let fullToken = token.hasPrefix("secret-token:") ? token : "secret-token:\(token)" 172 let posConfig = try await api.fetchPosConfig(merchantUrl: url, accessToken: fullToken) 173 let mc = MerchantConfig(baseUrl: url, apiKey: fullToken) 174 let backendConfig = try await api.fetchBackendConfig(merchantConfig: mc) 175 176 if let expected = LibtoolVersion.parse(kBackendApiVersion), 177 let actual = LibtoolVersion.parse(backendConfig.version), 178 !expected.isCompatible(with: actual) { 179 if !silent { 180 self.configResult = .error("Backend API version \(backendConfig.version) is incompatible (expected \(kBackendApiVersion)).") 181 self.isLoading = false 182 } 183 return 184 } 185 186 let spec = backendConfig.currencies?[backendConfig.currency] 187 188 let posConfigWithCachedStock = applyCachedStockData(posConfig) 189 190 for receiver in configurationReceivers { 191 let error: String? 192 if inventoryOnly { 193 error = receiver.onInventoryUpdated(posConfig: posConfigWithCachedStock, currency: backendConfig.currency, currencySpec: spec) 194 } else { 195 error = receiver.onConfigurationReceived(posConfig: posConfigWithCachedStock, currency: backendConfig.currency, currencySpec: spec) 196 } 197 if let error { 198 if !silent { 199 self.configResult = .error(error) 200 self.isLoading = false 201 } 202 return 203 } 204 } 205 206 if save { 207 self.merchantUrl = url 208 self.accessToken = token 209 self.saveConfig() 210 } 211 self.merchantConfig = mc 212 self.currency = backendConfig.currency 213 self.currencySpec = spec 214 self.backendVersion = backendConfig.version 215 self.saveCachedRuntimeConfig(CachedRuntimeConfig( 216 posConfig: posConfigWithCachedStock, 217 merchantConfig: mc, 218 currency: backendConfig.currency, 219 currencySpec: spec 220 )) 221 if !silent { 222 self.configResult = .success(currency: backendConfig.currency) 223 self.isLoading = false 224 } 225 226 await enrichStockDetails(posConfig: posConfig, merchantConfig: mc, currency: backendConfig.currency, currencySpec: spec) 227 } catch let apiError as ApiError where apiError.isUnauthorized { 228 self.forgetPassword() 229 self.sessionExpired = true 230 if !silent { 231 self.configResult = .error("Authentication error: check your access token.") 232 self.isLoading = false 233 } 234 } catch { 235 if !silent { 236 self.configResult = .error(error.localizedDescription) 237 self.isLoading = false 238 } 239 } 240 } 241 } 242 243 // MARK: - MFA Token Exchange 244 245 /// Attempts to fetch a limited access token. 246 /// Throws ChallengeRequiredError if MFA is needed. 247 func fetchLimitedAccessToken( 248 baseUrl: String, 249 username: String, 250 initialSecret: String, 251 challengeIds: [String] = [] 252 ) async throws -> String { 253 return try await api.fetchLimitedAccessToken( 254 baseUrl: baseUrl, 255 username: username, 256 initialSecret: initialSecret, 257 duration: .forever, 258 challengeIds: challengeIds 259 ) 260 } 261 262 func requestChallenge(baseUrl: String, username: String, challengeId: String) async throws { 263 try await api.requestChallenge(baseUrl: baseUrl, username: username, challengeId: challengeId) 264 } 265 266 func confirmChallenge(baseUrl: String, username: String, challengeId: String, tan: String) async throws { 267 try await api.confirmChallenge(baseUrl: baseUrl, username: username, challengeId: challengeId, tan: tan) 268 } 269 270 func logout() { 271 inventoryRefreshTask?.cancel() 272 merchantUrl = "" 273 accessToken = "" 274 merchantConfig = nil 275 currency = nil 276 currencySpec = nil 277 configResult = nil 278 defaults.removeObject(forKey: kMerchantUrl) 279 defaults.removeObject(forKey: kAccessToken) 280 clearCachedRuntimeConfig() 281 } 282 283 func forgetPassword() { 284 accessToken = "" 285 merchantConfig = nil 286 currency = nil 287 currencySpec = nil 288 defaults.removeObject(forKey: kAccessToken) 289 clearCachedRuntimeConfig() 290 } 291 292 private func saveConfig() { 293 defaults.set(merchantUrl, forKey: kMerchantUrl) 294 if savePassword { 295 defaults.set(accessToken, forKey: kAccessToken) 296 } else { 297 defaults.removeObject(forKey: kAccessToken) 298 } 299 defaults.set(savePassword, forKey: kSavePassword) 300 } 301 302 private func applyCachedStockData(_ posConfig: PosConfig) -> PosConfig { 303 guard let cached = cachedRuntimeConfig else { return posConfig } 304 let cachedByProductId = Dictionary( 305 cached.posConfig.products.compactMap { p -> (String, ConfigProduct)? in 306 guard let pid = p.productId else { return nil } 307 return (pid, p) 308 }, 309 uniquingKeysWith: { _, last in last } 310 ) 311 var changed = false 312 var products = posConfig.products 313 for i in products.indices { 314 guard let pid = products[i].productId, 315 let cachedProduct = cachedByProductId[pid] else { continue } 316 if cachedProduct.totalSold == nil && cachedProduct.totalLost == nil && 317 cachedProduct.unitTotalSold == nil && cachedProduct.unitTotalLost == nil { continue } 318 changed = true 319 products[i] = ConfigProduct( 320 id: products[i].id, 321 productId: products[i].productId, 322 productName: products[i].productName, 323 description: products[i].description, 324 descriptionI18n: products[i].descriptionI18n, 325 price: products[i].price, 326 pricesAreNet: products[i].pricesAreNet, 327 location: products[i].location, 328 image: products[i].image, 329 taxes: products[i].taxes, 330 categories: products[i].categories, 331 quantity: products[i].quantity, 332 totalStock: products[i].totalStock, 333 unitTotalStock: products[i].unitTotalStock, 334 totalSold: cachedProduct.totalSold, 335 unitTotalSold: cachedProduct.unitTotalSold, 336 totalLost: cachedProduct.totalLost, 337 unitTotalLost: cachedProduct.unitTotalLost, 338 availableToSell: products[i].availableToSell, 339 remainingStock: products[i].remainingStock, 340 currencyMismatch: products[i].currencyMismatch 341 ) 342 } 343 return changed ? PosConfig(categories: posConfig.categories, products: products) : posConfig 344 } 345 346 private func enrichStockDetails(posConfig: PosConfig, merchantConfig: MerchantConfig, currency: String, currencySpec: CurrencySpecification?) async { 347 var enrichedProducts = posConfig.products 348 var changed = false 349 for (i, product) in enrichedProducts.enumerated() { 350 guard let pid = product.productId, product.stockLimit != nil else { continue } 351 do { 352 let detail = try await api.fetchProductDetail(merchantConfig: merchantConfig, productId: pid) 353 enrichedProducts[i] = ConfigProduct( 354 id: product.id, 355 productId: product.productId, 356 productName: product.productName, 357 description: product.description, 358 descriptionI18n: product.descriptionI18n, 359 price: product.price, 360 pricesAreNet: product.pricesAreNet, 361 location: product.location, 362 image: product.image, 363 taxes: product.taxes, 364 categories: product.categories, 365 quantity: product.quantity, 366 totalStock: product.totalStock, 367 unitTotalStock: product.unitTotalStock, 368 totalSold: detail.totalSold ?? product.totalSold, 369 unitTotalSold: detail.unitTotalSold ?? product.unitTotalSold, 370 totalLost: detail.totalLost ?? product.totalLost, 371 unitTotalLost: detail.unitTotalLost ?? product.unitTotalLost, 372 availableToSell: product.availableToSell, 373 remainingStock: product.remainingStock, 374 currencyMismatch: product.currencyMismatch 375 ) 376 changed = true 377 } catch { 378 continue 379 } 380 } 381 if changed { 382 let enriched = PosConfig(categories: posConfig.categories, products: enrichedProducts) 383 for receiver in configurationReceivers { 384 _ = receiver.onInventoryUpdated(posConfig: enriched, currency: currency, currencySpec: currencySpec) 385 } 386 self.saveCachedRuntimeConfig(CachedRuntimeConfig( 387 posConfig: enriched, 388 merchantConfig: merchantConfig, 389 currency: currency, 390 currencySpec: currencySpec 391 )) 392 } 393 } 394 395 func restoreCachedRuntimeConfig() { 396 guard isValid, hasPassword else { 397 clearCachedRuntimeConfig() 398 return 399 } 400 guard let data = defaults.data(forKey: kCachedRuntimeConfig) else { return } 401 guard let restored = try? JSONDecoder().decode(CachedRuntimeConfig.self, from: data) else { 402 clearCachedRuntimeConfig() 403 return 404 } 405 cachedRuntimeConfig = restored 406 merchantConfig = restored.merchantConfig 407 currency = restored.currency 408 currencySpec = restored.currencySpec 409 } 410 411 private func saveCachedRuntimeConfig(_ snapshot: CachedRuntimeConfig) { 412 cachedRuntimeConfig = snapshot 413 if let data = try? JSONEncoder().encode(snapshot) { 414 defaults.set(data, forKey: kCachedRuntimeConfig) 415 } 416 } 417 418 private func clearCachedRuntimeConfig() { 419 cachedRuntimeConfig = nil 420 defaults.removeObject(forKey: kCachedRuntimeConfig) 421 } 422 }