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 }