taler-ios

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

Controller.swift (27673B)


      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 lastNewBalances: UInt = 0
    171     var lastProgressError: RequestProgressError? = nil
    172     var lastProgressPhase: RequestProgressPhase? = nil
    173     var progressOperation: String? = nil
    174     var progressToken: String? = nil
    175 
    176     private let monitor = NWPathMonitor()
    177     private var isMonitoringConnection = false
    178 
    179     private var diagnosticModeObservation: NSKeyValueObservation?
    180 #if OIM
    181     private var lastOIMmode: UIDeviceOrientation = .portrait
    182     func setOIMmode(for newOrientation: UIDeviceOrientation, _ sheetActive: Bool) {
    183         if lastOIMmode == .landscapeRight {
    184             if newOrientation == .faceUp {
    185                 return
    186             }
    187         }
    188         let isLandscapeRight = newOrientation == .landscapeRight
    189         lastOIMmode = newOrientation
    190         oimSheetActive = sheetActive && isLandscapeRight
    191          oimModeActive = sheetActive ? false
    192                                      : isLandscapeRight
    193 //        print("😱 oimSheetActive = \(oimSheetActive)")
    194     }
    195 #endif
    196 
    197     var isIpad : Bool { UIDevice.current.userInterfaceIdiom == .pad }
    198     var isIphone : Bool { UIDevice.current.userInterfaceIdiom == .phone }
    199 
    200     var localizedAppName: String {
    201 #if TALER_WALLET
    202         let appName = "Taler Wallet"
    203 #elseif TALER_NIGHTLY
    204         let appName = "Taler Nightly"
    205 #else
    206         let appName = "GNU Taler"
    207 #endif
    208         return Bundle.main.bundleName ?? appName
    209     }
    210 
    211     func biometryType() -> LABiometryType? {
    212         let context = LAContext()
    213         var error: NSError? = nil
    214         if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
    215             return context.biometryType
    216         }
    217         // else device has no enabled biometrics
    218 #if DEBUG
    219         if let error {
    220             print(error)
    221         }
    222 #endif
    223         return nil
    224     }
    225 
    226     @discardableResult
    227     func saveURL(_ passedURL: URL, urlCommand: UrlCommand) -> Bool {
    228         let savedURL = scannedURLs.first { scannedURL in
    229             scannedURL.url == passedURL
    230         }
    231         if savedURL == nil {        // doesn't exist yet
    232             var save = false
    233             switch urlCommand {
    234                 case .addExchange:      save = true
    235                 case .withdraw:         save = true
    236                 case .withdrawExchange: save = true
    237                 case .pay:              save = true
    238                 case .payPull:          save = true
    239                 case .payPush:          save = true
    240                 case .payTemplate:      save = true
    241 
    242                 default:    break
    243             }
    244             if save {
    245                 let scannedURL = ScannedURL(url: passedURL, command: urlCommand, time: .now)
    246                 if scannedURLs.count > 5 {
    247                     self.logger.trace("removing: \(self.scannedURLs.first?.command.rawValue ?? EMPTYSTRING)")
    248                     scannedURLs.remove(at: 0)
    249                 }
    250                 scannedURLs.append(scannedURL)
    251                 self.logger.trace("saveURL: \(urlCommand.rawValue)")
    252                 return true
    253             }
    254         }
    255         return false
    256     }
    257 
    258     func removeURL(_ passedURL: URL) {
    259         scannedURLs.removeAll { scannedURL in
    260             scannedURL.url == passedURL
    261         }
    262     }
    263     func removeURLs(after: TimeInterval) {
    264         let now = Date.now
    265         scannedURLs.removeAll { scannedURL in
    266             let timeInterval = now.timeIntervalSince(scannedURL.time)
    267             self.logger.trace("timeInterval: \(timeInterval)")
    268             return timeInterval > after
    269         }
    270     }
    271     func updateAmount(_ amount: Amount, forSaved url: URL) {
    272         if let index = scannedURLs.firstIndex(where: { $0.url == url }) {
    273             var savedURL = scannedURLs[index]
    274             savedURL.amount = amount
    275             scannedURLs[index] = savedURL
    276         }
    277     }
    278     func updateBase(_ baseURL: String, forSaved url: URL) {
    279         if let index = scannedURLs.firstIndex(where: { $0.url == url }) {
    280             var savedURL = scannedURLs[index]
    281             savedURL.baseURL = baseURL
    282             scannedURLs[index] = savedURL
    283         }
    284     }
    285 
    286     func startObserving() {
    287         let defaults = UserDefaults.standard
    288         self.diagnosticModeObservation = defaults.observe(\.diagnosticModeEnabled, options: [.new, .old,.prior,.initial]) {  [weak self](_, _) in
    289             self?.diagnosticModeEnabled = UserDefaults.standard.diagnosticModeEnabled
    290         }
    291     }
    292 
    293     // NWPathMonitor cannot be restarted after `cancel()` - so we just keep it running forever
    294 //    func stopCheckingConnection() {
    295 //        self.logger.log("Stop monitoring internet connection")
    296 //        isMonitoringConnection = false
    297 //        monitor.cancel()
    298 //    }
    299 
    300     func checkInternetConnection() {
    301         guard !isMonitoringConnection else { return }      // don't try to start NWPathMonitor a second time
    302         isMonitoringConnection = true
    303         monitor.pathUpdateHandler = { path in
    304             let status = switch path.status {
    305                 case .satisfied: "active"
    306                 case .unsatisfied: "inactive"
    307                 default: "unknown"
    308             }
    309             self.logger.log("Internet connection is \(status)")
    310             DispatchQueue.main.async {
    311                 if path.status == .unsatisfied {
    312                     self.isConnected = false
    313                     Task.detached {
    314                         await WalletModel.shared.hintNetworkAvailabilityT(false)
    315                     }
    316                 } else {
    317                     self.isConnected = true
    318                     Task.detached {
    319                         await WalletModel.shared.hintNetworkAvailabilityT(true)
    320                     }
    321                 }
    322             }
    323         }
    324         self.logger.log("Start monitoring internet connection")
    325         let queue = DispatchQueue(label: "InternetMonitor")
    326         monitor.start(queue: queue)
    327     }
    328 
    329     func printFonts() {
    330         for family in UIFont.familyNames {
    331             print(family)
    332             for names in UIFont.fontNames(forFamilyName: family) {
    333                 print("== \(names)")
    334             }
    335         }
    336     }
    337 
    338     init() {
    339         backendState = .instantiated
    340         currencyTicker = 0
    341         currencyInfos = [:]
    342         exchanges = []
    343         balances = []
    344         discounts = []
    345         subscriptions = []
    346         defaultExchanges = []
    347 //        printFonts()
    348 //        checkInternetConnection()
    349         startObserving()
    350     }
    351 // MARK: -
    352     @MainActor
    353     @discardableResult
    354     func loadBalances(_ stack: CallStack) async -> Int? {
    355         let model = WalletModel.shared
    356         if let (response, id) = try? await model.getBalances(stack.push()) {
    357             let reloaded = response.balances
    358             if reloaded != balances {
    359                 for balance in reloaded {
    360                     let scope = balance.scopeInfo
    361                     checkInfo(for: scope)
    362                 }
    363 #if DEBUG
    364                 self.logger.log("\(id) ••Got new balances, will redraw")
    365 #endif
    366                 lastNewBalances = id
    367                 balances = reloaded         // redraw
    368             } else {
    369 #if DEBUG
    370                 self.logger.log("\(id) ••Same balances as \(self.lastNewBalances), no redraw")
    371 #endif
    372             }
    373             haveProdBalance = response.haveProdBalance
    374             return reloaded.count
    375         }
    376         return nil
    377     }
    378 
    379     func balance(for scope: ScopeInfo) -> Balance? {
    380         for balance in balances {
    381             if balance.scopeInfo == scope {
    382                 return balance
    383             }
    384         }
    385         return nil
    386     }
    387 // MARK: -
    388     @MainActor
    389     @discardableResult
    390     func loadDiscounts(_ stack: CallStack) async -> Int? {
    391         let model = WalletModel.shared
    392         if let response = try? await model.listDiscounts(stack.push()) {
    393             let reloaded = response.discounts
    394             if reloaded != discounts {
    395                 self.logger.log("••Got new discounts, will redraw")
    396                 discounts = reloaded         // redraw
    397             } else {
    398                 self.logger.log("••Same discounts, no redraw")
    399             }
    400             return reloaded.count
    401         }
    402         return nil
    403     }
    404 // MARK: -
    405     @MainActor
    406     @discardableResult
    407     func loadSubscriptions(_ stack: CallStack) async -> Int? {
    408         let model = WalletModel.shared
    409         if let response = try? await model.listSubscriptions(stack.push()) {
    410             let reloaded = response.subscriptions
    411             if reloaded != subscriptions {
    412                 self.logger.log("••Got new passes, will redraw")
    413                 subscriptions = reloaded         // redraw
    414             } else {
    415                 self.logger.log("••Same passes, no redraw")
    416             }
    417             return reloaded.count
    418         }
    419         return nil
    420     }
    421     // MARK: -
    422     @MainActor
    423     @discardableResult
    424     func loadChoicesForPayment(_ stack: CallStack,
    425                                   txId: String) async -> Bool {
    426         let model = WalletModel.shared
    427         if isLoadingChoices != txId {
    428             isLoadingChoices = txId
    429             if let choiceResponse = try? await model.getChoicesForPayment(txId) {
    430                 choicesTuple = (txId, choiceResponse)
    431                 isLoadingChoices = nil
    432                 return true
    433             } else {
    434                 isLoadingChoices = nil
    435                 self.logger.log("getChoicesForPayment failed: \(txId)")
    436             }
    437         } else {
    438             self.logger.log("getChoicesForPayment already in progress: \(txId)")
    439         }
    440         return false
    441     }
    442 // MARK: -
    443     func info(for scope: ScopeInfo) -> CurrencyInfo? {
    444 //        return CurrencyInfo.euro()              // Fake EUR instead of the real Currency
    445 //        return CurrencyInfo.francs()            // Fake CHF instead of the real Currency
    446         return currencyInfos[scope]
    447     }
    448     func info(for scope: ScopeInfo, _ ticker: Int) -> CurrencyInfo {
    449         if ticker != currencyTicker {
    450             print("  ❗️Yikes - race condition while getting info for \(scope.currency)")
    451         }
    452         return info(for: scope) ?? CurrencyInfo.zero(scope.currency)
    453     }
    454 
    455     func info2(for currency: String) -> CurrencyInfo? {
    456 //        return CurrencyInfo.euro()              // Fake EUR instead of the real Currency
    457 //        return CurrencyInfo.francs()            // Fake CHF instead of the real Currency
    458         for (scope, info) in currencyInfos {
    459             if scope.currency == currency {
    460                 return info
    461             }
    462         }
    463 //        logger.log("  ❗️ no info for \(currency)")
    464         return nil
    465     }
    466     func info2(for currency: String, _ ticker: Int) -> CurrencyInfo {
    467         if ticker != currencyTicker {
    468             print("  ❗️Yikes - race condition while getting info for \(currency)")
    469         }
    470         return info2(for: currency) ?? CurrencyInfo.zero(currency)
    471     }
    472 
    473     func hasInfo(for currency: String) -> Bool {
    474         for (scope, info) in currencyInfos {
    475             if scope.currency == currency {
    476                 return true
    477             }
    478         }
    479 //        logger.log("  ❗️ no info for \(currency)")
    480         return false
    481     }
    482 
    483     @MainActor
    484     func exchange(for baseUrl: String?) async -> Exchange? {
    485         if let baseUrl {
    486             if let exchange = exchanges.first(where: {$0.exchangeBaseUrl == baseUrl}) {
    487                 return exchange
    488             }
    489             let model = WalletModel.shared
    490             if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl) {
    491 //                logger.log("  ❗️ will add \(baseUrl)")
    492                 exchanges.append(exchange2)
    493                 return exchange2
    494             }
    495         }
    496         return nil
    497     }
    498     @MainActor
    499     func exchange(for scope: ScopeInfo) -> Exchange? {
    500         if let baseUrl = scope.url {
    501             if let exchange = exchanges.first(where: {$0.exchangeBaseUrl == baseUrl}) {
    502                 return exchange
    503             }
    504         }
    505         return nil
    506     }
    507     @MainActor
    508     func updateExchange(for baseUrl: String, state: ExchangeState) {
    509         Task {
    510             if let exchange = await exchange(for: baseUrl) {
    511                 if exchange.tosStatus != state.tosStatus {
    512                     logger.log("  ❗️updating exchange: \(baseUrl)")
    513                     let model = WalletModel.shared
    514                     if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl) {
    515                         if let index = exchanges.firstIndex(where: {$0.exchangeBaseUrl == baseUrl}) {
    516                             exchanges[index] = exchange2
    517                         } else {
    518                             exchanges.append(exchange2)
    519                         }
    520                     }
    521                 }
    522             }
    523         }
    524     }
    525 
    526     @MainActor
    527     func updateInfo2(_ scope: ScopeInfo) async {
    528         let model = WalletModel.shared
    529         if let info = try? await model.getCurrencyInfo(scope: scope) {
    530             await setInfo(info, for: scope)
    531 //            logger.log("  ❗️info set for \(scope.currency)")
    532         }
    533     }
    534 
    535     func checkCurrencyInfo(for baseUrl: String) async -> Exchange? {
    536         if let exchange = await exchange(for: baseUrl) {
    537             let scope = exchange.scopeInfo
    538             if currencyInfos[scope] == nil {
    539                 logger.log("  ❗️got no info for \(baseUrl.trimURL) \(scope.currency) -> will update")
    540                 await updateInfo2(scope)
    541             }
    542             return exchange
    543         } else {
    544             // Yikes❗️  TODO: error?
    545         }
    546         return nil
    547     }
    548 
    549     /// called whenever a new currency pops up - will first load the Exchange and then currencyInfos
    550     func checkInfo(for scope: ScopeInfo) {
    551         if currencyInfos[scope] == nil {
    552             Task {
    553                 let exchange = await exchange(for: scope.url)
    554                 if let scope2 = exchange?.scopeInfo {
    555                     let exchangeName = scope2.url ?? "UNKNOWN"
    556                     logger.log("  ❗️got no info for \(scope.currency) -> will update \(exchangeName.trimURL)")
    557                     await updateInfo2(scope)
    558                 } else {
    559                     logger.error("  ❗️got no info for \(scope.currency), and couldn't load the exchange info❗️")
    560                 }
    561             }
    562         }
    563     }
    564 
    565     @MainActor
    566     func getInfo(from baseUrl: String, model: WalletModel) async throws -> CurrencyInfo? {
    567         let exchange = try await model.getExchangeByUrl(url: baseUrl)
    568         let scope = exchange.scopeInfo
    569         if let info = info(for: scope) {
    570             return info
    571         }
    572         let info = try await model.getCurrencyInfo(scope: scope)
    573         await setInfo(info, for: scope)
    574         return info
    575     }
    576 
    577     @MainActor
    578     func setInfo(_ newInfo: CurrencyInfo, for scope: ScopeInfo) async {
    579         await semaphore.wait()
    580         defer { semaphore.signal() }
    581 
    582         currencyInfos[scope] = newInfo
    583         currencyTicker += 1         // triggers published view update
    584     }
    585 // MARK: -
    586     @MainActor
    587     func initWalletCore(setTesting: Bool, delay: TimeInterval)
    588       async throws {
    589         if backendState == .instantiated {
    590             backendState = .initing
    591             do {
    592                 let walletCore = WalletCore.shared
    593                 let model = WalletModel.shared
    594                 let response = try await model.initWalletCore(setTesting: setTesting)
    595 //  throw WalletBackendError.initializationError      // check how the app behaves if initWalletCore throws
    596                 walletCore.versionInfo = response.versionInfo
    597                 walletCore.nativeDB = (response.databaseBackend == "sqlite")
    598                 if developerMode {
    599                     // best-effort dev-mode setup: a network hiccup here must not abort
    600                     // wallet-core startup (see the force `try!` at the call site)
    601                     do {
    602                         try await model.setConfig(setTesting: true)
    603                         if developDelay == true {
    604                             try await model.devExperimentT(
    605                                 "taler://dev-experiment/start-tc?delay_resp=\(TCDELAY)")
    606                         }
    607 //                        try await model.devExperimentT("taler://dev-experiment/start-tc?fake_500=0.7")
    608 //                        try await model.devExperimentT("taler://dev-experiment/default-exchange-demo?val=1")
    609                         try await model.devExperimentT(
    610                             "taler://dev-experiment/demo-shortcuts?val=KUDOS:4,KUDOS:8,KUDOS:16,KUDOS:32")
    611                     } catch {
    612                         self.logger.error("developer-mode setup failed, continuing without it: \(error.localizedDescription)")
    613                     }
    614                 }
    615                 defaultExchanges = await model.getDefaultExchanges()
    616 #if GNU_TALER
    617                 if defaultExchanges.count == 1 {
    618                     if let talerOps = defaultExchanges.first {
    619                         let stageURI = "taler://withdraw-exchange/exchange.stage.taler-ops.ch/"
    620                         let baseUrl = "https://exchange.stage.taler-ops.ch/"
    621                         if talerOps.talerUri != stageURI {
    622                             let stageExc = DefaultExchange(talerUri: stageURI,
    623                                                     exchangeBaseUrl: baseUrl,
    624                                                            currency: talerOps.currency,
    625                                                        currencySpec: talerOps.currencySpec,
    626                                                 exchangeEntryStatus: .preset,
    627                                                exchangeUpdateStatus: .initial
    628                             )
    629                             defaultExchanges.append(stageExc)
    630                         }
    631                     }
    632                 }
    633 #endif
    634                 let launchDelay: TimeInterval = walletCore.nativeDB ? 0.1 : delay
    635                 DispatchQueue.main.asyncAfter(deadline: .now() + launchDelay) {
    636                     // dismiss the launch animation
    637                     self.backendState = walletCore.nativeDB ? .ready : .update
    638                 }
    639                 await loadBalances(CallStack())
    640             } catch {       // rethrows
    641                 self.logger.error("\(error.localizedDescription)")
    642                 backendState = .error(error.toEquatableError())                 // ❗️Yikes app cannot continue
    643 //                throw error         // will not be catched by MainView.task anyway
    644             }
    645         } else {
    646             self.logger.fault("Yikes❗️ wallet-core already initialized")
    647         }
    648     }
    649 }
    650 
    651 // MARK: -
    652 extension Controller {
    653     func urlCommand(_ url: URL, stack: CallStack) -> (UrlCommand, URL) {
    654         guard let scheme = url.scheme else {return (UrlCommand.unknown, url) }
    655 #if DEBUG
    656         symLog.log(url)
    657 #else
    658         let host = url.host ?? "  <- no command"
    659         self.logger.trace("urlCommand(\(scheme)\(host)")
    660 #endif
    661         var urlCommand = UrlCommand.unknown
    662         var talerURI: URL = url
    663         switch scheme.lowercased() {
    664             case "https":
    665                 (urlCommand, talerURI) = httpsScheme(url)
    666 //            case "payto":
    667 //                messageForSheet = url.absoluteString
    668 //                return paytoScheme(url)
    669             case "taler":
    670                 urlCommand = talerScheme(url)
    671             default:
    672                 self.logger.error("unknown scheme: <\(scheme)>")       // should never happen
    673         }
    674         saveURL(talerURI, urlCommand: urlCommand)
    675         return (urlCommand, talerURI)
    676     }
    677 }
    678 // MARK: -
    679 extension Controller {
    680     func httpsScheme(_ url: URL) -> (UrlCommand, URL) {
    681         // https://wallet.taler.net/#taler://${action}/${rest}
    682         // https://wallet.taler.net/#/${action}/${rest}
    683         // https://wallet.taler.net/#${action}/${rest}
    684         // are all mapped to taler://${action}/${rest}
    685         if let host = url.host, host == "wallet.taler.net" {
    686             if url.path == "/link" {
    687                 if let fragment = url.fragment {
    688                     var split = fragment.split(separator: "/")
    689                     if split.first == "taler:" {
    690                         split.removeFirst()
    691                     }
    692                     if let action = split.first {
    693                         let joined = split.joined(separator: "/")
    694                         if let talerURI = URL(string: "taler://\(joined)") {
    695                             return (command(String(action)), talerURI)
    696                         }
    697                     }
    698                 }
    699             }
    700         }
    701         self.logger.error("❗️unknown url:\(url.absoluteString)")
    702         return (.unknown, url)
    703     }
    704 
    705 //    func paytoScheme(_ url:URL) -> UrlCommand {
    706 //        let logItem = "scheme payto:// is not yet implemented"
    707 //        // TODO: write logItem to somewhere in Debug section of SettingsView
    708 //        symLog.log(logItem)        // TODO: symLog.error(logItem)
    709 //        return UrlCommand.unknown
    710 //    }
    711     
    712     func command(_ command:String) -> UrlCommand {
    713         switch command.lowercased() {
    714             case "withdraw":            return .withdraw
    715             case "withdraw-exchange":   return .withdrawExchange
    716             case "add-exchange":        return .addExchange
    717             case "pay":                 return .pay
    718             case "pay-pull":            return .payPull
    719             case "pay-push":            return .payPush
    720             case "pay-template":        return .payTemplate
    721             case "refund":              return .refund
    722 #if GNU_TALER || TALER_NIGHTLY
    723             case "dev-experiment":      return .devExperiment
    724 #endif
    725             default:
    726                 self.logger.error("❗️unknown command taler://\(command)")
    727         }
    728         messageForSheet = command.lowercased()
    729         return .unknown
    730     }
    731 
    732     func talerScheme(_ url:URL) -> UrlCommand {
    733         if let command = url.host {
    734             return self.command(command)
    735         } else {
    736             self.logger.error("❗️No taler command")
    737         }
    738         return .unknown
    739     }
    740 }