Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
79 changes: 75 additions & 4 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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()
Expand All @@ -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
)
Expand All @@ -1209,15 +1237,33 @@ 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()
self.sendInputControlModeUpdate()
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
Loading