diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79cb62c..a287ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI Build Verification on: push: - branches: [ main ] + branches: [ main, 3.1-maint, 3.2-dev ] pull_request: - branches: [ main ] + branches: [ main, 3.1-maint, 3.2-dev ] jobs: build-sender: @@ -23,6 +23,11 @@ jobs: cd TargetBridge-Sender ./scripts/build_targetbridge_sender_app.sh + - name: Run Sender Unit Tests + run: | + cd TargetBridge-Sender + xcodebuild test -project TargetBridge.xcodeproj -scheme TBDisplaySender -destination 'platform=macOS' -derivedDataPath .build/DerivedData + build-receiver: name: Build Receiver (${{ matrix.os }} - ${{ matrix.arch }}) strategy: diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 98df2b5..bcb3b6e 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -166,7 +166,8 @@ enum TBSenderAutomation { /// - `nil` when the explicit session is invalid. /// - `.some(nil)` when no session was requested and the caller should target all sessions. /// - `.some(index)` with a zero-based index for a specific session. - private static func resolveSessionIndex( + /// - Note: `internal` (not `private`) so the unit-test bundle can exercise the tri-state logic. + static func resolveSessionIndex( _ raw: String?, sessionCount: Int, createDefaultIfNeeded: Bool @@ -201,7 +202,9 @@ enum TBSenderAutomation { return service.discoveredReceivers.first } - private static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { + // The pure parsing helpers below are `internal` (not `private`) so the + // unit-test bundle can exercise them directly. + static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { let needle = value.lowercased() if receiver.id.lowercased() == needle { return true } if receiver.receiverName.lowercased() == needle { return true } @@ -211,7 +214,7 @@ enum TBSenderAutomation { || receiver.networkIP.lowercased() == needle } - private static func parseTransport(_ value: String) -> TBTransportKind { + static func parseTransport(_ value: String) -> TBTransportKind { switch value.lowercased() { case "net", "network", "networklink", "link": return .networkLink @@ -220,7 +223,7 @@ enum TBSenderAutomation { } } - private static func parseMode(_ value: String) -> TBDisplayCaptureSource? { + static func parseMode(_ value: String) -> TBDisplayCaptureSource? { switch value.lowercased() { case "extended", "extend", "extendeddesktop", "ext": return .extendedDesktop @@ -231,7 +234,7 @@ enum TBSenderAutomation { } } - private static func parsePreset(_ value: String) -> TBDisplayCapturePreset? { + static func parsePreset(_ value: String) -> TBDisplayCapturePreset? { if let preset = TBDisplayCapturePreset(rawValue: value) { return preset } switch value.lowercased() { case "1440p", "1440", "standard": return .standard1440p diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 84d7a70..004a525 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1584,7 +1584,20 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func drainPackets() { - while let (type, payload) = TBMonitorProtocol.drainPacket(from: &recvBuffer) { + do { + try drainPacketsOrThrow() + } catch { + // Corrupt length prefix: the framing is unrecoverable, so tear the + // connection down instead of buffering inbound data forever. + NSLog("[protocol] corrupt inbound stream (\(error)); closing connection") + recvBuffer.removeAll(keepingCapacity: false) + setStatus(.connectionClosed(String(describing: error))) + stop(resetStatusTo: nil) + } + } + + private func drainPacketsOrThrow() throws { + while let (type, payload) = try TBMonitorProtocol.drainPacket(from: &recvBuffer) { switch type { case .displayProfile: handleDisplayProfile(payload) diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift new file mode 100644 index 0000000..b1eeef7 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -0,0 +1,255 @@ +import XCTest +@testable import TargetBridge + +/// Wire-protocol unit tests. These run with no network, no Thunderbolt hardware, +/// and no receiver — they pin down the framing invariants both apps rely on: +/// `[4B BE length][1B type][payload]` where length counts type + payload. +final class TBMonitorProtocolTests: XCTestCase { + + // MARK: - BE32 primitives + + func testBE32RoundTrip() { + let values: [UInt32] = [0, 1, 0xFF, 0x1234_5678, 0x7FFF_FFFF, 0xFFFF_FFFF] + for value in values { + var data = Data() + TBMonitorProtocol.appendBE32(&data, value) + XCTAssertEqual(data.count, 4) + XCTAssertEqual(TBMonitorProtocol.readBE32(data, offset: 0), value, "round trip failed for \(value)") + } + } + + func testAppendBE32IsBigEndian() { + var data = Data() + TBMonitorProtocol.appendBE32(&data, 0x0102_0304) + XCTAssertEqual([UInt8](data), [0x01, 0x02, 0x03, 0x04]) + } + + func testReadBE32HonorsOffset() { + var data = Data() + TBMonitorProtocol.appendBE32(&data, 0xAAAA_AAAA) + TBMonitorProtocol.appendBE32(&data, 0x0000_BEEF) + XCTAssertEqual(TBMonitorProtocol.readBE32(data, offset: 4), 0x0000_BEEF) + } + + // MARK: - Packet framing + + func testMakePacketLayout() { + let packet = TBMonitorProtocol.makePacket(type: .heartbeat, payload: Data([0xAA, 0xBB, 0xCC])) + // length = 1 (type byte) + 3 (payload) = 4 + XCTAssertEqual([UInt8](packet), [0x00, 0x00, 0x00, 0x04, 0x30, 0xAA, 0xBB, 0xCC]) + } + + func testDrainPacketRoundTrip() throws { + let payload = Data("hello receiver".utf8) + var buffer = TBMonitorProtocol.makePacket(type: .helloReceiver, payload: payload) + + let drained = try TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertNotNil(drained) + XCTAssertEqual(drained?.0, .helloReceiver) + XCTAssertEqual(drained?.1, payload) + XCTAssertTrue(buffer.isEmpty, "drain must consume the packet") + } + + func testDrainPacketEmptyPayload() throws { + var buffer = TBMonitorProtocol.makePacket(type: .teardown, payload: Data()) + let drained = try TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(drained?.0, .teardown) + XCTAssertEqual(drained?.1, Data()) + XCTAssertTrue(buffer.isEmpty) + } + + func testDrainPacketWaitsForCompleteHeader() { + var buffer = Data([0x00, 0x00, 0x00, 0x04]) // header missing its 5th byte + XCTAssertNil(try TBMonitorProtocol.drainPacket(from: &buffer)) + XCTAssertEqual(buffer.count, 4, "incomplete data must not be consumed") + } + + func testDrainPacketWaitsForCompletePayload() { + let full = TBMonitorProtocol.makePacket(type: .frame, payload: Data(repeating: 0x42, count: 100)) + var buffer = full.prefix(50) as Data + XCTAssertNil(try TBMonitorProtocol.drainPacket(from: &buffer)) + XCTAssertEqual(buffer.count, 50, "incomplete data must not be consumed") + } + + func testDrainTwoContiguousPackets() throws { + var buffer = TBMonitorProtocol.makePacket(type: .cursor, payload: Data([0x01])) + buffer.append(TBMonitorProtocol.makePacket(type: .brightness, payload: Data([0x02, 0x03]))) + + let first = try TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(first?.0, .cursor) + XCTAssertEqual(first?.1, Data([0x01])) + + let second = try TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(second?.0, .brightness) + XCTAssertEqual(second?.1, Data([0x02, 0x03])) + + XCTAssertTrue(buffer.isEmpty) + } + + /// Simulates TCP fragmentation: the packet arrives one byte at a time and + /// must only drain once the final byte lands. + func testDrainPacketAcrossSplitFeeds() throws { + let packet = TBMonitorProtocol.makePacket(type: .clipboard, payload: Data("copy me".utf8)) + var buffer = Data() + + for (index, byte) in packet.enumerated() { + buffer.append(byte) + let drained = try TBMonitorProtocol.drainPacket(from: &buffer) + if index < packet.count - 1 { + XCTAssertNil(drained, "must not drain before byte \(packet.count - 1), drained at \(index)") + } else { + XCTAssertEqual(drained?.0, .clipboard) + XCTAssertEqual(drained?.1, Data("copy me".utf8)) + } + } + } + + // MARK: - Corrupt and unknown framing + + func testDrainPacketThrowsOnZeroLength() { + var buffer = Data([0x00, 0x00, 0x00, 0x00, 0x30]) + XCTAssertThrowsError(try TBMonitorProtocol.drainPacket(from: &buffer)) { error in + XCTAssertEqual(error as? TBMonitorProtocolError, .invalidPacketLength(0)) + } + } + + func testDrainPacketThrowsOnOversizedLength() { + var buffer = Data() + TBMonitorProtocol.appendBE32(&buffer, TBMonitorProtocol.maxPacketLength + 1) + buffer.append(0x21) + XCTAssertThrowsError(try TBMonitorProtocol.drainPacket(from: &buffer)) { error in + XCTAssertEqual(error as? TBMonitorProtocolError, .invalidPacketLength(TBMonitorProtocol.maxPacketLength + 1)) + } + } + + /// A corrupted length like 0xFFFFFFFF must fail fast instead of making the + /// drain loop buffer inbound data forever for a packet that never completes. + func testDrainPacketThrowsOnAllOnesLength() { + var buffer = Data([0xFF, 0xFF, 0xFF, 0xFF, 0x21, 0x00]) + XCTAssertThrowsError(try TBMonitorProtocol.drainPacket(from: &buffer)) { error in + XCTAssertEqual(error as? TBMonitorProtocolError, .invalidPacketLength(0xFFFF_FFFF)) + } + } + + func testDrainPacketAcceptsLengthAtCapWhileWaitingForPayload() { + var buffer = Data() + TBMonitorProtocol.appendBE32(&buffer, TBMonitorProtocol.maxPacketLength) + buffer.append(0x21) + // Length is legal but the payload has not arrived: need more data, no throw. + XCTAssertNil(try TBMonitorProtocol.drainPacket(from: &buffer)) + XCTAssertEqual(buffer.count, 5) + } + + func testDrainPacketThrowsOnCorruptLengthBehindValidPacket() throws { + var buffer = TBMonitorProtocol.makePacket(type: .heartbeat, payload: Data([0x01])) + buffer.append(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x21])) + + let first = try TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(first?.0, .heartbeat) + + XCTAssertThrowsError(try TBMonitorProtocol.drainPacket(from: &buffer)) + } + + /// An unrecognized type byte (e.g. a packet from a newer peer) must be + /// skipped so it cannot stall valid packets queued behind it. + func testDrainPacketSkipsUnknownTypeAndReturnsNextPacket() throws { + var buffer = Data() + TBMonitorProtocol.appendBE32(&buffer, 3) + buffer.append(contentsOf: [0xEE, 0x00, 0x00]) // unknown type 0xEE + 2 payload bytes + buffer.append(TBMonitorProtocol.makePacket(type: .heartbeat, payload: Data([0x07]))) + + let drained = try TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(drained?.0, .heartbeat) + XCTAssertEqual(drained?.1, Data([0x07])) + XCTAssertTrue(buffer.isEmpty) + } + + func testDrainPacketConsumesLoneUnknownType() throws { + var buffer = Data() + TBMonitorProtocol.appendBE32(&buffer, 1) + buffer.append(0xEE) + + XCTAssertNil(try TBMonitorProtocol.drainPacket(from: &buffer)) + XCTAssertTrue(buffer.isEmpty, "unknown packet must be consumed, not left to stall the stream") + } + + // MARK: - JSON payloads + + func testJSONPacketRoundTrip() throws { + let heartbeat = TBMonitorHeartbeat(sequence: 42) + guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .heartbeat, value: heartbeat) else { + XCTFail("encode failed"); return + } + guard let (type, payload) = try TBMonitorProtocol.drainPacket(from: &buffer) else { + XCTFail("drain failed"); return + } + XCTAssertEqual(type, .heartbeat) + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload)?.sequence, 42) + } + + // MARK: - Hand-rolled input-event encoder parity + // + // `makeInputEventPacket` documents this invariant: "emits the same JSON shape + // `JSONDecoder` reconstructs into a `TBMonitorInputEvent` (omitted fields + // decode as nil)". These tests guard it, since the receiver's snprintf-based + // emitter mirrors the same shape. + + private func makeEvent( + kind: String, + dx: Int? = nil, + dy: Int? = nil, + scrollX: Int? = nil, + scrollY: Int? = nil, + keyCode: UInt16? = nil + ) -> TBMonitorInputEvent { + TBMonitorInputEvent(kind: kind, dx: dx, dy: dy, scrollX: scrollX, scrollY: scrollY, keyCode: keyCode) + } + + private func assertEncoderParity( + _ event: TBMonitorInputEvent, + file: StaticString = #filePath, + line: UInt = #line + ) { + var buffer = TBMonitorProtocol.makeInputEventPacket(event) + guard let (type, payload) = try? TBMonitorProtocol.drainPacket(from: &buffer) ?? nil else { + XCTFail("packet did not drain", file: file, line: line) + return + } + XCTAssertEqual(type, .inputEvent, file: file, line: line) + guard let decoded = TBMonitorProtocol.decodeJSON(TBMonitorInputEvent.self, from: payload) else { + XCTFail("payload did not decode as TBMonitorInputEvent: \(String(decoding: payload, as: UTF8.self))", + file: file, line: line) + return + } + XCTAssertEqual(decoded.kind, event.kind, file: file, line: line) + XCTAssertEqual(decoded.dx, event.dx, file: file, line: line) + XCTAssertEqual(decoded.dy, event.dy, file: file, line: line) + XCTAssertEqual(decoded.scrollX, event.scrollX, file: file, line: line) + XCTAssertEqual(decoded.scrollY, event.scrollY, file: file, line: line) + XCTAssertEqual(decoded.keyCode, event.keyCode, file: file, line: line) + } + + func testInputEventEncoderParityMouseMove() { + assertEncoderParity(makeEvent(kind: "move", dx: 5, dy: -3)) + } + + func testInputEventEncoderParityScroll() { + assertEncoderParity(makeEvent(kind: "scroll", scrollX: -120, scrollY: 42)) + } + + func testInputEventEncoderParityKeyDown() { + assertEncoderParity(makeEvent(kind: "keyDown", keyCode: 0x24)) + } + + func testInputEventEncoderParityAllFields() { + assertEncoderParity(makeEvent(kind: "drag", dx: 1, dy: 2, scrollX: 3, scrollY: 4, keyCode: UInt16.max)) + } + + func testInputEventEncoderParityNoOptionalFields() { + assertEncoderParity(makeEvent(kind: "leftUp")) + } + + func testInputEventEncoderParityExtremeValues() { + assertEncoderParity(makeEvent(kind: "move", dx: Int.min, dy: Int.max)) + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift new file mode 100644 index 0000000..3ed1c84 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import TargetBridge + +/// Tests for the discovered-receiver value model: per-transport address +/// selection and the human-readable summary. These pin down which IP the +/// sender dials for Thunderbolt vs Network Link — the exact decision that +/// determines whether traffic goes over the bridge or the LAN. +final class TBReceiverDiscoveryModelTests: XCTestCase { + + private func makeReceiver( + serviceName: String = "TargetBridge Test-iMac", + receiverName: String = "Test-iMac", + preferredIP: String = "192.168.1.64", + thunderboltIP: String = "", + networkIP: String = "", + panelSummary: String = "", + version: String = "3.1.0", + supportsHEVCDecode: Bool = true, + hostName: String? = nil + ) -> TBDiscoveredReceiver { + TBDiscoveredReceiver( + serviceName: serviceName, + receiverName: receiverName, + preferredIP: preferredIP, + thunderboltIP: thunderboltIP, + networkIP: networkIP, + panelSummary: panelSummary, + version: version, + supportsHEVCDecode: supportsHEVCDecode, + hostName: hostName + ) + } + + // MARK: - ip(for:) transport selection + + func testThunderboltTransportPrefersThunderboltIP() { + let receiver = makeReceiver(preferredIP: "192.168.1.64", thunderboltIP: "169.254.89.80", networkIP: "192.168.1.64") + XCTAssertEqual(receiver.ip(for: .thunderboltBridge), "169.254.89.80") + } + + func testThunderboltTransportFallsBackToPreferredIP() { + let receiver = makeReceiver(preferredIP: "192.168.1.64", thunderboltIP: "", networkIP: "192.168.1.64") + XCTAssertEqual(receiver.ip(for: .thunderboltBridge), "192.168.1.64") + } + + func testNetworkTransportPrefersNetworkIP() { + let receiver = makeReceiver(preferredIP: "169.254.89.80", thunderboltIP: "169.254.89.80", networkIP: "192.168.1.64") + XCTAssertEqual(receiver.ip(for: .networkLink), "192.168.1.64") + } + + func testNetworkTransportFallsBackToPreferredIP() { + let receiver = makeReceiver(preferredIP: "169.254.89.80", thunderboltIP: "169.254.89.80", networkIP: "") + XCTAssertEqual(receiver.ip(for: .networkLink), "169.254.89.80") + } + + // MARK: - Identity + + func testIDCombinesServiceNameAndPreferredIP() { + let receiver = makeReceiver(serviceName: "TargetBridge Jonathans-iMac", preferredIP: "192.168.1.64") + XCTAssertEqual(receiver.id, "TargetBridge Jonathans-iMac|192.168.1.64") + } + + // MARK: - shortHostName + + func testShortHostNameStripsTrailingDotAndDomain() { + let receiver = makeReceiver(hostName: "Jonathans-iMac.local.") + XCTAssertEqual(receiver.shortHostName, "Jonathans-iMac") + } + + func testShortHostNameNilWhenHostMissingOrEmpty() { + XCTAssertNil(makeReceiver(hostName: nil).shortHostName) + XCTAssertNil(makeReceiver(hostName: "").shortHostName) + } + + // MARK: - displayText + + func testDisplayTextShowsBothTransportsWhenAvailable() { + let receiver = makeReceiver( + thunderboltIP: "169.254.89.80", + networkIP: "192.168.1.64", + hostName: "Jonathans-iMac.local." + ) + XCTAssertEqual(receiver.displayText, "Jonathans-iMac (TB 169.254.89.80 · NET 192.168.1.64)") + } + + func testDisplayTextSingleTransportOnly() { + XCTAssertEqual(makeReceiver(thunderboltIP: "169.254.89.80").displayText, "169.254.89.80") + XCTAssertEqual(makeReceiver(networkIP: "192.168.1.64").displayText, "192.168.1.64") + } + + func testDisplayTextFallsBackToPreferredIPWithoutTransportIPs() { + let receiver = makeReceiver(preferredIP: "192.168.1.64") + XCTAssertEqual(receiver.displayText, "192.168.1.64") + } + + func testDisplayTextAppendsPanelSummary() { + let receiver = makeReceiver(networkIP: "192.168.1.64", panelSummary: "iMac 5K (5120x2880)") + XCTAssertEqual(receiver.displayText, "192.168.1.64 · iMac 5K (5120x2880)") + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift new file mode 100644 index 0000000..a7889aa --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import TargetBridge + +/// Tests for the pure parsing helpers behind the `targetbridge://` URL scheme +/// and `--connect` launch arguments (docs/Automation.md). These decide which +/// transport/mode/preset/session a scripted connect uses, so regressions here +/// silently reroute automation traffic. +@MainActor +final class TBSenderAutomationParsingTests: XCTestCase { + + // MARK: - parseTransport + + func testParseTransportNetworkAliases() { + for alias in ["net", "network", "networklink", "link", "NET", "NetworkLink"] { + XCTAssertEqual(TBSenderAutomation.parseTransport(alias), .networkLink, "alias \(alias)") + } + } + + /// Documents the current permissive behavior: anything that is not a + /// network alias — including typos — selects Thunderbolt Bridge. + func testParseTransportDefaultsToThunderbolt() { + for value in ["tb", "thunderbolt", "", "bogus", "TB"] { + XCTAssertEqual(TBSenderAutomation.parseTransport(value), .thunderboltBridge, "value \(value)") + } + } + + // MARK: - parseMode + + func testParseModeExtendedAliases() { + for alias in ["extended", "extend", "extendeddesktop", "ext", "EXTENDED"] { + XCTAssertEqual(TBSenderAutomation.parseMode(alias), .extendedDesktop, "alias \(alias)") + } + } + + func testParseModeMirrorAliases() { + for alias in ["mirror", "mirrored", "desktopmirror", "Mirror"] { + XCTAssertEqual(TBSenderAutomation.parseMode(alias), .desktopMirror, "alias \(alias)") + } + } + + func testParseModeAcceptsExactRawValues() { + XCTAssertEqual(TBSenderAutomation.parseMode("extendedDesktop"), .extendedDesktop) + XCTAssertEqual(TBSenderAutomation.parseMode("desktopMirror"), .desktopMirror) + } + + func testParseModeRejectsUnknown() { + XCTAssertNil(TBSenderAutomation.parseMode("bogus")) + XCTAssertNil(TBSenderAutomation.parseMode("")) + } + + // MARK: - parsePreset + + func testParsePresetAcceptsExactRawValues() { + XCTAssertEqual(TBSenderAutomation.parsePreset("standard1440p"), .standard1440p) + XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1440p60"), .smooth1440p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1800p60"), .smooth1800p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("crisp2160p60"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("native5k"), .native5k) + } + + func testParsePresetAliases() { + XCTAssertEqual(TBSenderAutomation.parsePreset("1440p"), .standard1440p) + XCTAssertEqual(TBSenderAutomation.parsePreset("standard"), .standard1440p) + XCTAssertEqual(TBSenderAutomation.parsePreset("1440p60"), .smooth1440p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("smooth"), .smooth1440p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("1800p"), .smooth1800p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("4k"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("crisp"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("5k"), .native5k) + XCTAssertEqual(TBSenderAutomation.parsePreset("5K"), .native5k, "aliases are case-insensitive") + XCTAssertEqual(TBSenderAutomation.parsePreset("native"), .native5k) + XCTAssertEqual(TBSenderAutomation.parsePreset("5120x2880"), .native5k) + } + + func testParsePresetRejectsUnknown() { + XCTAssertNil(TBSenderAutomation.parsePreset("bogus")) + XCTAssertNil(TBSenderAutomation.parsePreset("")) + // Raw values are case-sensitive and "native5k" has no capitalized alias. + XCTAssertNil(TBSenderAutomation.parsePreset("NATIVE5K")) + } + + // MARK: - matches (receiver selection for --receiver ) + + private func makeReceiver() -> TBDiscoveredReceiver { + TBDiscoveredReceiver( + serviceName: "TargetBridge Jonathans-iMac", + receiverName: "Jonathans-iMac", + preferredIP: "192.168.1.64", + thunderboltIP: "169.254.89.80", + networkIP: "192.168.1.64", + panelSummary: "iMac 5K", + version: "3.1.0", + supportsHEVCDecode: true, + hostName: "Jonathans-iMac.local." + ) + } + + func testMatchesByName() { + XCTAssertTrue(TBSenderAutomation.matches("Jonathans-iMac", makeReceiver())) + XCTAssertTrue(TBSenderAutomation.matches("jonathans-imac", makeReceiver()), "name match is case-insensitive") + } + + func testMatchesByShortHostName() { + XCTAssertTrue(TBSenderAutomation.matches("jonathans-imac", makeReceiver())) + } + + func testMatchesByAnyAdvertisedIP() { + XCTAssertTrue(TBSenderAutomation.matches("192.168.1.64", makeReceiver()), "preferred/network IP") + XCTAssertTrue(TBSenderAutomation.matches("169.254.89.80", makeReceiver()), "thunderbolt IP") + } + + func testMatchesByID() { + XCTAssertTrue(TBSenderAutomation.matches("targetbridge jonathans-imac|192.168.1.64", makeReceiver())) + } + + func testDoesNotMatchUnrelatedValue() { + XCTAssertFalse(TBSenderAutomation.matches("other-mac", makeReceiver())) + XCTAssertFalse(TBSenderAutomation.matches("10.0.0.1", makeReceiver())) + } + + // MARK: - resolveSessionIndex tri-state + // + // Returns `nil` = invalid input, `.some(nil)` = target all sessions, + // `.some(index)` = zero-based session index. + + func testNoSessionParamTargetsAllSessionsWhenNotCreating() { + let result: Int?? = TBSenderAutomation.resolveSessionIndex(nil, sessionCount: 3, createDefaultIfNeeded: false) + XCTAssertEqual(result, Int??.some(.none), "absent session + no-create should mean 'all sessions'") + } + + func testNoSessionParamDefaultsToFirstSessionWhenCreating() { + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex(nil, sessionCount: 0, createDefaultIfNeeded: true), + Int??.some(0) + ) + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex(nil, sessionCount: 3, createDefaultIfNeeded: true), + Int??.some(0) + ) + } + + func testEmptySessionParamBehavesLikeAbsent() { + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex("", sessionCount: 2, createDefaultIfNeeded: false), + Int??.some(.none) + ) + } + + func testOneBasedIndexIsConvertedToZeroBased() { + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex("2", sessionCount: 3, createDefaultIfNeeded: false), + Int??.some(1) + ) + } + + func testOutOfRangeSessionIsInvalid() { + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("4", sessionCount: 3, createDefaultIfNeeded: false)) + } + + func testSessionOneOnEmptyListCreatesDefaultOnlyWhenAllowed() { + XCTAssertEqual( + TBSenderAutomation.resolveSessionIndex("1", sessionCount: 0, createDefaultIfNeeded: true), + Int??.some(0) + ) + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("1", sessionCount: 0, createDefaultIfNeeded: false)) + } + + func testNonNumericAndNonPositiveSessionsAreInvalid() { + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("abc", sessionCount: 3, createDefaultIfNeeded: true)) + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("0", sessionCount: 3, createDefaultIfNeeded: true)) + XCTAssertNil(TBSenderAutomation.resolveSessionIndex("-1", sessionCount: 3, createDefaultIfNeeded: true)) + } +} diff --git a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift index 6e3c4f2..fbcdeed 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift @@ -96,9 +96,29 @@ struct TBMonitorClipboard: Codable { var text: String } +/// Framing-level corruption that cannot be recovered by waiting for more +/// bytes. The connection carrying the stream should be torn down. +enum TBMonitorProtocolError: Error, Equatable, CustomStringConvertible { + case invalidPacketLength(UInt32) + + var description: String { + switch self { + case .invalidPacketLength(let length): + return "invalid packet length \(length)" + } + } +} + enum TBMonitorProtocol { static let port: UInt16 = 54321 + /// Upper bound for a single packet's declared length. Mirrors the + /// receiver's parser sanity check (net.c) so both ends agree on what a + /// corrupt length prefix is. Without this cap, a corrupted 4-byte length + /// (e.g. 0xFFFFFFFF) would make the drain loop buffer inbound data + /// forever, waiting for a packet that can never complete. + static let maxPacketLength: UInt32 = 64 * 1024 * 1024 + static func makePacket(type: TBMonitorPacketType, payload: Data) -> Data { var packet = Data() appendBE32(&packet, UInt32(1 + payload.count)) @@ -136,15 +156,33 @@ enum TBMonitorProtocol { return try? decoder.decode(type, from: payload) } - static func drainPacket(from buffer: inout Data) -> (TBMonitorPacketType, Data)? { - guard buffer.count >= 5 else { return nil } - let packetLength = Int(readBE32(buffer, offset: 0)) - guard packetLength >= 1, buffer.count >= 4 + packetLength else { return nil } - let typeByte = buffer[4] - let payload = buffer.subdata(in: 5..<(4 + packetLength)) - buffer.removeSubrange(0..<(4 + packetLength)) - guard let packetType = TBMonitorPacketType(rawValue: typeByte) else { return nil } - return (packetType, payload) + /// Drains the next complete packet from `buffer`. + /// + /// - Returns: the packet, or `nil` when the buffer does not yet hold a + /// complete packet (more bytes are needed). + /// - Throws: `TBMonitorProtocolError.invalidPacketLength` when the length + /// prefix is corrupt (zero or above `maxPacketLength`); the stream is + /// unrecoverable and the caller should close the connection. + /// + /// Packets with an unrecognized type byte (e.g. from a newer peer) are + /// skipped and draining continues with the next packet, so one unknown + /// packet cannot stall the packets queued behind it. + static func drainPacket(from buffer: inout Data) throws -> (TBMonitorPacketType, Data)? { + while buffer.count >= 5 { + let packetLength = readBE32(buffer, offset: 0) + guard packetLength >= 1, packetLength <= maxPacketLength else { + throw TBMonitorProtocolError.invalidPacketLength(packetLength) + } + let packetEnd = 4 + Int(packetLength) + guard buffer.count >= packetEnd else { return nil } + let typeByte = buffer[4] + let payload = buffer.subdata(in: 5.. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TargetBridge-Sender/project.yml b/TargetBridge-Sender/project.yml index be70966..1b7fa54 100644 --- a/TargetBridge-Sender/project.yml +++ b/TargetBridge-Sender/project.yml @@ -49,3 +49,33 @@ targets: static let versionDisplay = "\\(marketingVersion) + build \\(buildNumber)" } EOI + + TBDisplaySenderTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "14.0" + sources: + - path: TBDisplaySenderTests + dependencies: + - target: TBDisplaySender + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.targetbridge.sender.tests + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: minimal + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TargetBridge.app/Contents/MacOS/TargetBridge" + BUNDLE_LOADER: "$(TEST_HOST)" + +schemes: + TBDisplaySender: + build: + targets: + TBDisplaySender: all + run: + config: Debug + test: + config: Debug + gatherCoverageData: false + targets: + - TBDisplaySenderTests