From dae87f43696297d423f64c665190ff405f20b70a Mon Sep 17 00:00:00 2001 From: Theo Date: Fri, 13 Dec 2024 06:30:06 +0100 Subject: [PATCH 1/7] Point to NWWebsocket fork, update usage --- Package.resolved | 8 +- Package.swift | 2 +- .../PusherConnection+WebsocketDelegate.swift | 96 ++++++++++--------- Sources/PusherSwift.swift | 8 +- Tests/Helpers/Mocks.swift | 4 +- .../PusherConnectionDelegateTests.swift | 14 ++- 6 files changed, 73 insertions(+), 59 deletions(-) diff --git a/Package.resolved b/Package.resolved index 54a94f5c..9f016c6b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -3,11 +3,11 @@ "pins": [ { "package": "NWWebSocket", - "repositoryURL": "https://github.com/pusher/NWWebSocket.git", + "repositoryURL": "https://github.com/theolampert/NWWebSocket.git", "state": { - "branch": null, - "revision": "19d23951c8099304ad98e2fc762fa23d6bfab0b9", - "version": "0.5.3" + "branch": "main", + "revision": "c26d7c530444940a84e72a0d90c2248eeaacc14f", + "version": null } }, { diff --git a/Package.swift b/Package.swift index 6def1958..3bec7133 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,7 @@ let package = Package( .library(name: "PusherSwift", targets: ["PusherSwift"]) ], dependencies: [ - .package(url: "https://github.com/pusher/NWWebSocket.git", .upToNextMajor(from: "0.5.4")), + .package(url: "https://github.com/theolampert/NWWebSocket.git", .branch("main")), .package(url: "https://github.com/bitmark-inc/tweetnacl-swiftwrap", .upToNextMajor(from: "1.0.0")), ], targets: [ diff --git a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift index 8ed7beb8..afe5700c 100644 --- a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift +++ b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift @@ -48,7 +48,7 @@ extension PusherConnection: WebSocketConnectionDelegate { - parameter reason: Optional further information on the connection closure. */ public func webSocketDidDisconnect(connection: WebSocketConnection, - closeCode: NWProtocolWebSocket.CloseCode, + closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { resetConnection() @@ -64,7 +64,8 @@ extension PusherConnection: WebSocketConnectionDelegate { // Attempt reconnect if possible // `autoReconnect` option is ignored if the closure code is within the 4000-4999 range - if case .privateCode = closeCode {} else { + + if (4000...4999).contains(closeCode.rawValue) {} else { guard self.options.autoReconnect else { return } @@ -86,7 +87,7 @@ extension PusherConnection: WebSocketConnectionDelegate { } } - public func webSocketDidAttemptBetterPathMigration(result: Result) { + public func webSocketDidAttemptBetterPathMigration(result: Result) { switch result { case .success: updateConnectionState(to: .reconnecting) @@ -94,7 +95,7 @@ extension PusherConnection: WebSocketConnectionDelegate { case .failure(let error): Logger.shared.debug(for: .errorReceived, context: """ - Path migration error: \(error.debugDescription) + Path migration error: \(error) """) } } @@ -106,7 +107,7 @@ extension PusherConnection: WebSocketConnectionDelegate { `PusherChannelsProtocolCloseCode.ReconnectionStrategy`. - Parameter closeCode: The closure code received by the WebSocket connection. */ - func attemptReconnect(closeCode: NWProtocolWebSocket.CloseCode = .protocolCode(.normalClosure)) { + func attemptReconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure) { guard connectionState != .connected else { return } @@ -118,8 +119,8 @@ extension PusherConnection: WebSocketConnectionDelegate { // Reconnect attempt according to Pusher Channels Protocol close code (if present). // (Otherwise, the default behavior is to attempt reconnection after backing off). var channelsCloseCode: ChannelsProtocolCloseCode? - if case let .privateCode(code) = closeCode { - channelsCloseCode = ChannelsProtocolCloseCode(rawValue: code) + if (4000...4999).contains(closeCode.rawValue) { + channelsCloseCode = ChannelsProtocolCloseCode(rawValue: UInt16(closeCode.rawValue)) } let strategy = channelsCloseCode?.reconnectionStrategy ?? .reconnectAfterBackingOff @@ -186,29 +187,29 @@ extension PusherConnection: WebSocketConnectionDelegate { /// - Parameters: /// - closeCode: The closure code for the websocket connection. /// - reason: Optional further information on the connection closure. - func logDisconnection(closeCode: NWProtocolWebSocket.CloseCode, reason: Data?) { - var rawCode: UInt16! - switch closeCode { - case .protocolCode(let definedCode): - rawCode = definedCode.rawValue - - case .applicationCode(let applicationCode): - rawCode = applicationCode - - case .privateCode(let protocolCode): - rawCode = protocolCode - @unknown default: - fatalError() - } - - var closeMessage: String = "Close code: \(String(describing: rawCode))." - if let reason = reason, - let reasonString = String(data: reason, encoding: .utf8) { - closeMessage += " Reason: \(reasonString)." - } - - Logger.shared.debug(for: .disconnectionWithoutError, - context: closeMessage) + func logDisconnection(closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { +// var rawCode: UInt16! +// switch closeCode { +// case .protocolCode(let definedCode): +// rawCode = definedCode.rawValue +// +// case .applicationCode(let applicationCode): +// rawCode = applicationCode +// +// case .privateCode(let protocolCode): +// rawCode = protocolCode +// @unknown default: +// fatalError() +// } + +// var closeMessage: String = "Close code: \(String(describing: rawCode))." +// if let reason = reason, +// let reasonString = String(data: reason, encoding: .utf8) { +// closeMessage += " Reason: \(reasonString)." +// } +// +// Logger.shared.debug(for: .disconnectionWithoutError, +// context: closeMessage) } /** @@ -224,28 +225,29 @@ extension PusherConnection: WebSocketConnectionDelegate { // } - public func webSocketDidReceiveError(connection: WebSocketConnection, error: NWError) { + public func webSocketDidReceiveError(connection: WebSocketConnection, error: Error) { Logger.shared.debug(for: .errorReceived, context: """ - Error: \(error.debugDescription) + Error: \(error) """) // Resetting connection if we receive another POSIXError // than ENOTCONN (57 - Socket is not connected) - if case .posix(let code) = error, code != .ENOTCONN { - resetConnection() - - guard !intentionalDisconnect else { - Logger.shared.debug(for: .intentionalDisconnection) - return - } - - guard reconnectAttemptsMax == nil || reconnectAttempts < reconnectAttemptsMax! else { - Logger.shared.debug(for: .maxReconnectAttemptsLimitReached) - return - } - - attemptReconnect() - } + fatalError("TODO: FIX THIS PATH") +// if case .posix(let code) = error, code != .ENOTCONN { +// resetConnection() +// +// guard !intentionalDisconnect else { +// Logger.shared.debug(for: .intentionalDisconnection) +// return +// } +// +// guard reconnectAttemptsMax == nil || reconnectAttempts < reconnectAttemptsMax! else { +// Logger.shared.debug(for: .maxReconnectAttemptsLimitReached) +// return +// } +// +// attemptReconnect() +// } } } diff --git a/Sources/PusherSwift.swift b/Sources/PusherSwift.swift index 11261916..cb5c9401 100644 --- a/Sources/PusherSwift.swift +++ b/Sources/PusherSwift.swift @@ -27,9 +27,11 @@ let CLIENT_NAME = "pusher-websocket-swift" public init(key: String, options: PusherClientOptions = PusherClientOptions()) { self.key = key let urlString = URL.channelsSocketUrl(key: key, options: options) - let wsOptions = NWWebSocket.defaultOptions - wsOptions.setSubprotocols(["pusher-channels-protocol-\(PROTOCOL)"]) - let ws = NWWebSocket(url: URL(string: urlString)!, options: wsOptions) + var config = URLSessionConfiguration.default + config.httpAdditionalHeaders = [ + "Sec-WebSocket-Protocol": "pusher-channels-protocol-\(PROTOCOL)" + ] + let ws = NWWebSocket(url: URL(string: urlString)!, options: config) connection = PusherConnection(key: key, socket: ws, url: urlString, options: options) connection.createGlobalChannel() } diff --git a/Tests/Helpers/Mocks.swift b/Tests/Helpers/Mocks.swift index 57a3ee9f..39ce78cf 100644 --- a/Tests/Helpers/Mocks.swift +++ b/Tests/Helpers/Mocks.swift @@ -11,7 +11,7 @@ class MockWebSocket: NWWebSocket { var eventGivenToCallback: PusherEvent? init() { - super.init(url: URL(string: "test")!) + super.init(url: URL(string: "test")!, options: .default) } func appendToCallbackCheckString(_ str: String) { @@ -42,7 +42,7 @@ class MockWebSocket: NWWebSocket { ) } - override func disconnect(closeCode: NWProtocolWebSocket.CloseCode = .protocolCode(.normalClosure)) { + override func disconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure) { _ = stubber.stub( functionName: "disconnect", args: nil, diff --git a/Tests/Unit/Protocols/PusherConnectionDelegateTests.swift b/Tests/Unit/Protocols/PusherConnectionDelegateTests.swift index 652d5a36..6f94fa5f 100644 --- a/Tests/Unit/Protocols/PusherConnectionDelegateTests.swift +++ b/Tests/Unit/Protocols/PusherConnectionDelegateTests.swift @@ -81,7 +81,12 @@ class PusherConnectionDelegateTests: XCTestCase { isConnected.fulfill() // Spoof an unintentional disconnection event (that should not reconnect) - self.socket.disconnect(closeCode: .privateCode(ChannelsProtocolCloseCode.connectionIsUnauthorized.rawValue)) + let closeCode = URLSessionWebSocketTask.CloseCode( + rawValue: Int(ChannelsProtocolCloseCode.connectionIsUnauthorized.rawValue) + ) + self.socket.disconnect( + closeCode: closeCode! + ) } else if calls.count == 3 { XCTAssertEqual(calls[0].name, "connectionChange") XCTAssertEqual(calls[0].args?.first as? ConnectionState, ConnectionState.disconnected) @@ -115,7 +120,12 @@ class PusherConnectionDelegateTests: XCTestCase { isConnected.fulfill() // Spoof an unintentional disconnection event (that should attempt a reconnect) - self.socket.disconnect(closeCode: .privateCode(ChannelsProtocolCloseCode.genericReconnectImmediately.rawValue)) + let closeCode = URLSessionWebSocketTask.CloseCode( + rawValue: Int(ChannelsProtocolCloseCode.genericReconnectImmediately.rawValue) + ) + self.socket.disconnect( + closeCode: closeCode! + ) } else if calls.count == 6 { XCTAssertEqual(calls[0].name, "connectionChange") XCTAssertEqual(calls[0].args?.first as? ConnectionState, ConnectionState.disconnected) From 507f8b144d35a650f5ef1e69a1df15fdaddfabb1 Mon Sep 17 00:00:00 2001 From: Theo Date: Fri, 13 Dec 2024 07:47:07 +0100 Subject: [PATCH 2/7] Add back error handling --- .../PusherConnection+WebsocketDelegate.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift index afe5700c..58c99440 100644 --- a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift +++ b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift @@ -233,7 +233,21 @@ extension PusherConnection: WebSocketConnectionDelegate { // Resetting connection if we receive another POSIXError // than ENOTCONN (57 - Socket is not connected) - fatalError("TODO: FIX THIS PATH") + if let urlError = error as? URLError, urlError.code.rawValue != POSIXError.ENOTCONN.rawValue { + resetConnection() + + guard !intentionalDisconnect else { + Logger.shared.debug(for: .intentionalDisconnection) + return + } + + guard reconnectAttemptsMax == nil || reconnectAttempts < reconnectAttemptsMax! else { + Logger.shared.debug(for: .maxReconnectAttemptsLimitReached) + return + } + + attemptReconnect() + } // if case .posix(let code) = error, code != .ENOTCONN { // resetConnection() // From 59d32f0fa24b7a5d630a887a3b68909fed8a5849 Mon Sep 17 00:00:00 2001 From: Theo Date: Sat, 14 Dec 2024 11:05:09 +0100 Subject: [PATCH 3/7] Inline websocket client --- Package.resolved | 9 - Package.swift | 2 - .../PusherConnection+WebsocketDelegate.swift | 1 - Sources/Protocols/WebSocketConnection.swift | 93 +++++++++ Sources/PusherSwift.swift | 1 - Sources/Services/PusherConnection.swift | 1 - Sources/Services/WebSocketClient.swift | 191 ++++++++++++++++++ Tests/Helpers/Mocks.swift | 1 - 8 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 Sources/Protocols/WebSocketConnection.swift create mode 100644 Sources/Services/WebSocketClient.swift diff --git a/Package.resolved b/Package.resolved index 9f016c6b..c30ae33c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "NWWebSocket", - "repositoryURL": "https://github.com/theolampert/NWWebSocket.git", - "state": { - "branch": "main", - "revision": "c26d7c530444940a84e72a0d90c2248eeaacc14f", - "version": null - } - }, { "package": "TweetNacl", "repositoryURL": "https://github.com/bitmark-inc/tweetnacl-swiftwrap", diff --git a/Package.swift b/Package.swift index 3bec7133..8453d1d9 100644 --- a/Package.swift +++ b/Package.swift @@ -9,14 +9,12 @@ let package = Package( .library(name: "PusherSwift", targets: ["PusherSwift"]) ], dependencies: [ - .package(url: "https://github.com/theolampert/NWWebSocket.git", .branch("main")), .package(url: "https://github.com/bitmark-inc/tweetnacl-swiftwrap", .upToNextMajor(from: "1.0.0")), ], targets: [ .target( name: "PusherSwift", dependencies: [ - "NWWebSocket", "TweetNacl", ], path: "Sources" diff --git a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift index 58c99440..e545216e 100644 --- a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift +++ b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift @@ -1,6 +1,5 @@ import Foundation import Network -import NWWebSocket extension PusherConnection: WebSocketConnectionDelegate { diff --git a/Sources/Protocols/WebSocketConnection.swift b/Sources/Protocols/WebSocketConnection.swift new file mode 100644 index 00000000..5081fadb --- /dev/null +++ b/Sources/Protocols/WebSocketConnection.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Defines a WebSocket connection. +public protocol WebSocketConnection { + /// Connect to the WebSocket. + func connect() + + /// Send a UTF-8 formatted `String` over the WebSocket. + /// - Parameter string: The `String` that will be sent. + func send(string: String) + + /// Send some `Data` over the WebSocket. + /// - Parameter data: The `Data` that will be sent. + func send(data: Data) + + /// Start listening for messages over the WebSocket. + func listen() + + /// Ping the WebSocket periodically. + /// - Parameter interval: The `TimeInterval` (in seconds) with which to ping the server. + func ping(interval: TimeInterval) + + /// Ping the WebSocket once. + func ping() + + /// Disconnect from the WebSocket. + /// - Parameter closeCode: The code to use when closing the WebSocket connection. + func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) + + /// The WebSocket connection delegate. + var delegate: WebSocketConnectionDelegate? { get set } +} + +/// Defines a delegate for a WebSocket connection. +public protocol WebSocketConnectionDelegate: AnyObject { + /// Tells the delegate that the WebSocket did connect successfully. + /// - Parameter connection: The active `WebSocketConnection`. + func webSocketDidConnect(connection: WebSocketConnection) + + /// Tells the delegate that the WebSocket did disconnect. + /// - Parameters: + /// - connection: The `WebSocketConnection` that disconnected. + /// - closeCode: A `URLSessionWebSocketTask.CloseCode` describing how the connection closed. + /// - reason: Optional extra information explaining the disconnection. (Formatted as UTF-8 encoded `Data`). + func webSocketDidDisconnect(connection: WebSocketConnection, + closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data?) + + /// Tells the delegate that the WebSocket connection viability has changed. + /// + /// An example scenario of when this method would be called is a Wi-Fi connection being lost due to a device + /// moving out of signal range, and then the method would be called again once the device moved back in range. + /// - Parameters: + /// - connection: The `WebSocketConnection` whose viability has changed. + /// - isViable: A `Bool` indicating if the connection is viable or not. + func webSocketViabilityDidChange(connection: WebSocketConnection, + isViable: Bool) + + /// Tells the delegate that the WebSocket has attempted a migration based on a better network path becoming available. + /// + /// An example of when this method would be called is if a device is using a cellular connection, and a Wi-Fi connection + /// becomes available. This method will also be called if a device loses a Wi-Fi connection, and a cellular connection is available. + /// - Parameter result: A `Result` containing the `WebSocketConnection` if the migration was successful, or a + /// `NWError` if the migration failed for some reason. + func webSocketDidAttemptBetterPathMigration(result: Result) + + /// Tells the delegate that the WebSocket received an error. + /// + /// An error received by a WebSocket is not necessarily fatal. + /// - Parameters: + /// - connection: The `WebSocketConnection` that received an error. + /// - error: The `Error` that was received. + func webSocketDidReceiveError(connection: WebSocketConnection, + error: Error) + + /// Tells the delegate that the WebSocket received a 'pong' from the server. + /// - Parameter connection: The active `WebSocketConnection`. + func webSocketDidReceivePong(connection: WebSocketConnection) + + /// Tells the delegate that the WebSocket received a `String` message. + /// - Parameters: + /// - connection: The active `WebSocketConnection`. + /// - string: The UTF-8 formatted `String` that was received. + func webSocketDidReceiveMessage(connection: WebSocketConnection, + string: String) + + /// Tells the delegate that the WebSocket received a binary `Data` message. + /// - Parameters: + /// - connection: The active `WebSocketConnection`. + /// - data: The `Data` that was received. + func webSocketDidReceiveMessage(connection: WebSocketConnection, + data: Data) +} diff --git a/Sources/PusherSwift.swift b/Sources/PusherSwift.swift index cb5c9401..851b0342 100644 --- a/Sources/PusherSwift.swift +++ b/Sources/PusherSwift.swift @@ -1,5 +1,4 @@ import Foundation -import NWWebSocket let PROTOCOL = 7 let VERSION = "10.1.5" diff --git a/Sources/Services/PusherConnection.swift b/Sources/Services/PusherConnection.swift index 16c55cb3..5f232d9b 100644 --- a/Sources/Services/PusherConnection.swift +++ b/Sources/Services/PusherConnection.swift @@ -1,5 +1,4 @@ import Foundation -import NWWebSocket // swiftlint:disable file_length type_body_length diff --git a/Sources/Services/WebSocketClient.swift b/Sources/Services/WebSocketClient.swift new file mode 100644 index 00000000..99150b01 --- /dev/null +++ b/Sources/Services/WebSocketClient.swift @@ -0,0 +1,191 @@ +import Foundation + +/// A WebSocket client that manages a socket connection. +open class NWWebSocket: WebSocketConnection { + + // MARK: - Public properties + + /// The WebSocket connection delegate. + public weak var delegate: WebSocketConnectionDelegate? + + // MARK: - Private properties + + private var webSocketTask: URLSessionWebSocketTask? + private let url: URL + private let session: URLSession + private let connectionQueue: DispatchQueue + private var pingTimer: Timer? + private var isConnected = false + private var isIntentionalDisconnection = false + private var errorWhileWaitingCount = 0 + private let errorWhileWaitingLimit = 20 + private var disconnectionWorkItem: DispatchWorkItem? + private var activeListeners = Set() + + // MARK: - Initialization + + /// Creates a `NWWebSocket` instance which connects to a socket `url`. + /// - Parameters: + /// - request: The `URLRequest` containing the connection endpoint `URL`. + /// - connectAutomatically: Determines if a connection should occur automatically on initialization. + /// The default value is `false`. + /// - connectionQueue: A `DispatchQueue` on which to deliver all connection events. The default value is `.main`. + public convenience init(request: URLRequest, + options: URLSessionConfiguration = .default, + connectAutomatically: Bool = false, + connectionQueue: DispatchQueue = .main) { + self.init(url: request.url!, + options: options, + connectAutomatically: connectAutomatically, + connectionQueue: connectionQueue) + } + + /// Creates a `NWWebSocket` instance which connects to a socket `url`. + /// - Parameters: + /// - url: The connection endpoint `URL`. + /// - connectAutomatically: Determines if a connection should occur automatically on initialization. + /// The default value is `false`. + /// - connectionQueue: A `DispatchQueue` on which to deliver all connection events. The default value is `.main`. + public init(url: URL, + options: URLSessionConfiguration, + connectAutomatically: Bool = false, + connectionQueue: DispatchQueue = .main) { + self.url = url + self.connectionQueue = connectionQueue + self.session = URLSession(configuration: options) + + if connectAutomatically { + connect() + } + } + + deinit { + disconnect(closeCode: .normalClosure) + } + + // MARK: - WebSocketConnection conformance + + /// Connect to the WebSocket. + open func connect() { + guard !isConnected else { return } + + if webSocketTask != nil { + disconnect(closeCode: .normalClosure) + } + + webSocketTask = session.webSocketTask(with: url) + webSocketTask?.resume() + isConnected = true + activeListeners.removeAll() + delegate?.webSocketDidConnect(connection: self) + listen() + } + + /// Send a UTF-8 formatted `String` over the WebSocket. + /// - Parameter string: The `String` that will be sent. + open func send(string: String) { + let message = URLSessionWebSocketTask.Message.string(string) + webSocketTask?.send(message) { [weak self] error in + if let error = error { + self?.delegate?.webSocketDidReceiveError(connection: self!, error: error) + } + } + } + + /// Send some `Data` over the WebSocket. + /// - Parameter data: The `Data` that will be sent. + open func send(data: Data) { + let message = URLSessionWebSocketTask.Message.data(data) + webSocketTask?.send(message) { [weak self] error in + if let error = error { + self?.delegate?.webSocketDidReceiveError(connection: self!, error: error) + } + } + } + + /// Start listening for messages over the WebSocket. + public func listen() { + guard isConnected else { return } + + let listenerId = UUID() + activeListeners.insert(listenerId) + + webSocketTask?.receive { [weak self] result in + guard let self = self, + self.isConnected, + self.activeListeners.contains(listenerId) else { return } + + switch result { + case .success(let message): + switch message { + case .string(let string): + self.delegate?.webSocketDidReceiveMessage(connection: self, string: string) + case .data(let data): + self.delegate?.webSocketDidReceiveMessage(connection: self, data: data) + @unknown default: + break + } + + if self.isConnected && self.activeListeners.contains(listenerId) { + self.listen() + } + + case .failure(let error): + self.delegate?.webSocketDidReceiveError(connection: self, error: error) + if !self.isIntentionalDisconnection { + self.disconnect(closeCode: .abnormalClosure) + } + } + } + } + + /// Ping the WebSocket periodically. + /// - Parameter interval: The `TimeInterval` (in seconds) with which to ping the server. + open func ping(interval: TimeInterval) { + pingTimer = .scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.ping() + } + pingTimer?.tolerance = 0.01 + } + + /// Ping the WebSocket once. + open func ping() { + webSocketTask?.sendPing { [weak self] error in + guard let self = self else { return } + + if let error = error { + self.delegate?.webSocketDidReceiveError(connection: self, error: error) + } else { + self.delegate?.webSocketDidReceivePong(connection: self) + } + } + } + + /// Disconnect from the WebSocket. + /// - Parameter closeCode: The code to use when closing the WebSocket connection. + open func disconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure) { + guard isConnected || webSocketTask != nil else { return } + + isIntentionalDisconnection = true + isConnected = false + + activeListeners.removeAll() + + pingTimer?.invalidate() + pingTimer = nil + + webSocketTask?.cancel(with: closeCode, reason: nil) + webSocketTask = nil + + connectionQueue.async { [weak self] in + guard let self = self else { return } + self.delegate?.webSocketDidDisconnect(connection: self, + closeCode: closeCode, + reason: nil) + self.isIntentionalDisconnection = false + } + } +} + + diff --git a/Tests/Helpers/Mocks.swift b/Tests/Helpers/Mocks.swift index 39ce78cf..4418171f 100644 --- a/Tests/Helpers/Mocks.swift +++ b/Tests/Helpers/Mocks.swift @@ -1,6 +1,5 @@ import Foundation import Network -import NWWebSocket @testable import PusherSwift From 1a67aa33809eb8414380085ecce3948ada6a25ee Mon Sep 17 00:00:00 2001 From: Theo Date: Sun, 15 Dec 2024 11:59:31 +0100 Subject: [PATCH 4/7] Rename test classes --- Sources/PusherSwift.swift | 2 +- Sources/Services/PusherConnection.swift | 4 +- Sources/Services/WebSocketClient.swift | 2 +- Tests/Helpers/Mocks.swift | 2 +- .../Services/Server/NWWebSocketServer.swift | 106 +++++++++++ .../Server/WebSocketServerConnection.swift | 159 ++++++++++++++++ .../Unit/Services/WebSocketClientTests.swift | 174 ++++++++++++++++++ 7 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 Tests/Unit/Services/Server/NWWebSocketServer.swift create mode 100644 Tests/Unit/Services/Server/WebSocketServerConnection.swift create mode 100644 Tests/Unit/Services/WebSocketClientTests.swift diff --git a/Sources/PusherSwift.swift b/Sources/PusherSwift.swift index 851b0342..727a3f4d 100644 --- a/Sources/PusherSwift.swift +++ b/Sources/PusherSwift.swift @@ -30,7 +30,7 @@ let CLIENT_NAME = "pusher-websocket-swift" config.httpAdditionalHeaders = [ "Sec-WebSocket-Protocol": "pusher-channels-protocol-\(PROTOCOL)" ] - let ws = NWWebSocket(url: URL(string: urlString)!, options: config) + let ws = WebSocketClient(url: URL(string: urlString)!, options: config) connection = PusherConnection(key: key, socket: ws, url: urlString, options: options) connection.createGlobalChannel() } diff --git a/Sources/Services/PusherConnection.swift b/Sources/Services/PusherConnection.swift index 5f232d9b..7934e5cb 100644 --- a/Sources/Services/PusherConnection.swift +++ b/Sources/Services/PusherConnection.swift @@ -11,7 +11,7 @@ import Foundation open var socketId: String? open var connectionState = ConnectionState.disconnected open var channels = PusherChannels() - open var socket: NWWebSocket! + open var socket: WebSocketClient! open var URLSession: Foundation.URLSession open var userDataFetcher: (() -> PusherPresenceChannelMember)? open var reconnectAttemptsMax: Int? @@ -59,7 +59,7 @@ import Foundation */ public init( key: String, - socket: NWWebSocket, + socket: WebSocketClient, url: String, options: PusherClientOptions, URLSession: Foundation.URLSession = Foundation.URLSession.shared diff --git a/Sources/Services/WebSocketClient.swift b/Sources/Services/WebSocketClient.swift index 99150b01..b2a54f3e 100644 --- a/Sources/Services/WebSocketClient.swift +++ b/Sources/Services/WebSocketClient.swift @@ -1,7 +1,7 @@ import Foundation /// A WebSocket client that manages a socket connection. -open class NWWebSocket: WebSocketConnection { +open class WebSocketClient: WebSocketConnection { // MARK: - Public properties diff --git a/Tests/Helpers/Mocks.swift b/Tests/Helpers/Mocks.swift index 4418171f..365c4211 100644 --- a/Tests/Helpers/Mocks.swift +++ b/Tests/Helpers/Mocks.swift @@ -3,7 +3,7 @@ import Network @testable import PusherSwift -class MockWebSocket: NWWebSocket { +class MockWebSocket: WebSocketClient { let stubber = StubberForMocks() var callbackCheckString: String = "" var objectGivenToCallback: Any? diff --git a/Tests/Unit/Services/Server/NWWebSocketServer.swift b/Tests/Unit/Services/Server/NWWebSocketServer.swift new file mode 100644 index 00000000..ab1f088a --- /dev/null +++ b/Tests/Unit/Services/Server/NWWebSocketServer.swift @@ -0,0 +1,106 @@ +import Foundation +import Network + +internal class WebSocketServer { + + // MARK: - Private properties + + private let port: NWEndpoint.Port + private var listener: NWListener? + private let parameters: NWParameters + private var connectionsByID: [Int: WebSocketServerConnection] = [:] + + // MARK: - Lifecycle + + init(port: UInt16) { + self.port = NWEndpoint.Port(rawValue: port)! + parameters = NWParameters(tls: nil) + parameters.allowLocalEndpointReuse = true + parameters.includePeerToPeer = true + let wsOptions = NWProtocolWebSocket.Options() + wsOptions.autoReplyPing = true + parameters.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0) + } + + // MARK: - Public methods + + func start() throws { + print("Server starting...") + if listener == nil { + listener = try! NWListener(using: parameters, on: self.port) + } + listener?.stateUpdateHandler = self.stateDidChange(to:) + listener?.newConnectionHandler = self.didAccept(nwConnection:) + listener?.start(queue: .main) + } + + func stop() { + listener?.cancel() + } + + // MARK: - Private methods + + private func didAccept(nwConnection: NWConnection) { + let connection = WebSocketServerConnection(nwConnection: nwConnection) + connectionsByID[connection.id] = connection + + connection.start() + + connection.didStopHandler = { err in + if let err = err { + print(err) + } + self.connectionDidStop(connection) + } + connection.didReceiveStringHandler = { string in + self.connectionsByID.values.forEach { connection in + print("sent \(string) to open connection \(connection.id)") + connection.send(string: string) + } + } + connection.didReceiveDataHandler = { data in + self.connectionsByID.values.forEach { connection in + print("sent \(String(data: data, encoding: .utf8) ?? "NOTHING") to open connection \(connection.id)") + connection.send(data: data) + } + } + + print("server did open connection \(connection.id)") + } + + private func stateDidChange(to newState: NWListener.State) { + switch newState { + case .setup: + print("Server is setup.") + case .waiting(let error): + print("Server is waiting to start, non-fatal error: \(error.debugDescription)") + case .ready: + print("Server ready.") + case .cancelled: + self.stopSever(error: nil) + case .failed(let error): + self.stopSever(error: error) + @unknown default: + fatalError() + } + } + + private func connectionDidStop(_ connection: WebSocketServerConnection) { + self.connectionsByID.removeValue(forKey: connection.id) + print("server did close connection \(connection.id)") + } + + private func stopSever(error: NWError?) { + self.listener = nil + for connection in self.connectionsByID.values { + connection.didStopHandler = nil + connection.stop() + } + self.connectionsByID.removeAll() + if let error = error { + print("Server failure, error: \(error.debugDescription)") + } else { + print("Server stopped normally.") + } + } +} diff --git a/Tests/Unit/Services/Server/WebSocketServerConnection.swift b/Tests/Unit/Services/Server/WebSocketServerConnection.swift new file mode 100644 index 00000000..a6a63d8e --- /dev/null +++ b/Tests/Unit/Services/Server/WebSocketServerConnection.swift @@ -0,0 +1,159 @@ +import Foundation +import Network + +internal class WebSocketServerConnection { + + // MARK: - Public properties + + let id: Int + + var didStopHandler: ((Error?) -> Void)? = nil + var didReceiveStringHandler: ((String) -> ())? = nil + var didReceiveDataHandler: ((Data) -> ())? = nil + + // MARK: - Private properties + + private static var nextID: Int = 0 + private let connection: NWConnection + + // MARK: - Lifecycle + + init(nwConnection: NWConnection) { + connection = nwConnection + id = Self.nextID + Self.nextID += 1 + } + + deinit { + print("deinit") + } + + // MARK: - Public methods + + func start() { + print("connection \(id) will start") + connection.stateUpdateHandler = self.stateDidChange(to:) + listen() + connection.start(queue: .main) + } + + func receiveMessage(data: Data, context: NWConnection.ContentContext) { + guard let metadata = context.protocolMetadata.first as? NWProtocolWebSocket.Metadata else { + return + } + + switch metadata.opcode { + case .binary: + didReceiveDataHandler?(data) + case .cont: + // + break + case .text: + guard let string = String(data: data, encoding: .utf8) else { + return + } + didReceiveStringHandler?(string) + case .close: + // + break + case .ping: + pong() + break + case .pong: + // + break + @unknown default: + fatalError() + } + } + + func send(string: String) { + let metaData = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "textContext", + metadata: [metaData]) + self.send(data: string.data(using: .utf8), context: context) + } + + func send(data: Data) { + let metaData = NWProtocolWebSocket.Metadata(opcode: .binary) + let context = NWConnection.ContentContext(identifier: "binaryContext", + metadata: [metaData]) + self.send(data: data, context: context) + } + + func stop() { + print("connection \(id) will stop") + } + + // MARK: - Private methods + + private func stateDidChange(to state: NWConnection.State) { + switch state { + case .setup: + print("connection is setup.") + case .waiting(let error): + connectionDidReceiveError(error) + case .preparing: + print("connection is being establised.") + case .ready: + print("connection \(id) ready") + case .cancelled: + stopConnection(error: nil) + case .failed(let error): + stopConnection(error: error) + @unknown default: + fatalError() + } + } + + private func listen() { + connection.receiveMessage() { (data, context, isComplete, error) in + if let data = data, let context = context, !data.isEmpty { + self.receiveMessage(data: data, context: context) + } + if let error = error { + self.connectionDidReceiveError(error) + } else { + self.listen() + } + } + } + + private func pong() { + let metaData = NWProtocolWebSocket.Metadata(opcode: .pong) + let context = NWConnection.ContentContext(identifier: "pongContext", + metadata: [metaData]) + self.send(data: Data(), context: context) + } + + private func send(data: Data?, context: NWConnection.ContentContext) { + self.connection.send(content: data, + contentContext: context, + isComplete: true, + completion: .contentProcessed( { error in + if let error = error { + self.connectionDidReceiveError(error) + return + } + print("connection \(self.id) did send, data: \(String(describing: data))") + })) + } + + private func connectionDidReceiveError(_ error: NWError) { + print("connection did receive error: \(error.debugDescription)") + } + + private func stopConnection(error: Error?) { + connection.stateUpdateHandler = nil + if let didStopHandler = didStopHandler { + self.didStopHandler = nil + didStopHandler(error) + } + if let error = error { + print("connection \(id) did fail, error: \(error)") + } else { + print("connection \(id) did end") + } + } +} + diff --git a/Tests/Unit/Services/WebSocketClientTests.swift b/Tests/Unit/Services/WebSocketClientTests.swift new file mode 100644 index 00000000..0dbe1eec --- /dev/null +++ b/Tests/Unit/Services/WebSocketClientTests.swift @@ -0,0 +1,174 @@ +import XCTest +import Network +@testable import PusherSwift + +class WebSocketClientTests: XCTestCase { + static var socket: WebSocketClient! + static var server: WebSocketServer! + + static var connectExpectation: XCTestExpectation? { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var disconnectExpectation: XCTestExpectation! { + didSet { + Self.shouldDisconnectImmediately = true + } + } + static var stringMessageExpectation: XCTestExpectation! { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var dataMessageExpectation: XCTestExpectation! { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var pongExpectation: XCTestExpectation? { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var pingsWithIntervalExpectation: XCTestExpectation? { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var errorExpectation: XCTestExpectation? { + didSet { + Self.shouldDisconnectImmediately = false + } + } + + static var shouldDisconnectImmediately: Bool! + static var receivedPongTimestamps: [Date]! + + static let expectationTimeout = 5.0 + static let stringMessage = "This is a string message!" + static let dataMessage = "This is a data message!".data(using: .utf8)! + static let expectedReceivedPongsCount = 3 + static let repeatedPingInterval = 0.5 + static let validLocalhostServerPort: UInt16 = 3000 + static let invalidLocalhostServerPort: UInt16 = 2000 + + override func setUp() { + super.setUp() + + Self.server = WebSocketServer(port: Self.validLocalhostServerPort) + try! Self.server.start() + let serverURL = URL(string: "ws://localhost:\(Self.validLocalhostServerPort)")! + Self.socket = WebSocketClient(url: serverURL, options: .default) + Self.socket.delegate = self + Self.receivedPongTimestamps = [] + } + + // MARK: - Test methods + + func testConnect() { + Self.connectExpectation = XCTestExpectation(description: "connectExpectation") + Self.socket.connect() + wait(for: [Self.connectExpectation!], timeout: Self.expectationTimeout) + } + + func testDisconnect() { + Self.disconnectExpectation = XCTestExpectation(description: "disconnectExpectation") + Self.socket.connect() + wait(for: [Self.disconnectExpectation], timeout: Self.expectationTimeout) + } + + func testReceiveStringMessage() { + Self.stringMessageExpectation = XCTestExpectation(description: "stringMessageExpectation") + Self.socket.connect() + Self.socket.send(string: Self.stringMessage) + wait(for: [Self.stringMessageExpectation], timeout: Self.expectationTimeout) + } + + func testReceiveDataMessage() { + Self.dataMessageExpectation = XCTestExpectation(description: "dataMessageExpectation") + Self.socket.connect() + Self.socket.send(data: Self.dataMessage) + wait(for: [Self.dataMessageExpectation], timeout: Self.expectationTimeout) + } + + func testReceivePong() { + Self.pongExpectation = XCTestExpectation(description: "pongExpectation") + Self.socket.connect() + Self.socket.ping() + wait(for: [Self.pongExpectation!], timeout: Self.expectationTimeout) + } + + func testPingsWithInterval() { + Self.pingsWithIntervalExpectation = XCTestExpectation(description: "pingsWithIntervalExpectation") + Self.socket.connect() + Self.socket.ping(interval: Self.repeatedPingInterval) + wait(for: [Self.pingsWithIntervalExpectation!], timeout: Self.expectationTimeout) + } + + func testReceiveError() { + // Redefine socket with invalid path + Self.socket = WebSocketClient(request: URLRequest(url: URL(string: "ws://localhost:\(Self.invalidLocalhostServerPort)")!)) + Self.socket.delegate = self + + Self.errorExpectation = XCTestExpectation(description: "errorExpectation") + Self.socket.connect() + wait(for: [Self.errorExpectation!], timeout: Self.expectationTimeout) + } + +} + +// MARK: - WebSocketConnectionDelegate conformance + +extension WebSocketClientTests: WebSocketConnectionDelegate { + func webSocketDidConnect(connection: WebSocketConnection) { + Self.connectExpectation?.fulfill() + + if Self.shouldDisconnectImmediately { + Self.socket.disconnect() + } + } + + func webSocketDidDisconnect(connection: WebSocketConnection, + closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + Self.disconnectExpectation?.fulfill() + } + + func webSocketViabilityDidChange(connection: WebSocketConnection, isViable: Bool) { + if isViable == false { + XCTFail("WebSocket should not become unviable during testing.") + } + } + + func webSocketDidAttemptBetterPathMigration(result: Result) { + XCTFail("WebSocket should not attempt to migrate to a better path during testing.") + } + + func webSocketDidReceiveError(connection: WebSocketConnection, error: Error) { + Self.errorExpectation?.fulfill() + } + + func webSocketDidReceivePong(connection: WebSocketConnection) { + Self.pongExpectation?.fulfill() + + guard Self.pingsWithIntervalExpectation != nil else { + return + } + + if Self.receivedPongTimestamps.count == Self.expectedReceivedPongsCount { + Self.pingsWithIntervalExpectation?.fulfill() + } + Self.receivedPongTimestamps.append(Date()) + } + + func webSocketDidReceiveMessage(connection: WebSocketConnection, string: String) { + XCTAssertEqual(string, Self.stringMessage) + Self.stringMessageExpectation.fulfill() + } + + func webSocketDidReceiveMessage(connection: WebSocketConnection, data: Data) { + XCTAssertEqual(data, Self.dataMessage) + Self.dataMessageExpectation.fulfill() + } +} + From 4a23f95305250e0947649ecc6fb3d308c2e21ea2 Mon Sep 17 00:00:00 2001 From: Theo Date: Sun, 15 Dec 2024 12:04:10 +0100 Subject: [PATCH 5/7] Use let --- Sources/PusherSwift.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PusherSwift.swift b/Sources/PusherSwift.swift index 727a3f4d..b0e783a0 100644 --- a/Sources/PusherSwift.swift +++ b/Sources/PusherSwift.swift @@ -26,7 +26,7 @@ let CLIENT_NAME = "pusher-websocket-swift" public init(key: String, options: PusherClientOptions = PusherClientOptions()) { self.key = key let urlString = URL.channelsSocketUrl(key: key, options: options) - var config = URLSessionConfiguration.default + let config = URLSessionConfiguration.default config.httpAdditionalHeaders = [ "Sec-WebSocket-Protocol": "pusher-channels-protocol-\(PROTOCOL)" ] From 3edae7886e51b95b7d6771b47f8cb4933cae2613 Mon Sep 17 00:00:00 2001 From: Theo Date: Sun, 15 Dec 2024 12:06:36 +0100 Subject: [PATCH 6/7] Uncomment logging method --- .../PusherConnection+WebsocketDelegate.swift | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift index e545216e..6f803f0e 100644 --- a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift +++ b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift @@ -187,28 +187,14 @@ extension PusherConnection: WebSocketConnectionDelegate { /// - closeCode: The closure code for the websocket connection. /// - reason: Optional further information on the connection closure. func logDisconnection(closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { -// var rawCode: UInt16! -// switch closeCode { -// case .protocolCode(let definedCode): -// rawCode = definedCode.rawValue -// -// case .applicationCode(let applicationCode): -// rawCode = applicationCode -// -// case .privateCode(let protocolCode): -// rawCode = protocolCode -// @unknown default: -// fatalError() -// } + var closeMessage: String = "Close code: \(String(describing: closeCode.rawValue))." + if let reason = reason, + let reasonString = String(data: reason, encoding: .utf8) { + closeMessage += " Reason: \(reasonString)." + } -// var closeMessage: String = "Close code: \(String(describing: rawCode))." -// if let reason = reason, -// let reasonString = String(data: reason, encoding: .utf8) { -// closeMessage += " Reason: \(reasonString)." -// } -// -// Logger.shared.debug(for: .disconnectionWithoutError, -// context: closeMessage) + Logger.shared.debug(for: .disconnectionWithoutError, + context: closeMessage) } /** From 3a23201ed2774a187c94ee562e2d050cc13572a9 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 7 Jan 2025 13:34:14 +0100 Subject: [PATCH 7/7] Remove debugging code --- .../PusherConnection+WebsocketDelegate.swift | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift index 6f803f0e..a569bb3d 100644 --- a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift +++ b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift @@ -233,20 +233,5 @@ extension PusherConnection: WebSocketConnectionDelegate { attemptReconnect() } -// if case .posix(let code) = error, code != .ENOTCONN { -// resetConnection() -// -// guard !intentionalDisconnect else { -// Logger.shared.debug(for: .intentionalDisconnection) -// return -// } -// -// guard reconnectAttemptsMax == nil || reconnectAttempts < reconnectAttemptsMax! else { -// Logger.shared.debug(for: .maxReconnectAttemptsLimitReached) -// return -// } -// -// attemptReconnect() -// } } }