Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
kotlin/.gradle/
kotlin/**/build/

.build/
20 changes: 20 additions & 0 deletions Sources/SwiftMTH/TelnetSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,26 @@ public final class TelnetSession {
let sbLen = skipSB(src, at: offset, srclen: srclen)
if sbLen > srclen { return srclen + 1 }

// Surface raw module + JSON payload to the delegate before the MSDP
// fallback runs. SB framing: [IAC, SB, GMCP, <body>, IAC, SE].
if let delegate = delegate, sbLen >= 5 {
let bodyStart = offset + 3
let bodyEnd = offset + sbLen - 2
if bodyEnd > bodyStart {
var splitIdx = bodyStart
while splitIdx < bodyEnd && src[splitIdx] != UInt8(ascii: " ") {
splitIdx += 1
}
let module = String(decoding: src[bodyStart..<splitIdx], as: UTF8.self)
if !module.isEmpty {
let jsonStart = splitIdx < bodyEnd ? splitIdx + 1 : bodyEnd
let payload = Data(src[jsonStart..<bodyEnd])
delegate.telnetSession(
self, gmcpReceived: GMCPPacket(module: module, payload: payload))
}
}
Comment thread
jaborsh marked this conversation as resolved.
}
Comment thread
jaborsh marked this conversation as resolved.

// Convert JSON to MSDP and process
let gmcpPacket = Array(src[offset..<offset + srclen])
let msdpPacket = json2msdp(gmcpPacket)
Expand Down
59 changes: 59 additions & 0 deletions Sources/SwiftMTH/TelnetSessionDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
import Foundation

/// One inbound GMCP packet, ready for the host to consume.
///
/// Consumers identify the packet by `module` and pull the body out as a typed
/// value via `decode(_:)`. Module-only packets like `Core.Ping` carry no body
/// and `decode` returns `nil` for them; malformed JSON throws.
public struct GMCPPacket: Sendable {
/// GMCP module name (e.g. `"Char.Login.Credentials"`). Always non-empty:
/// packets with an empty SB body never produce a `GMCPPacket`.
public let module: String

/// JSON payload bytes following the module name. Internal — consumers
/// should go through `decode(_:)` rather than reach for the raw bytes.
let payload: Data

/// Size of the JSON payload in bytes. Useful for size-cap policies
/// (e.g. drop packets larger than N KB) without exposing the bytes.
public var byteCount: Int { payload.count }

init(module: String, payload: Data) {
self.module = module
self.payload = payload
}

/// Decode the payload as JSON into the requested `Decodable` type.
///
/// Returns `nil` for module-only packets that carry no body. Throws
/// `DecodingError` from `JSONDecoder` on malformed JSON or shape mismatch.
public func decode<T: Decodable>(
_ type: T.Type,
decoder: JSONDecoder = JSONDecoder()
) throws -> T? {
guard !payload.isEmpty else { return nil }
return try decoder.decode(type, from: payload)
}
}

/// Delegate for `TelnetSession` to communicate with the host application.
///
/// Replaces the C `descriptor_data` extensibility pattern. The session calls
Expand All @@ -14,10 +52,31 @@ public protocol TelnetSessionDelegate: AnyObject {
/// Return MSSP (Mud Server Status Protocol) key-value pairs.
/// Called when the client requests MSSP data.
func telnetSessionMSSPData(_ session: TelnetSession) -> [(key: String, value: String)]

/// Inbound GMCP packet, surfaced as a `GMCPPacket` with the raw module
/// name plus its (possibly empty) JSON payload — the form most consumers
/// actually want for nested-object packages like `Char.Login.Credentials`.
///
/// Fires for every well-formed inbound `IAC SB GMCP … IAC SE` block (those
/// with a non-empty body — empty SB GMCP blocks have no module name and
/// are skipped). Fires regardless of whether the host also has an
/// `MSDPManager` attached. The MSDP-flat-var fallback at `processSbGmcp`
/// still runs after the delegate returns, so existing flat-var consumers
/// (`Core.Hello`, etc.) keep working unchanged.
///
/// To consume a packet as a typed value, use the packet's `decode(_:)`:
/// ```
/// guard packet.module == "Char.Login.Credentials",
/// let creds = try packet.decode(Credentials.self)
/// else { return }
/// // use creds
/// ```
func telnetSession(_ session: TelnetSession, gmcpReceived packet: GMCPPacket)
Comment thread
jaborsh marked this conversation as resolved.
}

/// Default implementations for optional delegate methods.
public extension TelnetSessionDelegate {
func telnetSession(_ session: TelnetSession, log message: String) {}
func telnetSessionMSSPData(_ session: TelnetSession) -> [(key: String, value: String)] { [] }
func telnetSession(_ session: TelnetSession, gmcpReceived packet: GMCPPacket) {}
}
112 changes: 112 additions & 0 deletions Tests/MTHTests/TelnetSessionTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing
import MTH

Expand Down Expand Up @@ -44,6 +45,7 @@ private final class FakeDelegate: TelnetSessionDelegate {
var writtenChunks: [[UInt8]] = []
var logMessages: [String] = []
var msspPairs: [(key: String, value: String)] = []
var gmcpPackets: [GMCPPacket] = []

var allWrittenBytes: [UInt8] { writtenChunks.flatMap { $0 } }

Expand All @@ -58,6 +60,10 @@ private final class FakeDelegate: TelnetSessionDelegate {
func telnetSessionMSSPData(_ session: TelnetSession) -> [(key: String, value: String)] {
msspPairs
}

func telnetSession(_ session: TelnetSession, gmcpReceived packet: GMCPPacket) {
gmcpPackets.append(packet)
}
}

private func makeSession() -> (TelnetSession, FakeDelegate) {
Expand Down Expand Up @@ -550,6 +556,112 @@ private func makeSession() -> (TelnetSession, FakeDelegate) {
#expect(!d.writtenChunks.isEmpty)
}

@Test func sbGmcpDeliversModuleAndPayloadToDelegate() throws {
struct Credentials: Decodable, Equatable {
let account: String
let token: String
}

let (s, d) = makeSession()
_ = s.processInput([IAC, DO, GMCP])

let body = Array("Char.Login.Credentials {\"account\":\"Jake\",\"token\":\"abc\"}".utf8)
let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
_ = s.processInput(packet)

#expect(d.gmcpPackets.count == 1)
let received = try #require(d.gmcpPackets.first)
#expect(received.module == "Char.Login.Credentials")
#expect(try received.decode(Credentials.self)
== Credentials(account: "Jake", token: "abc"))
}

@Test func sbGmcpModuleOnlyPacketDecodesAsNil() throws {
struct AnyShape: Decodable {}

let (s, d) = makeSession()
_ = s.processInput([IAC, DO, GMCP])

let body = Array("Core.Ping".utf8)
let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
_ = s.processInput(packet)

let received = try #require(d.gmcpPackets.first)
#expect(received.module == "Core.Ping")
#expect(try received.decode(AnyShape.self) == nil)
}

@Test func sbGmcpEmptyBodyDoesNotCallDelegate() {
let (s, d) = makeSession()
_ = s.processInput([IAC, DO, GMCP])

// Malformed but technically valid SB framing: zero-byte body.
let packet: [UInt8] = [IAC, SB, GMCP, IAC, SE]
_ = s.processInput(packet)

#expect(d.gmcpPackets.isEmpty)
}

@Test func sbGmcpLeadingSpaceProducesEmptyModuleAndIsSkipped() {
// A body that starts with a space would parse as module="" + payload=...,
// which contradicts the "module always non-empty" contract. Skip it.
let (s, d) = makeSession()
_ = s.processInput([IAC, DO, GMCP])

let body = Array(" {\"x\":1}".utf8)
let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
_ = s.processInput(packet)

#expect(d.gmcpPackets.isEmpty)
}

@Test func sbGmcpDecodeThrowsOnMalformedJson() {
struct Anything: Decodable { let x: Int }

let (s, d) = makeSession()
_ = s.processInput([IAC, DO, GMCP])

let body = Array("Some.Module not-valid-json".utf8)
let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
_ = s.processInput(packet)

let received = d.gmcpPackets.first!
#expect(throws: DecodingError.self) {
try received.decode(Anything.self)
}
}

@Test func sbGmcpByteCountReportsPayloadSize() {
let (s, d) = makeSession()
_ = s.processInput([IAC, DO, GMCP])

let json = "{\"x\":1}"
let body = Array("Some.Module \(json)".utf8)
let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
_ = s.processInput(packet)

#expect(d.gmcpPackets.first?.byteCount == json.utf8.count)
}

@Test func sbGmcpStillRunsMsdpFallbackForFlatVarPackages() {
// The original `sbGmcpProcessesJsonCommand` exercises the flat-var path:
// an `MSDP {"LIST":"COMMANDS"}` packet should still produce a GMCP-JSON
// response from the MSDPManager. With the delegate hook added, that
// response must continue to fire.
let (s, d) = makeSession()
_ = s.processInput([IAC, DO, GMCP])
d.writtenChunks.removeAll()
d.gmcpPackets.removeAll()

let body = Array("MSDP {\"LIST\":\"COMMANDS\"}".utf8)
let packet: [UInt8] = [IAC, SB, GMCP] + body + [IAC, SE]
_ = s.processInput(packet)

#expect(d.gmcpPackets.count == 1)
#expect(d.gmcpPackets.first?.module == "MSDP")
#expect(!d.writtenChunks.isEmpty)
}

#if canImport(CZlib)
// MARK: - MCCP2 (Output Compression)

Expand Down
Loading