REF: SwiftTCPClient to use Network framework and Backgroud thread

This commit is contained in:
Marcos Rodriguez Velez 2024-11-17 18:55:37 -04:00
parent 4307248e33
commit e72ba878e2
3 changed files with 196 additions and 192 deletions

View File

@ -14,60 +14,61 @@ struct APIError: LocalizedError {
extension MarketAPI { extension MarketAPI {
static func fetchNextBlockFee(completion: @escaping ((MarketData?, Error?) -> Void), userElectrumSettings: UserDefaultsElectrumSettings = UserDefaultsGroup.getElectrumSettings()) { static func fetchNextBlockFee(completion: @escaping ((MarketData?, Error?) -> Void), userElectrumSettings: UserDefaultsElectrumSettings = UserDefaultsGroup.getElectrumSettings()) {
let settings = userElectrumSettings Task {
let portToUse = settings.sslPort ?? settings.port let client = SwiftTCPClient()
let isSSLSupported = settings.sslPort != nil defer {
print("Closing connection to \(userElectrumSettings.host ?? "unknown"):\(userElectrumSettings.sslPort ?? userElectrumSettings.port ?? 0).")
client.close()
}
DispatchQueue.global(qos: .background).async { guard let host = userElectrumSettings.host, let portToUse = userElectrumSettings.sslPort ?? userElectrumSettings.port else {
let client = SwiftTCPClient() completion(nil, APIError())
return
}
defer { let isSSLSupported = userElectrumSettings.sslPort != nil
print("Closing connection to \(String(describing: settings.host)):\(String(describing: portToUse)).") print("Attempting to connect to \(host):\(portToUse) with SSL supported: \(isSSLSupported).")
client.close()
}
guard let host = settings.host, let portToUse = portToUse else { return } let connected = await client.connect(to: host, port: portToUse, useSSL: isSSLSupported)
if connected {
print("Successfully connected to \(host):\(portToUse) with SSL: \(isSSLSupported).")
} else {
print("Failed to connect to \(host):\(portToUse) with SSL: \(isSSLSupported).")
completion(nil, APIError())
return
}
print("Attempting to connect to \(String(describing: settings.host)):\(portToUse) with SSL supported: \(isSSLSupported).") let message = "{\"id\": 1, \"method\": \"mempool.get_fee_histogram\", \"params\": []}\n"
guard let data = message.data(using: .utf8), await client.send(data: data) else {
print("Message sending failed to \(host):\(portToUse) with SSL supported: \(isSSLSupported).")
completion(nil, APIError())
return
}
print("Message sent successfully to \(host):\(portToUse) with SSL: \(isSSLSupported).")
if client.connect(to: host, port: UInt32(portToUse), useSSL: isSSLSupported) { do {
print("Successfully connected to \(String(describing: settings.host)):\(portToUse) with SSL:\(isSSLSupported).") let receivedData = try await client.receive()
} else { print("Data received. Parsing...")
print("Failed to connect to \(String(describing: settings.host)):\(portToUse) with SSL:\(isSSLSupported).") guard let responseString = String(data: receivedData, encoding: .utf8),
completion(nil, APIError()) let responseData = responseString.data(using: .utf8),
return let json = try JSONSerialization.jsonObject(with: responseData, options: .allowFragments) as? [String: AnyObject],
} let feeHistogram = json["result"] as? [[Double]] else {
print("Failed to parse response from \(host).")
completion(nil, APIError())
return
}
let message = "{\"id\": 1, \"method\": \"mempool.get_fee_histogram\", \"params\": []}\n" let fastestFee = calcEstimateFeeFromFeeHistogram(numberOfBlocks: 1, feeHistogram: feeHistogram)
guard let data = message.data(using: .utf8), client.send(data: data) else { let marketData = MarketData(nextBlock: String(format: "%.0f", fastestFee), sats: "0", price: "0", rate: 0, dateString: "")
print("Message sending failed to \(String(describing: settings.host)):\(portToUse) with SSL supported: \(isSSLSupported).") print("Parsed MarketData: \(marketData)")
completion(nil, APIError()) completion(marketData, nil)
return } catch {
} print("Error receiving data from \(host): \(error.localizedDescription)")
print("Message sent successfully to \(String(describing: settings.host)):\(portToUse) with SSL:\(isSSLSupported).") completion(nil, APIError())
}
do { }
let receivedData = try client.receive() }
print("Data received. Parsing...")
guard let responseString = String(data: receivedData, encoding: .utf8),
let responseData = responseString.data(using: .utf8),
let json = try JSONSerialization.jsonObject(with: responseData, options: .allowFragments) as? [String: AnyObject],
let feeHistogram = json["result"] as? [[Double]] else {
print("Failed to parse response from \(String(describing: settings.host)).")
completion(nil, APIError())
return
}
let fastestFee = calcEstimateFeeFromFeeHistogram(numberOfBlocks: 1, feeHistogram: feeHistogram)
let marketData = MarketData(nextBlock: String(format: "%.0f", fastestFee), sats: "0", price: "0", rate: 0)
completion(marketData, nil) // Successfully fetched data, return it
} catch {
print("Error receiving data from \(String(describing: settings.host)): \(error.localizedDescription)")
completion(nil, APIError())
}
}
}
static func fetchMarketData(currency: String, completion: @escaping ((MarketData?, Error?) -> Void)) { static func fetchMarketData(currency: String, completion: @escaping ((MarketData?, Error?) -> Void)) {
var marketDataEntry = MarketData(nextBlock: "...", sats: "...", price: "...", rate: 0) var marketDataEntry = MarketData(nextBlock: "...", sats: "...", price: "...", rate: 0)

View File

@ -9,9 +9,9 @@
import Foundation import Foundation
struct UserDefaultsElectrumSettings { struct UserDefaultsElectrumSettings {
let host: String? var host: String?
let port: Int32? var port: UInt16?
let sslPort: Int32? var sslPort: UInt16?
} }
let hardcodedPeers = [ let hardcodedPeers = [
@ -34,14 +34,14 @@ class UserDefaultsGroup {
return DefaultElectrumPeers.randomElement()! return DefaultElectrumPeers.randomElement()!
} }
let electrumSettingsTCPPort = suite?.string(forKey: UserDefaultsGroupKey.ElectrumSettingsTCPPort.rawValue) ?? "50001" let electrumSettingsTCPPort = suite?.value(forKey: UserDefaultsGroupKey.ElectrumSettingsTCPPort.rawValue) ?? 50001
let electrumSettingsSSLPort = suite?.string(forKey: UserDefaultsGroupKey.ElectrumSettingsSSLPort.rawValue) ?? "443" let electrumSettingsSSLPort = suite?.value(forKey: UserDefaultsGroupKey.ElectrumSettingsSSLPort.rawValue) ?? 443
let host = electrumSettingsHost let host = electrumSettingsHost
let sslPort = Int32(electrumSettingsSSLPort) let sslPort = electrumSettingsSSLPort
let port = Int32(electrumSettingsTCPPort) let port = electrumSettingsTCPPort
return UserDefaultsElectrumSettings(host: host, port: port, sslPort: sslPort) return UserDefaultsElectrumSettings(host: host, port: port as! UInt16, sslPort: sslPort as! UInt16)
} }
static func getAllWalletsBalance() -> Double { static func getAllWalletsBalance() -> Double {

View File

@ -1,152 +1,155 @@
// BlueWallet
//
// Created by Marcos Rodriguez on 3/23/23.
// Copyright © 2023 BlueWallet. All rights reserved.
import Foundation import Foundation
import Network
/** enum SwiftTCPClientError: Error, LocalizedError {
`SwiftTCPClient` is a simple TCP client class that allows for establishing a TCP connection, case connectionNil
sending data, and receiving data over the network. It supports both plain TCP and SSL-secured connections. case connectionCancelled
case readTimedOut
case noDataReceived
case unknown(Error)
The class uses `InputStream` and `OutputStream` for network communication, encapsulating the complexity of stream management and data transfer. var errorDescription: String? {
switch self {
- Note: When using SSL, this implementation disables certificate chain validation for simplicity. This is not recommended for production code due to security risks. case .connectionNil:
return "Connection is nil."
## Examples case .connectionCancelled:
return "Connection was cancelled."
### Creating an instance and connecting to a server: case .readTimedOut:
return "Read timed out."
```swift case .noDataReceived:
let client = SwiftTCPClient() return "No data received."
let success = client.connect(to: "example.com", port: 12345, useSSL: false) case .unknown(let error):
return error.localizedDescription
if success {
print("Connected successfully.")
} else {
print("Failed to connect.")
}
**/
class SwiftTCPClient: NSObject {
private var inputStream: InputStream?
private var outputStream: OutputStream?
private let bufferSize = 1024
private var readData = Data()
private let readTimeout = 5.0 // Timeout in seconds
func connect(to host: String, port: UInt32, useSSL: Bool = false) -> Bool {
var readStream: Unmanaged<CFReadStream>?
var writeStream: Unmanaged<CFWriteStream>?
CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault, host as CFString, port, &readStream, &writeStream)
guard let read = readStream?.takeRetainedValue(), let write = writeStream?.takeRetainedValue() else {
return false
} }
}
}
inputStream = read as InputStream class SwiftTCPClient {
outputStream = write as OutputStream private var connection: NWConnection?
private let queue = DispatchQueue(label: "SwiftTCPClientQueue")
private let readTimeout: TimeInterval = 5.0
func connect(to host: String, port: UInt16, useSSL: Bool = false) async -> Bool {
let parameters: NWParameters
if useSSL { if useSSL {
// Configure SSL settings for the streams parameters = NWParameters(tls: createTLSOptions(), tcp: .init())
let sslSettings: [NSString: Any] = [ } else {
kCFStreamSSLLevel as NSString: kCFStreamSocketSecurityLevelNegotiatedSSL as Any, parameters = NWParameters.tcp
kCFStreamSSLValidatesCertificateChain as NSString: kCFBooleanFalse as Any
// Note: Disabling certificate chain validation (kCFStreamSSLValidatesCertificateChain: kCFBooleanFalse)
// is typically not recommended for production code as it introduces significant security risks.
]
inputStream?.setProperty(sslSettings, forKey: kCFStreamPropertySSLSettings as Stream.PropertyKey)
outputStream?.setProperty(sslSettings, forKey: kCFStreamPropertySSLSettings as Stream.PropertyKey)
} }
inputStream?.delegate = self connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: parameters)
outputStream?.delegate = self connection?.start(queue: queue)
inputStream?.schedule(in: .current, forMode: RunLoop.Mode.default) let serialQueue = DispatchQueue(label: "SwiftTCPClient.connect.serialQueue")
outputStream?.schedule(in: .current, forMode: RunLoop.Mode.default) var hasResumed = false
inputStream?.open() do {
outputStream?.open() try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
connection?.stateUpdateHandler = { [weak self] state in
return true guard let self = self else { return }
} serialQueue.async {
if !hasResumed {
switch state {
func send(data: Data) -> Bool { case .ready:
guard let outputStream = outputStream else { self.connection?.stateUpdateHandler = nil
return false hasResumed = true
} continuation.resume()
case .failed(let error):
let bytesWritten = data.withUnsafeBytes { bufferPointer -> Int in self.connection?.stateUpdateHandler = nil
guard let baseAddress = bufferPointer.baseAddress else { hasResumed = true
return 0 continuation.resume(throwing: error)
} case .cancelled:
return outputStream.write(baseAddress.assumingMemoryBound(to: UInt8.self), maxLength: data.count) self.connection?.stateUpdateHandler = nil
} hasResumed = true
continuation.resume(throwing: SwiftTCPClientError.connectionCancelled)
return bytesWritten == data.count default:
} break
}
func receive() throws -> Data { }
guard let inputStream = inputStream else {
throw NSError(domain: "SwiftTCPClientError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Input stream is nil."])
}
// Check if the input stream is ready for reading
if inputStream.streamStatus != .open && inputStream.streamStatus != .reading {
throw NSError(domain: "SwiftTCPClientError", code: 3, userInfo: [NSLocalizedDescriptionKey: "Stream is not ready for reading."])
}
readData = Data()
// Wait for data to be available or timeout
let timeoutDate = Date().addingTimeInterval(readTimeout)
repeat {
RunLoop.current.run(mode: RunLoop.Mode.default, before: Date(timeIntervalSinceNow: 0.1))
if readData.count > 0 || Date() > timeoutDate {
break
}
} while inputStream.streamStatus == .open || inputStream.streamStatus == .reading
if readData.count == 0 && Date() > timeoutDate {
throw NSError(domain: "SwiftTCPClientError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Read timed out."])
}
return readData
}
func close() {
inputStream?.close()
outputStream?.close()
inputStream?.remove(from: .current, forMode: RunLoop.Mode.default)
outputStream?.remove(from: .current, forMode: RunLoop.Mode.default)
inputStream = nil
outputStream = nil
}
}
extension SwiftTCPClient: StreamDelegate {
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .hasBytesAvailable:
if aStream == inputStream, let inputStream = inputStream {
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
while inputStream.hasBytesAvailable {
let bytesRead = inputStream.read(buffer, maxLength: bufferSize)
if bytesRead > 0 {
readData.append(buffer, count: bytesRead)
} }
} }
buffer.deallocate()
} }
case .errorOccurred: return true
print("Stream error occurred") } catch {
case .endEncountered: print("Connection failed with error: \(error.localizedDescription)")
close() return false
default:
break
} }
} }
func send(data: Data) async -> Bool {
guard let connection = connection else {
print("Send failed: No active connection.")
return false
}
do {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
print("Send error: \(error.localizedDescription)")
continuation.resume(throwing: error)
} else {
continuation.resume()
}
})
}
return true
} catch {
print("Send failed with error: \(error.localizedDescription)")
return false
}
}
func receive() async throws -> Data {
guard let connection = connection else {
throw SwiftTCPClientError.connectionNil
}
return try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask {
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, error in
if let error = error {
continuation.resume(throwing: SwiftTCPClientError.unknown(error))
return
}
if let data = data, !data.isEmpty {
continuation.resume(returning: data)
} else if isComplete {
self.close()
continuation.resume(throwing: SwiftTCPClientError.noDataReceived)
} else {
continuation.resume(throwing: SwiftTCPClientError.readTimedOut)
}
}
}
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(self.readTimeout * 1_000_000_000))
throw SwiftTCPClientError.readTimedOut
}
if let firstResult = try await group.next() {
group.cancelAll()
return firstResult
} else {
throw SwiftTCPClientError.readTimedOut
}
}
}
func close() {
connection?.cancel()
connection = nil
}
private func createTLSOptions() -> NWProtocolTLS.Options {
let tlsOptions = NWProtocolTLS.Options()
sec_protocol_options_set_verify_block(tlsOptions.securityProtocolOptions, { _, _, completion in
completion(true)
}, DispatchQueue.global(qos: .background))
return tlsOptions
}
} }