From 7217d8dead61a912f511075d3686ec3c2d416b3e Mon Sep 17 00:00:00 2001 From: Radek Novak Date: Fri, 20 Oct 2023 13:23:49 +0200 Subject: [PATCH] Fetch balance + Cleanup --- Sample/Example.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Core/BlockchainAPIInteractor.swift | 91 +++++++++++++- Sources/Web3Modal/Core/SignInteractor.swift | 9 +- Sources/Web3Modal/Core/W3MAPIInteractor.swift | 38 +++++- Sources/Web3Modal/Core/Web3Modal.swift | 36 ++++-- Sources/Web3Modal/Core/Web3ModalClient.swift | 7 ++ Sources/Web3Modal/Models/Chain.swift | 2 +- .../Web3Modal/Networking/Web3ModalAPI.swift | 1 + .../ChainSwitch/WhatIsNetworkView.swift | 78 ++++++++++++ .../ConnectWallet/GetAWalletView.swift | 7 +- Sources/Web3Modal/Store.swift | 7 +- .../Components/W3MAllWalletsImage.swift | 116 ------------------ .../Miscellaneous/AsyncImage.swift | 115 ----------------- 14 files changed, 255 insertions(+), 258 deletions(-) create mode 100644 Sources/Web3Modal/Screens/ChainSwitch/WhatIsNetworkView.swift delete mode 100644 Sources/Web3ModalUI/Components/W3MAllWalletsImage.swift delete mode 100644 Sources/Web3ModalUI/Miscellaneous/AsyncImage.swift diff --git a/Sample/Example.xcodeproj/project.pbxproj b/Sample/Example.xcodeproj/project.pbxproj index 5b688dd..8ee9d57 100644 --- a/Sample/Example.xcodeproj/project.pbxproj +++ b/Sample/Example.xcodeproj/project.pbxproj @@ -322,7 +322,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; DEVELOPMENT_TEAM = W5R8AG9K22; ENABLE_HARDENED_RUNTIME = YES; @@ -364,7 +364,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; DEVELOPMENT_TEAM = W5R8AG9K22; ENABLE_HARDENED_RUNTIME = YES; diff --git a/Sample/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sample/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 32383ab..1110efe 100644 --- a/Sample/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sample/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,7 +60,7 @@ "location" : "https://github.com/WalletConnect/WalletConnectSwiftV2", "state" : { "branch" : "remove-wcm", - "revision" : "0085250fd993f40a638f8d3e300f4af8cbf9e7a8" + "revision" : "04bcc547a61768598286cfa4cb0a06c0475c4d35" } } ], diff --git a/Sources/Web3Modal/Core/BlockchainAPIInteractor.swift b/Sources/Web3Modal/Core/BlockchainAPIInteractor.swift index ca91807..a71ba4c 100644 --- a/Sources/Web3Modal/Core/BlockchainAPIInteractor.swift +++ b/Sources/Web3Modal/Core/BlockchainAPIInteractor.swift @@ -1,8 +1,9 @@ import Foundation import HTTPClient +import JSONRPC + final class BlockchainAPIInteractor: ObservableObject { - private let store: Store init(store: Store = .shared) { @@ -10,7 +11,6 @@ final class BlockchainAPIInteractor: ObservableObject { } func getIdentity() async throws { - let account = store.session?.accounts.first let address = account?.address let chainId = account?.blockchainIdentifier @@ -31,10 +31,95 @@ final class BlockchainAPIInteractor: ObservableObject { self.store.identity = response } } + + func getBalance() async throws { + enum GetBalanceError: Error { + case noAddress, invalidValue, noChain + } + + guard let address = store.session?.accounts.first?.address else { + throw GetBalanceError.noAddress + } + + guard let chain = store.selectedChain else { + throw GetBalanceError.noChain + } + + let request = RPCRequest( + method: "eth_getBalance", params: [ + address, "latest" + ] + ) + + var urlRequest = URLRequest(url: URL(string: chain.rpcUrl)!) + urlRequest.httpMethod = "POST" + urlRequest.httpBody = try JSONEncoder().encode(request) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, _) = try await URLSession.shared.data(for: urlRequest) + let decodedResponse = try JSONDecoder().decode(RPCResponse.self, from: data) + let weiFactor = pow(10, chain.token.decimal) + + guard let decimalValue = try decodedResponse.result? + .get(String.self) + .convertBalanceHexToBigDecimal()? + .toWei(weiFactor: weiFactor) + else { + throw GetBalanceError.invalidValue + } + + let doubleValue = Double(truncating: decimalValue as NSNumber) + + DispatchQueue.main.async { + self.store.balance = doubleValue + } + } } - struct Identity: Codable { let name: String? let avatar: String? } + +struct BalanceRequest: Encodable { + init(address: String) { + self.address = address + + self.id = RPCID() + self.params = [ + address, "latest" + ] + } + + let address: String + let id: RPCID + let jsonrpc: String = "2.0" + let method: String = "eth_getBalance" + let params: [String] +} + +struct BalanceRpcResponse: Codable { + let id: RPCID + let jsonrpc: String + let result: String? + let error: RpcError? + + struct RpcError: Codable { + let code: Int + let message: String + } +} + +private extension String { + func convertBalanceHexToBigDecimal() -> Decimal? { + let substring = dropFirst(2) + guard let longValue = UInt64(substring, radix: 16) else { return nil } + return Decimal(string: "\(longValue)") + } +} + +extension Decimal { + func toWei(weiFactor: Decimal) -> Decimal { + return self / weiFactor + } +} diff --git a/Sources/Web3Modal/Core/SignInteractor.swift b/Sources/Web3Modal/Core/SignInteractor.swift index eb7e04e..04a536a 100644 --- a/Sources/Web3Modal/Core/SignInteractor.swift +++ b/Sources/Web3Modal/Core/SignInteractor.swift @@ -11,6 +11,7 @@ class SignInteractor: ObservableObject { lazy var sessionResponsePublisher: AnyPublisher = Web3Modal.instance.sessionResponsePublisher lazy var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> = Web3Modal.instance.sessionRejectionPublisher lazy var sessionDeletePublisher: AnyPublisher<(String, Reason), Never> = Web3Modal.instance.sessionDeletePublisher + lazy var sessionEventPublisher: AnyPublisher<(event: Session.Event, sessionTopic: String, chainId: Blockchain?), Never> = Web3Modal.instance.sessionEventPublisher init(store: Store = .shared) { self.store = store @@ -31,6 +32,12 @@ class SignInteractor: ObservableObject { } } - try await Web3Modal.instance.disconnect(topic: store.session?.topic ?? "") + do { + try await Web3Modal.instance.disconnect(topic: store.session?.topic ?? "") + } catch { + print(error.localizedDescription) + } + try await Web3Modal.instance.cleanup() + try await createPairingAndConnect() } } diff --git a/Sources/Web3Modal/Core/W3MAPIInteractor.swift b/Sources/Web3Modal/Core/W3MAPIInteractor.swift index fb1d6db..8aa3726 100644 --- a/Sources/Web3Modal/Core/W3MAPIInteractor.swift +++ b/Sources/Web3Modal/Core/W3MAPIInteractor.swift @@ -103,7 +103,7 @@ final class W3MAPIInteractor: ObservableObject { request.setValue("ios-3.0.0-alpha.0", forHTTPHeaderField: "x-sdk-version") do { - let (data, _) = try await URLSession(configuration: .ephemeral).data(for: request) + let (data, _) = try await URLSession.shared.data(for: request) return (wallet.imageId, UIImage(data: data)) } catch { print(error.localizedDescription) @@ -125,4 +125,40 @@ final class W3MAPIInteractor: ObservableObject { } } } + + func prefetchChainImages() async throws { + var chainImages: [String: UIImage] = [:] + + try await ChainsPresets.ethChains.concurrentMap { chain in + + let url = URL(string: "https://api.web3modal.com/public/getAssetImage/\(chain.imageId)")! + var request = URLRequest(url: url) + + request.setValue(Web3Modal.config.projectId, forHTTPHeaderField: "x-project-id") + request.setValue("w3m", forHTTPHeaderField: "x-sdk-type") + request.setValue("ios-3.0.0-alpha.0", forHTTPHeaderField: "x-sdk-version") + + do { + let (data, _) = try await URLSession.shared.data(for: request) + return (chain.imageId, UIImage(data: data)) + } catch { + print(error.localizedDescription) + } + + return ("", UIImage?.none) + } + .forEach { key, value in + if value == nil { + return + } + + chainImages[key] = value + } + + DispatchQueue.main.async { [chainImages] in + self.store.chainImages.merge(chainImages) { _, new in + new + } + } + } } diff --git a/Sources/Web3Modal/Core/Web3Modal.swift b/Sources/Web3Modal/Core/Web3Modal.swift index 98ca61c..2c5cce5 100644 --- a/Sources/Web3Modal/Core/Web3Modal.swift +++ b/Sources/Web3Modal/Core/Web3Modal.swift @@ -4,7 +4,6 @@ import SwiftUI import WalletConnectSign import WalletConnectVerify - #if canImport(UIKit) import UIKit #endif @@ -29,10 +28,25 @@ public class Web3Modal { guard let config = Web3Modal.config else { fatalError("Error - you must call Web3Modal.configure(_:) before accessing the shared instance.") } - return Web3ModalClient( + + let client = Web3ModalClient( signClient: Sign.instance, pairingClient: Pair.instance as! (PairingClientProtocol & PairingInteracting & PairingRegisterer) ) + + if let session = client.getSessions().first { + Store.shared.session = session + + if let blockchain = session.accounts.first?.blockchain { + let matchingChain = ChainsPresets.ethChains.first(where: { + $0.chainNamespace == blockchain.namespace && $0.chainReference == blockchain.reference + }) + + Store.shared.selectedChain = matchingChain + } + } + + return client }() struct Config { @@ -68,7 +82,12 @@ public class Web3Modal { includeWebWallets: includeWebWallets, recommendedWalletIds: recommendedWalletIds, excludedWalletIds: excludedWalletIds - ) + ) + + Task { + try? await W3MAPIInteractor().fetchFeaturedWallets() + try? await W3MAPIInteractor().prefetchChainImages() + } } public static func set(sessionParams: SessionParams) { @@ -79,7 +98,6 @@ public class Web3Modal { #if canImport(UIKit) extension Web3Modal { - public static func present(from presentingViewController: UIViewController? = nil) { guard let vc = presentingViewController ?? topViewController() else { assertionFailure("No controller found for presenting modal") @@ -91,7 +109,6 @@ extension Web3Modal { } private static func topViewController(_ base: UIViewController? = nil) -> UIViewController? { - let base = base ?? UIApplication .shared .connectedScenes @@ -121,10 +138,8 @@ extension Web3Modal { import AppKit -extension Web3Modal { - - public static func present(from presentingViewController: NSViewController? = nil) { - +public extension Web3Modal { + static func present(from presentingViewController: NSViewController? = nil) { let modal = Web3ModalSheetController() presentingViewController!.presentAsModalWindow(modal) } @@ -137,7 +152,7 @@ public struct SessionParams { public let optionalNamespaces: [String: ProposalNamespace]? public let sessionProperties: [String: String]? - public init(requiredNamespaces: [String : ProposalNamespace], optionalNamespaces: [String : ProposalNamespace]? = nil, sessionProperties: [String : String]? = nil) { + public init(requiredNamespaces: [String: ProposalNamespace], optionalNamespaces: [String: ProposalNamespace]? = nil, sessionProperties: [String: String]? = nil) { self.requiredNamespaces = requiredNamespaces self.optionalNamespaces = optionalNamespaces self.sessionProperties = sessionProperties @@ -162,4 +177,3 @@ public struct SessionParams { ) }() } - diff --git a/Sources/Web3Modal/Core/Web3ModalClient.swift b/Sources/Web3Modal/Core/Web3ModalClient.swift index 910c1a0..3297669 100644 --- a/Sources/Web3Modal/Core/Web3ModalClient.swift +++ b/Sources/Web3Modal/Core/Web3ModalClient.swift @@ -49,6 +49,13 @@ public class Web3ModalClient { signClient.socketConnectionStatusPublisher.eraseToAnyPublisher() } + /// Publisher that sends session event + /// + /// Event will be emited on dApp client only + public var sessionEventPublisher: AnyPublisher<(event: Session.Event, sessionTopic: String, chainId: Blockchain?), Never> { + signClient.sessionEventPublisher.eraseToAnyPublisher() + } + // MARK: - Private Properties private let signClient: SignClientProtocol diff --git a/Sources/Web3Modal/Models/Chain.swift b/Sources/Web3Modal/Models/Chain.swift index b9e92ea..11b910f 100644 --- a/Sources/Web3Modal/Models/Chain.swift +++ b/Sources/Web3Modal/Models/Chain.swift @@ -11,7 +11,7 @@ public struct Chain: Identifiable, Hashable { "\(chainNamespace):\(chainReference)" } - var chainName: String + public var chainName: String var chainNamespace: String var chainReference: String var requiredMethods: [String] diff --git a/Sources/Web3Modal/Networking/Web3ModalAPI.swift b/Sources/Web3Modal/Networking/Web3ModalAPI.swift index 4a8fef4..bed062a 100644 --- a/Sources/Web3Modal/Networking/Web3ModalAPI.swift +++ b/Sources/Web3Modal/Networking/Web3ModalAPI.swift @@ -40,6 +40,7 @@ enum Web3ModalAPI: HTTPService { "search": params.search ?? "", "recommendedIds": params.recommendedIds.joined(separator: ","), "excludedIds": params.excludedIds.joined(separator: ","), + "platform": "ios", ] .compactMapValues { value in value.isEmpty ? nil : value diff --git a/Sources/Web3Modal/Screens/ChainSwitch/WhatIsNetworkView.swift b/Sources/Web3Modal/Screens/ChainSwitch/WhatIsNetworkView.swift new file mode 100644 index 0000000..1ab2a7b --- /dev/null +++ b/Sources/Web3Modal/Screens/ChainSwitch/WhatIsNetworkView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import Web3ModalUI + +struct WhatIsNetworkView: View { + + @Environment(\.verticalSizeClass) var verticalSizeClass + + @EnvironmentObject var router: Router + + var body: some View { + content() + .onAppear { + UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(Color.Foreground100) + UIPageControl.appearance().pageIndicatorTintColor = UIColor(Color.Foreground100).withAlphaComponent(0.2) + } + } + + func content() -> some View { + VStack(spacing: 0) { + if verticalSizeClass == .compact { + TabView { + ForEach(sections(), id: \.title) { section in + section + .padding(.bottom, 40) + } + } + .transform { + #if os(iOS) + $0.tabViewStyle(.page(indexDisplayMode: .always)) + #endif + } + .scaledToFill() + .layoutPriority(1) + + } else { + ForEach(sections(), id: \.title) { section in + section + .padding(.bottom, Spacing.s) + } + } + + Button(action: { + router.navigateToExternalLink(URL(string: "https://ethereum.org/en/developers/docs/networks/")!) + }) { + HStack { + Text("Learn More") + Image.ExternalLink + } + } + .buttonStyle(W3MButtonStyle(size: .m)) + } + .padding(.vertical, Spacing.xxl) + .padding(.horizontal, Spacing.xl) + } + + func sections() -> [HelpSection] { + [ + HelpSection( + title: "The system’s nuts and bolts", + description: "A network is what brings the blockchain to life, as this technical infrastructure allows apps to access the ledger and smart contract services.", + assets: [.imageNetwork, .imageLayers, .imageSystem] + ), + HelpSection( + title: "Designed for different uses", + description: "Each network is designed differently, and may therefore suit certain apps and experiences.", + assets: [.imageNoun, .imageDefiAlt, .imageDao] + ), + ] + } +} + +#if DEBUG +struct WhatIsNetworkView_Previews: PreviewProvider { + static var previews: some View { + WhatIsNetworkView() + } +} +#endif diff --git a/Sources/Web3Modal/Screens/ConnectWallet/GetAWalletView.swift b/Sources/Web3Modal/Screens/ConnectWallet/GetAWalletView.swift index 5caac20..cacf7b8 100644 --- a/Sources/Web3Modal/Screens/ConnectWallet/GetAWalletView.swift +++ b/Sources/Web3Modal/Screens/ConnectWallet/GetAWalletView.swift @@ -33,12 +33,7 @@ struct GetAWalletView: View { }) .buttonStyle(W3MListSelectStyle( imageContent: { _ in - W3MAllWalletsImage(images: [ - .init(image: Image("MockWalletImage", bundle: .UIModule), walletName: "Metamask"), - .init(image: Image("MockWalletImage", bundle: .UIModule), walletName: "Trust"), - .init(image: Image("MockWalletImage", bundle: .UIModule), walletName: "Safe"), - .init(image: Image("MockWalletImage", bundle: .UIModule), walletName: "Rainbow"), - ]) + Image.optionAll } )) } diff --git a/Sources/Web3Modal/Store.swift b/Sources/Web3Modal/Store.swift index 76181ec..6dd8ecb 100644 --- a/Sources/Web3Modal/Store.swift +++ b/Sources/Web3Modal/Store.swift @@ -7,6 +7,8 @@ class Store: ObservableObject { public static var shared: Store = Store() @Published var identity: Identity? + @Published var balance: Double? + @Published var session: Session? @Published var uri: WalletConnectURI? @@ -14,5 +16,8 @@ class Store: ObservableObject { @Published var featuredWallets: [Wallet] = [] @Published var searchedWallets: [Wallet] = [] @Published var totalNumberOfWallets: Int = 0 - @Published var walletImages: [String: UIImage] = [:] + var walletImages: [String: UIImage] = [:] + + @Published var selectedChain: Chain? + var chainImages: [String: UIImage] = [:] } diff --git a/Sources/Web3ModalUI/Components/W3MAllWalletsImage.swift b/Sources/Web3ModalUI/Components/W3MAllWalletsImage.swift deleted file mode 100644 index d9700a6..0000000 --- a/Sources/Web3ModalUI/Components/W3MAllWalletsImage.swift +++ /dev/null @@ -1,116 +0,0 @@ -import SwiftUI - -public struct W3MAllWalletsImage: View { - - @ScaledMetric var scale: CGFloat = 1 - - var images: [WalletImage] - - public init(images: [WalletImage]) { - self.images = images - } - - public var body: some View { - VStack(spacing: 2 * scale) { - HStack(spacing: 2 * scale) { - walletImage(images[safe: 0]) - walletImage(images[safe: 1]) - } - .padding(.horizontal, 3.5 * scale) - - HStack(spacing: 2 * scale) { - walletImage(images[safe: 2]) - walletImage(images[safe: 3]) - } - .padding(.horizontal, 3.5 * scale) - } - .padding(.vertical, 3.5 * scale) - .frame(width: 40 * scale, height: 40 * scale) - .cornerRadius(Radius.xxxs * scale) - .overlay { - RoundedRectangle(cornerRadius: Radius.xxxs) - .strokeBorder(.Overgray010, lineWidth: 0.5 * scale) - } - } - - @ViewBuilder - func walletImage(_ imageObject: WalletImage?) -> some View { - - Group { - if let image = imageObject?.image { - image - .resizable() - .scaledToFit() - } else { - AsyncImage(url: URL(string: imageObject?.url ?? "")) { image in - image - .resizable() - .scaledToFit() - } placeholder: { - Image.Wallet - .resizable() - .scaledToFit() - .padding(3 * scale) - } - } - } - .frame(width: 15 * scale, height: 15 * scale) - .cornerRadius(Radius.xxxxxs) - .overlay { - RoundedRectangle(cornerRadius: Radius.xxxxxs) - .strokeBorder(.Overgray010, lineWidth: 1) - } - } -} - -public struct WalletImage { - let image: Image? - let url: String? - let walletName: String? - - public init(url: String, walletName: String?) { - self.image = nil - self.url = url - self.walletName = walletName - } - - public init(image: Image, walletName: String?) { - self.image = image - self.url = nil - self.walletName = walletName - } -} - -private extension Collection { - /// Returns the element at the specified index if it is within bounds, otherwise nil. - subscript(safe index: Index) -> Element? { - return indices.contains(index) ? self[index] : nil - } -} - -#if DEBUG - public struct W3MAllWalletsImageView: View { - public init() {} - - public var body: some View { - VStack { - W3MAllWalletsImage(images: [ - .init(image: Image("MockWalletImage", bundle: .module), walletName: "Metamask"), - .init(image: Image("MockWalletImage", bundle: .module), walletName: "Trust"), - .init(image: Image("MockWalletImage", bundle: .module), walletName: "Safe"), - .init(image: Image("MockWalletImage", bundle: .module), walletName: "Rainbow"), - ]) - } - .padding() - .background(.Overgray002) - } - } - - struct W3MAllWalletsImage_Previews: PreviewProvider { - static var previews: some View { - W3MAllWalletsImageView() - .previewLayout(.sizeThatFits) - } - } - -#endif diff --git a/Sources/Web3ModalUI/Miscellaneous/AsyncImage.swift b/Sources/Web3ModalUI/Miscellaneous/AsyncImage.swift deleted file mode 100644 index fb9dcde..0000000 --- a/Sources/Web3ModalUI/Miscellaneous/AsyncImage.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Combine -import SwiftUI -import UIKit - -struct AsyncImage: View where Content: View { - @StateObject fileprivate var loader: ImageLoader - @ViewBuilder private var content: (AsyncImagePhase) -> Content - - init( - url: URL?, - @ViewBuilder content: @escaping (AsyncImagePhase) -> Content - ) { - _loader = .init(wrappedValue: ImageLoader(url: url)) - self.content = content - } - - init( - url: URL?, - @ViewBuilder content: @escaping (Image) -> I, - @ViewBuilder placeholder: @escaping () -> P - ) where - Content == _ConditionalContent, - I: View, - P: View - { - self.init(url: url) { phase in - switch phase { - case .success(let image): - content(image) - case .empty, .failure: - placeholder() - } - } - } - - var body: some View { - content(loader.phase).onAppear { - loader.load() - } - } -} - -enum ImageSource { - case remote(url: URL?) - case local(name: String) - case captured(image: UIImage) -} - -enum AsyncImagePhase { - case empty - case success(Image) - case failure(Error) -} - -public class ImageLoader: ObservableObject { - - public static var headers: [String: String] = [:] - - private static let session: URLSession = { - let configuration = URLSessionConfiguration.default - configuration.requestCachePolicy = .returnCacheDataElseLoad - let session = URLSession(configuration: configuration) - return session - }() - - private enum LoaderError: Swift.Error { - case missingURL - case failedToDecodeFromData - } - - @Published var phase = AsyncImagePhase.empty - private var subscriptions: [AnyCancellable] = [] - - private let url: URL? - - init(url: URL?) { - self.url = url - } - - deinit { - cancel() - } - - func load() { - guard let url = url else { - phase = .failure(LoaderError.missingURL) - return - } - - var request = URLRequest(url: url) - request.allHTTPHeaderFields = ImageLoader.headers - - ImageLoader.session.dataTaskPublisher(for: request) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure(let error): - self.phase = .failure(error) - } - }, receiveValue: { - if let image = UIImage(data: $0.data) { - self.phase = .success(Image(uiImage: image)) - } else { - self.phase = .failure(LoaderError.failedToDecodeFromData) - } - }) - .store(in: &subscriptions) - } - - func cancel() { - subscriptions.forEach { $0.cancel() } - } -}