diff --git a/EasyCode.podspec b/EasyCode.podspec index eb18dc9..cb89651 100644 --- a/EasyCode.podspec +++ b/EasyCode.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'EasyCode' s.license = 'MIT' - s.version = '1.4.1' + s.version = '1.4.2' s.summary = 'EasyCode' s.homepage = 'https://github.com/Salmik/EaseCode' s.authors = { 'Salmik' => 'salmik94@gmail.com' } diff --git a/EasyCode.xcodeproj/project.pbxproj b/EasyCode.xcodeproj/project.pbxproj index 0f15b33..11a18df 100644 --- a/EasyCode.xcodeproj/project.pbxproj +++ b/EasyCode.xcodeproj/project.pbxproj @@ -101,6 +101,9 @@ BBCC22D92C352ADF00CC19C9 /* MailConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBCC22D82C352ADF00CC19C9 /* MailConfiguration.swift */; }; BBE498CA2D2D06930085233A /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBE498C92D2D06930085233A /* BackgroundTaskManager.swift */; }; BBEB8E3A2D2CFB6B00988E03 /* UnownedInject.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEB8E392D2CFB6B00988E03 /* UnownedInject.swift */; }; + BBEBFB5E2D3E03D600E566A6 /* WebSocketManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEBFB5D2D3E03D600E566A6 /* WebSocketManagerDelegate.swift */; }; + BBEBFB602D3E03E800E566A6 /* WebSocketEndPointProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEBFB5F2D3E03E800E566A6 /* WebSocketEndPointProtocol.swift */; }; + BBEBFB622D3E03F500E566A6 /* WebSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEBFB612D3E03F500E566A6 /* WebSocketManager.swift */; }; BBEE8D752C6B679C00133020 /* PaddedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEE8D742C6B679C00133020 /* PaddedLabel.swift */; }; BBEE8D772C6B6BB500133020 /* TipViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEE8D762C6B6BB500133020 /* TipViewController.swift */; }; /* End PBXBuildFile section */ @@ -211,6 +214,9 @@ BBCC22D82C352ADF00CC19C9 /* MailConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailConfiguration.swift; sourceTree = ""; }; BBE498C92D2D06930085233A /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; }; BBEB8E392D2CFB6B00988E03 /* UnownedInject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnownedInject.swift; sourceTree = ""; }; + BBEBFB5D2D3E03D600E566A6 /* WebSocketManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketManagerDelegate.swift; sourceTree = ""; }; + BBEBFB5F2D3E03E800E566A6 /* WebSocketEndPointProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketEndPointProtocol.swift; sourceTree = ""; }; + BBEBFB612D3E03F500E566A6 /* WebSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketManager.swift; sourceTree = ""; }; BBEE8D742C6B679C00133020 /* PaddedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddedLabel.swift; sourceTree = ""; }; BBEE8D762C6B6BB500133020 /* TipViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -269,6 +275,7 @@ BB3C2EF92C6A0D3B00149867 /* Network Layer */ = { isa = PBXGroup; children = ( + BBEBFB5C2D3E03C600E566A6 /* WebSocket */, BBBCE96C2CFCB67E0061DB04 /* WindowLogger */, BB3C2EFA2C6A0D5A00149867 /* NetworkResponseProtocol.swift */, BB3C2EFC2C6A0D6C00149867 /* EndPointProtocol.swift */, @@ -417,6 +424,16 @@ path = BottomSheet; sourceTree = ""; }; + BBEBFB5C2D3E03C600E566A6 /* WebSocket */ = { + isa = PBXGroup; + children = ( + BBEBFB5D2D3E03D600E566A6 /* WebSocketManagerDelegate.swift */, + BBEBFB5F2D3E03E800E566A6 /* WebSocketEndPointProtocol.swift */, + BBEBFB612D3E03F500E566A6 /* WebSocketManager.swift */, + ); + path = WebSocket; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -596,6 +613,7 @@ BBBD51EA2C36803600557CD3 /* ContactsWorker.swift in Sources */, BBCC22D92C352ADF00CC19C9 /* MailConfiguration.swift in Sources */, BBBCE9812CFCBD550061DB04 /* BottomSheetPresentationController.swift in Sources */, + BBEBFB602D3E03E800E566A6 /* WebSocketEndPointProtocol.swift in Sources */, BBB163A22C3701D800182E37 /* PasswordValidator.swift in Sources */, BBBCE9722CFCB7D80061DB04 /* LoggerDetailsViewController.swift in Sources */, BBAFFC2E2C342A0D005703B0 /* UserDefaultsStore.swift in Sources */, @@ -607,6 +625,7 @@ BB0A70632CBF822500084289 /* RSAWorker.swift in Sources */, BBAFFBF62C341CC2005703B0 /* UIView+extension.swift in Sources */, BBAFFC042C342152005703B0 /* Date+extension.swift in Sources */, + BBEBFB5E2D3E03D600E566A6 /* WebSocketManagerDelegate.swift in Sources */, BBBD51EC2C369B5700557CD3 /* ImageFilter.swift in Sources */, BB3C2EFF2C6A0D8900149867 /* ParameterEncoder.swift in Sources */, BBBCE97A2CFCB8500061DB04 /* NetworkGlobals.swift in Sources */, @@ -630,6 +649,7 @@ BBAFFC022C342126005703B0 /* Character+extension.swift in Sources */, BBAFFC202C34278A005703B0 /* UIImage+extension.swift in Sources */, BB3B164B2CECAB2C0049F5F2 /* PublishedAction.swift in Sources */, + BBEBFB622D3E03F500E566A6 /* WebSocketManager.swift in Sources */, BB3B163D2CEC97470049F5F2 /* Provider.swift in Sources */, BBAFFC002C342012005703B0 /* RegularExpression.swift in Sources */, BBAFFC282C342862005703B0 /* UICollectionView+extension.swift in Sources */, diff --git a/EasyCode/Source/Network Layer/NetworkManager.swift b/EasyCode/Source/Network Layer/NetworkManager.swift index c069357..6eb2d07 100644 --- a/EasyCode/Source/Network Layer/NetworkManager.swift +++ b/EasyCode/Source/Network Layer/NetworkManager.swift @@ -26,6 +26,8 @@ public class NetworkManager: NSObject { /// An array of `Data` objects representing the SSL certificates to be used for SSL pinning. public var certDataItems = [Data]() + private var longPollTimer: Timer? + /// Initializes a new instance of `NetworkManager`. public override init() {} @@ -345,6 +347,59 @@ public class NetworkManager: NSObject { return composedResponse } } + + /// Starts a long-polling process to periodically send HTTP requests to the specified endpoint. + /// + /// Long-polling involves repeatedly sending requests at a specified interval to fetch updated data. + /// Use the `completion` callback to handle each response, and provide a way to stop the polling. + /// + /// - Parameters: + /// - endpoint: An object conforming to `EndPointProtocol` that defines the request details. + /// - interval: The time interval (in seconds) between successive requests. + /// - completion: A closure that gets called with each response. It provides: + /// - `response`: The response object conforming to `NetworkResponseProtocol`. + /// - `stop`: A closure to terminate the long-polling process. + /// + /// # Example + /// ```swift + /// networkManager.startLongPolling(endpoint: myEndpoint, interval: 10) { response, stop in + /// if response.success { + /// print("Long-polling succeeded with data: \(response.data ?? Data())") + /// stop() + /// } else { + /// print("Long-polling failed with error: \(response.error?.errorMessage ?? "Unknown error")") + /// } + /// } + /// ``` + public func startLongPolling( + endpoint: EndPointProtocol, + interval: TimeInterval, + completion: @escaping (_ response: NetworkResponseProtocol, _ stop: () -> Void) -> Void + ) { + stopLongPolling() + + longPollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + guard let self else { return } + + self.request(endpoint) { response in + let stopClosure = { [weak self] in + guard let self else { return } + self.stopLongPolling() + } + completion(response, stopClosure) + } + } + + longPollTimer?.fire() + } + + /// Stops the currently running long-polling process. + /// + /// Call this method to manually terminate the long-polling process. + public func stopLongPolling() { + longPollTimer?.invalidate() + longPollTimer = nil + } } // MARK: - URLSessionDelegate diff --git a/EasyCode/Source/Network Layer/WebSocket/WebSocketEndPointProtocol.swift b/EasyCode/Source/Network Layer/WebSocket/WebSocketEndPointProtocol.swift new file mode 100644 index 0000000..a8c79b0 --- /dev/null +++ b/EasyCode/Source/Network Layer/WebSocket/WebSocketEndPointProtocol.swift @@ -0,0 +1,14 @@ +// +// WebSocketEndPointProtocol.swift +// EasyCode +// +// Created by Zhanibek Lukpanov on 20.01.2025. +// + +import Foundation + +public protocol WebSocketEndPointProtocol { + + var url: URL { get } + var headers: [String: String]? { get } +} diff --git a/EasyCode/Source/Network Layer/WebSocket/WebSocketManager.swift b/EasyCode/Source/Network Layer/WebSocket/WebSocketManager.swift new file mode 100644 index 0000000..54c94be --- /dev/null +++ b/EasyCode/Source/Network Layer/WebSocket/WebSocketManager.swift @@ -0,0 +1,350 @@ +// +// WebSocketManager.swift +// EasyCode +// +// Created by Zhanibek Lukpanov on 20.01.2025. +// + +import Foundation +import Network + +open class WebSocketManager: NSObject { + + public weak var delegate: WebSocketManagerDelegate? + public private(set) var isConnected = false + + public var heartbeatInterval: TimeInterval = 15 + public var isAutoReconnectEnabled = true + public var isNeedToLog = true + public var isSSLPinningEnabled = false + public var certDataItems: [Data] = [] + + private var session: URLSession + private var webSocketTask: URLSessionWebSocketTask? + private var heartbeatTimer: Timer? + private var endpoint: WebSocketEndPointProtocol? + private var isReconnecting = false + + private var pathMonitor: NWPathMonitor? + private var isNetworkViable = true + + public init(session: URLSession) { + self.session = session + super.init() + } + + public convenience init( + isSSLPinningEnabled: Bool = false, + certDataItems: [Data] = Bundle.main.SSLCertificates, + isNeedToLog: Bool = true, + isAutoReconnectEnabled: Bool = true, + heartbeatInterval: TimeInterval = 15 + ) { + let queue = OperationQueue() + queue.qualityOfService = .userInitiated + + let tempSession = URLSession(configuration: .default, delegate: nil, delegateQueue: queue) + self.init(session: tempSession) + + self.isNeedToLog = isNeedToLog + self.isAutoReconnectEnabled = isAutoReconnectEnabled + self.heartbeatInterval = heartbeatInterval + self.isSSLPinningEnabled = isSSLPinningEnabled + self.certDataItems = certDataItems + + recreateSession() + } + + private func recreateSession() { + let config = session.configuration + let newSession = URLSession(configuration: config, delegate: self, delegateQueue: .main) + session.invalidateAndCancel() + self.webSocketTask = nil + session = newSession + } + + private func startHeartbeat() { + heartbeatTimer?.invalidate() + guard heartbeatInterval > 0 else { return } + + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: heartbeatInterval, repeats: true) { [weak self] _ in + self?.sendPing() + } + } + + private func sendPing() { + guard let task = webSocketTask else { return } + + if isNeedToLog { + Logger.printDivider() + print("⚪️ PING") + Logger.printDivider() + } + + task.sendPing { [weak self] error in + if let error { + if self?.isNeedToLog == true { + Logger.print("🔴 PING ERROR: \(error.localizedDescription)") + } + self?.delegate?.webSocketDidReceiveError(error) + } else { + if self?.isNeedToLog == true { + Logger.print("⚪️ PONG (RECEIVED RESPONSE TO PING)") + } + } + } + } + + private func listen() { + webSocketTask?.receive { [weak self] result in + guard let self else { return } + + if self.isNeedToLog { + Logger.printDivider() + } + + switch result { + case .failure(let error): + if isNeedToLog { + print("🔴 WEBSOCKET RECEIVED ERROR") + print("➤ ERROR: \(error.localizedDescription)") + Logger.printDivider() + } + + self.isConnected = false + self.delegate?.webSocketDidReceiveError(error) + + if self.isAutoReconnectEnabled { + if self.isNeedToLog { + print("⚪️ WEBSOCKET SUGGESTS RECONNECTION") + Logger.printDivider() + } + self.tryReconnect() + } else { + self.delegate?.webSocketDidDisconnect( + code: nil, + reason: error.localizedDescription + ) + } + + case .success(let message): + if !self.isConnected { + self.isConnected = true + if self.isNeedToLog { + print("🟢 WEBSOCKET CONNECTED") + Logger.printDivider() + } + if self.isReconnecting { + print("🟢 WEBSOCKET RECONNECTED") + Logger.printDivider() + self.delegate?.webSocketDidConnect() + } + self.delegate?.reconnectingPassed() + self.isReconnecting = false + } + + switch message { + case .string(let text): + if self.isNeedToLog { + print("🟢 WEBSOCKET RECEIVED MESSAGE: \(text)") + } + self.delegate?.webSocketDidReceiveMessage(text: text) + + case .data(let data): + if self.isNeedToLog { + let base64String = data.base64EncodedString() + print("🟢 WEBSOCKET RECEIVED DATA: \(base64String)") + } + self.delegate?.webSocketDidReceiveData(data) + + @unknown default: + break + } + if self.isNeedToLog { + Logger.printDivider() + } + self.listen() + } + } + } + + private func tryReconnect() { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self, let endpoint = self.endpoint else { return } + if isNeedToLog { + print("⚪️ RECONNECTING WEBSOCKET...") + Logger.printDivider() + } + self.isReconnecting = true + self.connect(endpoint: endpoint) + } + } + + private func startPathMonitor() { + let monitor = NWPathMonitor() + self.pathMonitor = monitor + monitor.pathUpdateHandler = { [weak self] path in + let isViable = path.status == .satisfied + guard let self else { return } + + if self.isNetworkViable != isViable { + self.isNetworkViable = isViable + + if self.isNeedToLog { + Logger.printDivider() + print("⚪️ VIABILITY_CHANGED: \(isViable)") + Logger.printDivider() + } + } + } + let queue = DispatchQueue(label: "com.myapp.WebSocketManager.pathMonitor") + monitor.start(queue: queue) + } + + open func connect(endpoint: WebSocketEndPointProtocol) { + if isNeedToLog { + Logger.printDivider() + print("➤ ATTEMPTING TO CONNECT TO WEBSOCKET") + } + + self.endpoint = endpoint + var request = URLRequest(url: endpoint.url) + endpoint.headers?.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + + webSocketTask = session.webSocketTask(with: request) + webSocketTask?.resume() + + listen() + startHeartbeat() + startPathMonitor() + + if isNeedToLog { + print("➤ WEBSOCKET TASK RESUMED\n") + Logger.printDivider() + } + } + + open func disconnect(code: URLSessionWebSocketTask.CloseCode = .normalClosure, reason: Data? = nil) { + if isNeedToLog { + Logger.printDivider() + print("🔴 WEBSOCKET DISCONNECT INITIATED") + print("➤ CODE: \(code.rawValue)") + } + if let reasonString = reason.flatMap({ String(data: $0, encoding: .utf8) }) { + if isNeedToLog { + print("➤ REASON: \(reasonString)") + } + } + + heartbeatTimer?.invalidate() + heartbeatTimer = nil + pathMonitor?.cancel() + pathMonitor = nil + + webSocketTask?.cancel(with: code, reason: reason) + webSocketTask = nil + isConnected = false + + if isNeedToLog { + Logger.print("🔴 WEBSOCKET CANCELLED\n") + } + + delegate?.webSocketDidDisconnect( + code: code, + reason: reason.flatMap { String(data: $0, encoding: .utf8) } + ) + } + + open func send(text: String) { + guard let task = webSocketTask else { return } + let message = URLSessionWebSocketTask.Message.string(text) + + if isNeedToLog { + Logger.printDivider() + print("➤ SENDING TEXT MESSAGE: \(text)") + Logger.printDivider() + } + + task.send(message) { [weak self] error in + if let error { + if self?.isNeedToLog == true { + Logger.print("🔴 WEBSOCKET SEND ERROR: \(error.localizedDescription)") + } + self?.delegate?.webSocketDidReceiveError(error) + } + } + } + + open func send(data: Data) { + guard let task = webSocketTask else { return } + let message = URLSessionWebSocketTask.Message.data(data) + + if isNeedToLog { + Logger.printDivider() + print("➤ SENDING BINARY DATA, size: \(data.sizeString)") + Logger.printDivider() + } + + task.send(message) { [weak self] error in + if let error { + if self?.isNeedToLog == true { + Logger.print("🔴 WEBSOCKET SEND ERROR: \(error.localizedDescription)") + } + self?.delegate?.webSocketDidReceiveError(error) + } + } + } +} + +// MARK: - URLSessionDelegate (SSL Pinning) +extension WebSocketManager: URLSessionDelegate { + + public func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard isSSLPinningEnabled else { + completionHandler(.performDefaultHandling, nil) + return + } + + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + var secError: CFError? + let isServerTrusted = SecTrustEvaluateWithError(serverTrust, &secError) + + if #available(iOS 15.0, *) { + if isServerTrusted, + let certificates = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate], + let serverCertificate = certificates.first { + let serverCertificateData = SecCertificateCopyData(serverCertificate) as Data + + for localCertData in certDataItems where localCertData == serverCertificateData { + let credential = URLCredential(trust: serverTrust) + completionHandler(.useCredential, credential) + return + } + } + } else { + let serverCertificates = (0..