initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
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, useTLS: Bool = false, tlsInsecure: Bool = false) 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 params: NWParameters
if useTLS {
let tlsOptions = NWProtocolTLS.Options()
if tlsInsecure {
// Skip certificate verification (dev only)
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ _, _, completionHandler in completionHandler(true) },
.main
)
}
params = NWParameters(tls: tlsOptions, tcp: .init())
} else {
params = .tcp
}
let conn = NWConnection(host: nwHost, port: nwPort, using: params)
self.connection = conn
self.receiveBuffer = Data()
return try await withCheckedThrowingContinuation { continuation in
conn.stateUpdateHandler = { [weak self] newState in
Task { [weak self] in
guard let self = self else { return }
switch newState {
case .ready:
await self.updateState(.connected)
continuation.resume()
case .failed(let error):
await self.updateState(.failed(error.localizedDescription))
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
case .cancelled:
await self.updateState(.disconnected)
case .waiting(let error):
await self.updateState(.failed(error.localizedDescription))
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)
}
}

View File

@@ -0,0 +1,90 @@
import Foundation
/// Newline-delimited JSON protocol handler.
/// Matches Python: protocol.py build_request, build_response, parse_message, encode_binary, decode_binary
enum ProtocolHandler {
/// Build a request message (newline-terminated JSON).
/// Matches Python: build_request(msg_type, request_id=None, **kwargs)
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).
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)
static func parseMessage(_ data: Data) throws -> [String: Any] {
let trimmed = data.trimmingNewlines()
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)
static func encodeBinary(_ data: Data) -> String {
data.base64EncodedString(options: [])
}
/// Decode base64 string to bytes.
/// Matches Python: decode_binary(data)
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).
static func newRequestId() -> String {
UUID().uuidString
}
}
// MARK: - Data Helpers
private extension Data {
func trimmingNewlines() -> Data {
var data = self
while let last = data.last, last == 0x0A || last == 0x0D {
data.removeLast()
}
while let first = data.first, first == 0x0A || first == 0x0D {
data.removeFirst()
}
return data
}
}