taler-ios

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

Controller.swift (25918B)


      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 #endif
     61 
     62     var isOutgoing: Bool {
     63         switch self {
     64             case .pay, .payPull, .payTemplate:
     65                 true
     66             default:
     67                 false
     68         }
     69     }
     70 
     71     var localizedCommand: String {
     72         switch self {
     73             case .unknown:          String(EMPTYSTRING)
     74             case .withdraw,
     75                  .withdrawExchange: String(localized: "Withdraw",
     76                                              comment: "UrlCommand")
     77             case .addExchange:      String(localized: "Add payment service",
     78                                              comment: "UrlCommand")
     79             case .pay:              String(localized: "Pay merchant",
     80                                              comment: "UrlCommand")
     81             case .payPull:          String(localized: "Pay others",
     82                                              comment: "UrlCommand")
     83             case .payPush:          String(localized: "Receive",
     84                                              comment: "UrlCommand")
     85             case .payTemplate:      String(localized: "Pay ...",
     86                                              comment: "UrlCommand")
     87             case .refund:           String(localized: "Refund",
     88                                              comment: "UrlCommand")
     89 #if GNU_TALER || TALER_NIGHTLY
     90             case .devExperiment:    String("DevExperiment")
     91 #endif
     92         }
     93     }
     94     var transactionType: TransactionType {
     95         switch self {
     96             case .unknown:          .dummy
     97             case .withdraw:         .withdrawal
     98             case .withdrawExchange: .withdrawal
     99             case .addExchange:      .dummy
    100             case .pay:              .payment
    101             case .payPull:          .scanPullDebit
    102             case .payPush:          .scanPushCredit
    103             case .payTemplate:      .payment
    104             case .refund:           .refund
    105 #if GNU_TALER || TALER_NIGHTLY
    106             case .devExperiment:    .dummy
    107 #endif
    108         }
    109     }
    110 }
    111 
    112 struct ScannedURL: Identifiable {
    113     var id: String {
    114         url.absoluteString
    115     }
    116     var url: URL
    117     var command: UrlCommand
    118     var amount: Amount?
    119     var baseURL: String?
    120     var scope: ScopeInfo?
    121     var time: Date
    122 }
    123 
    124 // MARK: -
    125 class Controller: ObservableObject {
    126     public static let shared = Controller()
    127     private let symLog = SymLogC()
    128 
    129     @Published var haveProdBalance: Bool = false
    130     @Published var exchanges: [Exchange] = []
    131     @Published var balances: [Balance] = []
    132     @Published var discounts: [TalerToken] = []
    133     @Published var subscriptions: [TalerToken] = []
    134     @Published var defaultExchanges: [DefaultExchange] = []
    135     @Published var scannedURLs: [ScannedURL] = []
    136 
    137     @Published var backendState: BackendState = .none       // only used for launch animation
    138     @Published var currencyTicker: Int = 0                  // updates whenever a new currency is added
    139     @Published var userAction: Int = 0                      // make Action button jump
    140 
    141     @Published var isConnected: Bool = true
    142     @Published var networkUnavailable: Bool = false
    143     @Published var slowConnection: Bool = false
    144     @Published var stalledConnection: Bool = false
    145     @Published var errorConnection: Bool = false
    146     @Published var oimModeActive: Bool = false
    147     @Published var oimSheetActive: Bool = false
    148     @Published var diagnosticModeEnabled: Bool = false
    149     @Published var talerURI: URL? = nil
    150     @Published var choicesTuple: ChoicesTuple = (nil, nil)
    151 
    152     @AppStorage("useHaptics") var useHaptics: Bool = true   // extension mustn't define this, so it must be here
    153     @AppStorage("playSounds") var playSounds: Bool = false
    154     @AppStorage("talerFontIndex") var talerFontIndex: Int = 0         // extension mustn't define this, so it must be here
    155 #if DEBUG
    156     @AppStorage("developerMode") var developerMode: Bool = true
    157 #else
    158     @AppStorage("developerMode") var developerMode: Bool = false
    159 #endif
    160     @AppStorage("deviceTokenAPNs") var deviceTokenAPNs: String?
    161     @AppStorage("developDelay") var developDelay: Bool = false
    162     let hapticCapability = CHHapticEngine.capabilitiesForHardware()
    163     let logger = Logger(subsystem: "net.taler.gnu", category: "Controller")
    164     let player = AVQueuePlayer()
    165     let semaphore = AsyncSemaphore(value: 1)
    166     private var currencyInfos: [ScopeInfo : CurrencyInfo]
    167     var messageForSheet: String? = nil
    168     var isLoadingChoices: String? = nil
    169 
    170     var lastProgressError: RequestProgressError? = nil
    171     var lastProgressPhase: RequestProgressPhase? = nil
    172     var progressOperation: String? = nil
    173     var progressToken: String? = nil
    174 
    175     private let monitor = NWPathMonitor()
    176     private var isMonitoringConnection = false
    177 
    178     private var diagnosticModeObservation: NSKeyValueObservation?
    179 #if OIM
    180     private var lastOIMmode: UIDeviceOrientation = .portrait
    181     func setOIMmode(for newOrientation: UIDeviceOrientation, _ sheetActive: Bool) {
    182         if lastOIMmode == .landscapeRight {
    183             if newOrientation == .faceUp {
    184                 return
    185             }
    186         }
    187         let isLandscapeRight = newOrientation == .landscapeRight
    188         lastOIMmode = newOrientation
    189         oimSheetActive = sheetActive && isLandscapeRight
    190          oimModeActive = sheetActive ? false
    191                                      : isLandscapeRight
    192 //        print("😱 oimSheetActive = \(oimSheetActive)")
    193     }
    194 #endif
    195 
    196     var localizedAppName: String {
    197 #if TALER_WALLET
    198         let appName = "Taler Wallet"
    199 #elseif TALER_NIGHTLY
    200         let appName = "Taler Nightly"
    201 #else
    202         let appName = "GNU Taler"
    203 #endif
    204         return Bundle.main.bundleName ?? appName
    205     }
    206 
    207     func biometryType() -> LABiometryType? {
    208         let context = LAContext()
    209         var error: NSError? = nil
    210         if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
    211             return context.biometryType
    212         }
    213         // else device has no enabled biometrics
    214 #if DEBUG
    215         if let error {
    216             print(error)
    217         }
    218 #endif
    219         return nil
    220     }
    221 
    222     @discardableResult
    223     func saveURL(_ passedURL: URL, urlCommand: UrlCommand) -> Bool {
    224         let savedURL = scannedURLs.first { scannedURL in
    225             scannedURL.url == passedURL
    226         }
    227         if savedURL == nil {        // doesn't exist yet
    228             var save = false
    229             switch urlCommand {
    230                 case .addExchange:      save = true
    231                 case .withdraw:         save = true
    232                 case .withdrawExchange: save = true
    233                 case .pay:              save = true
    234                 case .payPull:          save = true
    235                 case .payPush:          save = true
    236                 case .payTemplate:      save = true
    237 
    238                 default:    break
    239             }
    240             if save {
    241                 let scannedURL = ScannedURL(url: passedURL, command: urlCommand, time: .now)
    242                 if scannedURLs.count > 5 {
    243                     self.logger.trace("removing: \(self.scannedURLs.first?.command.rawValue ?? EMPTYSTRING)")
    244                     scannedURLs.remove(at: 0)
    245                 }
    246                 scannedURLs.append(scannedURL)
    247                 self.logger.trace("saveURL: \(urlCommand.rawValue)")
    248                 return true
    249             }
    250         }
    251         return false
    252     }
    253 
    254     func removeURL(_ passedURL: URL) {
    255         scannedURLs.removeAll { scannedURL in
    256             scannedURL.url == passedURL
    257         }
    258     }
    259     func removeURLs(after: TimeInterval) {
    260         let now = Date.now
    261         scannedURLs.removeAll { scannedURL in
    262             let timeInterval = now.timeIntervalSince(scannedURL.time)
    263             self.logger.trace("timeInterval: \(timeInterval)")
    264             return timeInterval > after
    265         }
    266     }
    267     func updateAmount(_ amount: Amount, forSaved url: URL) {
    268         if let index = scannedURLs.firstIndex(where: { $0.url == url }) {
    269             var savedURL = scannedURLs[index]
    270             savedURL.amount = amount
    271             scannedURLs[index] = savedURL
    272         }
    273     }
    274     func updateBase(_ baseURL: String, forSaved url: URL) {
    275         if let index = scannedURLs.firstIndex(where: { $0.url == url }) {
    276             var savedURL = scannedURLs[index]
    277             savedURL.baseURL = baseURL
    278             scannedURLs[index] = savedURL
    279         }
    280     }
    281 
    282     func startObserving() {
    283         let defaults = UserDefaults.standard
    284         self.diagnosticModeObservation = defaults.observe(\.diagnosticModeEnabled, options: [.new, .old,.prior,.initial]) {  [weak self](_, _) in
    285             self?.diagnosticModeEnabled = UserDefaults.standard.diagnosticModeEnabled
    286         }
    287     }
    288 
    289     // NWPathMonitor cannot be restarted after `cancel()` - so we just keep it running forever
    290 //    func stopCheckingConnection() {
    291 //        self.logger.log("Stop monitoring internet connection")
    292 //        isMonitoringConnection = false
    293 //        monitor.cancel()
    294 //    }
    295 
    296     func checkInternetConnection() {
    297         guard !isMonitoringConnection else { return }      // don't try to start NWPathMonitor a second time
    298         isMonitoringConnection = true
    299         monitor.pathUpdateHandler = { path in
    300             let status = switch path.status {
    301                 case .satisfied: "active"
    302                 case .unsatisfied: "inactive"
    303                 default: "unknown"
    304             }
    305             self.logger.log("Internet connection is \(status)")
    306             DispatchQueue.main.async {
    307                 if path.status == .unsatisfied {
    308                     self.isConnected = false
    309                     Task.detached {
    310                         await WalletModel.shared.hintNetworkAvailabilityT(false)
    311                     }
    312                 } else {
    313                     self.isConnected = true
    314                     Task.detached {
    315                         await WalletModel.shared.hintNetworkAvailabilityT(true)
    316                     }
    317                 }
    318             }
    319         }
    320         self.logger.log("Start monitoring internet connection")
    321         let queue = DispatchQueue(label: "InternetMonitor")
    322         monitor.start(queue: queue)
    323     }
    324 
    325     func printFonts() {
    326         for family in UIFont.familyNames {
    327             print(family)
    328             for names in UIFont.fontNames(forFamilyName: family) {
    329                 print("== \(names)")
    330             }
    331         }
    332     }
    333 
    334     init() {
    335         backendState = .instantiated
    336         currencyTicker = 0
    337         currencyInfos = [:]
    338         exchanges = []
    339         balances = []
    340         discounts = []
    341         subscriptions = []
    342         defaultExchanges = []
    343 //        printFonts()
    344 //        checkInternetConnection()
    345         startObserving()
    346     }
    347 // MARK: -
    348     @MainActor
    349     @discardableResult
    350     func loadBalances(_ stack: CallStack) async -> Int? {
    351         let model = WalletModel.shared
    352         if let response = try? await model.getBalances(stack.push()) {
    353             let reloaded = response.balances
    354             if reloaded != balances {
    355                 for balance in reloaded {
    356                     let scope = balance.scopeInfo
    357                     checkInfo(for: scope)
    358                 }
    359                 self.logger.log("••Got new balances, will redraw")
    360                 balances = reloaded         // redraw
    361             } else {
    362                 self.logger.log("••Same balances, no redraw")
    363             }
    364             haveProdBalance = response.haveProdBalance
    365             return reloaded.count
    366         }
    367         return nil
    368     }
    369 
    370     func balance(for scope: ScopeInfo) -> Balance? {
    371         for balance in balances {
    372             if balance.scopeInfo == scope {
    373                 return balance
    374             }
    375         }
    376         return nil
    377     }
    378 // MARK: -
    379     @MainActor
    380     @discardableResult
    381     func loadDiscounts(_ stack: CallStack) async -> Int? {
    382         let model = WalletModel.shared
    383         if let response = try? await model.listDiscounts(stack.push()) {
    384             let reloaded = response.discounts
    385             if reloaded != discounts {
    386                 self.logger.log("••Got new discounts, will redraw")
    387                 discounts = reloaded         // redraw
    388             } else {
    389                 self.logger.log("••Same discounts, no redraw")
    390             }
    391             return reloaded.count
    392         }
    393         return nil
    394     }
    395 // MARK: -
    396     @MainActor
    397     @discardableResult
    398     func loadSubscriptions(_ stack: CallStack) async -> Int? {
    399         let model = WalletModel.shared
    400         if let response = try? await model.listSubscriptions(stack.push()) {
    401             let reloaded = response.subscriptions
    402             if reloaded != subscriptions {
    403                 self.logger.log("••Got new passes, will redraw")
    404                 subscriptions = reloaded         // redraw
    405             } else {
    406                 self.logger.log("••Same passes, no redraw")
    407             }
    408             return reloaded.count
    409         }
    410         return nil
    411     }
    412     // MARK: -
    413     @MainActor
    414     @discardableResult
    415     func loadChoicesForPayment(_ stack: CallStack,
    416                                   txId: String) async -> Bool {
    417         self.logger.log("getChoicesForPayment: \(txId)")
    418         let model = WalletModel.shared
    419         if isLoadingChoices != txId {
    420             isLoadingChoices = txId
    421             if let choiceResponse = try? await model.getChoicesForPayment(txId) {
    422                 choicesTuple = (txId, choiceResponse)
    423                 isLoadingChoices = nil
    424                 return true
    425             } else {
    426                 isLoadingChoices = nil
    427                 self.logger.log("getChoicesForPayment failed: \(txId)")
    428             }
    429         } else {
    430             self.logger.log("getChoicesForPayment already in progress: \(txId)")
    431         }
    432         return false
    433     }
    434 // MARK: -
    435     func info(for scope: ScopeInfo) -> CurrencyInfo? {
    436 //        return CurrencyInfo.euro()              // Fake EUR instead of the real Currency
    437 //        return CurrencyInfo.francs()            // Fake CHF instead of the real Currency
    438         return currencyInfos[scope]
    439     }
    440     func info(for scope: ScopeInfo, _ ticker: Int) -> CurrencyInfo {
    441         if ticker != currencyTicker {
    442             print("  ❗️Yikes - race condition while getting info for \(scope.currency)")
    443         }
    444         return info(for: scope) ?? CurrencyInfo.zero(scope.currency)
    445     }
    446 
    447     func info2(for currency: String) -> CurrencyInfo? {
    448 //        return CurrencyInfo.euro()              // Fake EUR instead of the real Currency
    449 //        return CurrencyInfo.francs()            // Fake CHF instead of the real Currency
    450         for (scope, info) in currencyInfos {
    451             if scope.currency == currency {
    452                 return info
    453             }
    454         }
    455 //        logger.log("  ❗️ no info for \(currency)")
    456         return nil
    457     }
    458     func info2(for currency: String, _ ticker: Int) -> CurrencyInfo {
    459         if ticker != currencyTicker {
    460             print("  ❗️Yikes - race condition while getting info for \(currency)")
    461         }
    462         return info2(for: currency) ?? CurrencyInfo.zero(currency)
    463     }
    464 
    465     func hasInfo(for currency: String) -> Bool {
    466         for (scope, info) in currencyInfos {
    467             if scope.currency == currency {
    468                 return true
    469             }
    470         }
    471 //        logger.log("  ❗️ no info for \(currency)")
    472         return false
    473     }
    474 
    475     @MainActor
    476     func exchange(for baseUrl: String?) async -> Exchange? {
    477         if let baseUrl {
    478             if let exchange = exchanges.first(where: {$0.exchangeBaseUrl == baseUrl}) {
    479                 return exchange
    480             }
    481             let model = WalletModel.shared
    482             if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl) {
    483 //                logger.log("  ❗️ will add \(baseUrl)")
    484                 exchanges.append(exchange2)
    485                 return exchange2
    486             }
    487         }
    488         return nil
    489     }
    490     @MainActor
    491     func exchange(for scope: ScopeInfo) -> Exchange? {
    492         if let baseUrl = scope.url {
    493             if let exchange = exchanges.first(where: {$0.exchangeBaseUrl == baseUrl}) {
    494                 return exchange
    495             }
    496         }
    497         return nil
    498     }
    499     @MainActor
    500     func updateExchange(for baseUrl: String, state: ExchangeState) {
    501         Task {
    502             if let exchange = await exchange(for: baseUrl) {
    503                 if exchange.tosStatus != state.tosStatus {
    504                     logger.log("  ❗️updating exchange: \(baseUrl)")
    505                     let model = WalletModel.shared
    506                     if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl) {
    507                         if let index = exchanges.firstIndex(where: {$0.exchangeBaseUrl == baseUrl}) {
    508                             exchanges[index] = exchange2
    509                         } else {
    510                             exchanges.append(exchange2)
    511                         }
    512                     }
    513                 }
    514             }
    515         }
    516     }
    517 
    518     @MainActor
    519     func updateInfo2(_ scope: ScopeInfo) async {
    520         let model = WalletModel.shared
    521         if let info = try? await model.getCurrencyInfo(scope: scope) {
    522             await setInfo(info, for: scope)
    523 //            logger.log("  ❗️info set for \(scope.currency)")
    524         }
    525     }
    526 
    527     func checkCurrencyInfo(for baseUrl: String) async -> Exchange? {
    528         if let exchange = await exchange(for: baseUrl) {
    529             let scope = exchange.scopeInfo
    530             if currencyInfos[scope] == nil {
    531                 logger.log("  ❗️got no info for \(baseUrl.trimURL) \(scope.currency) -> will update")
    532                 await updateInfo2(scope)
    533             }
    534             return exchange
    535         } else {
    536             // Yikes❗️  TODO: error?
    537         }
    538         return nil
    539     }
    540 
    541     /// called whenever a new currency pops up - will first load the Exchange and then currencyInfos
    542     func checkInfo(for scope: ScopeInfo) {
    543         if currencyInfos[scope] == nil {
    544             Task {
    545                 let exchange = await exchange(for: scope.url)
    546                 if let scope2 = exchange?.scopeInfo {
    547                     let exchangeName = scope2.url ?? "UNKNOWN"
    548                     logger.log("  ❗️got no info for \(scope.currency) -> will update \(exchangeName.trimURL)")
    549                     await updateInfo2(scope)
    550                 } else {
    551                     logger.error("  ❗️got no info for \(scope.currency), and couldn't load the exchange info❗️")
    552                 }
    553             }
    554         }
    555     }
    556 
    557     @MainActor
    558     func getInfo(from baseUrl: String, model: WalletModel) async throws -> CurrencyInfo? {
    559         let exchange = try await model.getExchangeByUrl(url: baseUrl)
    560         let scope = exchange.scopeInfo
    561         if let info = info(for: scope) {
    562             return info
    563         }
    564         let info = try await model.getCurrencyInfo(scope: scope)
    565         await setInfo(info, for: scope)
    566         return info
    567     }
    568 
    569     @MainActor
    570     func setInfo(_ newInfo: CurrencyInfo, for scope: ScopeInfo) async {
    571         await semaphore.wait()
    572         defer { semaphore.signal() }
    573 
    574         currencyInfos[scope] = newInfo
    575         currencyTicker += 1         // triggers published view update
    576     }
    577 // MARK: -
    578     @MainActor
    579     func initWalletCore(setTesting: Bool, delay: TimeInterval)
    580       async throws {
    581         if backendState == .instantiated {
    582             backendState = .initing
    583             do {
    584                 let walletCore = WalletCore.shared
    585                 let model = WalletModel.shared
    586                 let response = try await model.initWalletCore(setTesting: setTesting)
    587                 walletCore.versionInfo = response.versionInfo
    588                 walletCore.nativeDB = (response.databaseBackend == "sqlite")
    589                 if developerMode {
    590                     // best-effort dev-mode setup: a network hiccup here must not abort
    591                     // wallet-core startup (see the force `try!` at the call site)
    592                     do {
    593                         try await model.setConfig(setTesting: true)
    594                         if developDelay == true {
    595                             try await model.devExperimentT(
    596                                 "taler://dev-experiment/start-tc?delay_resp=\(TCDELAY)")
    597                         }
    598 //                        try await model.devExperimentT("taler://dev-experiment/start-tc?fake_500=0.7")
    599 //                        try await model.devExperimentT("taler://dev-experiment/default-exchange-demo?val=1")
    600                         try await model.devExperimentT(
    601                             "taler://dev-experiment/demo-shortcuts?val=KUDOS:4,KUDOS:8,KUDOS:16,KUDOS:32")
    602                     } catch {
    603                         self.logger.error("developer-mode setup failed, continuing without it: \(error.localizedDescription)")
    604                     }
    605                 }
    606                 defaultExchanges = await model.getDefaultExchanges()
    607 #if GNU_TALER
    608                 if defaultExchanges.count == 1 {
    609                     if let talerOps = defaultExchanges.first {
    610                         let stageURI = "taler://withdraw-exchange/exchange.stage.taler-ops.ch/"
    611                         let baseUrl = "https://exchange.stage.taler-ops.ch/"
    612                         if talerOps.talerUri != stageURI {
    613                             let stageExc = DefaultExchange(talerUri: stageURI,
    614                                                     exchangeBaseUrl: baseUrl,
    615                                                            currency: talerOps.currency,
    616                                                        currencySpec: talerOps.currencySpec,
    617                                                 exchangeEntryStatus: .preset,
    618                                                exchangeUpdateStatus: .initial
    619                             )
    620                             defaultExchanges.append(stageExc)
    621                         }
    622                     }
    623                 }
    624 #endif
    625                 let launchDelay: TimeInterval = walletCore.nativeDB ? 0.1 : delay
    626                 DispatchQueue.main.asyncAfter(deadline: .now() + launchDelay) {
    627                     // dismiss the launch animation
    628                     self.backendState = walletCore.nativeDB ? .ready : .update
    629                 }
    630                 await loadBalances(CallStack())
    631             } catch {       // rethrows
    632                 self.logger.error("\(error.localizedDescription)")
    633                 backendState = .error(error.toEquatableError())                 // ❗️Yikes app cannot continue
    634                 throw error
    635             }
    636         } else {
    637             self.logger.fault("Yikes❗️ wallet-core already initialized")
    638         }
    639     }
    640 }
    641 
    642 // MARK: -
    643 extension Controller {
    644     func urlCommand(_ url: URL, stack: CallStack) -> UrlCommand {
    645         guard let scheme = url.scheme else {return UrlCommand.unknown}
    646 #if DEBUG
    647         symLog.log(url)
    648 #else
    649         let host = url.host ?? "  <- no command"
    650         self.logger.trace("urlCommand(\(scheme)\(host)")
    651 #endif
    652         var urlCommand = UrlCommand.unknown
    653         switch scheme.lowercased() {
    654             case "taler":
    655                 urlCommand = talerScheme(url)
    656 //            case "payto":
    657 //                messageForSheet = url.absoluteString
    658 //                return paytoScheme(url)
    659             default:
    660                 self.logger.error("unknown scheme: <\(scheme)>")       // should never happen
    661         }
    662         saveURL(url, urlCommand: urlCommand)
    663         return urlCommand
    664     }
    665 }
    666 // MARK: -
    667 extension Controller {
    668 //    func paytoScheme(_ url:URL) -> UrlCommand {
    669 //        let logItem = "scheme payto:// is not yet implemented"
    670 //        // TODO: write logItem to somewhere in Debug section of SettingsView
    671 //        symLog.log(logItem)        // TODO: symLog.error(logItem)
    672 //        return UrlCommand.unknown
    673 //    }
    674     
    675     func talerScheme(_ url:URL) -> UrlCommand {
    676       if let command = url.host {
    677         switch command.lowercased() {
    678             case "withdraw":            return .withdraw
    679             case "withdraw-exchange":   return .withdrawExchange
    680             case "add-exchange":        return .addExchange
    681             case "pay":                 return .pay
    682             case "pay-pull":            return .payPull
    683             case "pay-push":            return .payPush
    684             case "pay-template":        return .payTemplate
    685             case "refund":              return .refund
    686 #if GNU_TALER || TALER_NIGHTLY
    687             case "dev-experiment":      return .devExperiment
    688 #endif
    689             default:
    690                 self.logger.error("❗️unknown command taler://\(command)")
    691         }
    692         messageForSheet = command.lowercased()
    693       } else {
    694           self.logger.error("❗️No taler command")
    695       }
    696       return .unknown
    697     }
    698 }