diff --git a/Example/DApp/SceneDelegate.swift b/Example/DApp/SceneDelegate.swift index 8630806f5..f784fac19 100644 --- a/Example/DApp/SceneDelegate.swift +++ b/Example/DApp/SceneDelegate.swift @@ -24,9 +24,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { WalletConnectModal.configure( projectId: InputConfig.projectId, - metadata: metadata + metadata: metadata, + accentColor: .green ) - + setupWindow(scene: scene) } diff --git a/Example/ExampleApp.xcodeproj/project.pbxproj b/Example/ExampleApp.xcodeproj/project.pbxproj index d0585642d..92c706fdd 100644 --- a/Example/ExampleApp.xcodeproj/project.pbxproj +++ b/Example/ExampleApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ diff --git a/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/BuildAll.xcscheme b/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/BuildAll.xcscheme index 9bdb81b02..6b4a6df22 100644 --- a/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/BuildAll.xcscheme +++ b/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/BuildAll.xcscheme @@ -83,6 +83,9 @@ + + { return Push.wallet.requestPublisher } + + var sessionProposalPublisher: AnyPublisher<(proposal: Session.Proposal, context: VerifyContext?), Never> { + return Web3Wallet.instance.sessionProposalPublisher + } + + var sessionRequestPublisher: AnyPublisher<(request: Request, context: VerifyContext?), Never> { + return Web3Wallet.instance.sessionRequestPublisher + } + + var requestPublisher: AnyPublisher<(request: AuthRequest, context: VerifyContext?), Never> { + return Web3Wallet.instance.authRequestPublisher + } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/Main/MainPresenter.swift b/Example/WalletApp/PresentationLayer/Wallet/Main/MainPresenter.swift index dc8a74069..9c51f9869 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Main/MainPresenter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Main/MainPresenter.swift @@ -35,7 +35,6 @@ final class MainPresenter { // MARK: - Private functions extension MainPresenter { - private func setupInitialState() { configurationService.configure(importAccount: importAccount) pushRegisterer.registerForPushNotifications() @@ -45,5 +44,25 @@ extension MainPresenter { .sink { [unowned self] request in router.present(pushRequest: request) }.store(in: &disposeBag) + + interactor.sessionProposalPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] session in + router.present(proposal: session.proposal, importAccount: importAccount, context: session.context) + } + .store(in: &disposeBag) + + interactor.sessionRequestPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] request, context in + router.present(sessionRequest: request, importAccount: importAccount, sessionContext: context) + }.store(in: &disposeBag) + + interactor.requestPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] result in + router.present(request: result.request, importAccount: importAccount, context: result.context) + } + .store(in: &disposeBag) } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/Main/MainRouter.swift b/Example/WalletApp/PresentationLayer/Wallet/Main/MainRouter.swift index e78a40f6d..11f5e2a6f 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Main/MainRouter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Main/MainRouter.swift @@ -36,4 +36,19 @@ final class MainRouter { // PushRequestModule.create(app: app, pushRequest: pushRequest) // .presentFullScreen(from: viewController, transparentBackground: true) } + + func present(proposal: Session.Proposal, importAccount: ImportAccount, context: VerifyContext?) { + SessionProposalModule.create(app: app, importAccount: importAccount, proposal: proposal, context: context) + .presentFullScreen(from: viewController, transparentBackground: true) + } + + func present(sessionRequest: Request, importAccount: ImportAccount, sessionContext: VerifyContext?) { + SessionRequestModule.create(app: app, sessionRequest: sessionRequest, importAccount: importAccount, sessionContext: sessionContext) + .presentFullScreen(from: viewController, transparentBackground: true) + } + + func present(request: AuthRequest, importAccount: ImportAccount, context: VerifyContext?) { + AuthRequestModule.create(app: app, request: request, importAccount: importAccount, context: context) + .presentFullScreen(from: viewController, transparentBackground: true) + } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletInteractor.swift b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletInteractor.swift index bb806887b..1860ad808 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletInteractor.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletInteractor.swift @@ -4,18 +4,6 @@ import Web3Wallet import WalletConnectPush final class WalletInteractor { - var sessionProposalPublisher: AnyPublisher<(proposal: Session.Proposal, context: VerifyContext?), Never> { - return Web3Wallet.instance.sessionProposalPublisher - } - - var sessionRequestPublisher: AnyPublisher<(request: Request, context: VerifyContext?), Never> { - return Web3Wallet.instance.sessionRequestPublisher - } - - var requestPublisher: AnyPublisher<(request: AuthRequest, context: VerifyContext?), Never> { - return Web3Wallet.instance.authRequestPublisher - } - var sessionsPublisher: AnyPublisher<[Session], Never> { return Web3Wallet.instance.sessionsPublisher } diff --git a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletPresenter.swift b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletPresenter.swift index ffd4d3a40..917d1dc94 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletPresenter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletPresenter.swift @@ -98,29 +98,6 @@ final class WalletPresenter: ObservableObject { // MARK: - Private functions extension WalletPresenter { private func setupInitialState() { - interactor.sessionProposalPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] session in - showPairingLoading = false - router.present(proposal: session.proposal, importAccount: importAccount, context: session.context) - } - .store(in: &disposeBag) - - interactor.sessionRequestPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] request, context in - showPairingLoading = false - router.present(sessionRequest: request, importAccount: importAccount, sessionContext: context) - }.store(in: &disposeBag) - - interactor.requestPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] result in - showPairingLoading = false - router.present(request: result.request, importAccount: importAccount, context: result.context) - } - .store(in: &disposeBag) - interactor.sessionsPublisher .receive(on: DispatchQueue.main) .sink { [weak self] sessions in @@ -158,10 +135,6 @@ extension WalletPresenter { private func removePairingIndicator() { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - if self.showPairingLoading { - self.errorMessage = "WalletConnect - Pairing timeout error" - self.showError.toggle() - } self.showPairingLoading = false } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletRouter.swift b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletRouter.swift index e47308175..c9907a0b5 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletRouter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletRouter.swift @@ -11,20 +11,10 @@ final class WalletRouter { self.app = app } - func present(proposal: Session.Proposal, importAccount: ImportAccount, context: VerifyContext?) { - SessionProposalModule.create(app: app, importAccount: importAccount, proposal: proposal, context: context) - .presentFullScreen(from: viewController, transparentBackground: true) - } - func present(sessionRequest: Request, importAccount: ImportAccount, sessionContext: VerifyContext?) { SessionRequestModule.create(app: app, sessionRequest: sessionRequest, importAccount: importAccount, sessionContext: sessionContext) .presentFullScreen(from: viewController, transparentBackground: true) } - - func present(request: AuthRequest, importAccount: ImportAccount, context: VerifyContext?) { - AuthRequestModule.create(app: app, request: request, importAccount: importAccount, context: context) - .presentFullScreen(from: viewController, transparentBackground: true) - } func present(sessionProposal: Session.Proposal, importAccount: ImportAccount, sessionContext: VerifyContext?) { SessionProposalModule.create(app: app, importAccount: importAccount, proposal: sessionProposal, context: sessionContext) diff --git a/Sources/HTTPClient/HTTPClient.swift b/Sources/HTTPClient/HTTPClient.swift index 0fb4c8a68..99eeca29a 100644 --- a/Sources/HTTPClient/HTTPClient.swift +++ b/Sources/HTTPClient/HTTPClient.swift @@ -3,4 +3,5 @@ import Foundation public protocol HTTPClient { func request(_ type: T.Type, at service: HTTPService) async throws -> T func request(service: HTTPService) async throws + func updateHost(host: String) async } diff --git a/Sources/HTTPClient/HTTPError.swift b/Sources/HTTPClient/HTTPError.swift index 446c8cbf2..959663bcc 100644 --- a/Sources/HTTPClient/HTTPError.swift +++ b/Sources/HTTPClient/HTTPError.swift @@ -1,10 +1,27 @@ import Foundation -enum HTTPError: Error { +public enum HTTPError: Error, Equatable { case malformedURL(HTTPService) + case couldNotConnect case dataTaskError(Error) case noResponse case badStatusCode(Int) case responseDataNil case jsonDecodeFailed(Error, Data) + + public static func ==(lhs: HTTPError, rhs: HTTPError) -> Bool { + switch (lhs, rhs) { + case (.malformedURL, .malformedURL), + (.couldNotConnect, .couldNotConnect), + (.noResponse, .noResponse), + (.responseDataNil, .responseDataNil), + (.dataTaskError, .dataTaskError), + (.badStatusCode, .badStatusCode), + (.jsonDecodeFailed, .jsonDecodeFailed): + return true + + default: + return false + } + } } diff --git a/Sources/HTTPClient/HTTPNetworkClient.swift b/Sources/HTTPClient/HTTPNetworkClient.swift index 1cb5da106..a00ca2119 100644 --- a/Sources/HTTPClient/HTTPNetworkClient.swift +++ b/Sources/HTTPClient/HTTPNetworkClient.swift @@ -2,7 +2,7 @@ import Foundation public actor HTTPNetworkClient: HTTPClient { - let host: String + private var host: String private let session: URLSession @@ -31,6 +31,10 @@ public actor HTTPNetworkClient: HTTPClient { } } } + + public func updateHost(host: String) async { + self.host = host + } private func request(_ type: T.Type, at service: HTTPService, completion: @escaping (Result) -> Void) { guard let request = service.resolve(for: host) else { @@ -67,6 +71,9 @@ public actor HTTPNetworkClient: HTTPClient { } private static func validate(_ urlResponse: URLResponse?, _ error: Error?) throws { + if let error = (error as? NSError), error.code == -1004 { + throw HTTPError.couldNotConnect + } if let error = error { throw HTTPError.dataTaskError(error) } diff --git a/Sources/WalletConnectEcho/EchoClientFactory.swift b/Sources/WalletConnectEcho/EchoClientFactory.swift index 666498f15..3e13db72f 100644 --- a/Sources/WalletConnectEcho/EchoClientFactory.swift +++ b/Sources/WalletConnectEcho/EchoClientFactory.swift @@ -14,14 +14,19 @@ public struct EchoClientFactory { environment: environment) } - public static func create(projectId: String, - echoHost: String, - keychainStorage: KeychainStorageProtocol, - environment: APNSEnvironment) -> EchoClient { - + public static func create( + projectId: String, + echoHost: String, + keychainStorage: KeychainStorageProtocol, + environment: APNSEnvironment + ) -> EchoClient { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.timeoutIntervalForRequest = 5.0 + sessionConfiguration.timeoutIntervalForResource = 5.0 + let session = URLSession(configuration: sessionConfiguration) + let logger = ConsoleLogger(suffix: "👂🏻", loggingLevel: .debug) - - let httpClient = HTTPNetworkClient(host: echoHost) + let httpClient = HTTPNetworkClient(host: echoHost, session: session) let clientIdStorage = ClientIdStorage(keychain: keychainStorage) diff --git a/Sources/WalletConnectEcho/Register/EchoRegisterService.swift b/Sources/WalletConnectEcho/Register/EchoRegisterService.swift index b3df306ce..702ba10cb 100644 --- a/Sources/WalletConnectEcho/Register/EchoRegisterService.swift +++ b/Sources/WalletConnectEcho/Register/EchoRegisterService.swift @@ -7,7 +7,10 @@ actor EchoRegisterService { private let environment: APNSEnvironment private let echoAuthenticator: EchoAuthenticating private let clientIdStorage: ClientIdStoring - + /// The property is used to determine whether echo.walletconnect.org will be used + /// in case echo.walletconnect.com doesn't respond for some reason (most likely due to being blocked in the user's location). + private var fallback = false + enum Errors: Error { case registrationFailed } @@ -33,14 +36,28 @@ actor EchoRegisterService { let clientId = try clientIdStorage.getClientId() let clientIdMutlibase = try DIDKey(did: clientId).multibase(variant: .ED25519) logger.debug("APNS device token: \(token)") - let response = try await httpClient.request( - EchoResponse.self, - at: EchoAPI.register(clientId: clientIdMutlibase, token: token, projectId: projectId, environment: environment, auth: echoAuthToken) - ) - guard response.status == .success else { - throw Errors.registrationFailed + + do { + let response = try await httpClient.request( + EchoResponse.self, + at: EchoAPI.register(clientId: clientIdMutlibase, token: token, projectId: projectId, environment: environment, auth: echoAuthToken) + ) + guard response.status == .success else { + throw Errors.registrationFailed + } + logger.debug("Successfully registered at Echo Server") + } catch { + if (error as? HTTPError) == .couldNotConnect && !fallback { + fallback = true + await echoHostFallback() + try await register(deviceToken: deviceToken) + } + throw error } - logger.debug("Successfully registered at Echo Server") + } + + func echoHostFallback() async { + await httpClient.updateHost(host: "echo.walletconnect.org") } #if DEBUG @@ -48,14 +65,24 @@ actor EchoRegisterService { let echoAuthToken = try echoAuthenticator.createAuthToken() let clientId = try clientIdStorage.getClientId() let clientIdMutlibase = try DIDKey(did: clientId).multibase(variant: .ED25519) - let response = try await httpClient.request( - EchoResponse.self, - at: EchoAPI.register(clientId: clientIdMutlibase, token: deviceToken, projectId: projectId, environment: environment, auth: echoAuthToken) - ) - guard response.status == .success else { - throw Errors.registrationFailed + + do { + let response = try await httpClient.request( + EchoResponse.self, + at: EchoAPI.register(clientId: clientIdMutlibase, token: deviceToken, projectId: projectId, environment: environment, auth: echoAuthToken) + ) + guard response.status == .success else { + throw Errors.registrationFailed + } + logger.debug("Successfully registered at Echo Server") + } catch { + if (error as? HTTPError) == .couldNotConnect && !fallback { + fallback = true + await echoHostFallback() + try await register(deviceToken: deviceToken) + } + throw error } - logger.debug("Successfully registered at Echo Server") } #endif } diff --git a/Sources/WalletConnectModal/Mocks/Listing+Mocks.swift b/Sources/WalletConnectModal/Mocks/Listing+Mocks.swift index 7206886ea..0c8af0885 100644 --- a/Sources/WalletConnectModal/Mocks/Listing+Mocks.swift +++ b/Sources/WalletConnectModal/Mocks/Listing+Mocks.swift @@ -4,9 +4,63 @@ import Foundation extension Listing { static let stubList: [Listing] = [ - Listing(id: UUID().uuidString, name: "Sample Wallet", homepage: "https://example.com", order: 1, imageId: UUID().uuidString, app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "sampleapp://deeplink", universal: "https://example.com/universal")), - Listing(id: UUID().uuidString, name: "Awesome Wallet", homepage: "https://example.com/awesome", order: 2, imageId: UUID().uuidString, app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "awesomeapp://deeplink", universal: "https://example.com/awesome/universal")), - Listing(id: UUID().uuidString, name: "Cool Wallet", homepage: "https://example.com/cool", order: 3, imageId: UUID().uuidString, app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "coolapp://deeplink", universal: "https://example.com/cool/universal")) + Listing( + id: UUID().uuidString, + name: "Sample Wallet", + homepage: "https://example.com", + order: 1, + imageId: UUID().uuidString, + app: Listing.App( + ios: "https://example.com/download-ios", + browser: "https://example.com/download-safari" + ), + mobile: .init( + native: "sampleapp://deeplink", + universal: "https://example.com/universal" + ), + desktop: .init( + native: nil, + universal: "https://example.com/universal" + ) + ), + Listing( + id: UUID().uuidString, + name: "Awesome Wallet", + homepage: "https://example.com/awesome", + order: 2, + imageId: UUID().uuidString, + app: Listing.App( + ios: "https://example.com/download-ios", + browser: "https://example.com/download-safari" + ), + mobile: .init( + native: "awesomeapp://deeplink", + universal: "https://example.com/awesome/universal" + ), + desktop: .init( + native: nil, + universal: "https://example.com/awesome/universal" + ) + ), + Listing( + id: UUID().uuidString, + name: "Cool Wallet", + homepage: "https://example.com/cool", + order: 3, + imageId: UUID().uuidString, + app: Listing.App( + ios: "https://example.com/download-ios", + browser: "https://example.com/download-safari" + ), + mobile: .init( + native: "coolapp://deeplink", + universal: "https://example.com/cool/universal" + ), + desktop: .init( + native: nil, + universal: "https://example.com/cool/universal" + ) + ) ] } diff --git a/Sources/WalletConnectModal/Modal/ModalSheet.swift b/Sources/WalletConnectModal/Modal/ModalSheet.swift index 64c9bb78d..c51e69d2b 100644 --- a/Sources/WalletConnectModal/Modal/ModalSheet.swift +++ b/Sources/WalletConnectModal/Modal/ModalSheet.swift @@ -14,7 +14,6 @@ public struct ModalSheet: View { VStack(spacing: 0) { contentHeader() content() - } .frame(maxWidth: .infinity) .background(Color.background1) @@ -75,13 +74,12 @@ public struct ModalSheet: View { EmptyView() } } - .animation(.default) + .animation(.default, value: viewModel.destination) .foregroundColor(.accent) .frame(height: 60) .overlay( VStack { if viewModel.destination.hasSearch { - HStack { Image(systemName: "magnifyingglass") TextField("Search", text: $viewModel.searchTerm, onEditingChanged: { editing in @@ -128,7 +126,7 @@ public struct ModalSheet: View { viewModel.destination }, set: { _ in }), navigateTo: viewModel.navigateTo(_:), - onListingTap: { viewModel.onListingTap($0, preferUniversal: false) } + onListingTap: { viewModel.onListingTap($0) } ) } @@ -144,7 +142,6 @@ public struct ModalSheet: View { @ViewBuilder private func content() -> some View { - switch viewModel.destination { case .welcome, .viewAll: @@ -155,7 +152,7 @@ public struct ModalSheet: View { case .getWallet: GetAWalletView( wallets: Array(viewModel.wallets.prefix(6)), - onWalletTap: viewModel.onGetWalletTap(_:), + onWalletTap: viewModel.openAppstore(wallet:), navigateToExternalLink: viewModel.navigateToExternalLink(_:) ) .frame(minHeight: verticalSizeClass == .compact ? 200 : 550) @@ -163,10 +160,10 @@ public struct ModalSheet: View { case let .walletDetail(wallet): WalletDetail( - wallet: wallet, - deeplink: { viewModel.onListingTap($0, preferUniversal: false) }, - deeplinkUniversal: { viewModel.onListingTap($0, preferUniversal: true) }, - openAppStore: viewModel.onGetWalletTap(_:) + viewModel: .init( + wallet: wallet, + deeplinkHandler: viewModel + ) ) } } diff --git a/Sources/WalletConnectModal/Modal/ModalViewModel.swift b/Sources/WalletConnectModal/Modal/ModalViewModel.swift index bdd5e9a9f..33b26c141 100644 --- a/Sources/WalletConnectModal/Modal/ModalViewModel.swift +++ b/Sources/WalletConnectModal/Modal/ModalViewModel.swift @@ -118,23 +118,8 @@ final class ModalViewModel: ObservableObject { uiApplicationWrapper.openURL(url, nil) } - func onListingTap(_ listing: Listing, preferUniversal: Bool) { + func onListingTap(_ listing: Listing) { setLastTimeUsed(listing.id) - - navigateToDeepLink( - universalLink: listing.mobile.universal ?? "", - nativeLink: listing.mobile.native ?? "", - preferUniversal: preferUniversal - ) - } - - func onGetWalletTap(_ listing: Listing) { - guard - let storeLinkString = listing.app.ios, - let storeLink = URL(string: storeLinkString) - else { return } - - uiApplicationWrapper.openURL(storeLink, nil) } func onBackButton() { @@ -257,28 +242,29 @@ private extension ModalViewModel { // MARK: - Deeplinking -private extension ModalViewModel { - enum DeeplinkErrors: LocalizedError { - case noWalletLinkFound - case uriNotCreated - case failedToOpen +protocol WalletDeeplinkHandler { + func openAppstore(wallet: Listing) + func navigateToDeepLink(wallet: Listing, preferUniversal: Bool, preferBrowser: Bool) +} + +extension ModalViewModel: WalletDeeplinkHandler { + + func openAppstore(wallet: Listing) { + guard + let storeLinkString = wallet.app.ios, + let storeLink = URL(string: storeLinkString) + else { return } - var errorDescription: String? { - switch self { - case .noWalletLinkFound: - return NSLocalizedString("No valid link for opening given wallet found", comment: "") - case .uriNotCreated: - return NSLocalizedString("Couldn't generate link due to missing connection URI", comment: "") - case .failedToOpen: - return NSLocalizedString("Given link couldn't be opened", comment: "") - } - } + uiApplicationWrapper.openURL(storeLink, nil) } - - func navigateToDeepLink(universalLink: String, nativeLink: String, preferUniversal: Bool) { + + func navigateToDeepLink(wallet: Listing, preferUniversal: Bool, preferBrowser: Bool) { do { - let nativeUrlString = try formatNativeUrlString(nativeLink) - let universalUrlString = try formatUniversalUrlString(universalLink) + let nativeScheme = preferBrowser ? nil : wallet.mobile.native + let universalScheme = preferBrowser ? wallet.desktop.universal : wallet.mobile.universal + + let nativeUrlString = try formatNativeUrlString(nativeScheme) + let universalUrlString = try formatUniversalUrlString(universalScheme) if let nativeUrl = nativeUrlString?.toURL(), !preferUniversal { uiApplicationWrapper.openURL(nativeUrl) { success in @@ -299,13 +285,32 @@ private extension ModalViewModel { toast = Toast(style: .error, message: error.localizedDescription) } } +} + +private extension ModalViewModel { + enum DeeplinkErrors: LocalizedError { + case noWalletLinkFound + case uriNotCreated + case failedToOpen + + var errorDescription: String? { + switch self { + case .noWalletLinkFound: + return NSLocalizedString("No valid link for opening given wallet found", comment: "") + case .uriNotCreated: + return NSLocalizedString("Couldn't generate link due to missing connection URI", comment: "") + case .failedToOpen: + return NSLocalizedString("Given link couldn't be opened", comment: "") + } + } + } func isHttpUrl(url: String) -> Bool { return url.hasPrefix("http://") || url.hasPrefix("https://") } - func formatNativeUrlString(_ string: String) throws -> String? { - if string.isEmpty { return nil } + func formatNativeUrlString(_ string: String?) throws -> String? { + guard let string = string, !string.isEmpty else { return nil } if isHttpUrl(url: string) { return try formatUniversalUrlString(string) @@ -324,8 +329,8 @@ private extension ModalViewModel { return "\(safeAppUrl)wc?uri=\(deeplinkUri)" } - func formatUniversalUrlString(_ string: String) throws -> String? { - if string.isEmpty { return nil } + func formatUniversalUrlString(_ string: String?) throws -> String? { + guard let string = string, !string.isEmpty else { return nil } if !isHttpUrl(url: string) { return try formatNativeUrlString(string) diff --git a/Sources/WalletConnectModal/Modal/Screens/WalletDetail.swift b/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetail.swift similarity index 50% rename from Sources/WalletConnectModal/Modal/Screens/WalletDetail.swift rename to Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetail.swift index c8f56798c..3bae82d17 100644 --- a/Sources/WalletConnectModal/Modal/Screens/WalletDetail.swift +++ b/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetail.swift @@ -3,29 +3,71 @@ import SwiftUI struct WalletDetail: View { @Environment(\.verticalSizeClass) var verticalSizeClass - @State var wallet: Listing - @State var retryShown: Bool = false + @ObservedObject var viewModel: WalletDetailViewModel - let deeplink: (Listing) -> Void - var deeplinkUniversal: (Listing) -> Void - var openAppStore: (Listing) -> Void + @State var retryShown: Bool = false var body: some View { - content() - .onAppear { - if verticalSizeClass == .compact { - retryShown = true - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - withAnimation { - retryShown = true + VStack { + if viewModel.showToggle { + Web3ModalPicker( + WalletDetailViewModel.Platform.allCases, + selection: viewModel.preferredPlatform + ) { item in + + HStack { + switch item { + case .native: + Image(systemName: "iphone") + case .browser: + Image(systemName: "safari") + } + Text(item.rawValue.capitalized) + } + .font(.system(size: 14).weight(.semibold)) + .multilineTextAlignment(.center) + .foregroundColor(viewModel.preferredPlatform == item ? .foreground1 : .foreground2) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.15)) { + viewModel.preferredPlatform = item } } } + .pickerBackgroundColor(.background2) + .cornerRadius(20) + .borderWidth(1) + .borderColor(.thinOverlay) + .accentColor(.thinOverlay) + .frame(maxWidth: 250) + .padding() } - .onDisappear { - retryShown = false - } + + content() + .onAppear { + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewModel.handle(.onAppear) + } + + if verticalSizeClass == .compact { + retryShown = true + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + retryShown = true + } + } + } + } + .onDisappear { + retryShown = false + } + .animation(.easeInOut, value: viewModel.preferredPlatform) + } } @ViewBuilder @@ -41,9 +83,11 @@ struct WalletDetail: View { retrySection() } - Divider() - - appStoreRow() + VStack { + Divider() + appStoreRow() + } + .opacity(viewModel.preferredPlatform != .native ? 0 : 1) } .padding(.horizontal, 20) } @@ -56,12 +100,15 @@ struct WalletDetail: View { VStack(spacing: 15) { if retryShown { retrySection() + .frame(maxWidth: .infinity) .padding(.top, 15) } - Divider() - - appStoreRow() + VStack { + Divider() + appStoreRow() + } + .opacity(viewModel.preferredPlatform != .native ? 0 : 1) } .padding(.horizontal, 20) .padding(.bottom, 40) @@ -72,7 +119,7 @@ struct WalletDetail: View { func walletImage() -> some View { VStack(spacing: 20) { - WalletImage(wallet: wallet, size: .large) + WalletImage(wallet: viewModel.wallet, size: .large) .frame(width: 96, height: 96) .cornerRadius(24) .overlay( @@ -80,7 +127,7 @@ struct WalletDetail: View { .stroke(.gray.opacity(0.4), lineWidth: 1) ) - Text("Continue in \(wallet.name)...") + Text("Continue in \(viewModel.wallet.name)...") .font(.system(size: 16, weight: .medium)) .foregroundColor(.foreground1) } @@ -88,10 +135,7 @@ struct WalletDetail: View { func retrySection() -> some View { VStack(spacing: 15) { - let hasUniversalLink = wallet.mobile.universal?.isEmpty == false - let hasNativeLink = wallet.mobile.native?.isEmpty == false - - Text("You can try opening \(wallet.name) again \((hasNativeLink && hasUniversalLink) ? "or try using a Universal Link instead" : "")") + Text("You can try opening \(viewModel.wallet.name) again \((viewModel.hasNativeLink && viewModel.showUniversalLink) ? "or try using a Universal Link instead" : "")") .font(.system(size: 14, weight: .medium)) .multilineTextAlignment(.center) .foregroundColor(.foreground2) @@ -99,15 +143,15 @@ struct WalletDetail: View { HStack { Button { - deeplink(wallet) + viewModel.handle(.didTapTryAgain) } label: { Text("Try Again") } .buttonStyle(WCMAccentButtonStyle()) - if hasUniversalLink { + if viewModel.showUniversalLink { Button { - deeplinkUniversal(wallet) + viewModel.handle(.didTapUniversalLink) } label: { Text("Universal link") } @@ -115,16 +159,17 @@ struct WalletDetail: View { } } } + .frame(height: 100) } func appStoreRow() -> some View { HStack(spacing: 0) { HStack(spacing: 10) { - WalletImage(wallet: wallet, size: .small) + WalletImage(wallet: viewModel.wallet, size: .small) .frame(width: 28, height: 28) .cornerRadius(8) - Text("Get \(wallet.name)") + Text("Get \(viewModel.wallet.name)") .font(.system(size: 16).weight(.semibold)) .foregroundColor(.foreground1) } @@ -141,7 +186,7 @@ struct WalletDetail: View { } } .onTapGesture { - openAppStore(wallet) + viewModel.handle(.didTapAppStore) } } } diff --git a/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetailViewModel.swift b/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetailViewModel.swift new file mode 100644 index 000000000..4b146927c --- /dev/null +++ b/Sources/WalletConnectModal/Modal/Screens/WalletDetail/WalletDetailViewModel.swift @@ -0,0 +1,63 @@ +import Foundation + +final class WalletDetailViewModel: ObservableObject { + enum Platform: String, CaseIterable, Identifiable { + case native + case browser + + var id: Self { self } + } + + enum Event { + case onAppear + case didTapUniversalLink + case didTapTryAgain + case didTapAppStore + } + + let wallet: Listing + let deeplinkHandler: WalletDeeplinkHandler + + @Published var preferredPlatform: Platform = .native + + var showToggle: Bool { wallet.app.browser != nil && wallet.app.ios != nil } + var showUniversalLink: Bool { preferredPlatform == .native && wallet.mobile.universal?.isEmpty == false } + var hasNativeLink: Bool { wallet.mobile.native?.isEmpty == false } + + init( + wallet: Listing, + deeplinkHandler: WalletDeeplinkHandler + ) { + self.wallet = wallet + self.deeplinkHandler = deeplinkHandler + preferredPlatform = wallet.app.ios != nil ? .native : .browser + } + + func handle(_ event: Event) { + switch event { + case .onAppear: + deeplinkHandler.navigateToDeepLink( + wallet: wallet, + preferUniversal: true, + preferBrowser: preferredPlatform == .browser + ) + + case .didTapUniversalLink: + deeplinkHandler.navigateToDeepLink( + wallet: wallet, + preferUniversal: true, + preferBrowser: preferredPlatform == .browser + ) + + case .didTapTryAgain: + deeplinkHandler.navigateToDeepLink( + wallet: wallet, + preferUniversal: false, + preferBrowser: preferredPlatform == .browser + ) + + case .didTapAppStore: + deeplinkHandler.openAppstore(wallet: wallet) + } + } +} diff --git a/Sources/WalletConnectModal/Networking/Explorer/ListingsResponse.swift b/Sources/WalletConnectModal/Networking/Explorer/ListingsResponse.swift index bc7c91619..3eef522e8 100644 --- a/Sources/WalletConnectModal/Networking/Explorer/ListingsResponse.swift +++ b/Sources/WalletConnectModal/Networking/Explorer/ListingsResponse.swift @@ -11,8 +11,9 @@ struct Listing: Codable, Hashable, Identifiable { let order: Int? let imageId: String let app: App - let mobile: Mobile - var lastTimeUsed: Date? + let mobile: Links + let desktop: Links + var lastTimeUsed: Date? private enum CodingKeys: String, CodingKey { case id @@ -22,16 +23,16 @@ struct Listing: Codable, Hashable, Identifiable { case imageId = "image_id" case app case mobile + case desktop case lastTimeUsed } struct App: Codable, Hashable { let ios: String? - let mac: String? - let safari: String? + let browser: String? } - struct Mobile: Codable, Hashable { + struct Links: Codable, Hashable { let native: String? let universal: String? } diff --git a/Sources/WalletConnectModal/Resources/Color.swift b/Sources/WalletConnectModal/Resources/Color.swift index 35fa0a860..3a01b85f8 100644 --- a/Sources/WalletConnectModal/Resources/Color.swift +++ b/Sources/WalletConnectModal/Resources/Color.swift @@ -30,7 +30,7 @@ extension Color { static let negative = Color(AssetColor.negative) static let thickOverlay = Color(AssetColor.thickOverlay) static let thinOverlay = Color(AssetColor.thinOverlay) - static let accent = Color(AssetColor.accent) + static var accent = Color(AssetColor.accent) } #if canImport(UIKit) diff --git a/Sources/WalletConnectModal/UI/Common/Web3ModalPicker.swift b/Sources/WalletConnectModal/UI/Common/Web3ModalPicker.swift new file mode 100644 index 000000000..0822b7fc9 --- /dev/null +++ b/Sources/WalletConnectModal/UI/Common/Web3ModalPicker.swift @@ -0,0 +1,127 @@ +import SwiftUI + +struct Web3ModalPicker: View where Data: Hashable, Content: View { + let sources: [Data] + let selection: Data? + let itemBuilder: (Data) -> Content + + @State private var backgroundColor: Color = Color.black.opacity(0.05) + + func pickerBackgroundColor(_ color: Color) -> Web3ModalPicker { + var view = self + view._backgroundColor = State(initialValue: color) + return view + } + + @State private var cornerRadius: CGFloat? + + func cornerRadius(_ cornerRadius: CGFloat) -> Web3ModalPicker { + var view = self + view._cornerRadius = State(initialValue: cornerRadius) + return view + } + + @State private var borderColor: Color? + + func borderColor(_ borderColor: Color) -> Web3ModalPicker { + var view = self + view._borderColor = State(initialValue: borderColor) + return view + } + + @State private var borderWidth: CGFloat? + + func borderWidth(_ borderWidth: CGFloat) -> Web3ModalPicker { + var view = self + view._borderWidth = State(initialValue: borderWidth) + return view + } + + private var customIndicator: AnyView? + + init( + _ sources: [Data], + selection: Data?, + @ViewBuilder itemBuilder: @escaping (Data) -> Content + ) { + self.sources = sources + self.selection = selection + self.itemBuilder = itemBuilder + } + + public var body: some View { + ZStack(alignment: .center) { + if let selection = selection, let selectedIdx = sources.firstIndex(of: selection) { + + GeometryReader { geo in + RoundedRectangle(cornerRadius: cornerRadius ?? 6.0) + .stroke(borderColor ?? .clear, lineWidth: borderWidth ?? 0) + .foregroundColor(.accentColor) + .padding(EdgeInsets(top: borderWidth ?? 2, leading: borderWidth ?? 2, bottom: borderWidth ?? 2, trailing: borderWidth ?? 2)) + .frame(width: geo.size.width / CGFloat(sources.count)) + .animation(.spring().speed(1.5), value: selection) + .offset(x: geo.size.width / CGFloat(sources.count) * CGFloat(selectedIdx), y: 0) + }.frame(height: 32) + } + + HStack(spacing: 0) { + ForEach(sources, id: \.self) { item in + itemBuilder(item) + } + } + } + .background( + RoundedRectangle(cornerRadius: cornerRadius ?? 6.0) + .fill(backgroundColor) + .padding(-5) + ) + } +} + +struct PreviewWeb3ModalPicker: View { + + enum Platform: String, CaseIterable { + case native + case browser + } + + @State private var selectedItem: Platform? = .native + + var body: some View { + Web3ModalPicker( + Platform.allCases, + selection: selectedItem + ) { item in + + HStack { + Image(systemName: "iphone") + Text(item.rawValue.capitalized) + } + .font(.system(size: 14).weight(.semibold)) + .multilineTextAlignment(.center) + .foregroundColor(selectedItem == item ? .foreground1 : .foreground2) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.15)) { + selectedItem = item + } + } + } + .pickerBackgroundColor(.background2) + .cornerRadius(20) + .borderWidth(1) + .borderColor(.thinOverlay) + .accentColor(.thinOverlay) + .frame(maxWidth: 250) + .padding() + } +} + +struct Web3ModalPicker_Previews: PreviewProvider { + static var previews: some View { + PreviewWeb3ModalPicker() + } +} diff --git a/Sources/WalletConnectModal/WalletConnectModal.swift b/Sources/WalletConnectModal/WalletConnectModal.swift index d405d9178..7fe3c3674 100644 --- a/Sources/WalletConnectModal/WalletConnectModal.swift +++ b/Sources/WalletConnectModal/WalletConnectModal.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI #if canImport(UIKit) import UIKit @@ -53,7 +54,8 @@ public class WalletConnectModal { metadata: AppMetadata, sessionParams: SessionParams = .default, recommendedWalletIds: [String] = [], - excludedWalletIds: [String] = [] + excludedWalletIds: [String] = [], + accentColor: Color? = nil ) { Pair.configure(metadata: metadata) WalletConnectModal.config = WalletConnectModal.Config( @@ -63,6 +65,10 @@ public class WalletConnectModal { recommendedWalletIds: recommendedWalletIds, excludedWalletIds: excludedWalletIds ) + + if let accentColor { + Color.accent = accentColor + } } public static func set(sessionParams: SessionParams) { diff --git a/Sources/WalletConnectNetworking/NetworkInteracting.swift b/Sources/WalletConnectNetworking/NetworkInteracting.swift index ca15160a8..507e82603 100644 --- a/Sources/WalletConnectNetworking/NetworkInteracting.swift +++ b/Sources/WalletConnectNetworking/NetworkInteracting.swift @@ -3,6 +3,7 @@ import Combine public protocol NetworkInteracting { var socketConnectionStatusPublisher: AnyPublisher { get } + var networkConnectionStatusPublisher: AnyPublisher { get } var requestPublisher: AnyPublisher<(topic: String, request: RPCRequest, decryptedPayload: Data, publishedAt: Date, derivedTopic: String?), Never> { get } func subscribe(topic: String) async throws func unsubscribe(topic: String) diff --git a/Sources/WalletConnectNetworking/NetworkingInteractor.swift b/Sources/WalletConnectNetworking/NetworkingInteractor.swift index d51379c97..76135c8fa 100644 --- a/Sources/WalletConnectNetworking/NetworkingInteractor.swift +++ b/Sources/WalletConnectNetworking/NetworkingInteractor.swift @@ -1,7 +1,6 @@ import Foundation import Combine - public class NetworkingInteractor: NetworkInteracting { private var publishers = Set() private let relayClient: RelayClient @@ -24,8 +23,10 @@ public class NetworkingInteractor: NetworkInteracting { logger.logsPublisher.eraseToAnyPublisher() } - + public var networkConnectionStatusPublisher: AnyPublisher public var socketConnectionStatusPublisher: AnyPublisher + + private let networkMonitor: NetworkMonitoring public init( relayClient: RelayClient, @@ -38,6 +39,8 @@ public class NetworkingInteractor: NetworkInteracting { self.rpcHistory = rpcHistory self.logger = logger self.socketConnectionStatusPublisher = relayClient.socketConnectionStatusPublisher + self.networkMonitor = NetworkMonitor() + self.networkConnectionStatusPublisher = networkMonitor.networkConnectionStatusPublisher setupRelaySubscribtion() } diff --git a/Sources/WalletConnectPairing/PairingClient.swift b/Sources/WalletConnectPairing/PairingClient.swift index 389c11354..3eafd2838 100644 --- a/Sources/WalletConnectPairing/PairingClient.swift +++ b/Sources/WalletConnectPairing/PairingClient.swift @@ -7,6 +7,7 @@ public class PairingClient: PairingRegisterer, PairingInteracting, PairingClient } public let socketConnectionStatusPublisher: AnyPublisher + private let pairingStorage: WCPairingStorage private let walletPairService: WalletPairService private let appPairService: AppPairService private let appPairActivateService: AppPairActivationService @@ -22,20 +23,23 @@ public class PairingClient: PairingRegisterer, PairingInteracting, PairingClient private let cleanupService: PairingCleanupService - init(appPairService: AppPairService, - networkingInteractor: NetworkInteracting, - logger: ConsoleLogging, - walletPairService: WalletPairService, - deletePairingService: DeletePairingService, - resubscribeService: PairingResubscribeService, - expirationService: ExpirationService, - pairingRequestsSubscriber: PairingRequestsSubscriber, - appPairActivateService: AppPairActivationService, - cleanupService: PairingCleanupService, - pingService: PairingPingService, - socketConnectionStatusPublisher: AnyPublisher, - pairingsProvider: PairingsProvider + init( + pairingStorage: WCPairingStorage, + appPairService: AppPairService, + networkingInteractor: NetworkInteracting, + logger: ConsoleLogging, + walletPairService: WalletPairService, + deletePairingService: DeletePairingService, + resubscribeService: PairingResubscribeService, + expirationService: ExpirationService, + pairingRequestsSubscriber: PairingRequestsSubscriber, + appPairActivateService: AppPairActivationService, + cleanupService: PairingCleanupService, + pingService: PairingPingService, + socketConnectionStatusPublisher: AnyPublisher, + pairingsProvider: PairingsProvider ) { + self.pairingStorage = pairingStorage self.appPairService = appPairService self.walletPairService = walletPairService self.networkingInteractor = networkingInteractor @@ -81,6 +85,15 @@ public class PairingClient: PairingRegisterer, PairingInteracting, PairingClient public func activate(pairingTopic: String, peerMetadata: AppMetadata?) { appPairActivateService.activate(for: pairingTopic, peerMetadata: peerMetadata) } + + public func setReceived(pairingTopic: String) { + guard var pairing = pairingStorage.getPairing(forTopic: pairingTopic) else { + return logger.error("Pairing not found for topic: \(pairingTopic)") + } + + pairing.receivedRequest() + pairingStorage.setPairing(pairing) + } public func getPairings() -> [Pairing] { pairingsProvider.getPairings() diff --git a/Sources/WalletConnectPairing/PairingClientFactory.swift b/Sources/WalletConnectPairing/PairingClientFactory.swift index 8666747ac..9706702af 100644 --- a/Sources/WalletConnectPairing/PairingClientFactory.swift +++ b/Sources/WalletConnectPairing/PairingClientFactory.swift @@ -24,6 +24,7 @@ public struct PairingClientFactory { let resubscribeService = PairingResubscribeService(networkInteractor: networkingClient, pairingStorage: pairingStore) return PairingClient( + pairingStorage: pairingStore, appPairService: appPairService, networkingInteractor: networkingClient, logger: logger, diff --git a/Sources/WalletConnectPairing/PairingRegisterer.swift b/Sources/WalletConnectPairing/PairingRegisterer.swift index 6aa017038..e09cf9a42 100644 --- a/Sources/WalletConnectPairing/PairingRegisterer.swift +++ b/Sources/WalletConnectPairing/PairingRegisterer.swift @@ -7,5 +7,6 @@ public protocol PairingRegisterer { ) -> AnyPublisher, Never> func activate(pairingTopic: String, peerMetadata: AppMetadata?) + func setReceived(pairingTopic: String) func validatePairingExistance(_ topic: String) throws } diff --git a/Sources/WalletConnectPairing/Services/Wallet/WalletPairService.swift b/Sources/WalletConnectPairing/Services/Wallet/WalletPairService.swift index cfbd6c930..d14d82e3c 100644 --- a/Sources/WalletConnectPairing/Services/Wallet/WalletPairService.swift +++ b/Sources/WalletConnectPairing/Services/Wallet/WalletPairService.swift @@ -3,6 +3,7 @@ import Foundation actor WalletPairService { enum Errors: Error { case pairingAlreadyExist(topic: String) + case networkNotConnected } let networkingInteractor: NetworkInteracting @@ -21,16 +22,42 @@ actor WalletPairService { guard !hasPairing(for: uri.topic) else { throw Errors.pairingAlreadyExist(topic: uri.topic) } - var pairing = WCPairing(uri: uri) + + let pairing = WCPairing(uri: uri) let symKey = try SymmetricKey(hex: uri.symKey) try kms.setSymmetricKey(symKey, for: pairing.topic) - pairing.activate() pairingStorage.setPairing(pairing) + + let networkConnectionStatus = await resolveNetworkConnectionStatus() + guard networkConnectionStatus == .connected else { + throw Errors.networkNotConnected + } + try await networkingInteractor.subscribe(topic: pairing.topic) } +} +// MARK: - Private functions +extension WalletPairService { func hasPairing(for topic: String) -> Bool { - return pairingStorage.hasPairing(forTopic: topic) + if let pairing = pairingStorage.getPairing(forTopic: topic) { + return pairing.requestReceived + } + return false + } + + private func resolveNetworkConnectionStatus() async -> NetworkConnectionStatus { + return await withCheckedContinuation { continuation in + let cancellable = networkingInteractor.networkConnectionStatusPublisher.sink { value in + continuation.resume(returning: value) + } + + Task(priority: .high) { + await withTaskCancellationHandler { + cancellable.cancel() + } onCancel: { } + } + } } } @@ -38,7 +65,8 @@ actor WalletPairService { extension WalletPairService.Errors: LocalizedError { var errorDescription: String? { switch self { - case .pairingAlreadyExist(let topic): return "Pairing with topic (\(topic)) already exist" + case .pairingAlreadyExist(let topic): return "Pairing with topic (\(topic)) already exists. Use 'Web3Wallet.instance.getPendingProposals()' or 'Web3Wallet.instance.getPendingRequests()' in order to receive proposals or requests." + case .networkNotConnected: return "Pairing failed. You seem to be offline" } } } diff --git a/Sources/WalletConnectPairing/Types/WCPairing.swift b/Sources/WalletConnectPairing/Types/WCPairing.swift index 6ceb323cd..d87bd8946 100644 --- a/Sources/WalletConnectPairing/Types/WCPairing.swift +++ b/Sources/WalletConnectPairing/Types/WCPairing.swift @@ -11,6 +11,7 @@ public struct WCPairing: SequenceObject { public private (set) var peerMetadata: AppMetadata? public private (set) var expiryDate: Date public private (set) var active: Bool + public private (set) var requestReceived: Bool #if DEBUG public static var dateInitializer: () -> Date = Date.init @@ -26,11 +27,12 @@ public struct WCPairing: SequenceObject { 30 * .day } - public init(topic: String, relay: RelayProtocolOptions, peerMetadata: AppMetadata, isActive: Bool = false, expiryDate: Date) { + public init(topic: String, relay: RelayProtocolOptions, peerMetadata: AppMetadata, isActive: Bool = false, requestReceived: Bool = false, expiryDate: Date) { self.topic = topic self.relay = relay self.peerMetadata = peerMetadata self.active = isActive + self.requestReceived = requestReceived self.expiryDate = expiryDate } @@ -38,6 +40,7 @@ public struct WCPairing: SequenceObject { self.topic = topic self.relay = RelayProtocolOptions(protocol: "irn", data: nil) self.active = false + self.requestReceived = false self.expiryDate = Self.dateInitializer().advanced(by: Self.timeToLiveInactive) } @@ -45,6 +48,7 @@ public struct WCPairing: SequenceObject { self.topic = uri.topic self.relay = uri.relay self.active = false + self.requestReceived = false self.expiryDate = Self.dateInitializer().advanced(by: Self.timeToLiveInactive) } @@ -52,6 +56,10 @@ public struct WCPairing: SequenceObject { active = true try? updateExpiry() } + + public mutating func receivedRequest() { + requestReceived = true + } public mutating func updatePeerMetadata(_ metadata: AppMetadata?) { peerMetadata = metadata diff --git a/Sources/WalletConnectRelay/NetworkMonitoring.swift b/Sources/WalletConnectRelay/NetworkMonitoring.swift index c4200171f..1d3932db5 100644 --- a/Sources/WalletConnectRelay/NetworkMonitoring.swift +++ b/Sources/WalletConnectRelay/NetworkMonitoring.swift @@ -1,27 +1,32 @@ import Foundation +import Combine import Network -protocol NetworkMonitoring: AnyObject { - var onSatisfied: (() -> Void)? {get set} - var onUnsatisfied: (() -> Void)? {get set} - func startMonitoring() +public enum NetworkConnectionStatus { + case connected + case notConnected } -class NetworkMonitor: NetworkMonitoring { - var onSatisfied: (() -> Void)? - var onUnsatisfied: (() -> Void)? - - private let monitor = NWPathMonitor() - private let monitorQueue = DispatchQueue(label: "com.walletconnect.sdk.network.monitor") +public protocol NetworkMonitoring: AnyObject { + var networkConnectionStatusPublisher: AnyPublisher { get } +} - func startMonitoring() { - monitor.pathUpdateHandler = { [weak self] path in - if path.status == .satisfied { - self?.onSatisfied?() - } else { - self?.onUnsatisfied?() - } +public final class NetworkMonitor: NetworkMonitoring { + private let networkMonitor = NWPathMonitor() + private let workerQueue = DispatchQueue(label: "com.walletconnect.sdk.network.monitor") + + private let networkConnectionStatusPublisherSubject = CurrentValueSubject(.connected) + + public var networkConnectionStatusPublisher: AnyPublisher { + networkConnectionStatusPublisherSubject + .share() + .eraseToAnyPublisher() + } + + public init() { + networkMonitor.pathUpdateHandler = { [weak self] path in + self?.networkConnectionStatusPublisherSubject.send((path.status == .satisfied) ? .connected : .notConnected) } - monitor.start(queue: monitorQueue) + networkMonitor.start(queue: workerQueue) } } diff --git a/Sources/WalletConnectRelay/PackageConfig.json b/Sources/WalletConnectRelay/PackageConfig.json index c331e32ba..9ed5ec2e3 100644 --- a/Sources/WalletConnectRelay/PackageConfig.json +++ b/Sources/WalletConnectRelay/PackageConfig.json @@ -1 +1 @@ -{"version": "1.6.17"} +{"version": "1.6.18"} diff --git a/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift b/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift index 804ee14f0..99c61c61c 100644 --- a/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift +++ b/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift @@ -45,10 +45,12 @@ class AutomaticSocketConnectionHandler { } private func setUpNetworkMonitoring() { - networkMonitor.onSatisfied = { [weak self] in - self?.reconnectIfNeeded() + networkMonitor.networkConnectionStatusPublisher.sink { [weak self] networkConnectionStatus in + if networkConnectionStatus == .connected { + self?.reconnectIfNeeded() + } } - networkMonitor.startMonitoring() + .store(in: &publishers) } private func registerBackgroundTask() { diff --git a/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift b/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift index 924721f16..3071e12e6 100644 --- a/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift +++ b/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift @@ -298,6 +298,8 @@ private extension ApproveEngine { } proposalPayloadsStore.set(payload, forKey: proposal.proposer.publicKey) + pairingRegisterer.setReceived(pairingTopic: payload.topic) + Task(priority: .high) { let assertionId = payload.decryptedPayload.sha256().toHexString() do { diff --git a/Sources/WalletConnectVerify/OriginVerifier.swift b/Sources/WalletConnectVerify/OriginVerifier.swift index 039cccecf..2ab3267ae 100644 --- a/Sources/WalletConnectVerify/OriginVerifier.swift +++ b/Sources/WalletConnectVerify/OriginVerifier.swift @@ -5,22 +5,44 @@ public final class OriginVerifier { case registrationFailed } - private let verifyHost: String + private var verifyHost: String + /// The property is used to determine whether verify.walletconnect.org will be used + /// in case verify.walletconnect.com doesn't respond for some reason (most likely due to being blocked in the user's location). + private var fallback = false init(verifyHost: String) { self.verifyHost = verifyHost } func verifyOrigin(assertionId: String) async throws -> String { - let httpClient = HTTPNetworkClient(host: verifyHost) - let response = try await httpClient.request( - VerifyResponse.self, - at: VerifyAPI.resolve(assertionId: assertionId) - ) - guard let origin = response.origin else { - throw Errors.registrationFailed + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.timeoutIntervalForRequest = 5.0 + sessionConfiguration.timeoutIntervalForResource = 5.0 + let session = URLSession(configuration: sessionConfiguration) + + let httpClient = HTTPNetworkClient(host: verifyHost, session: session) + + do { + let response = try await httpClient.request( + VerifyResponse.self, + at: VerifyAPI.resolve(assertionId: assertionId) + ) + guard let origin = response.origin else { + throw Errors.registrationFailed + } + return origin + } catch { + if (error as? HTTPError) == .couldNotConnect && !fallback { + fallback = true + verifyHostFallback() + return try await verifyOrigin(assertionId: assertionId) + } + throw error } - return origin + } + + func verifyHostFallback() { + verifyHost = "verify.walletconnect.org" } } diff --git a/Tests/RelayerTests/AutomaticSocketConnectionHandlerTests.swift b/Tests/RelayerTests/AutomaticSocketConnectionHandlerTests.swift index 92c0895af..12f7c1d94 100644 --- a/Tests/RelayerTests/AutomaticSocketConnectionHandlerTests.swift +++ b/Tests/RelayerTests/AutomaticSocketConnectionHandlerTests.swift @@ -24,7 +24,7 @@ final class AutomaticSocketConnectionHandlerTests: XCTestCase { func testConnectsOnConnectionSatisfied() { webSocketSession.disconnect() XCTAssertFalse(webSocketSession.isConnected) - networkMonitor.onSatisfied?() + networkMonitor.networkConnectionStatusPublisherSubject.send(.connected) XCTAssertTrue(webSocketSession.isConnected) } diff --git a/Tests/RelayerTests/Mocks/NetworkMonitoringMock.swift b/Tests/RelayerTests/Mocks/NetworkMonitoringMock.swift index 7f6a5245e..1095d1677 100644 --- a/Tests/RelayerTests/Mocks/NetworkMonitoringMock.swift +++ b/Tests/RelayerTests/Mocks/NetworkMonitoringMock.swift @@ -1,9 +1,14 @@ import Foundation +import Combine + @testable import WalletConnectRelay class NetworkMonitoringMock: NetworkMonitoring { - var onSatisfied: (() -> Void)? - var onUnsatisfied: (() -> Void)? - - func startMonitoring() { } + var networkConnectionStatusPublisher: AnyPublisher { + networkConnectionStatusPublisherSubject.eraseToAnyPublisher() + } + + let networkConnectionStatusPublisherSubject = CurrentValueSubject(.connected) + + public init() { } } diff --git a/Tests/TestingUtils/Mocks/HTTPClientMock.swift b/Tests/TestingUtils/Mocks/HTTPClientMock.swift index 4746bed92..5593b4096 100644 --- a/Tests/TestingUtils/Mocks/HTTPClientMock.swift +++ b/Tests/TestingUtils/Mocks/HTTPClientMock.swift @@ -2,7 +2,6 @@ import Foundation @testable import HTTPClient public final class HTTPClientMock: HTTPClient { - private let object: T public init(object: T) { @@ -16,4 +15,8 @@ public final class HTTPClientMock: HTTPClient { public func request(service: HTTPService) async throws { } + + public func updateHost(host: String) async { + + } } diff --git a/Tests/TestingUtils/Mocks/PairingRegistererMock.swift b/Tests/TestingUtils/Mocks/PairingRegistererMock.swift index ee0cfec9b..6aa18183f 100644 --- a/Tests/TestingUtils/Mocks/PairingRegistererMock.swift +++ b/Tests/TestingUtils/Mocks/PairingRegistererMock.swift @@ -4,7 +4,6 @@ import Combine import WalletConnectNetworking public class PairingRegistererMock: PairingRegisterer where RequestParams: Codable { - public let subject = PassthroughSubject, Never>() public var isActivateCalled: Bool = false @@ -20,4 +19,8 @@ public class PairingRegistererMock: PairingRegisterer where Reque public func validatePairingExistance(_ topic: String) throws { } + + public func setReceived(pairingTopic: String) { + + } } diff --git a/Tests/TestingUtils/NetworkingInteractorMock.swift b/Tests/TestingUtils/NetworkingInteractorMock.swift index 14f7f9d24..175e6aed0 100644 --- a/Tests/TestingUtils/NetworkingInteractorMock.swift +++ b/Tests/TestingUtils/NetworkingInteractorMock.swift @@ -25,9 +25,14 @@ public class NetworkingInteractorMock: NetworkInteracting { var onRespondError: ((Int) -> Void)? public let socketConnectionStatusPublisherSubject = PassthroughSubject() + public let networkConnectionStatusPublisherSubject = CurrentValueSubject(.connected) + public var socketConnectionStatusPublisher: AnyPublisher { socketConnectionStatusPublisherSubject.eraseToAnyPublisher() } + public var networkConnectionStatusPublisher: AnyPublisher { + networkConnectionStatusPublisherSubject.eraseToAnyPublisher() + } public let requestPublisherSubject = PassthroughSubject<(topic: String, request: RPCRequest, decryptedPayload: Data, publishedAt: Date, derivedTopic: String?), Never>() public let responsePublisherSubject = PassthroughSubject<(topic: String, request: RPCRequest, response: RPCResponse, publishedAt: Date, derivedTopic: String?), Never>() diff --git a/Tests/WalletConnectModalTests/ModalViewModelTests.swift b/Tests/WalletConnectModalTests/ModalViewModelTests.swift index 6372b6dcc..55de25cc9 100644 --- a/Tests/WalletConnectModalTests/ModalViewModelTests.swift +++ b/Tests/WalletConnectModalTests/ModalViewModelTests.swift @@ -18,11 +18,47 @@ final class ModalViewModelTests: XCTestCase { sut = .init( isShown: .constant(true), interactor: ModalSheetInteractorMock(listings: [ - Listing(id: "1", name: "Sample App", homepage: "https://example.com", order: 1, imageId: "1", app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: nil, universal: "https://example.com/universal")), - Listing(id: "2", name: "Awesome App", homepage: "https://example.com/awesome", order: 2, imageId: "2", app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "awesomeapp://deeplink", universal: "https://awesome.com/awesome/universal")), + Listing( + id: "1", + name: "Sample App", + homepage: "https://example.com", + order: 1, + imageId: "1", + app: Listing.App( + ios: "https://example.com/download-ios", + browser: "https://example.com/wallet" + ), + mobile: Listing.Links( + native: nil, + universal: "https://example.com/universal" + ), + desktop: Listing.Links( + native: nil, + universal: "https://example.com/universal" + ) + ), + Listing( + id: "2", + name: "Awesome App", + homepage: "https://example.com/awesome", + order: 2, + imageId: "2", + app: Listing.App( + ios: "https://example.com/download-ios", + browser: "https://example.com/wallet" + ), + mobile: Listing.Links( + native: "awesomeapp://deeplink", + universal: "https://awesome.com/awesome/universal" + ), + desktop: Listing.Links( + native: "awesomeapp://deeplink", + universal: "https://awesome.com/awesome/desktop/universal" + ) + ), ]), uiApplicationWrapper: .init( - openURL: { url, _ in + openURL: { url, _ in self.openURLFuncTest.call(url) self.expectation.fulfill() }, @@ -53,8 +89,7 @@ final class ModalViewModelTests: XCTestCase { expectation = XCTestExpectation(description: "Wait for openUrl to be called") - sut.onListingTap(sut.wallets[0], preferUniversal: true) - + sut.navigateToDeepLink(wallet: sut.wallets[0], preferUniversal: true, preferBrowser: false) XCTWaiter.wait(for: [expectation], timeout: 3) XCTAssertEqual( @@ -64,8 +99,7 @@ final class ModalViewModelTests: XCTestCase { expectation = XCTestExpectation(description: "Wait for openUrl to be called using universal link") - sut.onListingTap(sut.wallets[1], preferUniversal: false) - + sut.navigateToDeepLink(wallet: sut.wallets[1], preferUniversal: false, preferBrowser: false) XCTWaiter.wait(for: [expectation], timeout: 3) XCTAssertEqual( @@ -73,16 +107,24 @@ final class ModalViewModelTests: XCTestCase { URL(string: "awesomeapp://deeplinkwc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! ) - expectation = XCTestExpectation(description: "Wait for openUrl to be called using native link") - sut.onListingTap(sut.wallets[1], preferUniversal: true) - + sut.navigateToDeepLink(wallet: sut.wallets[1], preferUniversal: true, preferBrowser: false) XCTWaiter.wait(for: [expectation], timeout: 3) XCTAssertEqual( openURLFuncTest.currentValue, URL(string: "https://awesome.com/awesome/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! ) + + expectation = XCTestExpectation(description: "Wait for openUrl to be called using native link") + + sut.navigateToDeepLink(wallet: sut.wallets[1], preferUniversal: false, preferBrowser: true) + XCTWaiter.wait(for: [expectation], timeout: 3) + + XCTAssertEqual( + openURLFuncTest.currentValue, + URL(string: "https://awesome.com/awesome/desktop/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! + ) } } diff --git a/Tests/WalletConnectPairingTests/WalletPairServiceTests.swift b/Tests/WalletConnectPairingTests/WalletPairServiceTests.swift index 7617aee57..facccf6b4 100644 --- a/Tests/WalletConnectPairingTests/WalletPairServiceTests.swift +++ b/Tests/WalletConnectPairingTests/WalletPairServiceTests.swift @@ -18,16 +18,20 @@ final class WalletPairServiceTestsTests: XCTestCase { cryptoMock = KeyManagementServiceMock() service = WalletPairService(networkingInteractor: networkingInteractor, kms: cryptoMock, pairingStorage: storageMock) } + + func testPairWhenNetworkNotConnectedThrows() async { + let uri = WalletConnectURI.stub() + networkingInteractor.networkConnectionStatusPublisherSubject.send(.notConnected) + await XCTAssertThrowsErrorAsync(try await service.pair(uri)) + } - func testPairMultipleTimesOnSameURIThrows() async { + func testPairOnSameURIWhenRequestReceivedThrows() async { let uri = WalletConnectURI.stub() - for i in 1...10 { - if i == 1 { - await XCTAssertNoThrowAsync(try await service.pair(uri)) - } else { - await XCTAssertThrowsErrorAsync(try await service.pair(uri)) - } - } + try! await service.pair(uri) + var pairing = storageMock.getPairing(forTopic: uri.topic) + pairing?.receivedRequest() + storageMock.setPairing(pairing!) + await XCTAssertThrowsErrorAsync(try await service.pair(uri)) } func testPair() async {