From 487943a9f5322af806072bbb8bad45c07b453868 Mon Sep 17 00:00:00 2001 From: Jason Titus <870238+jasontitus@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:11:15 -0700 Subject: [PATCH 1/4] test(sender): add unit-test target covering wire protocol and automation parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a TBDisplaySenderTests unit bundle (hosted by the app for @testable access) with 49 tests over the pure logic that has no hardware dependency: - TBMonitorProtocol: BE32 primitives, packet layout, drainPacket handling of fragmented/contiguous/split feeds, JSON payload round-trips, and parity of the hand-rolled input-event encoder with JSONDecoder — guarding the invariant documented on makeInputEventPacket (PR #123). - TBDiscoveredReceiver: per-transport ip(for:) selection (which IP the sender dials for Thunderbolt vs Network Link), id, shortHostName, displayText. - TBSenderAutomation: parseTransport/parseMode/parsePreset aliases, receiver matching, and the resolveSessionIndex tri-state. The automation parsing helpers move from private to internal so the test bundle can exercise them; behavior is unchanged. project.pbxproj and the shared TBDisplaySender scheme are regenerated via xcodegen so a fresh checkout can run `xcodebuild test` directly. Co-Authored-By: Claude Fable 5 --- .../TBDisplaySenderAutomation.swift | 13 +- .../TBMonitorProtocolTests.swift | 186 ++++++++++++++++++ .../TBReceiverDiscoveryModelTests.swift | 100 ++++++++++ .../TBSenderAutomationParsingTests.swift | 173 ++++++++++++++++ .../TargetBridge.xcodeproj/project.pbxproj | 115 +++++++++++ .../xcschemes/TBDisplaySender.xcscheme | 104 ++++++++++ TargetBridge-Sender/project.yml | 30 +++ 7 files changed, 716 insertions(+), 5 deletions(-) create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift create mode 100644 TargetBridge-Sender/TargetBridge.xcodeproj/xcshareddata/xcschemes/TBDisplaySender.xcscheme 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/TBDisplaySenderTests/TBMonitorProtocolTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift new file mode 100644 index 0000000..6642ce0 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -0,0 +1,186 @@ +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() { + let payload = Data("hello receiver".utf8) + var buffer = TBMonitorProtocol.makePacket(type: .helloReceiver, payload: payload) + + let drained = TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertNotNil(drained) + XCTAssertEqual(drained?.0, .helloReceiver) + XCTAssertEqual(drained?.1, payload) + XCTAssertTrue(buffer.isEmpty, "drain must consume the packet") + } + + func testDrainPacketEmptyPayload() { + var buffer = TBMonitorProtocol.makePacket(type: .teardown, payload: Data()) + let drained = 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(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(TBMonitorProtocol.drainPacket(from: &buffer)) + XCTAssertEqual(buffer.count, 50, "incomplete data must not be consumed") + } + + func testDrainTwoContiguousPackets() { + var buffer = TBMonitorProtocol.makePacket(type: .cursor, payload: Data([0x01])) + buffer.append(TBMonitorProtocol.makePacket(type: .brightness, payload: Data([0x02, 0x03]))) + + let first = TBMonitorProtocol.drainPacket(from: &buffer) + XCTAssertEqual(first?.0, .cursor) + XCTAssertEqual(first?.1, Data([0x01])) + + let second = 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() { + 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 = 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: - JSON payloads + + func testJSONPacketRoundTrip() { + let heartbeat = TBMonitorHeartbeat(sequence: 42) + guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .heartbeat, value: heartbeat) else { + XCTFail("encode failed"); return + } + guard let (type, payload) = 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) = TBMonitorProtocol.drainPacket(from: &buffer) 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/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 7a3bdb3..72a9c43 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -11,10 +11,13 @@ 0D17E4E85D8E9CDAB8707EB6 /* audio-relay.json in Resources */ = {isa = PBXBuildFile; fileRef = A3964153C9F34085F90C2C26 /* audio-relay.json */; }; 198725EB3B2C7662DBB0B8D0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1819A55F8D993D9347F0E407 /* Assets.xcassets */; }; 233BAEE5ABED3F316F9D3D12 /* input-dockstation.json in Resources */ = {isa = PBXBuildFile; fileRef = A51B69D318A85FF3061D6ED4 /* input-dockstation.json */; }; + 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */; }; 337BD561622AA19F43D3B95B /* ReceiverBackedVirtualDisplaySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */; }; 4290B6572B8CA96E01C56425 /* TBDisplaySenderBuildInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */; }; + 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */; }; 4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */; }; 5C0CB53005B6D67362F14719 /* TBDisplaySenderContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */; }; + 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */; }; 6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */; }; 7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; }; 7FB34AA1E75B01E7D1EC3E21 /* it.json in Resources */ = {isa = PBXBuildFile; fileRef = 036E14097FA59989FFC456FD /* it.json */; }; @@ -36,11 +39,22 @@ F2274C924BAC479635B44A47 /* TBDisplaySenderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4644CFB05098C895378B648A /* TBDisplaySenderManager.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F78BF9D660B681542156B1E4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 378AC1A4A812118F1A10D05E /* Project object */; + proxyType = 1; + remoteGlobalIDString = F472012D8B21AAC5A144C3A6; + remoteInfo = TBDisplaySender; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 036E14097FA59989FFC456FD /* it.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = it.json; sourceTree = ""; }; 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscovery.swift; sourceTree = ""; }; 06D011FF8AF7BB6D26EDC25B /* TBAddonStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonStore.swift; sourceTree = ""; }; 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderService.swift; sourceTree = ""; }; + 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscoveryModelTests.swift; sourceTree = ""; }; 17FD38CAA4E2CF350CC210D2 /* TBDisplaySender.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TBDisplaySender.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1819A55F8D993D9347F0E407 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderContentView.swift; sourceTree = ""; }; @@ -59,12 +73,15 @@ B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderSettingsView.swift; sourceTree = ""; }; C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiverBackedVirtualDisplaySession.swift; sourceTree = ""; }; C3658E86F5BE2168F4591491 /* de.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = de.json; sourceTree = ""; }; + C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMonitorProtocolTests.swift; sourceTree = ""; }; C9AA5BB4EE39D2C6FB37B90F /* zh.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zh.json; sourceTree = ""; }; CD235429FA67D231F565C93E /* TBDisplaySenderApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderApp.swift; sourceTree = ""; }; CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderBuildInfo.swift; sourceTree = ""; }; D386D0FCD0F93494220CC3DC /* en.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = en.json; sourceTree = ""; }; DA01317B43B46B1B323A7008 /* TBMonitorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMonitorProtocol.swift; sourceTree = ""; }; DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderSurfaceViews.swift; sourceTree = ""; }; + DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TBDisplaySenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBSenderAutomationParsingTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -99,6 +116,16 @@ path = TBDisplaySender; sourceTree = ""; }; + 578C8E4277884577DC1F2820 /* TBDisplaySenderTests */ = { + isa = PBXGroup; + children = ( + C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */, + 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */, + ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */, + ); + path = TBDisplaySenderTests; + sourceTree = ""; + }; 67A9FD97DC6A4925CB987EB9 /* TBDisplayShared */ = { isa = PBXGroup; children = ( @@ -115,6 +142,7 @@ isa = PBXGroup; children = ( 17FD38CAA4E2CF350CC210D2 /* TBDisplaySender.app */, + DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */, ); name = Products; sourceTree = ""; @@ -125,6 +153,7 @@ EC5C3CD2E8BF9290331E4183 /* TargetBridge-Shared */, B4FD562D7D8645AB2EE6621C /* TargetBridgeSupport */, 08A7C67DCAA583BD6D8B0603 /* TBDisplaySender */, + 578C8E4277884577DC1F2820 /* TBDisplaySenderTests */, 67A9FD97DC6A4925CB987EB9 /* TBDisplayShared */, 7703C219B31BE7C47F726BA2 /* Products */, ); @@ -162,6 +191,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 72E4A9704015C337033B3918 /* TBDisplaySenderTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6B3F38F57A583A1C40A78FAC /* Build configuration list for PBXNativeTarget "TBDisplaySenderTests" */; + buildPhases = ( + 41C3FAAB0014D20C2CB2D35D /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 750E39CE5F2E49E0F2974C21 /* PBXTargetDependency */, + ); + name = TBDisplaySenderTests; + packageProductDependencies = ( + ); + productName = TBDisplaySenderTests; + productReference = DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; F472012D8B21AAC5A144C3A6 /* TBDisplaySender */ = { isa = PBXNativeTarget; buildConfigurationList = 2BBAEA859D7D9F128EE14417 /* Build configuration list for PBXNativeTarget "TBDisplaySender" */; @@ -207,6 +254,7 @@ projectRoot = ""; targets = ( F472012D8B21AAC5A144C3A6 /* TBDisplaySender */, + 72E4A9704015C337033B3918 /* TBDisplaySenderTests */, ); }; /* End PBXProject section */ @@ -251,6 +299,16 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 41C3FAAB0014D20C2CB2D35D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */, + 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */, + 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A5AEAA1457A961B01CAF2C8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -279,6 +337,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 750E39CE5F2E49E0F2974C21 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F472012D8B21AAC5A144C3A6 /* TBDisplaySender */; + targetProxy = F78BF9D660B681542156B1E4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 3324300638A25CFE4B431234 /* Debug */ = { isa = XCBuildConfiguration; @@ -328,6 +394,26 @@ }; name = Release; }; + 76944D69134383E0672FE470 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.targetbridge.sender.tests; + SDKROOT = macosx; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TargetBridge.app/Contents/MacOS/TargetBridge"; + }; + name = Debug; + }; 9AC2A7DAA262FB9F7282A255 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -384,6 +470,26 @@ }; name = Release; }; + B1C4017F7EDFDAB39E9A8A34 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.targetbridge.sender.tests; + SDKROOT = macosx; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TargetBridge.app/Contents/MacOS/TargetBridge"; + }; + name = Release; + }; B8A682D4A10103F0490BB3C4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -468,6 +574,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 6B3F38F57A583A1C40A78FAC /* Build configuration list for PBXNativeTarget "TBDisplaySenderTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 76944D69134383E0672FE470 /* Debug */, + B1C4017F7EDFDAB39E9A8A34 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 378AC1A4A812118F1A10D05E /* Project object */; diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/xcshareddata/xcschemes/TBDisplaySender.xcscheme b/TargetBridge-Sender/TargetBridge.xcodeproj/xcshareddata/xcschemes/TBDisplaySender.xcscheme new file mode 100644 index 0000000..07f0863 --- /dev/null +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/xcshareddata/xcschemes/TBDisplaySender.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 From f37f8678341f88aed14b2b2ae62e8944b2cb0565 Mon Sep 17 00:00:00 2001 From: Jason Titus <870238+jasontitus@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:11:15 -0700 Subject: [PATCH 2/4] ci: run sender unit tests and build maintenance branches - Executes the new TBDisplaySenderTests suite after the sender build. - Triggers CI on 3.1-maint and 3.2-dev in addition to main, so the branches that actually receive PRs get build/test coverage. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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: From 60b6e060fe9f2b51a2d0df6362ed4f0c955bca5e Mon Sep 17 00:00:00 2001 From: Jason Titus <870238+jasontitus@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:14:36 -0700 Subject: [PATCH 3/4] fix(sender): fail fast on corrupt packet lengths and skip unknown packet types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two wire-protocol drain hardenings in TBMonitorProtocol.drainPacket, matching the receiver parser's behavior (net.c caps packets at 64 MiB and treats a bad length as fatal): - A corrupt length prefix (zero, or above the new 64 MiB maxPacketLength) now throws instead of returning "need more data". Previously a corrupted length such as 0xFFFFFFFF made the sender buffer inbound bytes forever, waiting for a packet that could never complete — unbounded memory growth on a corrupt stream. The drain loop in TBDisplaySenderService now closes the connection and surfaces the reason in the session status. - A packet with an unrecognized type byte (e.g. from a newer receiver) is now skipped and draining continues. Previously it consumed the packet but returned nil, which the caller treated as "buffer empty" — stalling every valid packet queued behind the unknown one until the next network read. Covered by 7 new unit tests (zero/oversized/all-ones lengths, cap boundary, corruption behind a valid packet, unknown-type skip and lone unknown-type). Co-Authored-By: Claude Fable 5 --- .../TBDisplaySenderService.swift | 15 ++- .../TBMonitorProtocolTests.swift | 97 ++++++++++++++++--- .../TBDisplayShared/TBMonitorProtocol.swift | 56 +++++++++-- 3 files changed, 144 insertions(+), 24 deletions(-) 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 index 6642ce0..b1eeef7 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -39,20 +39,20 @@ final class TBMonitorProtocolTests: XCTestCase { XCTAssertEqual([UInt8](packet), [0x00, 0x00, 0x00, 0x04, 0x30, 0xAA, 0xBB, 0xCC]) } - func testDrainPacketRoundTrip() { + func testDrainPacketRoundTrip() throws { let payload = Data("hello receiver".utf8) var buffer = TBMonitorProtocol.makePacket(type: .helloReceiver, payload: payload) - let drained = TBMonitorProtocol.drainPacket(from: &buffer) + 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() { + func testDrainPacketEmptyPayload() throws { var buffer = TBMonitorProtocol.makePacket(type: .teardown, payload: Data()) - let drained = TBMonitorProtocol.drainPacket(from: &buffer) + let drained = try TBMonitorProtocol.drainPacket(from: &buffer) XCTAssertEqual(drained?.0, .teardown) XCTAssertEqual(drained?.1, Data()) XCTAssertTrue(buffer.isEmpty) @@ -60,26 +60,26 @@ final class TBMonitorProtocolTests: XCTestCase { func testDrainPacketWaitsForCompleteHeader() { var buffer = Data([0x00, 0x00, 0x00, 0x04]) // header missing its 5th byte - XCTAssertNil(TBMonitorProtocol.drainPacket(from: &buffer)) + 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(TBMonitorProtocol.drainPacket(from: &buffer)) + XCTAssertNil(try TBMonitorProtocol.drainPacket(from: &buffer)) XCTAssertEqual(buffer.count, 50, "incomplete data must not be consumed") } - func testDrainTwoContiguousPackets() { + func testDrainTwoContiguousPackets() throws { var buffer = TBMonitorProtocol.makePacket(type: .cursor, payload: Data([0x01])) buffer.append(TBMonitorProtocol.makePacket(type: .brightness, payload: Data([0x02, 0x03]))) - let first = TBMonitorProtocol.drainPacket(from: &buffer) + let first = try TBMonitorProtocol.drainPacket(from: &buffer) XCTAssertEqual(first?.0, .cursor) XCTAssertEqual(first?.1, Data([0x01])) - let second = TBMonitorProtocol.drainPacket(from: &buffer) + let second = try TBMonitorProtocol.drainPacket(from: &buffer) XCTAssertEqual(second?.0, .brightness) XCTAssertEqual(second?.1, Data([0x02, 0x03])) @@ -88,13 +88,13 @@ final class TBMonitorProtocolTests: XCTestCase { /// Simulates TCP fragmentation: the packet arrives one byte at a time and /// must only drain once the final byte lands. - func testDrainPacketAcrossSplitFeeds() { + 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 = TBMonitorProtocol.drainPacket(from: &buffer) + 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 { @@ -104,14 +104,83 @@ final class TBMonitorProtocolTests: XCTestCase { } } + // 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() { + 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) = TBMonitorProtocol.drainPacket(from: &buffer) else { + guard let (type, payload) = try TBMonitorProtocol.drainPacket(from: &buffer) else { XCTFail("drain failed"); return } XCTAssertEqual(type, .heartbeat) @@ -142,7 +211,7 @@ final class TBMonitorProtocolTests: XCTestCase { line: UInt = #line ) { var buffer = TBMonitorProtocol.makeInputEventPacket(event) - guard let (type, payload) = TBMonitorProtocol.drainPacket(from: &buffer) else { + guard let (type, payload) = try? TBMonitorProtocol.drainPacket(from: &buffer) ?? nil else { XCTFail("packet did not drain", file: file, line: line) return } 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.. Date: Wed, 1 Jul 2026 23:19:32 -0700 Subject: [PATCH 4/4] fix(sender): scope Thunderbolt dials to the bridge interface and surface real connect errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause, observed in the field: connect() pins only requiredLocalEndpoint (the source ADDRESS), never the egress interface. macOS keeps a single routing-table entry for 169.254.0.0/16 pointing at the primary interface (usually Wi-Fi), so a dial to a Thunderbolt Bridge peer's self-assigned link-local address leaves via the wrong link and black-holes — with both Macs configured correctly and the TB link healthy. The connection then sits in .waiting(...) carrying the true reason ("No route to host", "Network is down"), which the state handler silently dropped, and the 5s watchdog reported a bare fixed-string "Connection timed out". Fixes, kept surgical: - Link-local receiver addresses are now dialed scoped ("169.254.x.y%bridge0") to the interface that owns the session's local IP, so routing happens on the Thunderbolt link regardless of the routing table. Non-link-local dials are unchanged. Falls back to the unscoped host if the scoped form does not parse. This makes plain DHCP/link-local Thunderbolt Bridge setups (the macOS default) work without manual static-IP workarounds. - The .waiting(error) state is now handled: logged, and remembered so the watchdog/failure paths can report it. - Timeout and failure statuses now append where we dialed, from which local IP/interface, the transport, and the last network state, via the pure TBConnectionDiagnostics.failureDetail composer. - New os.Logger subsystem com.targetbridge.sender (category "connection") traces dial/ready/waiting/failed/timeout, so field issues can be triaged with `log stream --predicate 'subsystem == "com.targetbridge.sender"'`. Covered by 9 new unit tests for the scoping and detail-composition logic. Co-Authored-By: Claude Fable 5 --- .../TBDisplaySenderService.swift | 66 +++++++++- .../TBConnectionDiagnosticsTests.swift | 122 ++++++++++++++++++ .../TBConnectionDiagnostics.swift | 106 +++++++++++++++ .../TargetBridge.xcodeproj/project.pbxproj | 8 ++ 4 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift create mode 100644 TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 004a525..85cad20 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -985,6 +985,13 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var firstFrameTimer: Timer? private var cursorTimer: Timer? private var connectTimeoutWorkItem: DispatchWorkItem? + /// Name of the local interface the current connect attempt is bound to + /// (e.g. "bridge0"), resolved when dialing. Diagnostic context only. + private var connectInterfaceName: String? + /// Last state reported by NWConnection for the current attempt (e.g. + /// "waiting(No route to host)") — surfaced when a connect fails or times + /// out so the real reason is not lost. + private var lastConnectionStateDetail: String? private var heartbeatSequence: UInt64 = 0 private var statusState: TBDisplaySenderStatusState = .ready private var streamingActivity: NSObjectProtocol? @@ -1184,6 +1191,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u activeProfile = nil activeCodecType = nil activeCodecName = nil + lastConnectionStateDetail = nil setStatus(.connecting(receiverDisplayName)) let tcpOptions = NWProtocolTCP.Options() @@ -1194,8 +1202,28 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if let localPort = NWEndpoint.Port(rawValue: 0) { params.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(localInterfaceIP), port: localPort) } + + // Scope link-local dials to the interface that owns the local IP. + // requiredLocalEndpoint pins the source address but NOT the egress + // interface — the routing table keeps 169.254/16 on the primary + // interface (usually Wi-Fi), so an unscoped dial to a Thunderbolt + // Bridge peer leaves via the wrong link and times out. + let interfaces = TBConnectionDiagnostics.currentIPv4Interfaces() + connectInterfaceName = TBConnectionDiagnostics.interfaceName(forLocalIP: localInterfaceIP, in: interfaces) + let scopedHost = TBConnectionDiagnostics.scopedReceiverHost( + receiverIP: receiverIP, + localIP: localInterfaceIP, + interfaces: interfaces + ) + let dialHost: NWEndpoint.Host + if scopedHost != receiverIP, let scopedAddress = IPv4Address(scopedHost) { + dialHost = .ipv4(scopedAddress) + } else { + dialHost = NWEndpoint.Host(receiverIP) + } + TBLog.connection.info("connect: dialing \(scopedHost, privacy: .public):\(TBMonitorProtocol.port) from \(self.localInterfaceIP, privacy: .public) (\(self.connectInterfaceName ?? "unknown interface", privacy: .public)) transport=\(self.transportKind.rawValue, privacy: .public)") let conn = NWConnection( - host: NWEndpoint.Host(receiverIP), + host: dialHost, port: NWEndpoint.Port(integerLiteral: TBMonitorProtocol.port), using: params ) @@ -1209,6 +1237,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u self.connectTimeoutWorkItem?.cancel() self.connectTimeoutWorkItem = nil self.isConnected = true + TBLog.connection.info("connect: ready — \(self.receiverIP, privacy: .public) via \(self.connectInterfaceName ?? "?", privacy: .public)") self.setStatus(.waitingDisplayProfile) self.startHeartbeat() self.sendHello() @@ -1216,8 +1245,25 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u self.sendBrightnessUpdate() self.sendVolumeUpdate() self.receiveLoop(on: conn) + case .waiting(let error): + // The dial cannot proceed yet (no route, host down, cable + // unplugged, firewall drop, …). Record and log the real + // reason so a later timeout can report it instead of a + // bare "Connection timed out". + self.lastConnectionStateDetail = "waiting(\(error.localizedDescription))" + TBLog.connection.warning("connect: waiting — \(error.localizedDescription, privacy: .public)") case .failed(let error): - self.setStatus(.connectionFailed(error.localizedDescription)) + self.lastConnectionStateDetail = "failed(\(error.localizedDescription))" + let detail = TBConnectionDiagnostics.failureDetail( + receiverHost: self.receiverIP, + port: TBMonitorProtocol.port, + localIP: self.localInterfaceIP, + interfaceName: self.connectInterfaceName, + transport: self.transportKind.rawValue, + lastNetworkState: nil + ) + TBLog.connection.error("connect: failed — \(error.localizedDescription, privacy: .public); \(detail, privacy: .public)") + self.setStatus(.connectionFailed("\(error.localizedDescription) — \(detail)")) self.stop(resetStatusTo: nil) case .cancelled: self.isConnected = false @@ -1589,7 +1635,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } 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") + TBLog.connection.error("corrupt inbound stream (\(String(describing: error), privacy: .public)); closing connection") recvBuffer.removeAll(keepingCapacity: false) setStatus(.connectionClosed(String(describing: error))) stop(resetStatusTo: nil) @@ -2775,7 +2821,19 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u case .chinese: timeoutMessage = "连接超时" } - self.setStatus(.connectionFailed(timeoutMessage)) + // Attach where we dialed, from which interface, and the last + // state the network stack reported — previously all of this + // was discarded and the user saw only the bare timeout. + let detail = TBConnectionDiagnostics.failureDetail( + receiverHost: self.receiverIP, + port: TBMonitorProtocol.port, + localIP: self.localInterfaceIP, + interfaceName: self.connectInterfaceName, + transport: self.transportKind.rawValue, + lastNetworkState: self.lastConnectionStateDetail + ) + TBLog.connection.error("connect: timed out — \(detail, privacy: .public)") + self.setStatus(.connectionFailed("\(timeoutMessage) — \(detail)")) self.stop(resetStatusTo: nil) } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift new file mode 100644 index 0000000..b35b2ff --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import TargetBridge + +/// Tests for the connect-path helpers: link-local interface scoping (the fix +/// for Thunderbolt Bridge dials leaving via the wrong interface) and the +/// failure-detail composer that keeps diagnostics attached to errors. +final class TBConnectionDiagnosticsTests: XCTestCase { + + private typealias Interface = TBConnectionDiagnostics.LocalInterface + + private let interfaces: [Interface] = [ + Interface(name: "en0", ip: "192.168.1.225"), + Interface(name: "bridge0", ip: "169.254.109.86"), + ] + + // MARK: - interfaceName(forLocalIP:) + + func testInterfaceNameFindsOwningInterface() { + XCTAssertEqual( + TBConnectionDiagnostics.interfaceName(forLocalIP: "169.254.109.86", in: interfaces), + "bridge0" + ) + XCTAssertEqual( + TBConnectionDiagnostics.interfaceName(forLocalIP: "192.168.1.225", in: interfaces), + "en0" + ) + } + + func testInterfaceNameNilForUnknownOrEmptyIP() { + XCTAssertNil(TBConnectionDiagnostics.interfaceName(forLocalIP: "10.0.0.1", in: interfaces)) + XCTAssertNil(TBConnectionDiagnostics.interfaceName(forLocalIP: "", in: interfaces)) + XCTAssertNil(TBConnectionDiagnostics.interfaceName(forLocalIP: "169.254.109.86", in: [])) + } + + // MARK: - scopedReceiverHost + + /// The Thunderbolt Bridge case: both ends self-assign 169.254.x, the + /// routing table pins 169.254/16 to the primary interface, and only a + /// scoped dial reaches the peer. + func testLinkLocalReceiverIsScopedToOwningInterface() { + XCTAssertEqual( + TBConnectionDiagnostics.scopedReceiverHost( + receiverIP: "169.254.89.80", + localIP: "169.254.109.86", + interfaces: interfaces + ), + "169.254.89.80%bridge0" + ) + } + + func testNonLinkLocalReceiverIsNotScoped() { + XCTAssertEqual( + TBConnectionDiagnostics.scopedReceiverHost( + receiverIP: "192.168.1.64", + localIP: "192.168.1.225", + interfaces: interfaces + ), + "192.168.1.64" + ) + } + + func testLinkLocalReceiverWithoutMatchingLocalInterfaceIsUnchanged() { + XCTAssertEqual( + TBConnectionDiagnostics.scopedReceiverHost( + receiverIP: "169.254.89.80", + localIP: "10.9.9.9", + interfaces: interfaces + ), + "169.254.89.80" + ) + } + + func testAlreadyScopedReceiverIsUnchanged() { + XCTAssertEqual( + TBConnectionDiagnostics.scopedReceiverHost( + receiverIP: "169.254.89.80%bridge0", + localIP: "169.254.109.86", + interfaces: interfaces + ), + "169.254.89.80%bridge0" + ) + } + + // MARK: - failureDetail + + func testFailureDetailIncludesFullContext() { + let detail = TBConnectionDiagnostics.failureDetail( + receiverHost: "169.254.89.80", + port: 54321, + localIP: "169.254.109.86", + interfaceName: "bridge0", + transport: "thunderboltBridge", + lastNetworkState: "waiting(No route to host)" + ) + XCTAssertEqual( + detail, + "dialed 169.254.89.80:54321 from 169.254.109.86 (bridge0) [thunderboltBridge] — last network state: waiting(No route to host)" + ) + } + + func testFailureDetailOmitsMissingInterfaceAndState() { + let detail = TBConnectionDiagnostics.failureDetail( + receiverHost: "192.168.1.64", + port: 54321, + localIP: "192.168.1.225", + interfaceName: nil, + transport: "networkLink", + lastNetworkState: nil + ) + XCTAssertEqual(detail, "dialed 192.168.1.64:54321 from 192.168.1.225 [networkLink]") + } + + // MARK: - currentIPv4Interfaces (live snapshot; environment-tolerant) + + func testCurrentIPv4InterfacesExcludesLoopbackAndHasNames() { + let interfaces = TBConnectionDiagnostics.currentIPv4Interfaces() + for iface in interfaces { + XCTAssertFalse(iface.name.isEmpty) + XCTAssertFalse(iface.ip.hasPrefix("127."), "loopback must be excluded, found \(iface.ip) on \(iface.name)") + } + } +} diff --git a/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift b/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift new file mode 100644 index 0000000..dd9f026 --- /dev/null +++ b/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift @@ -0,0 +1,106 @@ +import Foundation +import Network +import os + +/// Unified-logging entry points for the sender. `log stream --predicate +/// 'subsystem == "com.targetbridge.sender"'` (or Console.app) shows the +/// connection lifecycle without attaching a debugger. +enum TBLog { + static let connection = Logger(subsystem: "com.targetbridge.sender", category: "connection") +} + +/// Pure helpers for deciding how to dial a receiver and for composing +/// actionable connection-failure details. Kept free of session state so the +/// unit-test bundle can exercise them without hardware. +enum TBConnectionDiagnostics { + + /// A local IPv4 interface as (name, ip) — the test-injectable slice of + /// what `getifaddrs` reports. + struct LocalInterface: Equatable { + let name: String + let ip: String + + init(name: String, ip: String) { + self.name = name + self.ip = ip + } + } + + /// Returns the name of the local interface that owns `localIP`, if any. + static func interfaceName(forLocalIP localIP: String, in interfaces: [LocalInterface]) -> String? { + guard !localIP.isEmpty else { return nil } + return interfaces.first(where: { $0.ip == localIP })?.name + } + + /// For a link-local (`169.254.x`) receiver address, returns + /// `"%"` so the dial is scoped to the interface that owns + /// `localIP`. Everything else is returned unchanged. + /// + /// Why: macOS keeps a single routing-table entry for all of + /// 169.254.0.0/16, pointing at the primary interface (usually Wi-Fi). A + /// Thunderbolt Bridge peer is only reachable on the bridge interface, so + /// an unscoped dial to its self-assigned link-local address leaves via the + /// wrong interface and times out — with both Macs configured correctly. + /// A scoped address routes on the named interface regardless of the table. + static func scopedReceiverHost( + receiverIP: String, + localIP: String, + interfaces: [LocalInterface] + ) -> String { + guard receiverIP.hasPrefix("169.254."), !receiverIP.contains("%") else { return receiverIP } + guard let name = interfaceName(forLocalIP: localIP, in: interfaces) else { return receiverIP } + return "\(receiverIP)%\(name)" + } + + /// Human-readable context for a failed or timed-out connect attempt: + /// where we dialed, from which address/interface, over which transport, + /// and the last state reported by the network stack. + static func failureDetail( + receiverHost: String, + port: UInt16, + localIP: String, + interfaceName: String?, + transport: String, + lastNetworkState: String? + ) -> String { + var detail = "dialed \(receiverHost):\(port) from \(localIP)" + if let interfaceName, !interfaceName.isEmpty { + detail += " (\(interfaceName))" + } + detail += " [\(transport)]" + if let lastNetworkState, !lastNetworkState.isEmpty { + detail += " — last network state: \(lastNetworkState)" + } + return detail + } + + /// Snapshot of the machine's up, non-loopback IPv4 interfaces. + static func currentIPv4Interfaces() -> [LocalInterface] { + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0 else { return [] } + defer { freeifaddrs(ifaddr) } + + var interfaces: [LocalInterface] = [] + var pointer = ifaddr + while let iface = pointer { + defer { pointer = iface.pointee.ifa_next } + guard let sa = iface.pointee.ifa_addr, + sa.pointee.sa_family == UInt8(AF_INET) + else { continue } + let flags = Int32(iface.pointee.ifa_flags) + guard (flags & IFF_UP) != 0, (flags & IFF_LOOPBACK) == 0 else { continue } + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard getnameinfo( + sa, + socklen_t(sa.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST + ) == 0 else { continue } + interfaces.append(LocalInterface(name: String(cString: iface.pointee.ifa_name), ip: String(cString: buffer))) + } + return interfaces + } +} diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 72a9c43..e54ea6d 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -16,9 +16,11 @@ 4290B6572B8CA96E01C56425 /* TBDisplaySenderBuildInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */; }; 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */; }; 4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */; }; + 55163F49604063EA7E86D079 /* TBConnectionDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6EB41D253E67E350F5E25D /* TBConnectionDiagnostics.swift */; }; 5C0CB53005B6D67362F14719 /* TBDisplaySenderContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */; }; 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */; }; 6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */; }; + 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */; }; 7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; }; 7FB34AA1E75B01E7D1EC3E21 /* it.json in Resources */ = {isa = PBXBuildFile; fileRef = 036E14097FA59989FFC456FD /* it.json */; }; 8A729C68ECAC4642FA20A284 /* TBLocalizationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C932E960346E674A0659624 /* TBLocalizationStore.swift */; }; @@ -59,6 +61,7 @@ 1819A55F8D993D9347F0E407 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderContentView.swift; sourceTree = ""; }; 374B5A5AA247C6922BD9AF72 /* TBDisplaySenderAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderAboutView.swift; sourceTree = ""; }; + 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConnectionDiagnosticsTests.swift; sourceTree = ""; }; 4644CFB05098C895378B648A /* TBDisplaySenderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderManager.swift; sourceTree = ""; }; 4C932E960346E674A0659624 /* TBLocalizationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalizationStore.swift; sourceTree = ""; }; 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonManifest.swift; sourceTree = ""; }; @@ -82,6 +85,7 @@ DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderSurfaceViews.swift; sourceTree = ""; }; DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TBDisplaySenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBSenderAutomationParsingTests.swift; sourceTree = ""; }; + FD6EB41D253E67E350F5E25D /* TBConnectionDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConnectionDiagnostics.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -119,6 +123,7 @@ 578C8E4277884577DC1F2820 /* TBDisplaySenderTests */ = { isa = PBXGroup; children = ( + 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */, C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */, 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */, ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */, @@ -131,6 +136,7 @@ children = ( 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */, 06D011FF8AF7BB6D26EDC25B /* TBAddonStore.swift */, + FD6EB41D253E67E350F5E25D /* TBConnectionDiagnostics.swift */, 80543A7A5A3B583C1543ABC7 /* TBInputDebugLog.swift */, 4C932E960346E674A0659624 /* TBLocalizationStore.swift */, DA01317B43B46B1B323A7008 /* TBMonitorProtocol.swift */, @@ -303,6 +309,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */, 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */, 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */, 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */, @@ -316,6 +323,7 @@ 337BD561622AA19F43D3B95B /* ReceiverBackedVirtualDisplaySession.swift in Sources */, 4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */, E81B28F81AE82FF904E0871F /* TBAddonStore.swift in Sources */, + 55163F49604063EA7E86D079 /* TBConnectionDiagnostics.swift in Sources */, E4967472D5CB13242FE2F288 /* TBDisplaySenderAboutView.swift in Sources */, B9FA5989F04108E9FBFA8D3B /* TBDisplaySenderApp.swift in Sources */, 8D9D2A40A1CD90BD4D4DFC2C /* TBDisplaySenderAutomation.swift in Sources */,