taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }