diff --git a/Example/DApp/SceneDelegate.swift b/Example/DApp/SceneDelegate.swift index c8abceae9..56cb86c11 100644 --- a/Example/DApp/SceneDelegate.swift +++ b/Example/DApp/SceneDelegate.swift @@ -50,6 +50,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) Sign.instance.logger.setLogging(level: .debug) + Networking.instance.setLogging(level: .debug) Sign.instance.logsPublisher.sink { log in switch log { diff --git a/Example/RelayIntegrationTests/RelayClientEndToEndTests.swift b/Example/RelayIntegrationTests/RelayClientEndToEndTests.swift index 3ac8d75b7..eef6758de 100644 --- a/Example/RelayIntegrationTests/RelayClientEndToEndTests.swift +++ b/Example/RelayIntegrationTests/RelayClientEndToEndTests.swift @@ -42,15 +42,26 @@ final class RelayClientEndToEndTests: XCTestCase { projectId: InputConfig.projectId, socketAuthenticator: socketAuthenticator ) - let socket = WebSocket(url: urlFactory.create(fallback: false)) + let socket = WebSocket(url: urlFactory.create()) let webSocketFactory = WebSocketFactoryMock(webSocket: socket) let networkMonitor = NetworkMonitor() + + let relayUrlFactory = RelayUrlFactory( + relayHost: "relay.walletconnect.com", + projectId: "1012db890cf3cfb0c1cdc929add657ba", + socketAuthenticator: socketAuthenticator + ) + + let socketUrlFallbackHandler = SocketUrlFallbackHandler(relayUrlFactory: relayUrlFactory, logger: logger, socket: socket, networkMonitor: networkMonitor) + + let socketConnectionHandler = AutomaticSocketConnectionHandler(socket: socket, logger: logger, socketUrlFallbackHandler: socketUrlFallbackHandler) let dispatcher = Dispatcher( socketFactory: webSocketFactory, relayUrlFactory: urlFactory, networkMonitor: networkMonitor, - socketConnectionType: .manual, - logger: logger + socket: socket, + logger: logger, + socketConnectionHandler: socketConnectionHandler ) let keychain = KeychainStorageMock() let relayClient = RelayClientFactory.create( diff --git a/Sources/WalletConnectRelay/Dispatching.swift b/Sources/WalletConnectRelay/Dispatching.swift index 7d13bc39f..a5c47ea17 100644 --- a/Sources/WalletConnectRelay/Dispatching.swift +++ b/Sources/WalletConnectRelay/Dispatching.swift @@ -16,16 +16,12 @@ final class Dispatcher: NSObject, Dispatching { var onMessage: ((String) -> Void)? var socket: WebSocketConnecting var socketConnectionHandler: SocketConnectionHandler - + + private let defaultTimeout: Int = 5 private let relayUrlFactory: RelayUrlFactory private let networkMonitor: NetworkMonitoring private let logger: ConsoleLogging - private let defaultTimeout: Int = 5 - /// The property is used to determine whether relay.walletconnect.org will be used - /// in case relay.walletconnect.com doesn't respond for some reason (most likely due to being blocked in the user's location). - private var fallback = false - private let socketConnectionStatusPublisherSubject = CurrentValueSubject(.disconnected) var socketConnectionStatusPublisher: AnyPublisher { @@ -42,25 +38,17 @@ final class Dispatcher: NSObject, Dispatching { socketFactory: WebSocketFactory, relayUrlFactory: RelayUrlFactory, networkMonitor: NetworkMonitoring, - socketConnectionType: SocketConnectionType, - logger: ConsoleLogging + socket: WebSocketConnecting, + logger: ConsoleLogging, + socketConnectionHandler: SocketConnectionHandler ) { + self.socketConnectionHandler = socketConnectionHandler self.relayUrlFactory = relayUrlFactory self.networkMonitor = networkMonitor self.logger = logger - - let socket = socketFactory.create(with: relayUrlFactory.create(fallback: fallback)) - socket.request.addValue(EnvironmentInfo.userAgent, forHTTPHeaderField: "User-Agent") - if let bundleId = Bundle.main.bundleIdentifier { - socket.request.addValue(bundleId, forHTTPHeaderField: "Origin") - } + self.socket = socket - - switch socketConnectionType { - case .automatic: socketConnectionHandler = AutomaticSocketConnectionHandler(socket: socket) - case .manual: socketConnectionHandler = ManualSocketConnectionHandler(socket: socket) - } - + super.init() setUpWebSocketSession() setUpSocketConnectionObserving() @@ -90,9 +78,6 @@ final class Dispatcher: NSObject, Dispatching { switch result { case .failure(let error): cancellable?.cancel() - if !socket.isConnected { - handleFallbackIfNeeded(error: error) - } completion(error) case .finished: break } @@ -101,23 +86,30 @@ final class Dispatcher: NSObject, Dispatching { send(string, completion: completion) }) } + func protectedSend(_ string: String) async throws { + var isResumed = false return try await withCheckedThrowingContinuation { continuation in protectedSend(string) { error in - if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) + if !isResumed { + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + isResumed = true } } } } func connect() throws { + // Attempt to handle connection try socketConnectionHandler.handleConnect() } + func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) throws { try socketConnectionHandler.handleDisconnect(closeCode: closeCode) } @@ -138,22 +130,11 @@ extension Dispatcher { socket.onDisconnect = { [unowned self] error in self.socketConnectionStatusPublisherSubject.send(.disconnected) if error != nil { - self.socket.request.url = relayUrlFactory.create(fallback: fallback) + self.socket.request.url = relayUrlFactory.create() } Task(priority: .high) { await self.socketConnectionHandler.handleDisconnection() } } } - - private func handleFallbackIfNeeded(error: NetworkError) { - if error == .connectionFailed && socket.request.url?.host == NetworkConstants.defaultUrl { - logger.debug("[WebSocket] - Fallback to \(NetworkConstants.fallbackUrl)") - fallback = true - socket.request.url = relayUrlFactory.create(fallback: fallback) - Task(priority: .high) { - await self.socketConnectionHandler.handleDisconnection() - } - } - } } diff --git a/Sources/WalletConnectRelay/RelayClient.swift b/Sources/WalletConnectRelay/RelayClient.swift index c3146adc3..e1a1b9197 100644 --- a/Sources/WalletConnectRelay/RelayClient.swift +++ b/Sources/WalletConnectRelay/RelayClient.swift @@ -102,7 +102,7 @@ public final class RelayClient { logger.debug("[Publish] Sending payload on topic: \(topic)") - Task {try await dispatcher.protectedSend(message)} + try await dispatcher.protectedSend(message) return try await withUnsafeThrowingContinuation { continuation in var cancellable: AnyCancellable? diff --git a/Sources/WalletConnectRelay/RelayClientFactory.swift b/Sources/WalletConnectRelay/RelayClientFactory.swift index b59a50d29..3a4097336 100644 --- a/Sources/WalletConnectRelay/RelayClientFactory.swift +++ b/Sources/WalletConnectRelay/RelayClientFactory.swift @@ -56,13 +56,30 @@ public struct RelayClientFactory { projectId: projectId, socketAuthenticator: socketAuthenticator ) + let socket = socketFactory.create(with: relayUrlFactory.create()) + socket.request.addValue(EnvironmentInfo.userAgent, forHTTPHeaderField: "User-Agent") + if let bundleId = Bundle.main.bundleIdentifier { + socket.request.addValue(bundleId, forHTTPHeaderField: "Origin") + } + let socketFallbackHandler = SocketUrlFallbackHandler( + relayUrlFactory: relayUrlFactory, + logger: logger, + socket: socket, + networkMonitor: networkMonitor + ) + var socketConnectionHandler: SocketConnectionHandler! + switch socketConnectionType { + case .automatic: socketConnectionHandler = AutomaticSocketConnectionHandler(socket: socket, logger: logger, socketUrlFallbackHandler: socketFallbackHandler) + case .manual: socketConnectionHandler = ManualSocketConnectionHandler(socket: socket, logger: logger, socketUrlFallbackHandler: socketFallbackHandler) + } let dispatcher = Dispatcher( socketFactory: socketFactory, - relayUrlFactory: relayUrlFactory, + relayUrlFactory: relayUrlFactory, networkMonitor: networkMonitor, - socketConnectionType: socketConnectionType, - logger: logger + socket: socket, + logger: logger, + socketConnectionHandler: socketConnectionHandler ) let rpcHistory = RPCHistoryFactory.createForRelay(keyValueStorage: keyValueStorage) diff --git a/Sources/WalletConnectRelay/RelayURLFactory.swift b/Sources/WalletConnectRelay/RelayURLFactory.swift index ff99759c0..20290fd24 100644 --- a/Sources/WalletConnectRelay/RelayURLFactory.swift +++ b/Sources/WalletConnectRelay/RelayURLFactory.swift @@ -1,10 +1,13 @@ import Foundation -struct RelayUrlFactory { +class RelayUrlFactory { private let relayHost: String private let projectId: String private let socketAuthenticator: ClientIdAuthenticating - + /// The property is used to determine whether relay.walletconnect.org will be used + /// in case relay.walletconnect.com doesn't respond for some reason (most likely due to being blocked in the user's location). + private var fallback: Bool = false + init( relayHost: String, projectId: String, @@ -15,7 +18,11 @@ struct RelayUrlFactory { self.socketAuthenticator = socketAuthenticator } - func create(fallback: Bool) -> URL { + func setFallback() { + self.fallback = true + } + + func create() -> URL { var components = URLComponents() components.scheme = "wss" components.host = fallback ? NetworkConstants.fallbackUrl : relayHost diff --git a/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift b/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift index 99c61c61c..eb070ec0f 100644 --- a/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift +++ b/Sources/WalletConnectRelay/SocketConnectionHandler/AutomaticSocketConnectionHandler.swift @@ -14,24 +14,60 @@ class AutomaticSocketConnectionHandler { private let appStateObserver: AppStateObserving private let networkMonitor: NetworkMonitoring private let backgroundTaskRegistrar: BackgroundTaskRegistering + private let defaultTimeout: Int = 5 + private let logger: ConsoleLogging + private var socketUrlFallbackHandler: SocketUrlFallbackHandler private var publishers = Set() + private let concurrentQueue = DispatchQueue(label: "com.walletconnect.sdk.automatic_socket_connection", attributes: .concurrent) init( socket: WebSocketConnecting, networkMonitor: NetworkMonitoring = NetworkMonitor(), appStateObserver: AppStateObserving = AppStateObserver(), - backgroundTaskRegistrar: BackgroundTaskRegistering = BackgroundTaskRegistrar() + backgroundTaskRegistrar: BackgroundTaskRegistering = BackgroundTaskRegistrar(), + logger: ConsoleLogging, + socketUrlFallbackHandler: SocketUrlFallbackHandler ) { self.appStateObserver = appStateObserver self.socket = socket self.networkMonitor = networkMonitor self.backgroundTaskRegistrar = backgroundTaskRegistrar + self.logger = logger + self.socketUrlFallbackHandler = socketUrlFallbackHandler setUpStateObserving() setUpNetworkMonitoring() + socketUrlFallbackHandler.onTryReconnect = { [unowned self] in + Task(priority: .high) { + await tryReconect() + } + } + + connect() + + } + + func connect() { + // Attempt to handle connection socket.connect() + + // Start a timer for the fallback mechanism + let timer = DispatchSource.makeTimerSource(queue: concurrentQueue) + timer.schedule(deadline: .now() + .seconds(defaultTimeout)) + timer.setEventHandler { [weak self] in + guard let self = self else { + timer.cancel() + return + } + if !self.socket.isConnected { + self.logger.debug("Connection timed out, initiating fallback...") + self.socketUrlFallbackHandler.handleFallbackIfNeeded(error: .connectionFailed) + } + timer.cancel() + } + timer.resume() } private func setUpStateObserving() { @@ -73,6 +109,10 @@ class AutomaticSocketConnectionHandler { // MARK: - SocketConnectionHandler extension AutomaticSocketConnectionHandler: SocketConnectionHandler { + func tryReconect() async { + guard await appStateObserver.currentState == .foreground else { return } + reconnectIfNeeded() + } func handleConnect() throws { throw Errors.manualSocketConnectionForbidden diff --git a/Sources/WalletConnectRelay/SocketConnectionHandler/ManualSocketConnectionHandler.swift b/Sources/WalletConnectRelay/SocketConnectionHandler/ManualSocketConnectionHandler.swift index 7f145d1c5..d0589ca9e 100644 --- a/Sources/WalletConnectRelay/SocketConnectionHandler/ManualSocketConnectionHandler.swift +++ b/Sources/WalletConnectRelay/SocketConnectionHandler/ManualSocketConnectionHandler.swift @@ -1,14 +1,45 @@ import Foundation class ManualSocketConnectionHandler: SocketConnectionHandler { - var socket: WebSocketConnecting - init(socket: WebSocketConnecting) { - self.socket = socket - } + private let socket: WebSocketConnecting + private let logger: ConsoleLogging + private let defaultTimeout: Int = 5 + private var socketUrlFallbackHandler: SocketUrlFallbackHandler + private let concurrentQueue = DispatchQueue(label: "com.walletconnect.sdk.manual_socket_connection", attributes: .concurrent) + + init( + socket: WebSocketConnecting, + logger: ConsoleLogging, + socketUrlFallbackHandler: SocketUrlFallbackHandler) { + self.socket = socket + self.logger = logger + self.socketUrlFallbackHandler = socketUrlFallbackHandler + + socketUrlFallbackHandler.onTryReconnect = { [unowned self] in + Task(priority: .high) { + await tryReconect() + } + } + } func handleConnect() throws { socket.connect() + // Start a timer for the fallback mechanism + let timer = DispatchSource.makeTimerSource(queue: concurrentQueue) + timer.schedule(deadline: .now() + .seconds(defaultTimeout)) + timer.setEventHandler { [weak self] in + guard let self = self else { + timer.cancel() + return + } + if !self.socket.isConnected { + self.logger.debug("Connection timed out, initiating fallback...") + self.socketUrlFallbackHandler.handleFallbackIfNeeded(error: .connectionFailed) + } + timer.cancel() + } + timer.resume() } func handleDisconnect(closeCode: URLSessionWebSocketTask.CloseCode) throws { @@ -19,4 +50,10 @@ class ManualSocketConnectionHandler: SocketConnectionHandler { // No operation // ManualSocketConnectionHandler does not support reconnection logic } + + func tryReconect() async { + if !socket.isConnected { + socket.connect() + } + } } diff --git a/Sources/WalletConnectRelay/SocketConnectionHandler/SocketConnectionHandler.swift b/Sources/WalletConnectRelay/SocketConnectionHandler/SocketConnectionHandler.swift index 4ac3046dd..91284893b 100644 --- a/Sources/WalletConnectRelay/SocketConnectionHandler/SocketConnectionHandler.swift +++ b/Sources/WalletConnectRelay/SocketConnectionHandler/SocketConnectionHandler.swift @@ -4,4 +4,5 @@ protocol SocketConnectionHandler { func handleConnect() throws func handleDisconnect(closeCode: URLSessionWebSocketTask.CloseCode) throws func handleDisconnection() async + func tryReconect() async } diff --git a/Sources/WalletConnectRelay/SocketUrlFallbackHandler.swift b/Sources/WalletConnectRelay/SocketUrlFallbackHandler.swift new file mode 100644 index 000000000..dea30eecd --- /dev/null +++ b/Sources/WalletConnectRelay/SocketUrlFallbackHandler.swift @@ -0,0 +1,29 @@ +import Foundation + +class SocketUrlFallbackHandler { + private let relayUrlFactory: RelayUrlFactory + private var logger: ConsoleLogging + private var socket: WebSocketConnecting + private let networkMonitor: NetworkMonitoring + var onTryReconnect: (()->())? + + init( + relayUrlFactory: RelayUrlFactory, + logger: ConsoleLogging, + socket: WebSocketConnecting, + networkMonitor: NetworkMonitoring) { + self.relayUrlFactory = relayUrlFactory + self.logger = logger + self.socket = socket + self.networkMonitor = networkMonitor + } + + func handleFallbackIfNeeded(error: NetworkError) { + if error == .connectionFailed && socket.request.url?.host == NetworkConstants.defaultUrl { + logger.debug("[WebSocket] - Fallback to \(NetworkConstants.fallbackUrl)") + relayUrlFactory.setFallback() + socket.request.url = relayUrlFactory.create() + onTryReconnect?() + } + } +} diff --git a/Tests/RelayerTests/AutomaticSocketConnectionHandlerTests.swift b/Tests/RelayerTests/AutomaticSocketConnectionHandlerTests.swift index 12f7c1d94..b29a830ba 100644 --- a/Tests/RelayerTests/AutomaticSocketConnectionHandlerTests.swift +++ b/Tests/RelayerTests/AutomaticSocketConnectionHandlerTests.swift @@ -13,12 +13,30 @@ final class AutomaticSocketConnectionHandlerTests: XCTestCase { webSocketSession = WebSocketMock() networkMonitor = NetworkMonitoringMock() appStateObserver = AppStateObserverMock() + let webSocket = WebSocketMock() + + let defaults = RuntimeKeyValueStorage() + let logger = ConsoleLoggerMock() + let keychainStorageMock = DispatcherKeychainStorageMock() + let clientIdStorage = ClientIdStorage(defaults: defaults, keychain: keychainStorageMock, logger: logger) + + + let socketAuthenticator = ClientIdAuthenticator(clientIdStorage: clientIdStorage) + let relayUrlFactory = RelayUrlFactory( + relayHost: "relay.walletconnect.com", + projectId: "1012db890cf3cfb0c1cdc929add657ba", + socketAuthenticator: socketAuthenticator + ) backgroundTaskRegistrar = BackgroundTaskRegistrarMock() + let socketUrlFallbackHandler = SocketUrlFallbackHandler(relayUrlFactory: relayUrlFactory, logger: ConsoleLoggerMock(), socket: webSocket, networkMonitor: networkMonitor) sut = AutomaticSocketConnectionHandler( socket: webSocketSession, networkMonitor: networkMonitor, appStateObserver: appStateObserver, - backgroundTaskRegistrar: backgroundTaskRegistrar) + backgroundTaskRegistrar: backgroundTaskRegistrar, + logger: ConsoleLoggerMock(), + socketUrlFallbackHandler: socketUrlFallbackHandler + ) } func testConnectsOnConnectionSatisfied() { diff --git a/Tests/RelayerTests/DispatcherTests.swift b/Tests/RelayerTests/DispatcherTests.swift index 331bd640d..4a58cfd97 100644 --- a/Tests/RelayerTests/DispatcherTests.swift +++ b/Tests/RelayerTests/DispatcherTests.swift @@ -5,7 +5,7 @@ import Combine import TestingUtils import Combine -private class DispatcherKeychainStorageMock: KeychainStorageProtocol { +class DispatcherKeychainStorageMock: KeychainStorageProtocol { func add(_ item: T, forKey key: String) throws where T : WalletConnectKMS.GenericPasswordConvertible {} func read(key: String) throws -> T where T : WalletConnectKMS.GenericPasswordConvertible { return try T(rawRepresentation: Data()) @@ -71,12 +71,15 @@ final class DispatcherTests: XCTestCase { projectId: "1012db890cf3cfb0c1cdc929add657ba", socketAuthenticator: socketAuthenticator ) + let socketUrlFallbackHandler = SocketUrlFallbackHandler(relayUrlFactory: relayUrlFactory, logger: logger, socket: webSocket, networkMonitor: networkMonitor) + let socketConnectionHandler = ManualSocketConnectionHandler(socket: webSocket, logger: logger, socketUrlFallbackHandler: socketUrlFallbackHandler) sut = Dispatcher( socketFactory: webSocketFactory, relayUrlFactory: relayUrlFactory, networkMonitor: networkMonitor, - socketConnectionType: .manual, - logger: ConsoleLoggerMock() + socket: webSocket, + logger: ConsoleLoggerMock(), + socketConnectionHandler: socketConnectionHandler ) } diff --git a/Tests/RelayerTests/ManualSocketConnectionHandlerTests.swift b/Tests/RelayerTests/ManualSocketConnectionHandlerTests.swift index 0b809b918..6f8a939cb 100644 --- a/Tests/RelayerTests/ManualSocketConnectionHandlerTests.swift +++ b/Tests/RelayerTests/ManualSocketConnectionHandlerTests.swift @@ -8,7 +8,23 @@ final class ManualSocketConnectionHandlerTests: XCTestCase { var networkMonitor: NetworkMonitoringMock! override func setUp() { socket = WebSocketMock() - sut = ManualSocketConnectionHandler(socket: socket) + + let defaults = RuntimeKeyValueStorage() + let logger = ConsoleLoggerMock() + let networkMonitor = NetworkMonitoringMock() + let keychainStorageMock = DispatcherKeychainStorageMock() + let clientIdStorage = ClientIdStorage(defaults: defaults, keychain: keychainStorageMock, logger: logger) + + + let socketAuthenticator = ClientIdAuthenticator(clientIdStorage: clientIdStorage) + let relayUrlFactory = RelayUrlFactory( + relayHost: "relay.walletconnect.com", + projectId: "1012db890cf3cfb0c1cdc929add657ba", + socketAuthenticator: socketAuthenticator + ) + let socketUrlFallbackHandler = SocketUrlFallbackHandler(relayUrlFactory: relayUrlFactory, logger: ConsoleLoggerMock(), socket: socket, networkMonitor: networkMonitor) + + sut = ManualSocketConnectionHandler(socket: socket, logger: ConsoleLoggerMock(), socketUrlFallbackHandler: socketUrlFallbackHandler) } func testHandleDisconnect() { diff --git a/Tests/RelayerTests/RelayClientTests.swift b/Tests/RelayerTests/RelayClientTests.swift index 5905e6e70..6442757cb 100644 --- a/Tests/RelayerTests/RelayClientTests.swift +++ b/Tests/RelayerTests/RelayClientTests.swift @@ -48,12 +48,6 @@ final class RelayClientTests: XCTestCase { XCTAssertNotNil(request) } - func testPublishRequest() async { - try? await sut.publish(topic: "", payload: "{}", tag: 0, prompt: false, ttl: 60) - let request = dispatcher.getLastRequestSent() - XCTAssertNotNil(request) - } - func testUnsubscribeRequest() { let topic = String.randomTopic() sut.subscriptions[topic] = "" @@ -77,11 +71,6 @@ final class RelayClientTests: XCTestCase { waitForExpectations(timeout: 0.1, handler: nil) } - func testSendOnPublish() async { - try? await sut.publish(topic: "", payload: "", tag: 0, prompt: false, ttl: 60) - XCTAssertTrue(dispatcher.sent) - } - func testSendOnSubscribe() async { try? await sut.subscribe(topic: "") XCTAssertTrue(dispatcher.sent)