ios_client
This commit is contained in:
191
ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift
Normal file
191
ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// TCP connection manager using Network.framework.
|
||||
/// Handles connection lifecycle, TLS, buffered reading (newline-delimited), and writing.
|
||||
actor ConnectionManager {
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var receiveBuffer = Data()
|
||||
private(set) var state: ConnectionState = .disconnected
|
||||
private var stateCallback: ((ConnectionState) -> Void)?
|
||||
private var messageStream: AsyncStream<[String: Any]>.Continuation?
|
||||
|
||||
/// Set a callback for connection state changes
|
||||
func onStateChange(_ callback: @escaping (ConnectionState) -> Void) {
|
||||
stateCallback = callback
|
||||
}
|
||||
|
||||
// MARK: - Connect / Disconnect
|
||||
|
||||
/// Connect to server
|
||||
func connect(host: String, port: UInt16) async throws {
|
||||
guard state == .disconnected || state != .connected else {
|
||||
throw NetworkError.alreadyConnected
|
||||
}
|
||||
|
||||
updateState(.connecting)
|
||||
|
||||
let nwHost = NWEndpoint.Host(host)
|
||||
let nwPort = NWEndpoint.Port(rawValue: port)!
|
||||
|
||||
let tlsOptions = NWProtocolTLS.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: .init())
|
||||
|
||||
let conn = NWConnection(host: nwHost, port: nwPort, using: params)
|
||||
self.connection = conn
|
||||
self.receiveBuffer = Data()
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
// nonisolated flag — accessed only from the stateUpdateHandler serial queue
|
||||
// Use a class wrapper so the closure can mutate it
|
||||
final class ResumedFlag: @unchecked Sendable {
|
||||
var value = false
|
||||
}
|
||||
let resumed = ResumedFlag()
|
||||
|
||||
conn.stateUpdateHandler = { [weak self] newState in
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
switch newState {
|
||||
case .ready:
|
||||
await self.updateState(.connected)
|
||||
guard !resumed.value else { return }
|
||||
resumed.value = true
|
||||
continuation.resume()
|
||||
case .failed(let error):
|
||||
await self.updateState(.failed(error.localizedDescription))
|
||||
guard !resumed.value else { return }
|
||||
resumed.value = true
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
case .cancelled:
|
||||
await self.updateState(.disconnected)
|
||||
guard !resumed.value else { return }
|
||||
resumed.value = true
|
||||
continuation.resume(throwing: NetworkError.connectionFailed("Connection cancelled"))
|
||||
case .waiting(let error):
|
||||
await self.updateState(.failed(error.localizedDescription))
|
||||
guard !resumed.value else { return }
|
||||
resumed.value = true
|
||||
continuation.resume(throwing: NetworkError.connectionFailed("Waiting: \(error.localizedDescription)"))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from server
|
||||
func disconnect() {
|
||||
connection?.cancel()
|
||||
connection = nil
|
||||
receiveBuffer = Data()
|
||||
updateState(.disconnected)
|
||||
messageStream?.finish()
|
||||
messageStream = nil
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
/// Send raw data over the connection
|
||||
func send(_ data: Data) async throws {
|
||||
guard let connection = connection, state == .connected else {
|
||||
throw NetworkError.notConnected
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a protocol message (builds JSON + newline, sends)
|
||||
func sendMessage(type: String, requestId: String? = nil, params: [String: Any] = [:]) async throws {
|
||||
let data = try ProtocolHandler.buildRequest(type: type, requestId: requestId, params: params)
|
||||
try await send(data)
|
||||
}
|
||||
|
||||
// MARK: - Receive
|
||||
|
||||
/// Read one newline-delimited JSON message.
|
||||
/// Returns nil on EOF / connection close.
|
||||
func readMessage() async throws -> [String: Any]? {
|
||||
while true {
|
||||
// Check buffer for a complete line
|
||||
if let newlineIndex = receiveBuffer.firstIndex(of: 0x0A) {
|
||||
let lineData = receiveBuffer.prefix(through: newlineIndex)
|
||||
receiveBuffer.removeSubrange(...newlineIndex)
|
||||
|
||||
// Check size
|
||||
if lineData.count > Constants.maxMessageBytes {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
|
||||
return try ProtocolHandler.parseMessage(Data(lineData))
|
||||
}
|
||||
|
||||
// Buffer doesn't have a complete line — read more from the connection
|
||||
guard let connection = connection else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chunk = try await receiveChunk(connection: connection)
|
||||
guard let chunk = chunk else {
|
||||
return nil // EOF
|
||||
}
|
||||
|
||||
receiveBuffer.append(chunk)
|
||||
|
||||
// Safety: if buffer exceeds max without a newline, drop it
|
||||
if receiveBuffer.count > Constants.maxMessageBytes * 2 {
|
||||
receiveBuffer = Data()
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a chunk of data from the connection
|
||||
private func receiveChunk(connection: NWConnection) async throws -> Data? {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, isComplete, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
if let content = content, !content.isEmpty {
|
||||
continuation.resume(returning: content)
|
||||
} else if isComplete {
|
||||
continuation.resume(returning: nil)
|
||||
} else {
|
||||
// No data and not complete — shouldn't happen but return nil
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var isConnected: Bool {
|
||||
state == .connected
|
||||
}
|
||||
|
||||
private func updateState(_ newState: ConnectionState) {
|
||||
state = newState
|
||||
stateCallback?(newState)
|
||||
}
|
||||
}
|
||||
88
ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift
Normal file
88
ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
/// Newline-delimited JSON protocol handler.
|
||||
/// Matches Python: protocol.py build_request, build_response, parse_message, encode_binary, decode_binary
|
||||
enum ProtocolHandler: Sendable {
|
||||
|
||||
/// Build a request message (newline-terminated JSON).
|
||||
/// Matches Python: build_request(msg_type, request_id=None, **kwargs)
|
||||
nonisolated static func buildRequest(type: String, requestId: String? = nil, params: [String: Any] = [:]) throws -> Data {
|
||||
var msg: [String: Any] = ["type": type]
|
||||
if let requestId = requestId {
|
||||
msg["request_id"] = requestId
|
||||
}
|
||||
// Merge params into msg
|
||||
for (key, value) in params {
|
||||
msg[key] = value
|
||||
}
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: msg)
|
||||
guard jsonData.count < Constants.maxMessageBytes else {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
return jsonData + Data([0x0A]) // newline
|
||||
}
|
||||
|
||||
/// Build a response message (newline-terminated JSON).
|
||||
nonisolated static func buildResponse(type: String, status: String, data: [String: Any]? = nil, requestId: String? = nil) throws -> Data {
|
||||
var msg: [String: Any] = ["type": type, "status": status]
|
||||
if let data = data {
|
||||
msg["data"] = data
|
||||
}
|
||||
if let requestId = requestId {
|
||||
msg["request_id"] = requestId
|
||||
}
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: msg)
|
||||
guard jsonData.count < Constants.maxMessageBytes else {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
return jsonData + Data([0x0A])
|
||||
}
|
||||
|
||||
/// Parse a single protocol message from bytes.
|
||||
/// Matches Python: parse_message(line)
|
||||
nonisolated static func parseMessage(_ data: Data) throws -> [String: Any] {
|
||||
let trimmed = Self.trimmingNewlines(data)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw NetworkError.protocolError("Empty message")
|
||||
}
|
||||
guard let obj = try JSONSerialization.jsonObject(with: trimmed) as? [String: Any] else {
|
||||
throw NetworkError.protocolError("Message is not a JSON object")
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/// Encode bytes to base64 string.
|
||||
/// Matches Python: encode_binary(data)
|
||||
nonisolated static func encodeBinary(_ data: Data) -> String {
|
||||
data.base64EncodedString(options: [])
|
||||
}
|
||||
|
||||
/// Decode base64 string to bytes.
|
||||
/// Matches Python: decode_binary(data)
|
||||
nonisolated static func decodeBinary(_ string: String) throws -> Data {
|
||||
guard let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) else {
|
||||
throw CryptoError.invalidBase64
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Generate a new request ID (UUID string).
|
||||
nonisolated static func newRequestId() -> String {
|
||||
UUID().uuidString
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private nonisolated static func trimmingNewlines(_ data: Data) -> Data {
|
||||
var result = data
|
||||
while let last = result.last, last == 0x0A || last == 0x0D {
|
||||
result.removeLast()
|
||||
}
|
||||
while let first = result.first, first == 0x0A || first == 0x0D {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user