From 06c4e1617bbe322baa6697dde4bb0de094f8d3e9 Mon Sep 17 00:00:00 2001 From: Jake <73198594+jaborsh@users.noreply.github.com> Date: Sat, 9 May 2026 16:15:46 -0700 Subject: [PATCH] Surface inbound GMCP packets to the delegate --- .gitignore | 1 + Sources/SwiftMTH/TelnetSession.swift | 20 ++++ Sources/SwiftMTH/TelnetSessionDelegate.swift | 59 ++++++++++ Tests/MTHTests/TelnetSessionTests.swift | 112 +++++++++++++++++++ 4 files changed, 192 insertions(+) diff --git a/.gitignore b/.gitignore index aab9b8d..6f2a65f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ kotlin/.gradle/ kotlin/**/build/ +.build/ diff --git a/Sources/SwiftMTH/TelnetSession.swift b/Sources/SwiftMTH/TelnetSession.swift index e49c54d..a85f98a 100644 --- a/Sources/SwiftMTH/TelnetSession.swift +++ b/Sources/SwiftMTH/TelnetSession.swift @@ -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, , 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..( + _ 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 @@ -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) } /// 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) {} } diff --git a/Tests/MTHTests/TelnetSessionTests.swift b/Tests/MTHTests/TelnetSessionTests.swift index b643137..f10791b 100644 --- a/Tests/MTHTests/TelnetSessionTests.swift +++ b/Tests/MTHTests/TelnetSessionTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing import MTH @@ -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 } } @@ -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) { @@ -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)