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 }