diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4984c49..3ca6887 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,38 @@ jobs: - name: Test run: swift test + # ── Apple platform floors ────────────────────────────────────────────────── + # Build-only cross-compiles that validate the platform floors declared in + # Package.swift. Catches APIs that are unavailable on a platform (e.g. + # NetService on watchOS) or newer than a declared floor — neither of which + # the native macOS build can see. + # + # Versionless triples pick up the deployment target from Package.swift. + # visionOS needs an explicit version in the triple because SwiftPM + # synthesizes an invalid default (xros10.13) for a versionless xros triple; + # keep it in sync with the .visionOS floor in Package.swift. + apple-platforms: + name: ${{ matrix.platform.name }} (build only) + runs-on: macos-15 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + platform: + - { name: iOS, triple: arm64-apple-ios, sdk: iphoneos } + - { name: tvOS, triple: arm64-apple-tvos, sdk: appletvos } + - { name: watchOS, triple: arm64-apple-watchos, sdk: watchos } + - { name: visionOS, triple: arm64-apple-xros1.0, sdk: xros } + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + run: > + swift build + --triple ${{ matrix.platform.triple }} + --sdk "$(xcrun --sdk ${{ matrix.platform.sdk }} --show-sdk-path)" + # ── Linux ────────────────────────────────────────────────────────────────── linux: name: Linux diff --git a/Package.swift b/Package.swift index 63140e1..c5ece9c 100644 --- a/Package.swift +++ b/Package.swift @@ -4,11 +4,11 @@ import PackageDescription let package = Package( name: "mdns-swift", platforms: [ - .macOS(.v15), - .iOS(.v18), - .tvOS(.v18), - .watchOS(.v11), - .visionOS(.v2), + .macOS(.v14), + .iOS(.v17), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1), ], products: [ .library(name: "MDNSCore", targets: ["MDNSCore"]), diff --git a/Sources/MDNSApple/AppleServiceDiscovery.swift b/Sources/MDNSApple/AppleServiceDiscovery.swift index 7772e37..b489fc9 100644 --- a/Sources/MDNSApple/AppleServiceDiscovery.swift +++ b/Sources/MDNSApple/AppleServiceDiscovery.swift @@ -83,6 +83,26 @@ public final class AppleServiceDiscovery: ServiceDiscovery, @unchecked Sendable self.logger = logger } + // MARK: - TXT Encoding + + /// Encode TXT key/value pairs into RFC 6763 wire format: each entry is a + /// length byte followed by the UTF-8 bytes of `"key=value"`. + /// + /// Hand-rolled because `NetService.data(fromTXTRecord:)` is unavailable on + /// watchOS (and deprecated everywhere else). Keys are sorted so the output + /// is deterministic; entries longer than 255 bytes are truncated, matching + /// the Linux responder's TXT encoding. An empty dictionary yields empty + /// `Data`, which `DNSServiceRegister` accepts as "no TXT record". + static func txtRecordData(_ records: [String: String]) -> Data { + var out = Data() + for (key, value) in records.sorted(by: { $0.key < $1.key }) { + let bytes = Array("\(key)=\(value)".utf8) + out.append(UInt8(min(bytes.count, 255))) + out.append(contentsOf: bytes.prefix(255)) + } + return out + } + // MARK: - ServiceDiscovery public func advertise(service: ServiceRecord) async throws { @@ -96,9 +116,8 @@ public final class AppleServiceDiscovery: ServiceDiscovery, @unchecked Sendable DNSServiceRefDeallocate(ref) } - // Build TXT record payload using the NetService helper for correct wire encoding. - let txtDict = service.txtRecords.mapValues { $0.data(using: .utf8) ?? Data() } - let txtData = NetService.data(fromTXTRecord: txtDict) + // Build the TXT record payload in RFC 6763 wire format. + let txtData = Self.txtRecordData(service.txtRecords) // Build the regtype string. // DNSServiceRegister natively supports comma-separated subtypes: diff --git a/Tests/MDNSAppleTests/AppleServiceDiscoveryTests.swift b/Tests/MDNSAppleTests/AppleServiceDiscoveryTests.swift index f656574..0fceb4c 100644 --- a/Tests/MDNSAppleTests/AppleServiceDiscoveryTests.swift +++ b/Tests/MDNSAppleTests/AppleServiceDiscoveryTests.swift @@ -1,6 +1,7 @@ // AppleServiceDiscoveryTests.swift // Copyright 2026 Monagle Pty Ltd +import Foundation import Testing import MDNSCore @@ -9,7 +10,7 @@ import MDNSCore // This file verifies that the MDNSApple module compiles and types are correct. #if canImport(Network) -import MDNSApple +@testable import MDNSApple @Suite("AppleServiceDiscovery Compile-time Tests") struct AppleServiceDiscoveryTests { @@ -28,4 +29,31 @@ struct AppleServiceDiscoveryTests { _ = e2 } } + +@Suite("TXT Record Encoding") +struct TXTRecordEncodingTests { + + @Test("Entries are length-prefixed key=value, sorted by key") + func wireFormat() { + let data = AppleServiceDiscovery.txtRecordData(["id": "AA:BB", "c#": "1"]) + var expected = Data() + expected.append(4) + expected.append(contentsOf: Array("c#=1".utf8)) + expected.append(8) + expected.append(contentsOf: Array("id=AA:BB".utf8)) + #expect(data == expected) + } + + @Test("Empty dictionary encodes as empty data") + func emptyRecords() { + #expect(AppleServiceDiscovery.txtRecordData([:]).isEmpty) + } + + @Test("Entries longer than 255 bytes are truncated") + func oversizedEntry() { + let data = AppleServiceDiscovery.txtRecordData(["k": String(repeating: "x", count: 300)]) + #expect(data.count == 256) + #expect(data.first == 255) + } +} #endif