taler-ios

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

Controller.swift (31127B)


      1 /*
      2  * This file is part of GNU Taler, ©2022-26 Taler Systems S.A.
      3  * See LICENSE.md
      4  */
      5 /**
      6  * Controller
      7  *
      8  * @author Marc Stibane
      9  */
     10 import Foundation
     11 import AVFoundation
     12 import LocalAuthentication
     13 import SwiftUI
     14 import SymLog
     15 import os.log
     16 import CoreHaptics
     17 import Network
     18 import taler_swift
     19 
     20 enum BackendState: Equatable {
     21     case none
     22     case instantiated
     23     case initing
     24     case update
     25     case ready
     26     case error(EquatableError)
     27 
     28     static func == (lhs: BackendState, rhs: BackendState) -> Bool {
     29         switch (lhs, rhs) {
     30             case (.none, .none):
     31                 return true
     32             case (.instantiated, .instantiated):
     33                 return true
     34             case (.initing, .initing):
     35                 return true
     36             case (.update, .update):
     37                 return true
     38             case (.ready, .ready):
     39                 return true
     40             case (.error(let lhsError), .error(let rhsError)):
     41                 return lhsError == rhsError
     42             default:
     43                 return false
     44         }
     45     }
     46 }
     47 
     48 enum UrlCommand: String, Codable {
     49     case unknown
     50     case withdraw
     51     case withdrawExchange
     52     case addExchange
     53     case pay
     54     case payPull
     55     case payPush
     56     case payTemplate
     57     case refund
     58 #if GNU_TALER || TALER_NIGHTLY
     59     case devExperiment
     60     case addContact
     61 #endif
     62 
     63     var isOutgoing: Bool {
     64         switch self {
     65             case .pay, .payPull, .payTemplate:
     66                 true
     67             default:
     68                 false
     69         }
     70     }
     71 
     72     var localizedCommand: String {
     73         switch self {
     74             case .unknown:          String(EMPTYSTRING)
     75             case .withdraw,
     76                  .withdrawExchange: String(localized: "Withdraw",
     77                                              comment: "UrlCommand")
     78             case .addExchange:      String(localized: "Add payment service",
     79                                              comment: "UrlCommand")
     80             case .pay:              String(localized: "Pay merchant",
     81                                              comment: "UrlCommand")
     82             case .payPull:          String(localized: "Pay others",
     83                                              comment: "UrlCommand")
     84             case .payPush:          String(localized: "Receive",
     85                                              comment: "UrlCommand")
     86             case .payTemplate:      String(localized: "Pay ...",
     87                                              comment: "UrlCommand")
     88             case .refund:           String(localized: "Refund",
     89                                              comment: "UrlCommand")
     90 #if GNU_TALER || TALER_NIGHTLY
     91             case .devExperiment:    String("DevExperiment")
     92             case .addContact:       String(localized: "Add contact",
     93                                              comment: "UrlCommand")
     94 #endif
     95         }
     96     }
     97     var transactionType: TransactionType {
     98         switch self {
     99             case .unknown:          .dummy
    100             case .withdraw:         .withdrawal
    101             case .withdrawExchange: .withdrawal
    102             case .addExchange:      .dummy
    103             case .pay:              .payment
    104             case .payPull:          .scanPullDebit
    105             case .payPush:          .scanPushCredit
    106             case .payTemplate:      .payment
    107             case .refund:           .refund
    108 #if GNU_TALER || TALER_NIGHTLY
    109             case .devExperiment:    .dummy
    110             case .addContact:       .dummy
    111 #endif
    112         }
    113     }
    114 }
    115 
    116 struct ScannedURL: Identifiable {
    117     var id: String {
    118         url.absoluteString
    119     }
    120     var url: URL
    121     var command: UrlCommand
    122     var amount: Amount?
    123     var baseURL: String?
    124     var scope: ScopeInfo?
    125     var time: Date
    126 }
    127 
    128 // MARK: -
    129 class Controller: ObservableObject {
    130     public static let shared = Controller()
    131     private let symLog = SymLogC()
    132 
    133     @Published var haveProdBalance: Bool = false
    134     @Published var exchanges: [Exchange] = []
    135     @Published var balances: [Balance] = []
    136     @Published var discounts: [TalerToken] = []
    137     @Published var subscriptions: [TalerToken] = []
    138     @Published var defaultExchanges: [DefaultExchange] = []
    139     @Published var scannedURLs: [ScannedURL] = []
    140     @Published var contacts: [ContactEntry] = []
    141     @Published var mailboxConfiguration: MailboxConfiguration? = nil
    142     @Published var messages: [MailboxMessage] = []
    143 
    144     @Published var backendState: BackendState = .none       // only used for launch animation
    145     @Published var currencyTicker: Int = 0                  // updates whenever a new currency is added
    146     @Published var userAction: Int = 0                      // make Action button jump
    147 
    148     @Published var tcDelay: Int = 0
    149     @Published var fake500: Int = 0
    150     @Published var isConnected: Bool = true
    151     @Published var networkUnavailable: Bool = false
    152     @Published var slowConnection: Bool = false
    153     @Published var stalledConnection: Bool = false
    154     @Published var errorConnection: Bool = false
    155     @Published var oimModeActive: Bool = false
    156     @Published var oimSheetActive: Bool = false
    157     @Published var diagnosticModeEnabled: Bool = false
    158     @Published var talerURI: URL? = nil
    159     @Published var choicesTuple: ChoicesTuple = (nil, nil)
    160 
    161     @AppStorage("useHaptics") var useHaptics: Bool = true   // extension mustn't define this, so it must be here
    162     @AppStorage("playSounds") var playSounds: Bool = false
    163     @AppStorage("talerFontIndex") var talerFontIndex: Int = 0         // extension mustn't define this, so it must be here
    164 #if DEBUG
    165     @AppStorage("developerMode") var developerMode: Bool = true
    166 #else
    167     @AppStorage("developerMode") var developerMode: Bool = false
    168 #endif
    169     @AppStorage("deviceTokenAPNs") var deviceTokenAPNs: String?
    170 
    171     let hapticCapability = CHHapticEngine.capabilitiesForHardware()
    172     let logger = Logger(subsystem: "net.taler.gnu", category: "Controller")
    173     let player = AVQueuePlayer()
    174     let semaphore = AsyncSemaphore(value: 1)
    175     private var currencyInfos: [ScopeInfo : CurrencyInfo]
    176     var messageForSheet: String? = nil
    177     var isLoadingChoices: String? = nil
    178 
    179     var lastNewBalances: UInt = 0
    180     var lastProgressError: RequestProgressError? = nil
    181     var lastProgressPhase: RequestProgressPhase? = nil
    182     var progressOperation: String? = nil
    183     var progressToken: String? = nil
    184 
    185     private let monitor = NWPathMonitor()
    186     private var isMonitoringConnection = false
    187 
    188     private var diagnosticModeObservation: NSKeyValueObservation?
    189 #if OIM
    190     private var lastOIMmode: UIDeviceOrientation = .portrait
    191     func setOIMmode(for newOrientation: UIDeviceOrientation, _ sheetActive: Bool) {
    192         if lastOIMmode == .landscapeRight {
    193             if newOrientation == .faceUp {
    194                 return
    195             }
    196         }
    197         let isLandscapeRight = newOrientation == .landscapeRight
    198         lastOIMmode = newOrientation
    199         oimSheetActive = sheetActive && isLandscapeRight
    200          oimModeActive = sheetActive ? false
    201                                      : isLandscapeRight
    202 //        print("😱 oimSheetActive = \(oimSheetActive)")
    203     }
    204 #endif
    205 
    206     var localizedAppName: String {
    207 #if TALER_WALLET
    208         let appName = "Taler Wallet"
    209 #elseif TALER_NIGHTLY
    210         let appName = "Taler Nightly"
    211 #else
    212         let appName = "GNU Taler"
    213 #endif
    214         return Bundle.main.bundleName ?? appName
    215     }
    216 
    217     func biometryType() -> LABiometryType? {
    218         let context = LAContext()
    219         var error: NSError? = nil
    220         if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
    221             return context.biometryType
    222         }
    223         // else device has no enabled biometrics
    224 #if DEBUG
    225         if let error {
    226             print(error)
    227         }
    228 #endif
    229         return nil
    230     }
    231 
    232     @discardableResult
    233     func saveURL(_ passedURL: URL, urlCommand: UrlCommand) -> Bool {
    234         let savedURL = scannedURLs.first { scannedURL in
    235             scannedURL.url == passedURL
    236         }
    237         if savedURL == nil {        // doesn't exist yet
    238             var save = false
    239             switch urlCommand {
    240                 case .addExchange:      save = true
    241                 case .withdraw:         save = true
    242                 case .withdrawExchange: save = true
    243                 case .pay:              save = true
    244                 case .payPull:          save = true
    245                 case .payPush:          save = true
    246                 case .payTemplate:      save = true
    247 
    248                 default:    break
    249             }
    250             if save {
    251                 let scannedURL = ScannedURL(url: passedURL, command: urlCommand, time: .now)
    252                 if scannedURLs.count > 5 {
    253                     self.logger.trace("removing: \(self.scannedURLs.first?.command.rawValue ?? EMPTYSTRING)")
    254                     scannedURLs.remove(at: 0)
    255                 }
    256                 scannedURLs.append(scannedURL)
    257                 self.logger.trace("saveURL: \(urlCommand.rawValue)")
    258                 return true
    259             }
    260         }
    261         return false
    262     }
    263 
    264     func removeURL(_ passedURL: URL) {
    265         scannedURLs.removeAll { scannedURL in
    266             scannedURL.url == passedURL
    267         }
    268     }
    269     func removeURLs(after: TimeInterval) {
    270         let now = Date.now
    271         scannedURLs.removeAll { scannedURL in
    272             let timeInterval = now.timeIntervalSince(scannedURL.time)
    273             self.logger.trace("timeInterval: \(timeInterval)")
    274             return timeInterval > after
    275         }
    276     }
    277     func updateAmount(_ amount: Amount, forSaved url: URL) {
    278         if let index = scannedURLs.firstIndex(where: { $0.url == url }) {
    279             var savedURL = scannedURLs[index]
    280             savedURL.amount = amount
    281             scannedURLs[index] = savedURL
    282         }
    283     }
    284     func updateBase(_ baseURL: String, forSaved url: URL) {
    285         if let index = scannedURLs.firstIndex(where: { $0.url == url }) {
    286             var savedURL = scannedURLs[index]
    287             savedURL.baseURL = baseURL
    288             scannedURLs[index] = savedURL
    289         }
    290     }
    291 
    292     func startObserving() {
    293         let defaults = UserDefaults.standard
    294         self.diagnosticModeObservation = defaults.observe(\.diagnosticModeEnabled, options: [.new, .old,.prior,.initial]) {  [weak self](_, _) in
    295             self?.diagnosticModeEnabled = UserDefaults.standard.diagnosticModeEnabled
    296         }
    297     }
    298 
    299     // NWPathMonitor cannot be restarted after `cancel()` - so we just keep it running forever
    300 //    func stopCheckingConnection() {
    301 //        self.logger.log("Stop monitoring internet connection")
    302 //        isMonitoringConnection = false
    303 //        monitor.cancel()
    304 //    }
    305 
    306     func checkInternetConnection() {
    307         guard !isMonitoringConnection else { return }      // don't try to start NWPathMonitor a second time
    308         isMonitoringConnection = true
    309         monitor.pathUpdateHandler = { path in
    310             let status = switch path.status {
    311                 case .satisfied: "active"
    312                 case .unsatisfied: "inactive"
    313                 default: "unknown"
    314             }
    315             self.logger.log("Internet connection is \(status)")
    316             DispatchQueue.main.async {
    317                 if path.status == .unsatisfied {
    318                     self.isConnected = false
    319                     Task.detached {
    320                         await WalletModel.shared.hintNetworkAvailabilityT(false)
    321                     }
    322                 } else {
    323                     self.isConnected = true
    324                     Task.detached {
    325                         await WalletModel.shared.hintNetworkAvailabilityT(true)
    326                     }
    327                 }
    328             }
    329         }
    330         self.logger.log("Start monitoring internet connection")
    331         let queue = DispatchQueue(label: "InternetMonitor")
    332         monitor.start(queue: queue)
    333     }
    334 
    335     func printFonts() {
    336         for family in UIFont.familyNames {
    337             print(family)
    338             for names in UIFont.fontNames(forFamilyName: family) {
    339                 print("== \(names)")
    340             }
    341         }
    342     }
    343 
    344     init() {
    345         backendState = .instantiated
    346         currencyTicker = 0
    347         currencyInfos = [:]
    348         exchanges = []
    349         balances = []
    350         discounts = []
    351         subscriptions = []
    352         defaultExchanges = []
    353         contacts = []
    354 //        printFonts()
    355 //        checkInternetConnection()
    356         startObserving()
    357     }
    358 // MARK: -
    359     @MainActor
    360     @discardableResult
    361     func loadBalances(_ stack: CallStack) async -> Int? {
    362         let model = WalletModel.shared
    363         if let (response, debugId) = try? await model.getBalances(stack.push()) {
    364             let reloaded = response.balances
    365             if reloaded != balances {
    366                 for balance in reloaded {
    367                     let scope = balance.scopeInfo
    368                     checkInfo(for: scope)
    369                 }
    370 #if DEBUG
    371                 self.logger.log("\(debugId) ••Got new balances, will redraw")
    372 #endif
    373                 lastNewBalances = debugId
    374                 balances = reloaded         // redraw
    375             } else {
    376 #if DEBUG
    377                 self.logger.log("\(debugId) ••Same balances as \(self.lastNewBalances), no redraw")
    378 #endif
    379             }
    380             haveProdBalance = response.haveProdBalance
    381             return reloaded.count
    382         }
    383         return nil
    384     }
    385 
    386     func balance(for scope: ScopeInfo) -> Balance? {
    387         for balance in balances {
    388             if balance.scopeInfo == scope {
    389                 return balance
    390             }
    391         }
    392         return nil
    393     }
    394     // MARK: -
    395     @MainActor
    396     @discardableResult
    397     func createMailbox() async ->MailboxConfiguration? {
    398         if mailboxConfiguration == nil {
    399             let model = WalletModel.shared
    400             let configuration = try? await model.initializeMailbox()
    401             mailboxConfiguration = configuration
    402         }
    403         return mailboxConfiguration
    404     }
    405     @MainActor
    406     @discardableResult
    407     func loadContacts(_ stack: CallStack) async -> Int? {
    408         // Do we have a mailbox?
    409         let model = WalletModel.shared
    410         if mailboxConfiguration == nil {
    411             let configuration = try? await model.getMailbox()
    412             mailboxConfiguration = configuration
    413         }
    414         // if yes, then return how many contacts changed
    415         if mailboxConfiguration != nil {
    416             if let response = try? await model.getContacts() {
    417                 let count = contacts.count
    418                 contacts = response//.sorted(by: { })
    419                 return response.count - count
    420             }
    421         }
    422         return nil
    423     }
    424     @MainActor
    425     func refreshMailbox(_ stack: CallStack) async {
    426         if let mailboxConfiguration {            // Do we have a mailbox?
    427             let model = WalletModel.shared
    428             if let response = try? await model.refreshMailbox(mailboxConfiguration) {
    429                 let countNew = response.messages.count
    430                 if let response2 = try? await model.getMailboxMessages() {
    431                     messages = response2.messages
    432                 } else {
    433                     messages = response.messages
    434                 }
    435 #if DEBUG   // TODO: tell user?
    436                 if let expiration = response.mailboxExpiration {
    437                     if expiration != mailboxConfiguration.expiration {
    438                         let expirationDate = TalerDater.dateString(expiration)
    439                         self.logger.log("••Mailbox expiration changed: \(expirationDate.0)")
    440                     }
    441                 }
    442 #endif
    443             }
    444         }
    445     }
    446 // MARK: -
    447     @MainActor
    448     @discardableResult
    449     func loadDiscounts(_ stack: CallStack) async -> Int? {
    450         let model = WalletModel.shared
    451         if let response = try? await model.listDiscounts(stack.push()) {
    452             let reloaded = response.discounts
    453             if reloaded != discounts {
    454                 self.logger.log("••Got new discounts, will redraw")
    455                 discounts = reloaded         // redraw
    456             } else {
    457                 self.logger.log("••Same discounts, no redraw")
    458             }
    459             return reloaded.count
    460         }
    461         return nil
    462     }
    463 // MARK: -
    464     @MainActor
    465     @discardableResult
    466     func loadSubscriptions(_ stack: CallStack) async -> Int? {
    467         let model = WalletModel.shared
    468         if let response = try? await model.listSubscriptions(stack.push()) {
    469             let reloaded = response.subscriptions
    470             if reloaded != subscriptions {
    471                 self.logger.log("••Got new passes, will redraw")
    472                 subscriptions = reloaded         // redraw
    473             } else {
    474                 self.logger.log("••Same passes, no redraw")
    475             }
    476             return reloaded.count
    477         }
    478         return nil
    479     }
    480     // MARK: -
    481     @MainActor
    482     @discardableResult
    483     func loadChoicesForPayment(_ stack: CallStack,
    484                                   txId: String) async -> Bool {
    485         let model = WalletModel.shared
    486         if isLoadingChoices != txId {
    487             isLoadingChoices = txId
    488             if let choiceResponse = try? await model.getChoicesForPayment(txId) {
    489                 choicesTuple = (txId, choiceResponse)
    490                 isLoadingChoices = nil
    491                 return true
    492             } else {
    493                 isLoadingChoices = nil
    494                 self.logger.log("getChoicesForPayment failed: \(txId)")
    495             }
    496         } else {
    497             self.logger.log("getChoicesForPayment already in progress: \(txId)")
    498         }
    499         return false
    500     }
    501 // MARK: -
    502     func info(for scope: ScopeInfo) -> CurrencyInfo? {
    503 //        return CurrencyInfo.euro()              // Fake EUR instead of the real Currency
    504 //        return CurrencyInfo.francs()            // Fake CHF instead of the real Currency
    505         return currencyInfos[scope]
    506     }
    507     func info(for scope: ScopeInfo, _ ticker: Int) -> CurrencyInfo {
    508         if ticker != currencyTicker {
    509             print("  ❗️Yikes - race condition while getting info for \(scope.currency)")
    510         }
    511         return info(for: scope) ?? CurrencyInfo.zero(scope.currency)
    512     }
    513 
    514     func info2(for currency: String) -> CurrencyInfo? {
    515 //        return CurrencyInfo.euro()              // Fake EUR instead of the real Currency
    516 //        return CurrencyInfo.francs()            // Fake CHF instead of the real Currency
    517         for (scope, info) in currencyInfos {
    518             if scope.currency == currency {
    519                 return info
    520             }
    521         }
    522 //        logger.log("  ❗️ no info for \(currency)")
    523         return nil
    524     }
    525     func info2(for currency: String, _ ticker: Int) -> CurrencyInfo {
    526         if ticker != currencyTicker {
    527             print("  ❗️Yikes - race condition while getting info for \(currency)")
    528         }
    529         return info2(for: currency) ?? CurrencyInfo.zero(currency)
    530     }
    531 
    532     func hasInfo(for currency: String) -> Bool {
    533         for (scope, info) in currencyInfos {
    534             if scope.currency == currency {
    535                 return true
    536             }
    537         }
    538 //        logger.log("  ❗️ no info for \(currency)")
    539         return false
    540     }
    541 
    542     @MainActor
    543     func exchange(for baseUrl: String?) async -> Exchange? {
    544         if let baseUrl {
    545             if let exchange = exchanges.first(where: {$0.exchangeBaseUrl == baseUrl}) {
    546                 return exchange
    547             }
    548             let model = WalletModel.shared
    549             if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl) {
    550 //                logger.log("  ❗️ will add \(baseUrl)")
    551                 exchanges.append(exchange2)
    552                 return exchange2
    553             }
    554         }
    555         return nil
    556     }
    557     @MainActor
    558     func exchange(for scope: ScopeInfo) -> Exchange? {
    559         if let baseUrl = scope.url {
    560             if let exchange = exchanges.first(where: {$0.exchangeBaseUrl == baseUrl}) {
    561                 return exchange
    562             }
    563         }
    564         return nil
    565     }
    566     @MainActor
    567     func updateExchange(for baseUrl: String, state: ExchangeState) {
    568         Task {
    569             if let exchange = await exchange(for: baseUrl) {
    570                 // The notification only carries tosStatus - a detected, confirmed or reverted
    571                 // master-key change ("update-from-response", "key-change-confirmed") leaves it
    572                 // unchanged, so always re-fetch and compare what the views depend on.
    573                 let model = WalletModel.shared
    574                 if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl, viewHandles: true) {
    575                     if exchange.tosStatus != exchange2.tosStatus ||
    576                        exchange.masterPub != exchange2.masterPub ||
    577                        exchange.unconfirmedKeyChange != exchange2.unconfirmedKeyChange {
    578                         logger.log("  ❗️updating exchange: \(baseUrl)")
    579                         if let index = exchanges.firstIndex(where: {$0.exchangeBaseUrl == baseUrl}) {
    580                             exchanges[index] = exchange2
    581                         } else {
    582                             exchanges.append(exchange2)
    583                         }
    584                     }
    585                 }
    586             }
    587         }
    588     }
    589 
    590     @MainActor
    591     func updateInfo2(_ scope: ScopeInfo) async {
    592         let model = WalletModel.shared
    593         if let info = try? await model.getCurrencyInfo(scope: scope) {
    594             await setInfo(info, for: scope)
    595 //            logger.log("  ❗️info set for \(scope.currency)")
    596         }
    597     }
    598 
    599     func checkCurrencyInfo(for baseUrl: String) async -> Exchange? {
    600         if let exchange = await exchange(for: baseUrl) {
    601             let scope = exchange.scopeInfo
    602             if currencyInfos[scope] == nil {
    603                 logger.log("  ❗️got no info for \(baseUrl.trimURL) \(scope.currency) -> will update")
    604                 await updateInfo2(scope)
    605             }
    606             return exchange
    607         } else {
    608             // Yikes❗️  TODO: error?
    609         }
    610         return nil
    611     }
    612 
    613     /// called whenever a new currency pops up - will first load the Exchange and then currencyInfos
    614     func checkInfo(for scope: ScopeInfo) {
    615         if currencyInfos[scope] == nil {
    616             Task {
    617                 let exchange = await exchange(for: scope.url)
    618                 if let scope2 = exchange?.scopeInfo {
    619                     let exchangeName = scope2.url ?? "UNKNOWN"
    620                     logger.log("  ❗️got no info for \(scope.currency) -> will update \(exchangeName.trimURL)")
    621                     await updateInfo2(scope)
    622                 } else {
    623                     logger.error("  ❗️got no info for \(scope.currency), and couldn't load the exchange info❗️")
    624                 }
    625             }
    626         }
    627     }
    628 
    629     @MainActor
    630     func getInfo(from baseUrl: String, model: WalletModel) async throws -> CurrencyInfo? {
    631         let exchange = try await model.getExchangeByUrl(url: baseUrl)
    632         let scope = exchange.scopeInfo
    633         if let info = info(for: scope) {
    634             return info
    635         }
    636         let info = try await model.getCurrencyInfo(scope: scope)
    637         await setInfo(info, for: scope)
    638         return info
    639     }
    640 
    641     @MainActor
    642     func setInfo(_ newInfo: CurrencyInfo, for scope: ScopeInfo) async {
    643         await semaphore.wait()
    644         defer { semaphore.signal() }
    645 
    646         currencyInfos[scope] = newInfo
    647         currencyTicker += 1         // triggers published view update
    648     }
    649 #if GNU_TALER || TALER_NIGHTLY
    650     @MainActor
    651     func setDelayAsync(_ delay: Int, _ f500: Int) async {
    652         let delayTime = delay > 0 ? 3000
    653                       : delay < 0 ? 6000
    654                                   : 0
    655         let fakeValue = delay != 0 ? ( f500 > 0 ? 2
    656                                      : f500 < 0 ? 4
    657                                                 : 0 ) : 0
    658         symLog.log(delay != 0 ? "wallet-core delaying by \(delayTime) milliseconds, with \(fakeValue) fake_500"
    659                               : "wallet-core stop delaying")
    660         tcDelay = delay
    661         fake500 = f500
    662         let model = WalletModel.shared
    663         try? await model.setConfig(setTesting: true)
    664         try? await model.devExperimentT("taler://dev-experiment/start-tc?fake_500_count=\(fakeValue)&delay_resp=\(delayTime)")
    665     }
    666     @MainActor
    667     func setDelay(_ delay: Int, _ f500: Int) {
    668         Task {
    669             await setDelayAsync(delay, f500)
    670         }
    671     }
    672 #endif 
    673 // MARK: -
    674     @MainActor
    675     func initWalletCore(stack: CallStack, setTesting: Bool, delay: TimeInterval)
    676       async throws {
    677         if backendState == .instantiated {
    678             backendState = .initing
    679             do {
    680                 let walletCore = WalletCore.shared
    681                 let model = WalletModel.shared
    682                 let response = try await model.initWalletCore(setTesting: setTesting)
    683 //  throw WalletBackendError.initializationError      // check how the app behaves if initWalletCore throws
    684                 walletCore.versionInfo = response.versionInfo
    685                 walletCore.nativeDB = (response.databaseBackend == "sqlite")
    686                 if developerMode {
    687                     do {
    688 //                        try await model.setConfig(setTesting: true)
    689 //                        try await model.devExperimentT("taler://dev-experiment/default-exchange-demo?val=1")
    690 //                        try await model.devExperimentT(
    691 //                            "taler://dev-experiment/demo-shortcuts?val=KUDOS:4,KUDOS:8,KUDOS:16,KUDOS:32")
    692                     } catch {
    693                         self.logger.error("developer-mode setup failed, continuing without it: \(error.localizedDescription)")
    694                     }
    695                 }
    696                 defaultExchanges = await model.getDefaultExchanges()
    697 #if GNU_TALER
    698                 if defaultExchanges.count == 1 {
    699                     if let talerOps = defaultExchanges.first {
    700                         let stageURI = "taler://withdraw-exchange/exchange.stage.taler-ops.ch/"
    701                         let baseUrl = "https://exchange.stage.taler-ops.ch/"
    702                         if talerOps.talerUri != stageURI {
    703                             let stageExc = DefaultExchange(talerUri: stageURI,
    704                                                     exchangeBaseUrl: baseUrl,
    705                                                            currency: talerOps.currency,
    706                                                        currencySpec: talerOps.currencySpec,
    707                                                 exchangeEntryStatus: .preset,
    708                                                exchangeUpdateStatus: .initial
    709                             )
    710                             defaultExchanges.append(stageExc)
    711                         }
    712                     }
    713                 }
    714 #endif
    715                 let launchDelay: TimeInterval = walletCore.nativeDB ? 0.1 : delay
    716                 DispatchQueue.main.asyncAfter(deadline: .now() + launchDelay) {
    717                     // dismiss the launch animation after delay
    718                     self.symLog.log("backendState = ready")
    719                     self.backendState = walletCore.nativeDB ? .ready : .update
    720                 }
    721 //                await loadBalances(stack.push())
    722 //                await loadContacts(stack.push())
    723             } catch {
    724                 self.logger.error("\(error.localizedDescription)")
    725                 backendState = .error(error.toEquatableError())                 // ❗️Yikes app cannot continue
    726             }
    727         } else {
    728             self.logger.fault("Yikes❗️ wallet-core already initialized")
    729         }
    730     }
    731 }
    732 // MARK: -
    733 extension Controller {
    734     func urlCommand(_ url: URL, stack: CallStack) -> (UrlCommand, URL) {
    735         guard let scheme = url.scheme else {return (UrlCommand.unknown, url) }
    736 #if DEBUG
    737         symLog.log(url)
    738 #else
    739         let host = url.host ?? "  <- no command"
    740         self.logger.trace("urlCommand(\(scheme)\(host)")
    741 #endif
    742         var urlCommand = UrlCommand.unknown
    743         var talerURI: URL = url
    744         switch scheme.lowercased() {
    745             case "https":
    746                 (urlCommand, talerURI) = httpsScheme(url)
    747 //            case "payto":
    748 //                messageForSheet = url.absoluteString
    749 //                return paytoScheme(url)
    750             case "taler":
    751                 urlCommand = talerScheme(url)
    752             default:
    753                 self.logger.error("unknown scheme: <\(scheme)>")       // should never happen
    754         }
    755         saveURL(talerURI, urlCommand: urlCommand)
    756         return (urlCommand, talerURI)
    757     }
    758 }
    759 // MARK: -
    760 extension Controller {
    761     func httpsScheme(_ url: URL) -> (UrlCommand, URL) {
    762         // https://wallet.taler.net/link/#taler://${action}/${rest}
    763         // https://wallet.taler.net/link/#/${action}/${rest}
    764         // https://wallet.taler.net/link/#${action}/${rest}
    765         // are all mapped to taler://${action}/${rest}
    766         if let host = url.host, host == "wallet.taler.net" {
    767             if url.path == "/link" {
    768                 if let fragment = url.fragment {
    769                     // 'fragment(percentEncoded:)' is only available in iOS 16.0 or newer
    770                     var split = fragment.split(separator: "/")
    771                     if split.first == "taler:" {
    772                         split.removeFirst()
    773                     }
    774                     if let action = split.first {
    775                         let joined = split.joined(separator: "/")
    776                         if let talerURI = URL(string: "taler://\(joined)") {
    777                             return (command(String(action)), talerURI)
    778                         }
    779                     }
    780                 }
    781             }
    782         }
    783         self.logger.error("❗️unknown url:\(url.absoluteString)")
    784         return (.unknown, url)
    785     }
    786 
    787 //    func paytoScheme(_ url:URL) -> UrlCommand {
    788 //        let logItem = "scheme payto:// is not yet implemented"
    789 //        // TODO: write logItem to somewhere in Debug section of SettingsView
    790 //        symLog.log(logItem)        // TODO: symLog.error(logItem)
    791 //        return UrlCommand.unknown
    792 //    }
    793     
    794     func command(_ command:String) -> UrlCommand {
    795         let lcCommand = command.lowercased()
    796         switch lcCommand {
    797             case "withdraw":            return .withdraw
    798             case "withdraw-exchange":   return .withdrawExchange
    799             case "add-exchange":        return .addExchange
    800             case "pay":                 return .pay
    801             case "pay-pull":            return .payPull
    802             case "pay-push":            return .payPush
    803             case "pay-template":        return .payTemplate
    804             case "refund":              return .refund
    805 #if GNU_TALER || TALER_NIGHTLY
    806             case "dev-experiment":      return .devExperiment
    807             case "add-contact":         return .addContact
    808 #endif
    809             default:
    810                 self.logger.error("❗️unknown command taler://\(command)")
    811         }
    812         messageForSheet = lcCommand
    813         return .unknown
    814     }
    815 
    816     func talerScheme(_ url:URL) -> UrlCommand {
    817         if let command = url.host {
    818             return self.command(command)
    819         } else {
    820             self.logger.error("❗️No taler command")
    821         }
    822         return .unknown
    823     }
    824 }