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..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 @@ -1584,7 +1630,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. + 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) + } + } + + private func drainPacketsOrThrow() throws { + while let (type, payload) = try TBMonitorProtocol.drainPacket(from: &recvBuffer) { switch type { case .displayProfile: handleDisplayProfile(payload) @@ -2762,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/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/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/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