Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions Sources/SwiftScaffolding/Client/ScaffoldingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,24 @@ public final class ScaffoldingClient {
private var connection: NWConnection!
private var serverNodeIp: String!
private var protocols: [String]
private var heartbeatTask: Task<Void, Never>?

deinit {
Logger.debug("ScaffoldingClient is being deallocated")
connection?.cancel()
easyTier.terminate()
stop()
}

/// 使用指定的 EasyTier 创建连接到指定房间的 `ScaffoldingClient`。
///
/// - Parameters:
/// - easyTier: 使用的 EasyTier。
/// - playerName: 玩家名。
/// - vendor: 联机客户端信息。
public init(
easyTier: EasyTier,
playerName: String,
vendor: String
) {
/// - playerInfo: 玩家信息。
public init(easyTier: EasyTier, playerInfo: PlayerInfo) {
self.easyTier = easyTier
self.player = .init(
name: playerName,
name: playerInfo.name,
machineID: Scaffolding.getMachineID(),
vendor: vendor,
vendor: playerInfo.vendor,
kind: .guest
)

Expand All @@ -52,6 +47,16 @@ public final class ScaffoldingClient {
self.protocols = RequestHandler().protocols()
}

// 旧格式支持
@available(*, deprecated, renamed: "ScaffoldingClient.init(easyTier:playerInfo:)", message: "")
public convenience init(
easyTier: EasyTier,
playerName: String,
vendor: String
) {
self.init(easyTier: easyTier, playerInfo: .init(name: playerName, vendor: vendor))
}

/// 连接到房间。
///
/// 该方法返回后,必须每隔 5s 调用一次 `heartbeat()` 方法。
Expand All @@ -60,6 +65,9 @@ public final class ScaffoldingClient {
/// - roomCode: 房间码。
/// - checkServer: 是否检查联机中心返回的 Minecraft 服务器端口号。
public func connect(to roomCode: String, checkServer: Bool = true, terminationHandler: ((Process) -> Void)? = nil) async throws {
guard connection == nil else {
throw ConnectionError.alreadyConnected
}
guard RoomCode.isValid(code: roomCode) else {
throw RoomError.invalidRoomCode
}
Expand Down Expand Up @@ -160,10 +168,39 @@ public final class ScaffoldingClient {
}
}

/// 启动自动心跳任务。
///
/// 如果已有一个任务正在运行,该方法会直接返回。
/// - Throws: `ScaffoldingClient` 状态异常。
public func startHeartbeatTask(failureHandler: ((Error) async -> Void)?) throws {
guard connection != nil, room != nil else {
throw ConnectionError.missingConnection
}
if heartbeatTask != nil {
return
}
heartbeatTask = Task { [weak self] in
do {
while !Task.isCancelled {
guard let self else { break }
try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
try Task.checkCancellation()
try await self.heartbeat()
}
} catch is CancellationError {
} catch {
Logger.error("Heartbeat task failed: \(error.localizedDescription)")
await failureHandler?(error)
}
}
}

/// 退出房间并关闭连接。
public func stop() {
Logger.info("Stopping scaffolding client")
easyTier.terminate()
heartbeatTask?.cancel()
heartbeatTask = nil
connection?.cancel()
connection = nil
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/SwiftScaffolding/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public enum ConnectionError: LocalizedError, Equatable {
case failedToAllocatePort
case notEnoughBytes
case failedToDecodeString
case alreadyConnected

public var errorDescription: String? {
switch self {
Expand Down Expand Up @@ -61,6 +62,12 @@ public enum ConnectionError: LocalizedError, Equatable {
bundle: Bundle.module,
comment: "解析 UTF-8 字符串失败"
)
case .alreadyConnected:
return NSLocalizedString(
"ConnectionError.alreadyConnected",
bundle: Bundle.module,
comment: "已有一个已建立的连接"
)
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions Sources/SwiftScaffolding/Models/PlayerInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// PlayerInfo.swift
// SwiftScaffolding
//
// Created by AnemoFlower on 2026/2/28.
//

import Foundation

public struct PlayerInfo {
public let name: String
public let vendor: String

public init(name: String, vendor: String) {
self.name = name
self.vendor = vendor
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Network
public final class Room: ObservableObject {
/// 房客列表。
@Published public internal(set) var members: [Member] = []

/// Minecraft 服务器端口。
public internal(set) var serverPort: UInt16

Expand Down
111 changes: 58 additions & 53 deletions Sources/SwiftScaffolding/Server/RequestHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import SwiftyJSON
import Network

public class RequestHandler {
var server: ScaffoldingServer!
weak var server: ScaffoldingServer?
private var handlers: [String: (Sender, ByteBuffer) throws -> Scaffolding.Response] = [:]

internal init() {
Expand All @@ -35,17 +35,13 @@ public class RequestHandler {
return Array(handlers.keys)
}

internal func destroy() {
handlers.removeAll()
server = nil
}

internal func handleRequest(
from connection: NWConnection,
type: String,
requestBody: ByteBuffer,
responseBuffer: ByteBuffer
) throws {
guard let server else { return }
guard let handler = handlers[type] else {
Logger.warn("Unknown request: \(type)")
responseBuffer.writeUInt8(255)
Expand All @@ -66,62 +62,71 @@ public class RequestHandler {
}

private func registerHandlers() {
registerHandler(for: "c:ping") { sender, requestBody in
return .init(status: 0, data: requestBody.data)
}
registerHandler(for: "c:ping", handler: handlePing(sender:requestBody:))
registerHandler(for: "c:protocols", handler: handleProtocols(sender:requestBody:))
registerHandler(for: "c:server_port", handler: handleServerPort(sender:requestBody:))
registerHandler(for: "c:player_ping", handler: handlePlayerPing(sender:requestBody:))
registerHandler(for: "c:player_profiles_list", handler: handlePlayerList(sender:requestBody:))
}

private func handlePing(sender: Sender, requestBody: ByteBuffer) throws -> Scaffolding.Response {
return .init(status: 0, data: requestBody.data)
}

private func handleProtocols(sender: Sender, requestBody: ByteBuffer) throws -> Scaffolding.Response {
let protocols: String = Array(self.handlers.keys).joined(separator: "\0")
return .init(status: 0) { $0.writeString(protocols) }
}

private func handleServerPort(sender: Sender, requestBody: ByteBuffer) throws -> Scaffolding.Response {
guard let server else { return .init(status: 255, data: .init()) }
return .init(status: 0) { $0.writeUInt16(server.room.serverPort) }
}

private func handlePlayerPing(sender: Sender, requestBody: ByteBuffer) throws -> Scaffolding.Response {
guard let server else { return .init(status: 255, data: .init()) }
let connection: NWConnection = sender.connection
let rawMember: Member = try server.decoder.decode(Member.self, from: requestBody.data)

registerHandler(for: "c:protocols") { sender, requestBody in
let protocols: String = Array(self.handlers.keys).joined(separator: "\0")
return .init(status: 0) { $0.writeString(protocols) }
}
let member: Member = .init(
name: rawMember.name,
machineID: rawMember.machineId,
vendor: rawMember.vendor,
kind: .guest
)

let identifier: ObjectIdentifier = .init(connection)

registerHandler(for: "c:server_port") { sender, requestBody in
return .init(status: 0) { $0.writeUInt16(self.server.room.serverPort) }
if server.machineIdMap[identifier] == nil
&& server.machineIdMap.values.contains(member.machineId) {
Logger.warn("Detected a machine_id collision")
throw RoomError.playerInfoMismatch
}
if let machineId = server.machineIdMap[identifier], machineId != member.machineId {
Logger.warn("machine_id mismatch detected")
throw RoomError.playerInfoMismatch
}

registerHandler(for: "c:player_ping") { sender, requestBody in
let connection: NWConnection = sender.connection
let rawMember: Member = try self.server.decoder.decode(Member.self, from: requestBody.data)

let member: Member = .init(
name: rawMember.name,
machineID: rawMember.machineId,
vendor: rawMember.vendor,
kind: .guest
)

let identifier: ObjectIdentifier = .init(connection)

if self.server.machineIdMap[identifier] == nil
&& self.server.machineIdMap.values.contains(member.machineId) {
Logger.warn("Detected a machine_id collision")
throw RoomError.playerInfoMismatch
}
if let machineId = self.server.machineIdMap[identifier], machineId != member.machineId {
Logger.warn("machine_id mismatch detected")
server.machineIdMap[identifier] = member.machineId

if let storedMember: Member = server.room.members.first(where: { $0.machineId == member.machineId }) {
if storedMember != member {
Logger.warn("Member info mismatch for \(storedMember.name)")
throw RoomError.playerInfoMismatch
}

self.server.machineIdMap[identifier] = member.machineId

if let storedMember: Member = self.server.room.members.first(where: { $0.machineId == member.machineId }) {
if storedMember != member {
Logger.warn("Member info mismatch for \(storedMember.name)")
throw RoomError.playerInfoMismatch
}
} else {
Logger.info("Received player info from \(connection.endpoint.debugDescription): { \"name\": \"\(member.name)\", \"vendor\": \"\(member.vendor)\", \"machine_id\": \"\(member.machineId)\"}")
DispatchQueue.main.async {
self.server.room.members.append(member)
}
} else {
Logger.info("Received player info from \(connection.endpoint.debugDescription): { \"name\": \"\(member.name)\", \"vendor\": \"\(member.vendor)\", \"machine_id\": \"\(member.machineId)\"}")
DispatchQueue.main.async {
server.room.members.append(member)
}

return .init(status: 0, data: Data())
}

registerHandler(for: "c:player_profiles_list") { sender, requestBody in
return .init(status: 0, data: try self.server.encoder.encode(self.server.room.members))
}
return .init(status: 0, data: Data())
}

private func handlePlayerList(sender: Sender, requestBody: ByteBuffer) throws -> Scaffolding.Response {
guard let server else { return .init(status: 255, data: .init()) }
return .init(status: 0, data: try server.encoder.encode(server.room.members))
}

public struct Sender {
Expand Down
30 changes: 22 additions & 8 deletions Sources/SwiftScaffolding/Server/ScaffoldingServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,21 @@ public final class ScaffoldingServer {
/// - Parameters:
/// - easyTier: 使用的 EasyTier。
/// - roomCode: 房间码。若不合法,将在 `createRoom()` 中抛出 `RoomError.invalidRoomCode` 错误。
/// - playerName: 玩家名。
/// - vendor: 联机客户端信息。
/// - serverPort: Minecraft 服务器端口号。
/// - hostInfo: 房主信息。
public init(
easyTier: EasyTier,
roomCode: String,
playerName: String,
vendor: String,
serverPort: UInt16
serverPort: UInt16,
hostInfo: PlayerInfo
) {
self.room = Room(
members: [.init(name: playerName, machineID: Scaffolding.getMachineID(forHost: true), vendor: vendor, kind: .host)],
members: [.init(
name: hostInfo.name,
machineID: Scaffolding.getMachineID(forHost: true),
vendor: hostInfo.vendor,
kind: .host
)],
serverPort: serverPort
)
self.easyTier = easyTier
Expand All @@ -55,10 +58,22 @@ public final class ScaffoldingServer {
self.handler.server = self
}

// 旧格式支持
@available(*, deprecated, renamed: "ScaffoldingServer.init(easyTier:roomCode:serverPort:hostInfo:)", message: "")
public convenience init(
easyTier: EasyTier,
roomCode: String,
playerName: String,
vendor: String,
serverPort: UInt16
) {
self.init(easyTier: easyTier, roomCode: roomCode, serverPort: serverPort, hostInfo: .init(name: playerName, vendor: vendor))
}

/// 启动联机中心监听器。
///
/// 默认会在 `13452` 端口监听。若该端口被占用,会重新申请一个端口。
/// - Returns: 联机中心端口号
/// - Returns: 联机中心实际端口号
@discardableResult
public func startListener() async throws -> UInt16 {
let port: UInt16 = try ConnectionUtil.getPort(13452)
Expand Down Expand Up @@ -152,7 +167,6 @@ public final class ScaffoldingServer {
}
}
connections = []
handler.destroy()
}


Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftScaffolding/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"ConnectionError.failedToAllocatePort" = "Failed to allocate port.";
"ConnectionError.notEnoughBytes" = "The peer did not send enough data.";
"ConnectionError.failedToDecodeString" = "Failed to decode string.";
"ConnectionError.alreadyConnected" = "There is already an established connection.";

"RoomError.invalidRoomCode" = "The room code was invalid.";
"RoomError.roomClosed" = "The room has been closed, or the network is unstable.";
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftScaffolding/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"ConnectionError.failedToAllocatePort" = "端口分配失败。";
"ConnectionError.notEnoughBytes" = "对端未发送足够长的数据。";
"ConnectionError.failedToDecodeString" = "解析字符串失败。";
"ConnectionError.alreadyConnected" = "已有一个已建立的连接。";

"RoomError.invalidRoomCode" = "无效的房间码。";
"RoomError.roomClosed" = "房间已被关闭,或者网络不稳定。";
Expand Down