diff --git a/.github/workflows/godot.yml b/.github/workflows/godot.yml index 9bb89c4..f872668 100644 --- a/.github/workflows/godot.yml +++ b/.github/workflows/godot.yml @@ -4,13 +4,19 @@ on: push: paths: - 'platforms/godot/**' + - 'src/**' + - 'include/**' + - 'third_party/**' branches: - main pull_request: paths: - 'platforms/godot/**' + - 'src/**' + - 'include/**' + - 'third_party/**' workflow_dispatch: - + workflow_call: jobs: # Run test suite first (includes integration tests with example-server) test: diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..fc92848 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,68 @@ +name: Swift Platform + +on: + push: + paths: + - 'platforms/swift/**' + - 'src/**' + - 'include/**' + - 'third_party/**' + pull_request: + paths: + - 'platforms/swift/**' + - 'src/**' + - 'include/**' + - 'third_party/**' + workflow_call: + +jobs: + build: + name: Build xcframework + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Cleanup Zig cache + run: | + rm -rf ~/.cache/zig + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.15.2 + + - name: Show tool versions + run: | + zig version + swift --version + xcodebuild -version + + - name: Build xcframework (all slices) + working-directory: platforms/swift + run: bash build.sh release + + - name: Verify xcframework structure + run: | + echo "=== xcframework contents ===" + find platforms/swift/build/Colyseus.xcframework -type f | sort + echo "" + echo "=== Checksum ===" + cat platforms/swift/build/Colyseus.xcframework.zip.sha256 + + - name: Build example (macOS) + working-directory: platforms/swift/example + run: swift build -c release + + - name: Run Swift package tests + working-directory: platforms/swift + run: swift test + + - name: Upload xcframework artifact + uses: actions/upload-artifact@v4 + with: + name: Colyseus.xcframework + path: platforms/swift/build/Colyseus.xcframework.zip diff --git a/platforms/swift/.gitignore b/platforms/swift/.gitignore new file mode 100644 index 0000000..868e0d4 --- /dev/null +++ b/platforms/swift/.gitignore @@ -0,0 +1,6 @@ +build/ +.build/ +zig-out/ +zig-cache/ +*.xcframework.zip +*.xcframework.zip.sha256 diff --git a/platforms/swift/Package.swift b/platforms/swift/Package.swift new file mode 100644 index 0000000..51be6aa --- /dev/null +++ b/platforms/swift/Package.swift @@ -0,0 +1,58 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// MARK: - Package +// +// This package distributes the Colyseus Swift SDK in two modes: +// +// 1. SOURCE mode (default during development): +// The Swift wrapper sources are compiled directly. The pre-built +// xcframework is linked as a binary dependency. +// Use: .package(path: "../..") +// +// 2. RELEASE mode (tagged GitHub releases): +// Both the Swift sources and the libcolyseus static library are +// bundled in a single xcframework and distributed as a binary target. +// Consumers add: +// .package(url: "https://github.com/colyseus/colyseus-sdk", from: "0.17.0") +// +// For source-mode development the xcframework must be built first: +// cd platforms/swift && ./build.sh + +let package = Package( + name: "Colyseus", + platforms: [ + .macOS(.v13), + .iOS(.v15), + .tvOS(.v15), + ], + products: [ + .library( + name: "Colyseus", + targets: ["Colyseus"] + ), + ], + targets: [ + // Swift wrapper layer + .target( + name: "Colyseus", + dependencies: ["CColyseus"], + path: "Sources/Colyseus", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), + + // C library xcframework (pre-built via build.sh) + .binaryTarget( + name: "CColyseus", + path: "build/Colyseus.xcframework" + ), + + .testTarget( + name: "ColyseusTests", + dependencies: ["Colyseus"], + path: "Tests/ColyseusTests" + ), + ] +) diff --git a/platforms/swift/Sources/Colyseus/ClientContext.swift b/platforms/swift/Sources/Colyseus/ClientContext.swift new file mode 100644 index 0000000..52af525 --- /dev/null +++ b/platforms/swift/Sources/Colyseus/ClientContext.swift @@ -0,0 +1,24 @@ + +import CColyseus +import Foundation + +/// Heap-allocated context for client matchmaking callbacks. +/// Holds the success/error closures and is released after the callback fires. +final class ClientContext { + let onSuccess: (ColyseusRoom) -> Void + let onError: (Int32, String) -> Void + + init(onSuccess: @escaping (ColyseusRoom) -> Void, + onError: @escaping (Int32, String) -> Void) { + self.onSuccess = onSuccess + self.onError = onError + } + + func retain() -> UnsafeMutableRawPointer { + Unmanaged.passRetained(self).toOpaque() + } + + static func consume(_ ptr: UnsafeMutableRawPointer) -> ClientContext { + Unmanaged.fromOpaque(ptr).takeRetainedValue() + } +} diff --git a/platforms/swift/Sources/Colyseus/ColyseusClient.swift b/platforms/swift/Sources/Colyseus/ColyseusClient.swift new file mode 100644 index 0000000..4388fd2 --- /dev/null +++ b/platforms/swift/Sources/Colyseus/ColyseusClient.swift @@ -0,0 +1,168 @@ + +import CColyseus +import Foundation + +/// Top-level Colyseus client. Manages matchmaking and room lifecycle. +public final class ColyseusClient { + + private let raw: UnsafeMutablePointer + private let settings: ColyseusSettings // retain settings + + // MARK: - Lifecycle + + public init(settings: ColyseusSettings) { + self.settings = settings + guard let ptr = colyseus_client_create(settings.raw) else { + fatalError("colyseus_client_create returned nil") + } + raw = ptr + } + + deinit { + colyseus_client_free(raw) + } + + // MARK: - Matchmaking + + public func joinOrCreate( + _ roomName: String, + options: String = "{}", + onSuccess: @escaping (ColyseusRoom) -> Void, + onError: @escaping (Int32, String) -> Void + ) { + let ctx = ClientContext(onSuccess: onSuccess, onError: onError) + colyseus_client_join_or_create( + raw, roomName, options, + Self.roomSuccessCallback, Self.roomErrorCallback, + ctx.retain() + ) + } + + public func create( + _ roomName: String, + options: String = "{}", + onSuccess: @escaping (ColyseusRoom) -> Void, + onError: @escaping (Int32, String) -> Void + ) { + let ctx = ClientContext(onSuccess: onSuccess, onError: onError) + colyseus_client_create_room( + raw, roomName, options, + Self.roomSuccessCallback, Self.roomErrorCallback, + ctx.retain() + ) + } + + public func join( + _ roomName: String, + options: String = "{}", + onSuccess: @escaping (ColyseusRoom) -> Void, + onError: @escaping (Int32, String) -> Void + ) { + let ctx = ClientContext(onSuccess: onSuccess, onError: onError) + colyseus_client_join( + raw, roomName, options, + Self.roomSuccessCallback, Self.roomErrorCallback, + ctx.retain() + ) + } + + public func joinById( + _ roomId: String, + options: String = "{}", + onSuccess: @escaping (ColyseusRoom) -> Void, + onError: @escaping (Int32, String) -> Void + ) { + let ctx = ClientContext(onSuccess: onSuccess, onError: onError) + colyseus_client_join_by_id( + raw, roomId, options, + Self.roomSuccessCallback, Self.roomErrorCallback, + ctx.retain() + ) + } + + public func reconnect( + token: String, + onSuccess: @escaping (ColyseusRoom) -> Void, + onError: @escaping (Int32, String) -> Void + ) { + let ctx = ClientContext(onSuccess: onSuccess, onError: onError) + colyseus_client_reconnect( + raw, token, + Self.roomSuccessCallback, Self.roomErrorCallback, + ctx.retain() + ) + } + + // MARK: - Async/await wrappers + + public func joinOrCreate(_ roomName: String, options: String = "{}") async throws -> ColyseusRoom { + try await withCheckedThrowingContinuation { cont in + joinOrCreate(roomName, options: options, + onSuccess: { cont.resume(returning: $0) }, + onError: { code, msg in cont.resume(throwing: ColyseusError(code: code, message: msg)) } + ) + } + } + + public func create(_ roomName: String, options: String = "{}") async throws -> ColyseusRoom { + try await withCheckedThrowingContinuation { cont in + create(roomName, options: options, + onSuccess: { cont.resume(returning: $0) }, + onError: { code, msg in cont.resume(throwing: ColyseusError(code: code, message: msg)) } + ) + } + } + + public func join(_ roomName: String, options: String = "{}") async throws -> ColyseusRoom { + try await withCheckedThrowingContinuation { cont in + join(roomName, options: options, + onSuccess: { cont.resume(returning: $0) }, + onError: { code, msg in cont.resume(throwing: ColyseusError(code: code, message: msg)) } + ) + } + } + + public func joinById(_ roomId: String, options: String = "{}") async throws -> ColyseusRoom { + try await withCheckedThrowingContinuation { cont in + joinById(roomId, options: options, + onSuccess: { cont.resume(returning: $0) }, + onError: { code, msg in cont.resume(throwing: ColyseusError(code: code, message: msg)) } + ) + } + } + + public func reconnect(token: String) async throws -> ColyseusRoom { + try await withCheckedThrowingContinuation { cont in + reconnect(token: token, + onSuccess: { cont.resume(returning: $0) }, + onError: { code, msg in cont.resume(throwing: ColyseusError(code: code, message: msg)) } + ) + } + } + + // MARK: - C trampolines (must be @convention(c)) + + private static let roomSuccessCallback: colyseus_client_room_callback_t = { rawRoom, userdata in + guard let rawRoom = rawRoom, let userdata = userdata else { return } + let ctx = ClientContext.consume(userdata) + let room = ColyseusRoom(raw: rawRoom, owns: true) + room.installCallbacks() + DispatchQueue.main.async { ctx.onSuccess(room) } + } + + private static let roomErrorCallback: colyseus_client_error_callback_t = { code, message, userdata in + guard let userdata = userdata else { return } + let ctx = ClientContext.consume(userdata) + let msg = message.map { String(cString: $0) } ?? "" + DispatchQueue.main.async { ctx.onError(code, msg) } + } +} + +// MARK: - ColyseusError + +public struct ColyseusError: Error, LocalizedError { + public let code: Int32 + public let message: String + + public var errorDescription: String? { "Colyseus error \(code): \(message)" } +} diff --git a/platforms/swift/Sources/Colyseus/ColyseusMessage.swift b/platforms/swift/Sources/Colyseus/ColyseusMessage.swift new file mode 100644 index 0000000..93cfe1e --- /dev/null +++ b/platforms/swift/Sources/Colyseus/ColyseusMessage.swift @@ -0,0 +1,172 @@ +import CColyseus +import Foundation + +public indirect enum MessageValue { + case `nil` + case bool(Bool) + case int(Int64) + case uint(UInt64) + case float(Double) + case string(String) + case binary(Data) + case array([MessageValue]) + case map([String: MessageValue]) +} + +extension MessageValue: CustomStringConvertible { + public var description: String { + switch self { + case .nil: return "nil" + case .bool(let v): return String(v) + case .int(let v): return String(v) + case .uint(let v): return String(v) + case .float(let v): return String(v) + case .string(let v): return v + case .binary(let v): return "<\(v.count) bytes>" + case .array(let v): return String(describing: v) + case .map(let v): return String(describing: v) + } + } +} + +public enum MessageType { + case `nil`, bool, int, uint, float, string, binary, array, map + + init(_ raw: colyseus_message_type_t) { + switch raw { + case COLYSEUS_MESSAGE_TYPE_NIL: self = .nil + case COLYSEUS_MESSAGE_TYPE_BOOL: self = .bool + case COLYSEUS_MESSAGE_TYPE_INT: self = .int + case COLYSEUS_MESSAGE_TYPE_UINT: self = .uint + case COLYSEUS_MESSAGE_TYPE_FLOAT: self = .float + case COLYSEUS_MESSAGE_TYPE_STR: self = .string + case COLYSEUS_MESSAGE_TYPE_BIN: self = .binary + case COLYSEUS_MESSAGE_TYPE_ARRAY: self = .array + case COLYSEUS_MESSAGE_TYPE_MAP: self = .map + default: self = .nil + } + } +} + +public final class MessageReader { + let raw: OpaquePointer + init(_ raw: OpaquePointer) { self.raw = raw } + deinit { colyseus_message_reader_free(raw) } + + public var type: MessageType { MessageType(colyseus_message_reader_get_type(raw)) } + public var isNil: Bool { colyseus_message_reader_is_nil(raw) } + public var isBool: Bool { colyseus_message_reader_is_bool(raw) } + public var isInt: Bool { colyseus_message_reader_is_int(raw) } + public var isFloat: Bool { colyseus_message_reader_is_float(raw) } + public var isString: Bool { colyseus_message_reader_is_str(raw) } + public var isBinary: Bool { colyseus_message_reader_is_bin(raw) } + public var isArray: Bool { colyseus_message_reader_is_array(raw) } + public var isMap: Bool { colyseus_message_reader_is_map(raw) } + + public var boolValue: Bool { colyseus_message_reader_get_bool(raw) } + public var intValue: Int64 { colyseus_message_reader_get_int(raw) } + public var uintValue: UInt64 { colyseus_message_reader_get_uint(raw) } + public var floatValue: Double { colyseus_message_reader_get_float(raw) } + + public var stringValue: String? { + var len = 0 + guard let ptr = colyseus_message_reader_get_str(raw, &len) else { return nil } + return String(bytes: UnsafeRawBufferPointer(start: ptr, count: len), encoding: .utf8) + } + + public var binaryValue: Data? { + var len = 0 + guard let ptr = colyseus_message_reader_get_bin(raw, &len) else { return nil } + return Data(bytes: ptr, count: len) + } + + public var arrayCount: Int { colyseus_message_reader_get_array_size(raw) } + + public func arrayElement(at index: Int) -> MessageReader? { + colyseus_message_reader_get_array_element(raw, index).map { MessageReader($0) } + } + + public var mapCount: Int { colyseus_message_reader_get_map_size(raw) } + + public func mapValue(forKey key: String) -> MessageReader? { + colyseus_message_reader_map_get(raw, key).map { MessageReader($0) } + } + + public func mapString(forKey key: String) -> String? { + var ptr: UnsafePointer? = nil + var len = 0 + guard colyseus_message_reader_map_get_str(raw, key, &ptr, &len), let p = ptr else { return nil } + return String(bytes: UnsafeRawBufferPointer(start: p, count: len), encoding: .utf8) + } + + public func mapInt(forKey key: String) -> Int64? { var v: Int64 = 0; return colyseus_message_reader_map_get_int(raw, key, &v) ? v : nil } + public func mapUInt(forKey key: String) -> UInt64? { var v: UInt64 = 0; return colyseus_message_reader_map_get_uint(raw, key, &v) ? v : nil } + public func mapFloat(forKey key: String) -> Double? { var v: Double = 0; return colyseus_message_reader_map_get_float(raw, key, &v) ? v : nil } + public func mapBool(forKey key: String) -> Bool? { var v = false; return colyseus_message_reader_map_get_bool(raw, key, &v) ? v : nil } + + public func decode() -> MessageValue { + switch type { + case .nil: return .nil + case .bool: return .bool(boolValue) + case .int: return .int(intValue) + case .uint: return .uint(uintValue) + case .float: return .float(floatValue) + case .string: return .string(stringValue ?? "") + case .binary: return .binary(binaryValue ?? Data()) + case .array: + return .array((0 ..< arrayCount).compactMap { arrayElement(at: $0)?.decode() }) + case .map: + var result: [String: MessageValue] = [:] + var iter = colyseus_message_reader_map_iterator(raw) + while true { + var kp: OpaquePointer? = nil + var vp: OpaquePointer? = nil + guard colyseus_message_map_iterator_next(&iter, &kp, &vp), + let k = kp, let v = vp else { break } + let kr = MessageReader(k) + let vr = MessageReader(v) + result[kr.stringValue ?? String(kr.intValue)] = vr.decode() + } + return .map(result) + } + } +} + +public final class MessageBuilder { + let raw: OpaquePointer + private init(_ raw: OpaquePointer) { self.raw = raw } + deinit { colyseus_message_free(raw) } + + public static func map() -> MessageBuilder { MessageBuilder(colyseus_message_map_create()!) } + public static func array() -> MessageBuilder { MessageBuilder(colyseus_message_array_create()!) } + public static func nil_() -> MessageBuilder { MessageBuilder(colyseus_message_nil_create()!) } + public static func value(_ v: Bool) -> MessageBuilder { MessageBuilder(colyseus_message_bool_create(v)!) } + public static func value(_ v: Int64) -> MessageBuilder { MessageBuilder(colyseus_message_int_create(v)!) } + public static func value(_ v: UInt64) -> MessageBuilder { MessageBuilder(colyseus_message_uint_create(v)!) } + public static func value(_ v: Double) -> MessageBuilder { MessageBuilder(colyseus_message_float_create(v)!) } + public static func value(_ v: String) -> MessageBuilder { MessageBuilder(colyseus_message_str_create(v)!) } + + @discardableResult public func set(_ k: String, _ v: String) -> MessageBuilder { colyseus_message_map_put_str(raw, k, v); return self } + @discardableResult public func set(_ k: String, _ v: Int64) -> MessageBuilder { colyseus_message_map_put_int(raw, k, v); return self } + @discardableResult public func set(_ k: String, _ v: UInt64) -> MessageBuilder { colyseus_message_map_put_uint(raw, k, v); return self } + @discardableResult public func set(_ k: String, _ v: Double) -> MessageBuilder { colyseus_message_map_put_float(raw, k, v); return self } + @discardableResult public func set(_ k: String, _ v: Bool) -> MessageBuilder { colyseus_message_map_put_bool(raw, k, v); return self } + @discardableResult public func setNil(_ k: String) -> MessageBuilder { colyseus_message_map_put_nil(raw, k); return self } + @discardableResult public func set(_ k: String, _ v: MessageBuilder) -> MessageBuilder { colyseus_message_map_put_msg(raw, k, v.raw); return self } + + @discardableResult public func push(_ v: String) -> MessageBuilder { colyseus_message_array_push_str(raw, v); return self } + @discardableResult public func push(_ v: Int64) -> MessageBuilder { colyseus_message_array_push_int(raw, v); return self } + @discardableResult public func push(_ v: UInt64) -> MessageBuilder { colyseus_message_array_push_uint(raw, v); return self } + @discardableResult public func push(_ v: Double) -> MessageBuilder { colyseus_message_array_push_float(raw, v); return self } + @discardableResult public func push(_ v: Bool) -> MessageBuilder { colyseus_message_array_push_bool(raw, v); return self } + @discardableResult public func pushNil() -> MessageBuilder { colyseus_message_array_push_nil(raw); return self } + @discardableResult public func push(_ v: MessageBuilder) -> MessageBuilder { colyseus_message_array_push_msg(raw, v.raw); return self } + + public func encode() -> Data { + var length = 0 + guard let ptr = colyseus_message_encode(raw, &length) else { return Data() } + let data = Data(bytes: ptr, count: length) + colyseus_message_encoded_free(ptr, length) + return data + } +} diff --git a/platforms/swift/Sources/Colyseus/ColyseusRoom.swift b/platforms/swift/Sources/Colyseus/ColyseusRoom.swift new file mode 100644 index 0000000..c9798f4 --- /dev/null +++ b/platforms/swift/Sources/Colyseus/ColyseusRoom.swift @@ -0,0 +1,149 @@ + +import CColyseus +import Foundation + +/// Wraps a `colyseus_room_t*`. +/// All public event closures are invoked on the **main queue**. +public final class ColyseusRoom { + + let raw: UnsafeMutablePointer + private let ownsRaw: Bool + private var ctxPtr: UnsafeMutableRawPointer? + private var dynamicVtable: UnsafeMutablePointer? + + // MARK: - Public callbacks + + public var onJoin: (() -> Void)? + public var onLeave: ((Int32, String) -> Void)? + public var onError: ((Int32, String) -> Void)? + public var onStateChange: ((SchemaState) -> Void)? + public var onMessage: ((String, MessageValue) -> Void)? + public var onMessageRaw: ((String, Data) -> Void)? + + // MARK: - Lifecycle + + init(raw: UnsafeMutablePointer, owns: Bool = true) { + self.raw = raw + self.ownsRaw = owns + } + + deinit { + if let p = ctxPtr { RoomContext.release(p) } + if ownsRaw { colyseus_room_free(raw) } + if let v = dynamicVtable { colyseus_dynamic_vtable_free(v) } + } + + // MARK: - Internal setup + + func installCallbacks() { + let ctx = RoomContext(self) + let ptr = ctx.retain() + ctxPtr = ptr + + colyseus_room_on_join(raw, { ud in + guard let r = RoomContext.from(ud)?.room else { return } + DispatchQueue.main.async { r.onJoin?() } + }, ptr) + + colyseus_room_on_leave(raw, { code, reason, ud in + guard let r = RoomContext.from(ud)?.room else { return } + let s = reason.map { String(cString: $0) } ?? "" + DispatchQueue.main.async { r.onLeave?(code, s) } + }, ptr) + + colyseus_room_on_error(raw, { code, msg, ud in + guard let r = RoomContext.from(ud)?.room else { return } + let s = msg.map { String(cString: $0) } ?? "" + DispatchQueue.main.async { r.onError?(code, s) } + }, ptr) + + colyseus_room_on_state_change(raw, { ud in + guard let r = RoomContext.from(ud)?.room else { return } + guard let statePtr = colyseus_room_get_state(r.raw) else { return } + let state = SchemaWalker.walk(statePtr) + DispatchQueue.main.async { r.onStateChange?(state) } + }, ptr) + + // Single wildcard handler for all message types (encoded bytes path). + colyseus_room_on_message_any_with_type_encoded(raw, { msgType, data, length, ud in + guard let r = RoomContext.from(ud)?.room else { return } + let t = msgType.map { String(cString: $0) } ?? "" + guard let data = data else { return } + let bytes = Data(bytes: data, count: length) + if let readerPtr = colyseus_message_reader_create(data, length) { + let decoded = MessageReader(readerPtr).decode() + DispatchQueue.main.async { + r.onMessage?(t, decoded) + r.onMessageRaw?(t, bytes) + } + } else { + DispatchQueue.main.async { r.onMessageRaw?(t, bytes) } + } + }, ptr) + } + + // MARK: - Dynamic schema + + /// Opt-in to dynamic schema decoding. Call before the room connects. + /// After joining, `state` and `onStateChange` will be populated. + public func enableDynamicSchema() { + guard dynamicVtable == nil else { return } + guard let vtable = colyseus_dynamic_vtable_create("SwiftDynamic") else { return } + dynamicVtable = vtable + withUnsafePointer(to: vtable.pointee.base) { basePtr in + colyseus_room_set_state_type(raw, basePtr) + } + } + + // MARK: - Properties + + public var roomId: String? { colyseus_room_get_id(raw).map { String(cString: $0) } } + public var sessionId: String? { colyseus_room_get_session_id(raw).map { String(cString: $0) } } + public var name: String? { colyseus_room_get_name(raw).map { String(cString: $0) } } + public var reconnectionToken: String?{ colyseus_room_get_reconnection_token(raw).map { String(cString: $0) } } + public var isConnected: Bool { colyseus_room_is_connected(raw) } + + public var state: SchemaState? { + colyseus_room_get_state(raw).map { SchemaWalker.walk($0) } + } + + // MARK: - Send + + public func send(type: String, _ message: MessageBuilder) { + colyseus_room_send(raw, type, message.raw) + } + + public func send(type: Int32, _ message: MessageBuilder) { + colyseus_room_send_int(raw, type, message.raw) + } + + public func send(type: String, encoded data: Data) { + data.withUnsafeBytes { + colyseus_room_send_encoded(raw, type, $0.baseAddress?.assumingMemoryBound(to: UInt8.self), $0.count) + } + } + + public func send(type: Int32, encoded data: Data) { + data.withUnsafeBytes { + colyseus_room_send_int_encoded(raw, type, $0.baseAddress?.assumingMemoryBound(to: UInt8.self), $0.count) + } + } + + public func sendBytes(type: String, _ data: Data) { + data.withUnsafeBytes { + colyseus_room_send_bytes(raw, type, $0.baseAddress?.assumingMemoryBound(to: UInt8.self), $0.count) + } + } + + public func sendBytes(type: Int32, _ data: Data) { + data.withUnsafeBytes { + colyseus_room_send_int_bytes(raw, type, $0.baseAddress?.assumingMemoryBound(to: UInt8.self), $0.count) + } + } + + // MARK: - Leave + + public func leave(consented: Bool = true) { + colyseus_room_leave(raw, consented) + } +} diff --git a/platforms/swift/Sources/Colyseus/ColyseusSchema.swift b/platforms/swift/Sources/Colyseus/ColyseusSchema.swift new file mode 100644 index 0000000..017276b --- /dev/null +++ b/platforms/swift/Sources/Colyseus/ColyseusSchema.swift @@ -0,0 +1,135 @@ + +import CColyseus +import Foundation + +/// Dynamic state as a nested dictionary. +/// Primitive fields -> Swift native types. +/// ref fields -> nested SchemaState. +/// array/map fields -> [Any] or [String: Any]. +public typealias SchemaState = [String: Any] + +// MARK: - Internal state walker + +enum SchemaWalker { + + /// Walk a colyseus_dynamic_schema_t* and return a SchemaState. + static func walk(_ ptr: UnsafeMutableRawPointer) -> SchemaState { + let schema = ptr.assumingMemoryBound(to: colyseus_dynamic_schema_t.self) + + // Iterate over all fields stored in the hash table. + typealias Ctx = (result: SchemaState, schema: UnsafeMutablePointer) + var ctx: Ctx = (result: [:], schema: schema) + + withUnsafeMutablePointer(to: &ctx) { ctxPtr in + colyseus_dynamic_schema_foreach( + schema, + { idx, name, value, userdata in + guard let name = name, + let value = value, + let userdata = userdata else { return } + let key = String(cString: name) + let ctx = userdata.assumingMemoryBound(to: Ctx.self) + ctx.pointee.result[key] = SchemaWalker.valueToSwift(value) + }, + ctxPtr + ) + } + return ctx.result + } + + static func valueToSwift(_ v: UnsafeMutablePointer) -> Any { + switch v.pointee.type { + case COLYSEUS_FIELD_STRING: + return v.pointee.data.str.map { String(cString: $0) } as Any? ?? NSNull() + case COLYSEUS_FIELD_BOOLEAN: + return v.pointee.data.boolean + case COLYSEUS_FIELD_INT8: return Int(v.pointee.data.i8) + case COLYSEUS_FIELD_UINT8: return Int(v.pointee.data.u8) + case COLYSEUS_FIELD_INT16: return Int(v.pointee.data.i16) + case COLYSEUS_FIELD_UINT16: return Int(v.pointee.data.u16) + case COLYSEUS_FIELD_INT32: return Int(v.pointee.data.i32) + case COLYSEUS_FIELD_UINT32: return Int(v.pointee.data.u32) + case COLYSEUS_FIELD_INT64: return v.pointee.data.i64 + case COLYSEUS_FIELD_UINT64: return v.pointee.data.u64 + case COLYSEUS_FIELD_NUMBER, COLYSEUS_FIELD_FLOAT64: + return v.pointee.data.num + case COLYSEUS_FIELD_FLOAT32: + return Double(v.pointee.data.f32) + case COLYSEUS_FIELD_REF: + if let ref = v.pointee.data.ref { + return walk(UnsafeMutableRawPointer(ref)) + } + return SchemaState() + case COLYSEUS_FIELD_ARRAY: + return decodeArray(v.pointee.data.array) + case COLYSEUS_FIELD_MAP: + return decodeMap(v.pointee.data.map) + default: + return NSNull() + } + } + + private static func decodeArray(_ arr: UnsafeMutablePointer?) -> [Any] { + guard let arr = arr else { return [] } + typealias Ctx = (list: [Any], hasSchema: Bool, primitiveType: UnsafePointer?) + var ctx: Ctx = (list: [], hasSchema: arr.pointee.has_schema_child, primitiveType: arr.pointee.child_primitive_type) + withUnsafeMutablePointer(to: &ctx) { ctxPtr in + colyseus_array_schema_foreach( + arr, + { _, value, userdata in + guard let value = value, let userdata = userdata else { return } + let ctx = userdata.assumingMemoryBound(to: Ctx.self) + if ctx.pointee.hasSchema { + ctx.pointee.list.append(SchemaWalker.walk(value)) + } else { + ctx.pointee.list.append(SchemaWalker.primitiveFromPtr(value, ctx.pointee.primitiveType)) + } + }, + ctxPtr + ) + } + return ctx.list + } + + private static func decodeMap(_ map: UnsafeMutablePointer?) -> SchemaState { + guard let map = map else { return [:] } + typealias Ctx = (dict: SchemaState, hasSchema: Bool, primitiveType: UnsafePointer?) + var ctx: Ctx = (dict: [:], hasSchema: map.pointee.has_schema_child, primitiveType: map.pointee.child_primitive_type) + withUnsafeMutablePointer(to: &ctx) { ctxPtr in + colyseus_map_schema_foreach( + map, + { key, value, userdata in + guard let key = key, let value = value, let userdata = userdata else { return } + let ctx = userdata.assumingMemoryBound(to: Ctx.self) + let k = String(cString: key) + if ctx.pointee.hasSchema { + ctx.pointee.dict[k] = SchemaWalker.walk(value) + } else { + ctx.pointee.dict[k] = SchemaWalker.primitiveFromPtr(value, ctx.pointee.primitiveType) + } + }, + ctxPtr + ) + } + return ctx.dict + } + + // Decode a raw void* primitive value using the type string stored in the collection. + private static func primitiveFromPtr(_ ptr: UnsafeMutableRawPointer, _ typeStr: UnsafePointer?) -> Any { + let t = typeStr.map { String(cString: $0) } ?? "number" + switch t { + case "string": return String(cString: ptr.assumingMemoryBound(to: CChar.self)) + case "boolean": return ptr.load(as: Bool.self) + case "int8": return Int(ptr.load(as: Int8.self)) + case "uint8": return Int(ptr.load(as: UInt8.self)) + case "int16": return Int(ptr.load(as: Int16.self)) + case "uint16": return Int(ptr.load(as: UInt16.self)) + case "int32": return Int(ptr.load(as: Int32.self)) + case "uint32": return Int(ptr.load(as: UInt32.self)) + case "int64": return ptr.load(as: Int64.self) + case "uint64": return ptr.load(as: UInt64.self) + case "float32": return Double(ptr.load(as: Float.self)) + default: return ptr.load(as: Double.self) + } + } +} diff --git a/platforms/swift/Sources/Colyseus/ColyseusSettings.swift b/platforms/swift/Sources/Colyseus/ColyseusSettings.swift new file mode 100644 index 0000000..77617ee --- /dev/null +++ b/platforms/swift/Sources/Colyseus/ColyseusSettings.swift @@ -0,0 +1,83 @@ +import CColyseus +import Foundation + +/// Wraps `colyseus_settings_t` with a Swift-friendly interface. +public final class ColyseusSettings { + + let raw: UnsafeMutablePointer + + public init() { + guard let ptr = colyseus_settings_create() else { + fatalError("colyseus_settings_create returned nil") + } + raw = ptr + } + + deinit { + colyseus_settings_free(raw) + } + + public var address: String { + get { raw.pointee.server_address.map { String(cString: $0) } ?? "localhost" } + set { colyseus_settings_set_address(raw, newValue) } + } + + public var port: String { + get { raw.pointee.server_port.map { String(cString: $0) } ?? "2567" } + set { colyseus_settings_set_port(raw, newValue) } + } + + public var secure: Bool { + get { raw.pointee.use_secure_protocol } + set { colyseus_settings_set_secure(raw, newValue) } + } + + public var tlsSkipVerification: Bool { + get { raw.pointee.tls_skip_verification } + set { raw.pointee.tls_skip_verification = newValue } + } + + public func setHeader(_ value: String, forKey key: String) { + colyseus_settings_add_header(raw, key, value) + } + + public func removeHeader(forKey key: String) { + colyseus_settings_remove_header(raw, key) + } + + public func header(forKey key: String) -> String? { + guard let ptr = colyseus_settings_get_header(raw, key) else { return nil } + return String(cString: ptr) + } + + public static func localhost(port: String = "2567", secure: Bool = false) -> ColyseusSettings { + let s = ColyseusSettings() + s.address = "localhost" + s.port = port + s.secure = secure + return s + } + + public static func from(endpoint: String) -> ColyseusSettings { + let s = ColyseusSettings() + var rest = endpoint + if rest.hasPrefix("wss://") { + s.secure = true + rest = String(rest.dropFirst(6)) + } else if rest.hasPrefix("ws://") { + s.secure = false + rest = String(rest.dropFirst(5)) + } + if let slash = rest.firstIndex(of: "/") { + rest = String(rest[.. UnsafeMutableRawPointer { + Unmanaged.passRetained(self).toOpaque() + } + + /// Releases the context previously retained by `retain()`. + static func release(_ ptr: UnsafeMutableRawPointer) { + Unmanaged.fromOpaque(ptr).release() + } + + static func from(_ ptr: UnsafeMutableRawPointer?) -> RoomContext? { + guard let p = ptr else { return nil } + return Unmanaged.fromOpaque(p).takeUnretainedValue() + } +} diff --git a/platforms/swift/Tests/ColyseusTests/SchemaWalkerTests.swift b/platforms/swift/Tests/ColyseusTests/SchemaWalkerTests.swift new file mode 100644 index 0000000..c639c53 --- /dev/null +++ b/platforms/swift/Tests/ColyseusTests/SchemaWalkerTests.swift @@ -0,0 +1,171 @@ +import XCTest +@testable import Colyseus +import CColyseus + +/// Exercises `SchemaWalker` against the native dynamic-schema C API. +/// This validates Swift↔C struct layout and conversion — not full parity with +/// the JavaScript `@colyseus/schema` encoder/decoder (that would need fixture bytes + room decode tests). +final class SchemaWalkerTests: XCTestCase { + + // MARK: - Helpers + + private func addDynamicField( + _ vtable: UnsafeMutablePointer, + index: Int32, + name: String, + type: colyseus_field_type_t, + typeStr: String + ) { + name.withCString { nameC in + typeStr.withCString { tsC in + guard let field = colyseus_dynamic_field_create(index, nameC, type, tsC) else { + XCTFail("colyseus_dynamic_field_create failed") + return + } + colyseus_dynamic_vtable_add_field(vtable, field) + } + } + } + + private func setValue( + _ schema: UnsafeMutablePointer, + index: Int32, + name: String, + kind: colyseus_field_type_t, + _ configure: (UnsafeMutablePointer) -> Void + ) { + guard let value = colyseus_dynamic_value_create(kind) else { + XCTFail("colyseus_dynamic_value_create failed") + return + } + configure(value) + name.withCString { nameC in + colyseus_dynamic_schema_set(schema, index, nameC, value) + } + } + + // MARK: - Tests + + func testWalkFlatPrimitives() { + guard let vtable = colyseus_dynamic_vtable_create("TestFlat") else { + return XCTFail("vtable create") + } + defer { colyseus_dynamic_vtable_free(vtable) } + + addDynamicField(vtable, index: 0, name: "title", type: COLYSEUS_FIELD_STRING, typeStr: "string") + addDynamicField(vtable, index: 1, name: "score", type: COLYSEUS_FIELD_NUMBER, typeStr: "number") + addDynamicField(vtable, index: 2, name: "alive", type: COLYSEUS_FIELD_BOOLEAN, typeStr: "boolean") + addDynamicField(vtable, index: 3, name: "ticks", type: COLYSEUS_FIELD_INT64, typeStr: "int64") + addDynamicField(vtable, index: 4, name: "token", type: COLYSEUS_FIELD_UINT64, typeStr: "uint64") + + guard let schema = colyseus_dynamic_schema_create(vtable) else { + return XCTFail("schema create") + } + defer { colyseus_dynamic_schema_free(schema) } + + setValue(schema, index: 0, name: "title", kind: COLYSEUS_FIELD_STRING) { v in + "hello".withCString { colyseus_dynamic_value_set_string(v, $0) } + } + setValue(schema, index: 1, name: "score", kind: COLYSEUS_FIELD_NUMBER) { v in + colyseus_dynamic_value_set_number(v, 42.5) + } + setValue(schema, index: 2, name: "alive", kind: COLYSEUS_FIELD_BOOLEAN) { v in + colyseus_dynamic_value_set_boolean(v, true) + } + setValue(schema, index: 3, name: "ticks", kind: COLYSEUS_FIELD_INT64) { v in + colyseus_dynamic_value_set_int64(v, -100) + } + setValue(schema, index: 4, name: "token", kind: COLYSEUS_FIELD_UINT64) { v in + colyseus_dynamic_value_set_uint64(v, 9_000_000_000_000_000_001) + } + + let state = SchemaWalker.walk(UnsafeMutableRawPointer(schema)) + + XCTAssertEqual(state.count, 5) + XCTAssertEqual(state["title"] as? String, "hello") + guard let score = state["score"] as? Double else { return XCTFail("score type") } + XCTAssertEqual(score, 42.5, accuracy: 1e-9) + XCTAssertEqual(state["alive"] as? Bool, true) + XCTAssertEqual(state["ticks"] as? Int64, -100) + XCTAssertEqual(state["token"] as? UInt64, 9_000_000_000_000_000_001) + } + + func testWalkFloat32AndFloat64Kinds() { + guard let vtable = colyseus_dynamic_vtable_create("TestFloats") else { + return XCTFail("vtable create") + } + defer { colyseus_dynamic_vtable_free(vtable) } + + addDynamicField(vtable, index: 0, name: "f32", type: COLYSEUS_FIELD_FLOAT32, typeStr: "float32") + addDynamicField(vtable, index: 1, name: "f64", type: COLYSEUS_FIELD_FLOAT64, typeStr: "float64") + + guard let schema = colyseus_dynamic_schema_create(vtable) else { + return XCTFail("schema create") + } + defer { colyseus_dynamic_schema_free(schema) } + + setValue(schema, index: 0, name: "f32", kind: COLYSEUS_FIELD_FLOAT32) { v in + colyseus_dynamic_value_set_float32(v, 1.25) + } + setValue(schema, index: 1, name: "f64", kind: COLYSEUS_FIELD_NUMBER) { v in + colyseus_dynamic_value_set_number(v, 3.5) + v.pointee.type = COLYSEUS_FIELD_FLOAT64 + } + + let state = SchemaWalker.walk(UnsafeMutableRawPointer(schema)) + + guard let f32 = state["f32"] as? Double else { return XCTFail("f32 type") } + guard let f64 = state["f64"] as? Double else { return XCTFail("f64 type") } + XCTAssertEqual(f32, 1.25, accuracy: 1e-6) + XCTAssertEqual(f64, 3.5, accuracy: 1e-9) + } + + func testWalkNestedRef() { + guard let childVt = colyseus_dynamic_vtable_create("Child") else { + return XCTFail("child vtable") + } + defer { colyseus_dynamic_vtable_free(childVt) } + addDynamicField(childVt, index: 0, name: "n", type: COLYSEUS_FIELD_STRING, typeStr: "string") + + guard let parentVt = colyseus_dynamic_vtable_create("Parent") else { + return XCTFail("parent vtable") + } + defer { colyseus_dynamic_vtable_free(parentVt) } + addDynamicField(parentVt, index: 0, name: "label", type: COLYSEUS_FIELD_STRING, typeStr: "string") + addDynamicField(parentVt, index: 1, name: "child", type: COLYSEUS_FIELD_REF, typeStr: "ref") + colyseus_dynamic_vtable_set_child(parentVt, 1, childVt) + + guard let childSchema = colyseus_dynamic_schema_create(childVt) else { + return XCTFail("child schema") + } + setValue(childSchema, index: 0, name: "n", kind: COLYSEUS_FIELD_STRING) { v in + "inner".withCString { colyseus_dynamic_value_set_string(v, $0) } + } + + guard let parentSchema = colyseus_dynamic_schema_create(parentVt) else { + colyseus_dynamic_schema_free(childSchema) + return XCTFail("parent schema") + } + + setValue(parentSchema, index: 0, name: "label", kind: COLYSEUS_FIELD_STRING) { v in + "root".withCString { colyseus_dynamic_value_set_string(v, $0) } + } + guard let refVal = colyseus_dynamic_value_create(COLYSEUS_FIELD_REF) else { + colyseus_dynamic_schema_free(parentSchema) + colyseus_dynamic_schema_free(childSchema) + return XCTFail("ref value") + } + colyseus_dynamic_value_set_ref(refVal, childSchema) + "child".withCString { colyseus_dynamic_schema_set(parentSchema, 1, $0, refVal) } + + let state = SchemaWalker.walk(UnsafeMutableRawPointer(parentSchema)) + + colyseus_dynamic_schema_free(parentSchema) + colyseus_dynamic_schema_free(childSchema) + + XCTAssertEqual(state["label"] as? String, "root") + let nested = state["child"] as? SchemaState + XCTAssertNotNil(nested) + XCTAssertEqual(nested?["n"] as? String, "inner") + } +} diff --git a/platforms/swift/build.sh b/platforms/swift/build.sh new file mode 100755 index 0000000..c5faa28 --- /dev/null +++ b/platforms/swift/build.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# build.sh — Build Colyseus.xcframework for all Apple slices. +# +# Usage: +# ./build.sh [release|debug] # default: release +# ./build.sh release --skip-zip # skip zip archiving +# +# Outputs: +# build/Colyseus.xcframework # xcframework bundle +# build/Colyseus.xcframework.zip # distributable archive (for SPM) +# build/Colyseus.xcframework.zip.sha256 # checksum for Package.swift +# +# Requirements: zig 0.15+, Xcode Command Line Tools + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +ZIG_OUT="$BUILD_DIR/zig-out" +XCF_DIR="$BUILD_DIR/Colyseus.xcframework" + +OPTIMIZE="${1:-release}" +SKIP_ZIP="${2:-}" + +case "$OPTIMIZE" in + release) ZIG_OPT="-Doptimize=ReleaseFast" ;; + debug) ZIG_OPT="-Doptimize=Debug" ;; + *) echo "Unknown mode: $OPTIMIZE (use release or debug)"; exit 1 ;; +esac + +# Slices to build. +# Each entry: "zig-target sdk-name slice-dir-name" +SLICES=( + "aarch64-macos macosx macos-arm64" + "x86_64-macos macosx macos-x86_64" + "aarch64-ios iphoneos ios-arm64" + "aarch64-ios-simulator iphonesimulator ios-arm64-simulator" + "x86_64-ios-simulator iphonesimulator ios-x86_64-simulator" + "aarch64-tvos appletvos tvos-arm64" + "aarch64-tvos-simulator appletvsimulator tvos-arm64-simulator" +) + +rm -rf "$XCF_DIR" "$ZIG_OUT" +mkdir -p "$BUILD_DIR" + +echo "=== Building libcolyseus for all slices ===" + +XCFRAMEWORK_ARGS=() + +build_slice() { + local ZIG_TARGET="$1" + local SDK_NAME="$2" + local SLICE_NAME="$3" + + local SLICE_OUT="$ZIG_OUT/$SLICE_NAME" + mkdir -p "$SLICE_OUT" + + echo " Building $ZIG_TARGET ($SDK_NAME) ..." + + local SDK_PATH + SDK_PATH="$(xcrun --sdk "$SDK_NAME" --show-sdk-path 2>/dev/null || true)" + local SDK_ARG="" + if [[ -n "$SDK_PATH" ]]; then + SDK_ARG="-Dapple-sdk=$SDK_PATH" + fi + + ( + cd "$SCRIPT_DIR" + zig build \ + -Dtarget="$ZIG_TARGET" \ + $ZIG_OPT \ + $SDK_ARG \ + --prefix "$SLICE_OUT" \ + 2>&1 | sed "s/^/ [$SLICE_NAME] /" + ) + + echo " Done: $SLICE_OUT/lib/libcolyseus.a" +} + +for SLICE_SPEC in "${SLICES[@]}"; do + read -r ZIG_TARGET SDK_NAME SLICE_NAME <<< "$SLICE_SPEC" + build_slice "$ZIG_TARGET" "$SDK_NAME" "$SLICE_NAME" +done + +echo "" +echo "=== Assembling xcframework ===" + +# Group slices by SDK for lipo (fat binary per SDK variant). +# macOS: lipo arm64 + x86_64 -> universal +# iOS device: single arm64 +# iOS simulator: lipo arm64-sim + x86_64-sim -> universal +# tvOS device: single arm64 +# tvOS simulator: single arm64-sim + +# Merge all static libs in a slice's lib/ dir into one archive, then lipo. +merge_slice_libs() { + local SLICE_DIR="$1" + local OUT_LIB="$2" + local ALL_LIBS=("$SLICE_DIR"/lib/*.a) + libtool -static -o "$OUT_LIB" "${ALL_LIBS[@]}" +} + +lipo_or_copy() { + local OUT_LIB="$1"; shift + local INPUTS=("$@") + if [[ ${#INPUTS[@]} -gt 1 ]]; then + lipo -create "${INPUTS[@]}" -output "$OUT_LIB" + else + cp "${INPUTS[0]}" "$OUT_LIB" + fi +} + +# Copy headers from any slice (they're identical). +# zig-out installs colyseus headers into include/colyseus/; we copy the +# entire include tree plus our umbrella header and module map. +HEADERS_SRC="$ZIG_OUT/macos-arm64/include" +HEADERS_DST="$BUILD_DIR/Headers" +rm -rf "$HEADERS_DST" +cp -R "$HEADERS_SRC" "$HEADERS_DST" +# Umbrella header and module map must live at the top of the Headers dir. +cp "$SCRIPT_DIR/include/colyseus_swift.h" "$HEADERS_DST/" +cp "$SCRIPT_DIR/include/module.modulemap" "$HEADERS_DST/" + +build_variant() { + local VARIANT_NAME="$1"; shift + local SLICE_DIRS=("$@") + local LIB_DIR="$BUILD_DIR/libs/$VARIANT_NAME" + mkdir -p "$LIB_DIR" + local OUT_LIB="$LIB_DIR/libcolyseus.a" + + # Merge all sub-libs per slice into one archive, then lipo across arches. + local MERGED_LIBS=() + local idx=0 + for SLICE_DIR in "${SLICE_DIRS[@]}"; do + local MERGED="$LIB_DIR/merged_${idx}.a" + merge_slice_libs "$SLICE_DIR" "$MERGED" + MERGED_LIBS+=("$MERGED") + idx=$((idx + 1)) + done + + lipo_or_copy "$OUT_LIB" "${MERGED_LIBS[@]}" + rm -f "$LIB_DIR"/merged_*.a + + # xcodebuild -create-xcframework expects the PARENT directory containing + # the headers — it will create the Headers/ subdirectory itself. + XCFRAMEWORK_ARGS+=("-library" "$OUT_LIB" "-headers" "$HEADERS_DST") +} + +build_variant "macos" \ + "$ZIG_OUT/macos-arm64" \ + "$ZIG_OUT/macos-x86_64" + +build_variant "ios" \ + "$ZIG_OUT/ios-arm64" + +build_variant "ios-simulator" \ + "$ZIG_OUT/ios-arm64-simulator" \ + "$ZIG_OUT/ios-x86_64-simulator" + +build_variant "tvos" \ + "$ZIG_OUT/tvos-arm64" + +build_variant "tvos-simulator" \ + "$ZIG_OUT/tvos-arm64-simulator" + +xcodebuild -create-xcframework "${XCFRAMEWORK_ARGS[@]}" -output "$XCF_DIR" + +# xcodebuild copies the entire -headers directory as-is, which means any +# sub-directory named "Headers" inside HEADERS_DST gets nested as +# xcframework/.../Headers/Headers/. Remove those duplicates. +for slice in "$XCF_DIR"/*/; do + rm -rf "${slice}Headers/Headers" +done + +echo "" +echo "=== xcframework built: $XCF_DIR ===" + +if [[ "$SKIP_ZIP" != "--skip-zip" ]]; then + echo "" + echo "=== Archiving ===" + ZIP_PATH="$BUILD_DIR/Colyseus.xcframework.zip" + (cd "$BUILD_DIR" && zip -qr "$(basename "$ZIP_PATH")" Colyseus.xcframework) + CHECKSUM=$(swift package compute-checksum "$ZIP_PATH" 2>/dev/null || shasum -a 256 "$ZIP_PATH" | awk '{print $1}') + echo "$CHECKSUM" > "$ZIP_PATH.sha256" + echo "Archive: $ZIP_PATH" + echo "Checksum: $CHECKSUM" + echo "" + echo "Update Package.swift binaryTarget checksum with: $CHECKSUM" +fi + +echo "" +echo "Done." diff --git a/platforms/swift/build.zig b/platforms/swift/build.zig new file mode 100644 index 0000000..c9f0c94 --- /dev/null +++ b/platforms/swift/build.zig @@ -0,0 +1,599 @@ +const std = @import("std"); + +// Builds libcolyseus as a static library for each Apple slice. +// The build.sh wrapper calls this once per slice then uses xcodebuild +// to assemble the xcframework. +// +// Supported targets (pass via -Dtarget=…): +// aarch64-macos – macOS Apple Silicon +// x86_64-macos – macOS Intel +// aarch64-ios – iOS device +// aarch64-ios-simulator – iOS Simulator (Apple Silicon host) +// x86_64-ios-simulator – iOS Simulator (Intel host) +// aarch64-tvos – tvOS device +// aarch64-tvos-simulator – tvOS Simulator + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const os_tag = target.result.os.tag; + const is_ios = os_tag == .ios; + const is_tvos = os_tag == .tvos; + const is_apple = os_tag == .macos or is_ios or is_tvos; + + // Auto-detect Apple SDK path via xcrun + const apple_sdk_path: ?[]const u8 = b.option( + []const u8, + "apple-sdk", + "Path to Apple SDK (auto-detected via xcrun if omitted)", + ) orelse blk: { + if (!is_apple) break :blk null; + const sdk_name: []const u8 = switch (os_tag) { + .macos => "macosx", + .tvos => "appletvos", + else => "iphoneos", + }; + // For simulators, use the simulator SDK + const is_sim = target.result.abi == .simulator; + const sdk: []const u8 = if (is_sim) switch (os_tag) { + .ios => "iphonesimulator", + .tvos => "appletvsimulator", + else => sdk_name, + } else sdk_name; + + const result = std.process.Child.run(.{ + .allocator = b.allocator, + .argv = &.{ "xcrun", "--sdk", sdk, "--show-sdk-path" }, + }) catch break :blk null; + defer b.allocator.free(result.stdout); + defer b.allocator.free(result.stderr); + if (result.term.Exited == 0 and result.stdout.len > 0) { + const trimmed = std.mem.trimRight(u8, result.stdout, "\n\r"); + break :blk b.allocator.dupe(u8, trimmed) catch null; + } + break :blk null; + }; + + // ------------------------------------------------------------------------- + // wslay (WebSocket framing) + // ------------------------------------------------------------------------- + const wslay_config_h = b.addConfigHeader(.{ + .style = .blank, + .include_path = "config.h", + }, .{ + .HAVE_ARPA_INET_H = 1, + .HAVE_NETINET_IN_H = 1, + }); + + const wslay_version_h = b.addConfigHeader(.{ + .style = .{ .cmake = b.path("../../third_party/wslay/lib/includes/wslay/wslayver.h.in") }, + .include_path = "wslay/wslayver.h", + }, .{ .PACKAGE_VERSION = "1.1.1" }); + + const wslay = b.addLibrary(.{ + .name = "wslay", + .root_module = b.createModule(.{ .target = target, .optimize = optimize }), + .linkage = .static, + }); + appleLibc(wslay, apple_sdk_path); + wslay.addIncludePath(b.path("../../third_party/wslay/lib/includes")); + wslay.addIncludePath(b.path("../../third_party/wslay/lib")); + wslay.addConfigHeader(wslay_config_h); + wslay.addConfigHeader(wslay_version_h); + wslay.addCSourceFiles(.{ + .files = &.{ + "../../third_party/wslay/lib/wslay_event.c", + "../../third_party/wslay/lib/wslay_frame.c", + "../../third_party/wslay/lib/wslay_net.c", + "../../third_party/wslay/lib/wslay_queue.c", + }, + .flags = &.{ "-Wall", "-std=c11", "-DHAVE_CONFIG_H" }, + }); + + // ------------------------------------------------------------------------- + // mbedTLS + // ------------------------------------------------------------------------- + const mbedcrypto = buildMbedcrypto(b, target, optimize, apple_sdk_path); + const mbedx509 = buildMbedx509(b, target, optimize, apple_sdk_path, mbedcrypto); + const mbedtls = buildMbedtls(b, target, optimize, apple_sdk_path, mbedx509, mbedcrypto); + + // ------------------------------------------------------------------------- + // Zig helper modules + // ------------------------------------------------------------------------- + const msgpack_dep = b.dependency("zig_msgpack", .{ + .target = target, + .optimize = optimize, + }); + const msgpack_module = msgpack_dep.module("msgpack"); + + const msgpack_builder_mod = b.createModule(.{ + .root_source_file = b.path("../../src/msgpack/msgpack_builder.zig"), + .target = target, + .optimize = optimize, + .strip = if (is_tvos) true else null, + .unwind_tables = if (is_tvos) .none else null, + }); + msgpack_builder_mod.addImport("msgpack", msgpack_module); + const msgpack_builder = b.addLibrary(.{ + .name = "msgpack_builder", + .root_module = msgpack_builder_mod, + .linkage = .static, + }); + appleLibc(msgpack_builder, apple_sdk_path); + + const msgpack_reader_mod = b.createModule(.{ + .root_source_file = b.path("../../src/msgpack/msgpack_reader.zig"), + .target = target, + .optimize = optimize, + .strip = if (is_tvos) true else null, + .unwind_tables = if (is_tvos) .none else null, + }); + msgpack_reader_mod.addImport("msgpack", msgpack_module); + const msgpack_reader = b.addLibrary(.{ + .name = "msgpack_reader", + .root_module = msgpack_reader_mod, + .linkage = .static, + }); + appleLibc(msgpack_reader, apple_sdk_path); + + const strutil_mod = b.createModule(.{ + .root_source_file = b.path("../../src/utils/strUtil.zig"), + .target = target, + .optimize = optimize, + .strip = if (is_tvos) true else null, + .unwind_tables = if (is_tvos) .none else null, + }); + const strutil = b.addLibrary(.{ + .name = "strutil_zig", + .root_module = strutil_mod, + .linkage = .static, + }); + appleLibc(strutil, apple_sdk_path); + + const http_mod = b.createModule(.{ + .root_source_file = b.path("../../src/network/http.zig"), + .target = target, + .optimize = optimize, + .strip = if (is_tvos) true else null, + .unwind_tables = if (is_tvos) .none else null, + }); + http_mod.addIncludePath(b.path("../../include")); + http_mod.addIncludePath(b.path("../../third_party/uthash/src")); + const http_zig = b.addLibrary(.{ + .name = "http_zig", + .root_module = http_mod, + .linkage = .static, + }); + appleLibc(http_zig, apple_sdk_path); + + const syscerts_mod = b.createModule(.{ + .root_source_file = b.path("../../src/certs/system_certs.zig"), + .target = target, + .optimize = optimize, + .strip = if (is_tvos) true else null, + .unwind_tables = if (is_tvos) .none else null, + }); + const syscerts = b.addLibrary(.{ + .name = "system_certs_zig", + .root_module = syscerts_mod, + .linkage = .static, + }); + appleLibc(syscerts, apple_sdk_path); + + // ------------------------------------------------------------------------- + // Core colyseus C library + // ------------------------------------------------------------------------- + const colyseus = b.addLibrary(.{ + .name = "colyseus", + .root_module = b.createModule(.{ .target = target, .optimize = optimize }), + .linkage = .static, + }); + appleLibc(colyseus, apple_sdk_path); + + colyseus.addIncludePath(b.path("../../include")); + colyseus.addIncludePath(b.path("../../src")); + colyseus.addIncludePath(b.path("../../third_party/sds")); + colyseus.addIncludePath(b.path("../../third_party/uthash/src")); + colyseus.addIncludePath(b.path("../../third_party/cJSON")); + colyseus.addIncludePath(b.path("../../third_party/wslay/lib/includes")); + colyseus.addIncludePath(b.path("../../third_party/mbedtls/include")); + colyseus.addIncludePath(wslay_config_h.getOutput().dirname()); + colyseus.addIncludePath(wslay_version_h.getOutput().dirname().dirname()); + + colyseus.addCSourceFiles(.{ + .files = &.{ + "../../src/common/settings.c", + "../../src/client.c", + "../../src/room.c", + "../../src/network/websocket_transport.c", + "../../src/schema/decode.c", + "../../src/schema/ref_tracker.c", + "../../src/schema/collections.c", + "../../src/schema/decoder.c", + "../../src/schema/serializer.c", + "../../src/schema/callbacks.c", + "../../src/schema/dynamic_schema.c", + "../../src/utils/strUtil.c", + "../../src/utils/sha1_c.c", + "../../src/auth/auth.c", + "../../src/auth/secure_storage.c", + "../../src/certs/ca_bundle.c", + "../../third_party/sds/sds.c", + "../../third_party/cJSON/cJSON.c", + }, + .flags = &.{ "-Wall", "-Wextra", "-std=c11" }, + }); + + colyseus.linkLibrary(mbedtls); + colyseus.linkLibrary(mbedx509); + colyseus.linkLibrary(mbedcrypto); + colyseus.linkLibrary(wslay); + colyseus.linkLibrary(http_zig); + colyseus.linkLibrary(syscerts); + colyseus.linkLibrary(strutil); + colyseus.linkLibrary(msgpack_builder); + colyseus.linkLibrary(msgpack_reader); + + // Frameworks are needed at final link time (by the Swift consumer), not + // when building the static library. We add the SDK framework search path + // so that Zig can resolve them if it needs to, but we don't force-link + // them into the static archive. + if (apple_sdk_path) |sdk| { + const fw_path = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }); + colyseus.addFrameworkPath(.{ .cwd_relative = fw_path }); + } + // Record the framework dependencies so they are propagated to the linker + // when the static lib is consumed. + colyseus.linkFramework("CoreFoundation"); + colyseus.linkFramework("Security"); + if (os_tag == .macos) { + colyseus.linkSystemLibrary("pthread"); + } + + b.installArtifact(colyseus); + b.installArtifact(mbedtls); + b.installArtifact(mbedx509); + b.installArtifact(mbedcrypto); + b.installArtifact(wslay); + b.installArtifact(http_zig); + b.installArtifact(syscerts); + b.installArtifact(strutil); + b.installArtifact(msgpack_builder); + b.installArtifact(msgpack_reader); + + // Install colyseus public headers so build.sh can copy them into the xcframework. + const header_pairs = [_][2][]const u8{ + .{ "../../include/colyseus/client.h", "colyseus/client.h" }, + .{ "../../include/colyseus/room.h", "colyseus/room.h" }, + .{ "../../include/colyseus/settings.h", "colyseus/settings.h" }, + .{ "../../include/colyseus/protocol.h", "colyseus/protocol.h" }, + .{ "../../include/colyseus/transport.h", "colyseus/transport.h" }, + .{ "../../include/colyseus/messages.h", "colyseus/messages.h" }, + .{ "../../include/colyseus/schema.h", "colyseus/schema.h" }, + .{ "../../include/colyseus/http.h", "colyseus/http.h" }, + .{ "../../include/colyseus/auth/auth.h", "colyseus/auth/auth.h" }, + .{ "../../include/colyseus/auth/secure_storage.h", "colyseus/auth/secure_storage.h" }, + .{ "../../include/colyseus/schema/types.h", "colyseus/schema/types.h" }, + .{ "../../include/colyseus/schema/decode.h", "colyseus/schema/decode.h" }, + .{ "../../include/colyseus/schema/decoder.h", "colyseus/schema/decoder.h" }, + .{ "../../include/colyseus/schema/collections.h", "colyseus/schema/collections.h" }, + .{ "../../include/colyseus/schema/callbacks.h", "colyseus/schema/callbacks.h" }, + .{ "../../include/colyseus/schema/dynamic_schema.h", "colyseus/schema/dynamic_schema.h" }, + .{ "../../include/colyseus/schema/ref_tracker.h", "colyseus/schema/ref_tracker.h" }, + .{ "../../third_party/uthash/src/uthash.h", "uthash.h" }, + }; + for (header_pairs) |pair| { + const install_hdr = b.addInstallHeaderFile(b.path(pair[0]), pair[1]); + b.getInstallStep().dependOn(&install_hdr.step); + } +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +fn appleLibc(lib: *std.Build.Step.Compile, sdk_path: ?[]const u8) void { + lib.linkLibC(); + if (sdk_path) |sdk| { + const b = lib.step.owner; + const inc = b.pathJoin(&.{ sdk, "usr/include" }); + lib.addSystemIncludePath(.{ .cwd_relative = inc }); + } +} + +fn buildMbedcrypto( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + sdk_path: ?[]const u8, +) *std.Build.Step.Compile { + const is_aarch64_sim = target.result.cpu.arch == .aarch64 and + target.result.abi == .simulator; + + const lib = b.addLibrary(.{ + .name = "mbedcrypto", + .root_module = b.createModule(.{ .target = target, .optimize = optimize }), + .linkage = .static, + }); + appleLibc(lib, sdk_path); + lib.addIncludePath(b.path("../../third_party/mbedtls/include")); + lib.addIncludePath(b.path("../../third_party/mbedtls/library")); + + if (is_aarch64_sim) { + // aarch64-ios-simulator: Zig 0.15's baseline mcpu conflicts with + // mbedTLS hardware AES intrinsics. Disable AESCE entirely — the + // software AES fallback is sufficient for simulator builds. + lib.addCSourceFiles(.{ + .files = &MBEDCRYPTO_SOURCES_NO_AESCE, + .flags = &.{ "-Wall", "-std=c11", "-U__ARM_NEON", "-UMBEDTLS_AESCE_C" }, + }); + } else { + lib.addCSourceFiles(.{ + .files = &MBEDCRYPTO_SOURCES, + .flags = &.{ "-Wall", "-std=c11" }, + }); + } + return lib; +} + +fn buildMbedx509( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + sdk_path: ?[]const u8, + mbedcrypto: *std.Build.Step.Compile, +) *std.Build.Step.Compile { + const is_aarch64_sim = target.result.cpu.arch == .aarch64 and + target.result.abi == .simulator; + + const lib = b.addLibrary(.{ + .name = "mbedx509", + .root_module = b.createModule(.{ .target = target, .optimize = optimize }), + .linkage = .static, + }); + appleLibc(lib, sdk_path); + lib.addIncludePath(b.path("../../third_party/mbedtls/include")); + lib.addIncludePath(b.path("../../third_party/mbedtls/library")); + + const sim_flags = &[_][]const u8{ "-Wall", "-std=c11", "-U__ARM_NEON", "-UMBEDTLS_AESCE_C" }; + const base_flags = &[_][]const u8{ "-Wall", "-std=c11" }; + + lib.addCSourceFiles(.{ + .files = &.{ + "../../third_party/mbedtls/library/x509.c", + "../../third_party/mbedtls/library/x509_create.c", + "../../third_party/mbedtls/library/x509_crl.c", + "../../third_party/mbedtls/library/x509_crt.c", + "../../third_party/mbedtls/library/x509_csr.c", + "../../third_party/mbedtls/library/x509write.c", + "../../third_party/mbedtls/library/x509write_crt.c", + "../../third_party/mbedtls/library/x509write_csr.c", + }, + .flags = if (is_aarch64_sim) sim_flags else base_flags, + }); + lib.linkLibrary(mbedcrypto); + return lib; +} + +fn buildMbedtls( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + sdk_path: ?[]const u8, + mbedx509: *std.Build.Step.Compile, + mbedcrypto: *std.Build.Step.Compile, +) *std.Build.Step.Compile { + const is_aarch64_sim = target.result.cpu.arch == .aarch64 and + target.result.abi == .simulator; + + const lib = b.addLibrary(.{ + .name = "mbedtls", + .root_module = b.createModule(.{ .target = target, .optimize = optimize }), + .linkage = .static, + }); + appleLibc(lib, sdk_path); + lib.addIncludePath(b.path("../../third_party/mbedtls/include")); + lib.addIncludePath(b.path("../../third_party/mbedtls/library")); + + const sim_flags = &[_][]const u8{ "-Wall", "-std=c11", "-U__ARM_NEON", "-UMBEDTLS_AESCE_C" }; + const base_flags = &[_][]const u8{ "-Wall", "-std=c11" }; + + lib.addCSourceFiles(.{ + .files = &.{ + "../../third_party/mbedtls/library/debug.c", + "../../third_party/mbedtls/library/net_sockets.c", + "../../third_party/mbedtls/library/ssl_cache.c", + "../../third_party/mbedtls/library/ssl_ciphersuites.c", + "../../third_party/mbedtls/library/ssl_client.c", + "../../third_party/mbedtls/library/ssl_cookie.c", + "../../third_party/mbedtls/library/ssl_debug_helpers_generated.c", + "../../third_party/mbedtls/library/ssl_msg.c", + "../../third_party/mbedtls/library/ssl_ticket.c", + "../../third_party/mbedtls/library/ssl_tls.c", + "../../third_party/mbedtls/library/ssl_tls12_client.c", + "../../third_party/mbedtls/library/ssl_tls12_server.c", + "../../third_party/mbedtls/library/ssl_tls13_client.c", + "../../third_party/mbedtls/library/ssl_tls13_generic.c", + "../../third_party/mbedtls/library/ssl_tls13_keys.c", + "../../third_party/mbedtls/library/ssl_tls13_server.c", + }, + .flags = if (is_aarch64_sim) sim_flags else base_flags, + }); + lib.linkLibrary(mbedx509); + lib.linkLibrary(mbedcrypto); + return lib; +} + +const MBEDCRYPTO_SOURCES_NO_AESCE = [_][]const u8{ + "../../third_party/mbedtls/library/aes.c", + // aesce.c excluded — requires crypto CPU features incompatible with + // Zig 0.15 aarch64-ios-simulator baseline target + "../../third_party/mbedtls/library/aesni.c", + "../../third_party/mbedtls/library/aria.c", + "../../third_party/mbedtls/library/asn1parse.c", + "../../third_party/mbedtls/library/asn1write.c", + "../../third_party/mbedtls/library/base64.c", + "../../third_party/mbedtls/library/bignum.c", + "../../third_party/mbedtls/library/bignum_core.c", + "../../third_party/mbedtls/library/bignum_mod.c", + "../../third_party/mbedtls/library/bignum_mod_raw.c", + "../../third_party/mbedtls/library/block_cipher.c", + "../../third_party/mbedtls/library/camellia.c", + "../../third_party/mbedtls/library/ccm.c", + "../../third_party/mbedtls/library/chacha20.c", + "../../third_party/mbedtls/library/chachapoly.c", + "../../third_party/mbedtls/library/cipher.c", + "../../third_party/mbedtls/library/cipher_wrap.c", + "../../third_party/mbedtls/library/cmac.c", + "../../third_party/mbedtls/library/constant_time.c", + "../../third_party/mbedtls/library/ctr_drbg.c", + "../../third_party/mbedtls/library/des.c", + "../../third_party/mbedtls/library/dhm.c", + "../../third_party/mbedtls/library/ecdh.c", + "../../third_party/mbedtls/library/ecdsa.c", + "../../third_party/mbedtls/library/ecjpake.c", + "../../third_party/mbedtls/library/ecp.c", + "../../third_party/mbedtls/library/ecp_curves.c", + "../../third_party/mbedtls/library/ecp_curves_new.c", + "../../third_party/mbedtls/library/entropy.c", + "../../third_party/mbedtls/library/entropy_poll.c", + "../../third_party/mbedtls/library/error.c", + "../../third_party/mbedtls/library/gcm.c", + "../../third_party/mbedtls/library/hkdf.c", + "../../third_party/mbedtls/library/hmac_drbg.c", + "../../third_party/mbedtls/library/lmots.c", + "../../third_party/mbedtls/library/lms.c", + "../../third_party/mbedtls/library/md.c", + "../../third_party/mbedtls/library/md5.c", + "../../third_party/mbedtls/library/memory_buffer_alloc.c", + "../../third_party/mbedtls/library/mps_reader.c", + "../../third_party/mbedtls/library/mps_trace.c", + "../../third_party/mbedtls/library/nist_kw.c", + "../../third_party/mbedtls/library/oid.c", + "../../third_party/mbedtls/library/padlock.c", + "../../third_party/mbedtls/library/pem.c", + "../../third_party/mbedtls/library/pk.c", + "../../third_party/mbedtls/library/pk_ecc.c", + "../../third_party/mbedtls/library/pk_wrap.c", + "../../third_party/mbedtls/library/pkcs12.c", + "../../third_party/mbedtls/library/pkcs5.c", + "../../third_party/mbedtls/library/pkcs7.c", + "../../third_party/mbedtls/library/pkparse.c", + "../../third_party/mbedtls/library/pkwrite.c", + "../../third_party/mbedtls/library/platform.c", + "../../third_party/mbedtls/library/platform_util.c", + "../../third_party/mbedtls/library/poly1305.c", + "../../third_party/mbedtls/library/psa_crypto.c", + "../../third_party/mbedtls/library/psa_crypto_aead.c", + "../../third_party/mbedtls/library/psa_crypto_cipher.c", + "../../third_party/mbedtls/library/psa_crypto_client.c", + "../../third_party/mbedtls/library/psa_crypto_driver_wrappers_no_static.c", + "../../third_party/mbedtls/library/psa_crypto_ecp.c", + "../../third_party/mbedtls/library/psa_crypto_ffdh.c", + "../../third_party/mbedtls/library/psa_crypto_hash.c", + "../../third_party/mbedtls/library/psa_crypto_mac.c", + "../../third_party/mbedtls/library/psa_crypto_pake.c", + "../../third_party/mbedtls/library/psa_crypto_rsa.c", + "../../third_party/mbedtls/library/psa_crypto_se.c", + "../../third_party/mbedtls/library/psa_crypto_slot_management.c", + "../../third_party/mbedtls/library/psa_crypto_storage.c", + "../../third_party/mbedtls/library/psa_its_file.c", + "../../third_party/mbedtls/library/psa_util.c", + "../../third_party/mbedtls/library/ripemd160.c", + "../../third_party/mbedtls/library/rsa.c", + "../../third_party/mbedtls/library/rsa_alt_helpers.c", + "../../third_party/mbedtls/library/sha1.c", + "../../third_party/mbedtls/library/sha256.c", + "../../third_party/mbedtls/library/sha3.c", + "../../third_party/mbedtls/library/sha512.c", + "../../third_party/mbedtls/library/threading.c", + "../../third_party/mbedtls/library/timing.c", + "../../third_party/mbedtls/library/version.c", + "../../third_party/mbedtls/library/version_features.c", +}; + +const MBEDCRYPTO_SOURCES = [_][]const u8{ + "../../third_party/mbedtls/library/aes.c", + "../../third_party/mbedtls/library/aesce.c", + "../../third_party/mbedtls/library/aesni.c", + "../../third_party/mbedtls/library/aria.c", + "../../third_party/mbedtls/library/asn1parse.c", + "../../third_party/mbedtls/library/asn1write.c", + "../../third_party/mbedtls/library/base64.c", + "../../third_party/mbedtls/library/bignum.c", + "../../third_party/mbedtls/library/bignum_core.c", + "../../third_party/mbedtls/library/bignum_mod.c", + "../../third_party/mbedtls/library/bignum_mod_raw.c", + "../../third_party/mbedtls/library/block_cipher.c", + "../../third_party/mbedtls/library/camellia.c", + "../../third_party/mbedtls/library/ccm.c", + "../../third_party/mbedtls/library/chacha20.c", + "../../third_party/mbedtls/library/chachapoly.c", + "../../third_party/mbedtls/library/cipher.c", + "../../third_party/mbedtls/library/cipher_wrap.c", + "../../third_party/mbedtls/library/cmac.c", + "../../third_party/mbedtls/library/constant_time.c", + "../../third_party/mbedtls/library/ctr_drbg.c", + "../../third_party/mbedtls/library/des.c", + "../../third_party/mbedtls/library/dhm.c", + "../../third_party/mbedtls/library/ecdh.c", + "../../third_party/mbedtls/library/ecdsa.c", + "../../third_party/mbedtls/library/ecjpake.c", + "../../third_party/mbedtls/library/ecp.c", + "../../third_party/mbedtls/library/ecp_curves.c", + "../../third_party/mbedtls/library/ecp_curves_new.c", + "../../third_party/mbedtls/library/entropy.c", + "../../third_party/mbedtls/library/entropy_poll.c", + "../../third_party/mbedtls/library/error.c", + "../../third_party/mbedtls/library/gcm.c", + "../../third_party/mbedtls/library/hkdf.c", + "../../third_party/mbedtls/library/hmac_drbg.c", + "../../third_party/mbedtls/library/lmots.c", + "../../third_party/mbedtls/library/lms.c", + "../../third_party/mbedtls/library/md.c", + "../../third_party/mbedtls/library/md5.c", + "../../third_party/mbedtls/library/memory_buffer_alloc.c", + "../../third_party/mbedtls/library/mps_reader.c", + "../../third_party/mbedtls/library/mps_trace.c", + "../../third_party/mbedtls/library/nist_kw.c", + "../../third_party/mbedtls/library/oid.c", + "../../third_party/mbedtls/library/padlock.c", + "../../third_party/mbedtls/library/pem.c", + "../../third_party/mbedtls/library/pk.c", + "../../third_party/mbedtls/library/pk_ecc.c", + "../../third_party/mbedtls/library/pk_wrap.c", + "../../third_party/mbedtls/library/pkcs12.c", + "../../third_party/mbedtls/library/pkcs5.c", + "../../third_party/mbedtls/library/pkcs7.c", + "../../third_party/mbedtls/library/pkparse.c", + "../../third_party/mbedtls/library/pkwrite.c", + "../../third_party/mbedtls/library/platform.c", + "../../third_party/mbedtls/library/platform_util.c", + "../../third_party/mbedtls/library/poly1305.c", + "../../third_party/mbedtls/library/psa_crypto.c", + "../../third_party/mbedtls/library/psa_crypto_aead.c", + "../../third_party/mbedtls/library/psa_crypto_cipher.c", + "../../third_party/mbedtls/library/psa_crypto_client.c", + "../../third_party/mbedtls/library/psa_crypto_driver_wrappers_no_static.c", + "../../third_party/mbedtls/library/psa_crypto_ecp.c", + "../../third_party/mbedtls/library/psa_crypto_ffdh.c", + "../../third_party/mbedtls/library/psa_crypto_hash.c", + "../../third_party/mbedtls/library/psa_crypto_mac.c", + "../../third_party/mbedtls/library/psa_crypto_pake.c", + "../../third_party/mbedtls/library/psa_crypto_rsa.c", + "../../third_party/mbedtls/library/psa_crypto_se.c", + "../../third_party/mbedtls/library/psa_crypto_slot_management.c", + "../../third_party/mbedtls/library/psa_crypto_storage.c", + "../../third_party/mbedtls/library/psa_its_file.c", + "../../third_party/mbedtls/library/psa_util.c", + "../../third_party/mbedtls/library/ripemd160.c", + "../../third_party/mbedtls/library/rsa.c", + "../../third_party/mbedtls/library/rsa_alt_helpers.c", + "../../third_party/mbedtls/library/sha1.c", + "../../third_party/mbedtls/library/sha256.c", + "../../third_party/mbedtls/library/sha3.c", + "../../third_party/mbedtls/library/sha512.c", + "../../third_party/mbedtls/library/threading.c", + "../../third_party/mbedtls/library/timing.c", + "../../third_party/mbedtls/library/version.c", + "../../third_party/mbedtls/library/version_features.c", +}; diff --git a/platforms/swift/build.zig.zon b/platforms/swift/build.zig.zon new file mode 100644 index 0000000..03ec549 --- /dev/null +++ b/platforms/swift/build.zig.zon @@ -0,0 +1,12 @@ +.{ + .name = .colyseus_swift, + .version = "0.17.0", + .fingerprint = 0x4820f20cdd762ba, + .dependencies = .{ + .zig_msgpack = .{ + .url = "git+https://github.com/zigcc/zig-msgpack?ref=main#47c15d217efebd17a585dd04125f9b6b6f659d5a", + .hash = "zig_msgpack-0.0.14-evvueGolBQBs_m726tBf4ISJQqC926AM96EtxZy9LUaI", + }, + }, + .paths = .{""}, +} diff --git a/platforms/swift/example/ColyseusExample/ColyseusExampleApp.swift b/platforms/swift/example/ColyseusExample/ColyseusExampleApp.swift new file mode 100644 index 0000000..a357433 --- /dev/null +++ b/platforms/swift/example/ColyseusExample/ColyseusExampleApp.swift @@ -0,0 +1,11 @@ + +import SwiftUI + +@main +struct ColyseusExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/platforms/swift/example/ColyseusExample/ContentView.swift b/platforms/swift/example/ColyseusExample/ContentView.swift new file mode 100644 index 0000000..e951da7 --- /dev/null +++ b/platforms/swift/example/ColyseusExample/ContentView.swift @@ -0,0 +1,97 @@ + +import SwiftUI +import Colyseus + +@MainActor +final class GameViewModel: ObservableObject { + @Published var status: String = "Disconnected" + @Published var stateLog: [String] = [] + @Published var messages: [String] = [] + + private var client: ColyseusClient? + private var room: ColyseusRoom? + + func connect() { + status = "Connecting..." + let settings = ColyseusSettings.localhost(port: "2567") + client = ColyseusClient(settings: settings) + + Task { + do { + let r = try await client!.joinOrCreate("my_room") + r.enableDynamicSchema() + + r.onJoin = { [weak self] in + self?.status = "Joined room: \(r.roomId ?? "?")" + } + r.onLeave = { [weak self] code, reason in + self?.status = "Left (\(code)): \(reason)" + } + r.onError = { [weak self] code, msg in + self?.status = "Error \(code): \(msg)" + } + r.onStateChange = { [weak self] state in + let desc = state.map { "\($0.key): \($0.value)" }.sorted().joined(separator: ", ") + self?.stateLog.insert(desc, at: 0) + if (self?.stateLog.count ?? 0) > 20 { self?.stateLog.removeLast() } + } + r.onMessage = { [weak self] type, value in + self?.messages.insert("[\(type)] \(value)", at: 0) + if (self?.messages.count ?? 0) > 20 { self?.messages.removeLast() } + } + room = r + } catch let e as ColyseusError { + status = "Failed (\(e.code)): \(e.message)" + } + } + } + + func sendPing() { + let msg = MessageBuilder.map().set("action", "ping") + room?.send(type: "test", msg) + } + + func disconnect() { + room?.leave() + room = nil + client = nil + status = "Disconnected" + } +} + +struct ContentView: View { + @StateObject private var vm = GameViewModel() + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 12) { + Text(vm.status) + .font(.headline) + .padding(.horizontal) + + HStack { + Button("Connect", action: vm.connect) + Button("Ping", action: vm.sendPing) + Button("Disconnect", action: vm.disconnect) + .foregroundStyle(.red) + } + .buttonStyle(.bordered) + .padding(.horizontal) + + Divider() + + Text("State").font(.subheadline).bold().padding(.horizontal) + List(vm.stateLog, id: \.self) { Text($0).font(.caption) } + .frame(maxHeight: 200) + + Text("Messages").font(.subheadline).bold().padding(.horizontal) + List(vm.messages, id: \.self) { Text($0).font(.caption) } + } + .navigationTitle("Colyseus Example") + } + } +} + +#Preview { + ContentView() +} diff --git a/platforms/swift/example/Example.md b/platforms/swift/example/Example.md new file mode 100644 index 0000000..e215c36 --- /dev/null +++ b/platforms/swift/example/Example.md @@ -0,0 +1,9 @@ +# Testing Instructions +(W.I.P) +- Make sure example server is running + - Move to {project-root}/example-server + - npm install + - npm start +- Move to {project-root}/platforms/swift/example +- swift build -c release +- swift run \ No newline at end of file diff --git a/platforms/swift/example/Package.swift b/platforms/swift/example/Package.swift new file mode 100644 index 0000000..6a7a085 --- /dev/null +++ b/platforms/swift/example/Package.swift @@ -0,0 +1,24 @@ + +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "ColyseusExample", + platforms: [ + .macOS(.v13), + .iOS(.v15), + ], + dependencies: [ + // Point at the local SDK package. + .package(path: ".."), + ], + targets: [ + .executableTarget( + name: "ColyseusExample", + dependencies: [ + .product(name: "Colyseus", package: "swift"), + ], + path: "ColyseusExample" + ), + ] +) diff --git a/platforms/swift/include/colyseus_swift.h b/platforms/swift/include/colyseus_swift.h new file mode 100644 index 0000000..51c49f1 --- /dev/null +++ b/platforms/swift/include/colyseus_swift.h @@ -0,0 +1,22 @@ +#ifndef COLYSEUS_SWIFT_H +#define COLYSEUS_SWIFT_H + +// Umbrella header for Swift C interop. +// Swift imports this via the module map bundled in the xcframework. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#endif /* COLYSEUS_SWIFT_H */ diff --git a/platforms/swift/include/module.modulemap b/platforms/swift/include/module.modulemap new file mode 100644 index 0000000..7a3b8d7 --- /dev/null +++ b/platforms/swift/include/module.modulemap @@ -0,0 +1,5 @@ +module CColyseus { + umbrella header "colyseus_swift.h" + export * + module * { export * } +} diff --git a/src/msgpack/msgpack_builder.zig b/src/msgpack/msgpack_builder.zig index c9454a9..7872b33 100644 --- a/src/msgpack/msgpack_builder.zig +++ b/src/msgpack/msgpack_builder.zig @@ -22,7 +22,6 @@ const PayloadWrapper = struct { payload_type: PayloadType, payload: ?Payload = null, array_elements: ?std.ArrayList(Payload) = null, - encoded_data: ?[]u8 = null, fn deinit(self: *PayloadWrapper) void { if (self.array_elements) |*list| { @@ -34,9 +33,6 @@ const PayloadWrapper = struct { if (self.payload) |*p| { p.free(allocator); } - if (self.encoded_data) |data| { - allocator.free(data); - } } }; @@ -46,7 +42,6 @@ fn createMapWrapper() ?*PayloadWrapper { .payload_type = .map, .payload = Payload.mapPayload(allocator), .array_elements = null, - .encoded_data = null, }; return wrapper; } @@ -57,7 +52,6 @@ fn createArrayWrapper() ?*PayloadWrapper { .payload_type = .array, .payload = null, .array_elements = .{}, - .encoded_data = null, }; return wrapper; } @@ -68,7 +62,6 @@ fn createPrimitiveWrapper(payload: Payload) ?*PayloadWrapper { .payload_type = .primitive, .payload = payload, .array_elements = null, - .encoded_data = null, }; return wrapper; } @@ -221,18 +214,58 @@ export fn colyseus_message_array_push_msg(arr: ?*PayloadWrapper, value: ?*Payloa } // ============================================================================ -// Helper to get Payload for encoding +// Encode snapshot: deep clone so zig-msgpack encode cannot alias builder heap. // ============================================================================ +fn clonePayloadForEncode(p: Payload) (Payload.Error || error{ OutOfMemory, UnsupportedNonStringMapKey })!Payload { + return switch (p) { + .nil, .bool, .int, .uint, .float, .timestamp => p, + .str => |s| try Payload.strToPayload(s.str, allocator), + .bin => |b| try Payload.binToPayload(b.bin, allocator), + .ext => |e| try Payload.extToPayload(e.type, e.data, allocator), + .arr => |items| { + var arr_payload = try Payload.arrPayload(items.len, allocator); + errdefer arr_payload.free(allocator); + for (items, 0..) |item, i| { + const cloned = try clonePayloadForEncode(item); + try arr_payload.setArrElement(i, cloned); + } + return arr_payload; + }, + .map => |m| { + var new_payload = Payload.mapPayload(allocator); + errdefer new_payload.free(allocator); + var it = m.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + switch (key) { + .str => |ks| { + const val = try clonePayloadForEncode(entry.value_ptr.*); + try new_payload.mapPut(ks.str, val); + }, + else => return error.UnsupportedNonStringMapKey, + } + } + return new_payload; + }, + }; +} + fn getPayloadForEncoding(wrapper: *PayloadWrapper) ?Payload { switch (wrapper.payload_type) { - .map, .primitive => return wrapper.payload, + .map, .primitive => { + const p = wrapper.payload orelse return null; + wrapper.payload = null; + return p; + }, .array => { if (wrapper.array_elements) |list| { var arr_payload = Payload.arrPayload(list.items.len, allocator) catch return null; + errdefer arr_payload.free(allocator); for (list.items, 0..) |item, i| { - arr_payload.setArrElement(i, item) catch { - arr_payload.free(allocator); + const cloned = clonePayloadForEncode(item) catch return null; + arr_payload.setArrElement(i, cloned) catch { + cloned.free(allocator); return null; }; } @@ -253,17 +286,13 @@ export fn colyseus_message_encode(wrapper: ?*PayloadWrapper, out_len: *usize) ?[ return null; } - // Free previous encoded data if exists - if (wrapper.?.encoded_data) |data| { - allocator.free(data); - wrapper.?.encoded_data = null; - } - // Get the payload to encode const payload_to_encode = getPayloadForEncoding(wrapper.?) orelse { out_len.* = 0; return null; }; + // zig-msgpack: Pack.write does not consume the payload; caller must free after write. + defer payload_to_encode.free(allocator); // Create buffer for encoding var buffer: [16384]u8 = undefined; @@ -294,7 +323,7 @@ export fn colyseus_message_encode(wrapper: ?*PayloadWrapper, out_len: *usize) ?[ }; @memcpy(result, buffer[0..encoded_len]); - wrapper.?.encoded_data = result; + // Caller owns the returned buffer; free with colyseus_message_encoded_free. out_len.* = encoded_len; return result.ptr; } diff --git a/src/room.c b/src/room.c index e46c598..eca59ee 100644 --- a/src/room.c +++ b/src/room.c @@ -398,6 +398,7 @@ void colyseus_room_send(colyseus_room_t* room, const char* type, colyseus_messag if (encoded_data && encoded_len > 0) { colyseus_room_send_encoded(room, type, encoded_data, encoded_len); + colyseus_message_encoded_free(encoded_data, encoded_len); } } @@ -409,6 +410,7 @@ void colyseus_room_send_int(colyseus_room_t* room, int type, colyseus_message_t* if (encoded_data && encoded_len > 0) { colyseus_room_send_int_encoded(room, type, encoded_data, encoded_len); + colyseus_message_encoded_free(encoded_data, encoded_len); } } diff --git a/tests/README.md b/tests/README.md index 5439f3e..8726eef 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,23 +6,27 @@ All tests are written in Zig, taking advantage of Zig's built-in testing framewo ## Running Tests -### Run All Tests +### Full suite (`zig build test`) + +Integration tests need **example-server** on `http://localhost:2567` (same as CI). Terminal A: ```bash -zig build test +cd example-server +npm install +npx tsx src/index.ts +# or: npm start (tsx watch) ``` -This runs: -- **22 Zig tests** across 6 test suites -- Most tests run without requiring a server -- Integration test requires a running Colyseus server +Terminal B, from the **repository root**: -### Run Tests Without Integration Test +```bash +zig build test +``` -If you don't have a Colyseus server running: +### Without a server (unit tests only) ```bash -zig build test -Dskip-integration +zig build test -Dskip-integration=true ``` ### Run Individual Tests @@ -91,20 +95,12 @@ All tests are written in **idiomatic Zig** using the built-in test framework: - No running server needed - Run in milliseconds -### Integration Test -To run the full integration test (`test_integration.zig`), you need: +### Integration tests -1. A running Colyseus server on `localhost:2567` -2. A room named `my_room` available +`zig build test` runs `test_integration`, `test_schema_callbacks`, and `test_messages` against **example-server** (`localhost:2567`, room `my_room`). Start the server as in **Full suite** above. -Start the example server: -```bash -cd example-server -npm install -npm start -``` +Single integration binary: -Then run: ```bash zig build test_integration ``` @@ -137,10 +133,10 @@ test "my new test" { ## Test Output -When running `zig build test -Dskip-integration`: +Example success output: ``` -Build Summary: 17/17 steps succeeded; 22/22 tests passed +Build Summary: … steps succeeded; … tests passed test success ``` @@ -158,10 +154,5 @@ All HTTP tests passed! ## Continuous Integration -For CI/CD pipelines, use: -```bash -zig build test -Dskip-integration -``` - -This ensures tests run without requiring a live server. +CI starts **example-server**, waits until `localhost:2567` responds, then runs `zig build test`.