diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index 2bdb0446..3005ebec 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -88,6 +88,7 @@ struct app { char net_ip_text[64]; char ethernet_ip_text[64]; char wifi_ip_text[64]; + char receiver_id[64]; char display_host[128]; /* short hostname (or hostname+IP), cached at startup */ char status_text[128]; char sender_text[128]; @@ -574,6 +575,41 @@ static void bonjour_deinit(struct app *a) { } } +static int tb_receiver_load_or_create_id(char *dest, size_t size, CFStringRef app_id) { + if (!dest || size == 0) return -1; + dest[0] = '\0'; + if (size < 37 || !app_id) return -1; + + CFStringRef key = CFSTR("receiverID"); + CFPropertyListRef stored = CFPreferencesCopyAppValue(key, app_id); + CFUUIDRef uuid = NULL; + if (stored && CFGetTypeID(stored) == CFStringGetTypeID() && + CFStringGetLength((CFStringRef)stored) == 36) { + uuid = CFUUIDCreateFromString(kCFAllocatorDefault, (CFStringRef)stored); + } + if (stored) CFRelease(stored); + + const Boolean needs_persistence = uuid == NULL; + if (!uuid) uuid = CFUUIDCreate(kCFAllocatorDefault); + if (!uuid) return -1; + CFStringRef value = CFUUIDCreateString(kCFAllocatorDefault, uuid); + CFRelease(uuid); + if (!value) return -1; + + const Boolean copied = CFStringGetCString(value, dest, (CFIndex)size, kCFStringEncodingUTF8); + Boolean persisted = true; + if (copied && dest[0] != '\0' && needs_persistence) { + CFPreferencesSetAppValue(key, value, app_id); + persisted = CFPreferencesAppSynchronize(app_id); + } + CFRelease(value); + if (!copied || !persisted) { + dest[0] = '\0'; + return -1; + } + return dest[0] != '\0' ? 0 : -1; +} + static void on_bonjour_register(DNSServiceRef sdRef, DNSServiceFlags flags, DNSServiceErrorType errorCode, @@ -599,6 +635,9 @@ static void bonjour_update(struct app *a, uint16_t port) { TXTRecordRef txt; TXTRecordCreate(&txt, 0, NULL); TXTRecordSetValue(&txt, "name", (uint8_t)strlen(a->bonjour_name), a->bonjour_name); + if (a->receiver_id[0] != '\0') { + TXTRecordSetValue(&txt, "receiverID", (uint8_t)strlen(a->receiver_id), a->receiver_id); + } TXTRecordSetValue(&txt, "ip", (uint8_t)strlen(a->ip_text), a->ip_text); if (a->tb_ip_text[0] != '\0') { TXTRecordSetValue(&txt, "tbIP", (uint8_t)strlen(a->tb_ip_text), a->tb_ip_text); @@ -1960,6 +1999,12 @@ int main(int argc, char **argv) { memset(&a, 0, sizeof(a)); a.server_fd = -1; a.client_fd = -1; + if (tb_receiver_load_or_create_id(a.receiver_id, sizeof(a.receiver_id), + CFSTR("com.targetbridge.receiver")) == 0) { + fprintf(stderr, "[identity] receiver ID ready\n"); + } else { + fprintf(stderr, "[identity] unable to persist receiver ID; using Bonjour name fallback\n"); + } { char host[96] = {0}; if (gethostname(host, sizeof(host)) != 0 || host[0] == '\0') { diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 2b2eab72..7892d379 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -193,9 +193,24 @@ enum TBSenderAutomation { } let receiver = params["receiver"].flatMap { $0.isEmpty ? nil : $0 } ?? "auto" + let discoveredReceivers = await waitForReceivers( + service, + preferredIdentity: receiver.lowercased() == "auto" + ? session.selectedReceiverID + : receiver + ) if receiver.lowercased() == "auto" { - guard let discovered = await waitForReceiver(service) else { - NSLog("[automation] no receivers discovered; aborting connect") + guard let discovered = automaticReceiver( + in: discoveredReceivers, + preferredIdentity: session.selectedReceiverID + ) else { + if !session.selectedReceiverID.isEmpty { + NSLog("[automation] preferred monitor unavailable or ambiguous; waiting for that monitor") + } else if discoveredReceivers.count > 1 { + NSLog("[automation] multiple receivers discovered and no preferred monitor is selected; waiting for user choice") + } else { + NSLog("[automation] no receivers discovered; aborting connect") + } return nil } if let pathPreference { @@ -211,8 +226,7 @@ enum TBSenderAutomation { } else { service.applyDiscoveredReceiver(discovered, to: session) } - session.selectedReceiverID = discovered.id - } else if let discovered = service.discoveredReceivers.first(where: { matches(receiver, $0) }) { + } else if let discovered = discoveredReceivers.first(where: { matches(receiver, $0) }) { if let pathPreference { guard let selected = await selectConnectionPath( receiver: discovered, @@ -226,8 +240,11 @@ enum TBSenderAutomation { } else { service.applyDiscoveredReceiver(discovered, to: session) } - session.selectedReceiverID = discovered.id } else { + guard !isPersistedReceiverReference(receiver) else { + NSLog("[automation] requested monitor identity is not available; aborting connect") + return nil + } // Treat as a raw IP / hostname (bypasses Bonjour). session.receiverIP = receiver session.selectedReceiverID = "" @@ -329,13 +346,69 @@ enum TBSenderAutomation { return nil } - /// Discovery is async (Bonjour); briefly wait for the first receiver to appear. - private static func waitForReceiver(_ service: TBDisplaySenderService) async -> TBDiscoveredReceiver? { - for _ in 0..<20 { - if let first = service.discoveredReceivers.first { return first } - try? await Task.sleep(nanoseconds: 300_000_000) + /// Bonjour results arrive incrementally. Waiting for a short settled window + /// prevents `auto` from connecting to whichever of several displays happens + /// to advertise first. A previously selected stable identity may return + /// immediately because it is unambiguous. + private static func waitForReceivers( + _ service: TBDisplaySenderService, + preferredIdentity: String + ) async -> [TBDiscoveredReceiver] { + var lastSignature: [String] = [] + var unchangedIterations = 0 + var firstNonEmptyIteration: Int? + + for iteration in 0..<24 { + let receivers = service.discoveredReceivers + if !preferredIdentity.isEmpty, + receivers.contains(where: { $0.matchesPersistedIdentity(preferredIdentity) }) { + return receivers + } + + if !receivers.isEmpty { + if firstNonEmptyIteration == nil { firstNonEmptyIteration = iteration } + let signature = receivers + .map { "\($0.stableIdentity)|\($0.preferredIP)" } + .sorted() + if signature == lastSignature { + unchangedIterations += 1 + } else { + lastSignature = signature + unchangedIterations = 0 + } + + if let firstNonEmptyIteration, + iteration - firstNonEmptyIteration >= 6, + unchangedIterations >= 3 { + return receivers + } + } + try? await Task.sleep(nanoseconds: 250_000_000) } - return service.discoveredReceivers.first + return service.discoveredReceivers + } + + /// Automatic startup is safe only when the preferred display is present or + /// exactly one Receiver exists and no preference was saved. With multiple unpaired displays, returning + /// nil is intentional: silently choosing the first one would send the user's + /// desktop to the wrong Mac. + static func automaticReceiver( + in receivers: [TBDiscoveredReceiver], + preferredIdentity: String + ) -> TBDiscoveredReceiver? { + if !preferredIdentity.isEmpty { + let preferred = receivers.filter { + $0.matchesPersistedIdentity(preferredIdentity) + } + return preferred.count == 1 ? preferred[0] : nil + } + return receivers.count == 1 ? receivers[0] : nil + } + + static func isPersistedReceiverReference(_ value: String) -> Bool { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value.hasPrefix("receiver:") || value.hasPrefix("service:") || + value.contains("|") || UUID(uuidString: value) != nil } private static func selectConnectionPath( @@ -425,7 +498,7 @@ enum TBSenderAutomation { // 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.matchesPersistedIdentity(needle) { return true } if receiver.receiverName.lowercased() == needle { return true } if let host = receiver.shortHostName?.lowercased(), host == needle { return true } return receiver.preferredIP.lowercased() == needle diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index 0d4e2fcb..15c35309 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -474,6 +474,7 @@ final class TBDisplaySenderService: ObservableObject { } func applyDiscoveredReceiver(_ receiver: TBDiscoveredReceiver, to session: TBDisplaySenderSession) { + session.selectedReceiverID = receiver.stableIdentity session.receiverIP = receiver.ip(for: session.transportKind) session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode if session.localInterfaceIP.isEmpty { @@ -493,16 +494,13 @@ final class TBDisplaySenderService: ObservableObject { !session.selectedReceiverID.isEmpty && !session.isConnected && !session.isStreaming { - let savedServiceName = String( - session.selectedReceiverID.split(separator: "|", maxSplits: 1).first ?? "" - ) guard let receiver = receivers.first(where: { - $0.id == session.selectedReceiverID || $0.serviceName == savedServiceName + $0.matchesPersistedIdentity(session.selectedReceiverID) }) else { continue } - session.selectedReceiverID = receiver.id + session.selectedReceiverID = receiver.stableIdentity session.receiverIP = receiver.ip(for: session.transportKind) session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode } @@ -570,7 +568,9 @@ final class TBDisplaySenderService: ObservableObject { func transportDidChange(for session: TBDisplaySenderSession) { session.localInterfaceIP = defaultLocalInterfaceIP(for: session.transportKind) - if let receiver = discoveredReceivers.first(where: { $0.id == session.selectedReceiverID }) { + if let receiver = discoveredReceivers.first(where: { + $0.matchesPersistedIdentity(session.selectedReceiverID) + }) { session.receiverIP = receiver.ip(for: session.transportKind) } objectWillChange.send() diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 44793461..de4f1f32 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1082,7 +1082,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } var shortHostName: String? { if let receiver = TBDisplaySenderService.shared.discoveredReceivers.first(where: { - $0.id == selectedReceiverID || + $0.matchesPersistedIdentity(selectedReceiverID) || $0.preferredIP == receiverIP || $0.thunderboltIP == receiverIP || $0.networkIP == receiverIP @@ -1767,7 +1767,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // changes. Using the address here made macOS forget a receiver's virtual // display identity and saved placement after a wake or reconnect. if let receiver = TBDisplaySenderService.shared.discoveredReceivers.first(where: { - $0.id == selectedReceiverID || + $0.matchesPersistedIdentity(selectedReceiverID) || $0.preferredIP == receiverIP || $0.thunderboltIP == receiverIP || $0.networkIP == receiverIP @@ -1776,9 +1776,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } let selectedID = selectedReceiverID.trimmingCharacters(in: .whitespacesAndNewlines) - if let serviceName = selectedID.split(separator: "|", maxSplits: 1).first, - !serviceName.isEmpty { - return "service:\(serviceName)" + if !selectedID.isEmpty { + if selectedID.hasPrefix("receiver:") || selectedID.hasPrefix("service:") { + return selectedID + } + if let serviceName = selectedID.split(separator: "|", maxSplits: 1).first, + !serviceName.isEmpty { + return "service:\(serviceName)" + } } if let host = shortHostName?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift index 4e59de86..0e609be8 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift @@ -3,6 +3,7 @@ import Foundation struct TBDiscoveredReceiver: Identifiable, Equatable { let serviceName: String let receiverName: String + let receiverID: String let preferredIP: String let thunderboltIP: String let usbIP: String @@ -18,6 +19,7 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { init( serviceName: String, receiverName: String, + receiverID: String = "", preferredIP: String, thunderboltIP: String, usbIP: String = "", @@ -32,6 +34,7 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { ) { self.serviceName = serviceName self.receiverName = receiverName + self.receiverID = UUID(uuidString: receiverID)?.uuidString ?? "" self.preferredIP = preferredIP self.thunderboltIP = thunderboltIP self.usbIP = usbIP @@ -45,11 +48,37 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { self.hostName = hostName } - var id: String { "\(serviceName)|\(preferredIP)" } + /// SwiftUI selection and persisted sessions both use this stable identity. + /// New receivers advertise a UUID; older receivers fall back to their + /// Bonjour service name so existing installations remain selectable. + var id: String { stableIdentity } - /// Bonjour service names survive link-local IP address changes after wake. - /// Use this only for persisted receiver preferences, not Picker selection. - var stableIdentity: String { "service:\(serviceName)" } + var legacyID: String { "\(serviceName)|\(preferredIP)" } + + /// Stable across network changes, and across renaming for UUID receivers. + var stableIdentity: String { + receiverID.isEmpty ? "service:\(serviceName)" : "receiver:\(receiverID)" + } + + /// Accept the stable UUID, the previous service-name identity, and the + /// oldest `service|ip` picker value. This migrates saved sessions without + /// ever treating a changed IP as a different physical display. + func matchesPersistedIdentity(_ value: String) -> Bool { + let needle = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !needle.isEmpty else { return false } + + if needle == id.lowercased() || + needle == stableIdentity.lowercased() || + needle == legacyID.lowercased() || + needle == receiverID.lowercased() || + needle == serviceName.lowercased() || + needle == "service:\(serviceName)".lowercased() { + return true + } + + let legacyServiceName = needle.split(separator: "|", maxSplits: 1).first.map(String.init) ?? "" + return !legacyServiceName.isEmpty && legacyServiceName == serviceName.lowercased() + } var shortHostName: String? { guard let host = hostName, !host.isEmpty else { return nil } @@ -158,6 +187,7 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { } let receiverName = stringValue("name").isEmpty ? service.name : stringValue("name") + let receiverID = stringValue("receiverID") let receiverIP = stringValue("ip") let thunderboltIP = stringValue("tbIP") let usbIP = stringValue("usbIP") @@ -194,6 +224,7 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { let receiver = TBDiscoveredReceiver( serviceName: service.name, receiverName: receiverName, + receiverID: receiverID, preferredIP: preferredIP, thunderboltIP: thunderboltIP, usbIP: usbIP, @@ -207,7 +238,12 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { hostName: service.hostName ) - if let index = receivers.firstIndex(where: { $0.serviceName == receiver.serviceName }) { + if let index = receivers.firstIndex(where: { + if !receiver.receiverID.isEmpty && !$0.receiverID.isEmpty { + return $0.receiverID == receiver.receiverID + } + return $0.serviceName == receiver.serviceName + }) { receivers[index] = receiver } else { receivers.append(receiver) diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift index 0e90d79a..11e4b16e 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift @@ -10,6 +10,7 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { private func makeReceiver( serviceName: String = "TargetBridge Test-iMac", receiverName: String = "Test-iMac", + receiverID: String = "", preferredIP: String = "192.168.1.64", thunderboltIP: String = "", usbIP: String = "", @@ -22,6 +23,7 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { TBDiscoveredReceiver( serviceName: serviceName, receiverName: receiverName, + receiverID: receiverID, preferredIP: preferredIP, thunderboltIP: thunderboltIP, usbIP: usbIP, @@ -81,21 +83,62 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { // MARK: - Identity - func testIDCombinesServiceNameAndPreferredIP() { + func testLegacyIDCombinesServiceNameAndPreferredIP() { let receiver = makeReceiver(serviceName: "TargetBridge Jonathans-iMac", preferredIP: "192.168.1.64") - XCTAssertEqual(receiver.id, "TargetBridge Jonathans-iMac|192.168.1.64") + XCTAssertEqual(receiver.legacyID, "TargetBridge Jonathans-iMac|192.168.1.64") + XCTAssertEqual(receiver.id, "service:TargetBridge Jonathans-iMac") } func testStableIdentityDoesNotChangeWhenTheReceiverIPChanges() { - let beforeWake = makeReceiver(serviceName: "TargetBridge Jonathans-iMac", preferredIP: "169.254.89.80") - let afterWake = makeReceiver(serviceName: "TargetBridge Jonathans-iMac", preferredIP: "169.254.12.44") + let beforeWake = makeReceiver( + serviceName: "TargetBridge Jonathans-iMac", + receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906", + preferredIP: "169.254.89.80" + ) + let afterWake = makeReceiver( + serviceName: "TargetBridge Jonathans-iMac (2)", + receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906", + preferredIP: "169.254.12.44" + ) - XCTAssertNotEqual(beforeWake.id, afterWake.id) + XCTAssertEqual(beforeWake.id, afterWake.id) XCTAssertEqual(beforeWake.stableIdentity, afterWake.stableIdentity) } + func testDifferentReceiversWithTheSameNameRemainDistinct() { + let first = makeReceiver(receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906") + let second = makeReceiver(receiverID: "B4E28721-22A7-42A9-89D7-70F3DBB0E906") + + XCTAssertNotEqual(first.id, second.id) + } + + func testPersistedIdentityMigratesLegacyServiceAndIPValue() { + let receiver = makeReceiver( + serviceName: "TargetBridge Jonathans-iMac", + receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906", + preferredIP: "169.254.12.44" + ) + + XCTAssertTrue(receiver.matchesPersistedIdentity("receiver:a4e28721-22a7-42a9-89d7-70f3dbb0e906")) + XCTAssertTrue(receiver.matchesPersistedIdentity("A4E28721-22A7-42A9-89D7-70F3DBB0E906")) + XCTAssertTrue(receiver.matchesPersistedIdentity("service:TargetBridge Jonathans-iMac")) + XCTAssertTrue(receiver.matchesPersistedIdentity("TargetBridge Jonathans-iMac|169.254.89.80")) + XCTAssertFalse(receiver.matchesPersistedIdentity("TargetBridge Other-iMac|169.254.89.80")) + } + // MARK: - shortHostName + func testMalformedAdvertisedIDFallsBackToLegacyIdentity() { + XCTAssertEqual(makeReceiver(receiverID: "not-a-uuid").id, + "service:TargetBridge Test-iMac") + } + + func testAdvertisedUUIDCaseDoesNotChangePickerIdentity() { + let uuid = "A4E28721-22A7-42A9-89D7-70F3DBB0E906" + XCTAssertEqual(makeReceiver(receiverID: uuid).id, + makeReceiver(receiverID: uuid.lowercased()).id) + } + func testShortHostNameStripsTrailingDotAndDomain() { let receiver = makeReceiver(hostName: "Jonathans-iMac.local.") XCTAssertEqual(receiver.shortHostName, "Jonathans-iMac") diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index 36d8c9c2..33f31b9c 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -267,6 +267,7 @@ final class TBSenderAutomationParsingTests: XCTestCase { TBDiscoveredReceiver( serviceName: "TargetBridge Jonathans-iMac", receiverName: "Jonathans-iMac", + receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906", preferredIP: "192.168.1.64", thunderboltIP: "169.254.89.80", usbIP: "169.254.189.3", @@ -299,6 +300,8 @@ final class TBSenderAutomationParsingTests: XCTestCase { } func testMatchesByID() { + XCTAssertTrue(TBSenderAutomation.matches("receiver:A4E28721-22A7-42A9-89D7-70F3DBB0E906", makeReceiver())) + XCTAssertTrue(TBSenderAutomation.matches("A4E28721-22A7-42A9-89D7-70F3DBB0E906", makeReceiver())) XCTAssertTrue(TBSenderAutomation.matches("targetbridge jonathans-imac|192.168.1.64", makeReceiver())) } @@ -307,7 +310,80 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertFalse(TBSenderAutomation.matches("10.0.0.1", makeReceiver())) } + func testAutomaticReceiverUsesPreferredStableIdentityAmongSeveral() { + let preferred = makeReceiver() + let other = TBDiscoveredReceiver( + serviceName: "TargetBridge Other-iMac", + receiverName: "Other-iMac", + receiverID: "other-receiver", + preferredIP: "192.168.1.70", + thunderboltIP: "", + networkIP: "192.168.1.70", + panelSummary: "iMac 4K", + version: "3.5.2", + supportsHEVCDecode: true, + hostName: "Other-iMac.local." + ) + + XCTAssertEqual( + TBSenderAutomation.automaticReceiver( + in: [other, preferred], + preferredIdentity: preferred.stableIdentity + ), + preferred + ) + } + + func testAutomaticReceiverUsesOnlyReceiverWithoutPreference() { + let receiver = makeReceiver() + XCTAssertEqual( + TBSenderAutomation.automaticReceiver(in: [receiver], preferredIdentity: ""), + receiver + ) + } + + func testAutomaticReceiverRefusesAmbiguousFirstChoice() { + let first = makeReceiver() + let second = TBDiscoveredReceiver( + serviceName: "TargetBridge Other-iMac", + receiverName: "Other-iMac", + receiverID: "other-receiver", + preferredIP: "192.168.1.70", + thunderboltIP: "", + networkIP: "192.168.1.70", + panelSummary: "iMac 4K", + version: "3.5.2", + supportsHEVCDecode: true, + hostName: "Other-iMac.local." + ) + + XCTAssertNil( + TBSenderAutomation.automaticReceiver(in: [first, second], preferredIdentity: "") + ) + } + // MARK: - resolveSessionIndex tri-state + + func testAutomaticReceiverDoesNotReplaceMissingPreferredMonitor() { + XCTAssertNil(TBSenderAutomation.automaticReceiver( + in: [makeReceiver()], preferredIdentity: "receiver:B4E28721-22A7-42A9-89D7-70F3DBB0E906")) + } + + func testAutomaticReceiverRejectsAmbiguousLegacyPreference() { + let first = makeReceiver() + XCTAssertNil(TBSenderAutomation.automaticReceiver( + in: [first, first], preferredIdentity: "service:\(first.serviceName)")) + } + + func testMissingIdentityDoesNotBecomeRawHostname() { + for value in ["receiver:unknown", "service:TargetBridge iMac", "iMac|169.254.1.2", + "A4E28721-22A7-42A9-89D7-70F3DBB0E906"] { + XCTAssertTrue(TBSenderAutomation.isPersistedReceiverReference(value)) + } + for value in ["iMac.local", "192.168.1.64", "fe80::1234"] { + XCTAssertFalse(TBSenderAutomation.isPersistedReceiverReference(value)) + } + } // // Returns `nil` = invalid input, `.some(nil)` = target all sessions, // `.some(index)` = zero-based session index. diff --git a/TargetBridge-Sender/scripts/test_receiver_identity.rb b/TargetBridge-Sender/scripts/test_receiver_identity.rb new file mode 100644 index 00000000..111eaf74 --- /dev/null +++ b/TargetBridge-Sender/scripts/test_receiver_identity.rb @@ -0,0 +1,89 @@ +#!/usr/bin/env ruby +# macOS + full Xcode/XCTest. No app is launched and no capture/input permission +# is requested. Extract pure production code; do not duplicate its logic here. +require 'tmpdir' +require 'fileutils' +root = File.expand_path('..', __dir__) +def section(source, first, last) + start = source.index(first) or abort "Missing section: #{first}" + stop = source.index(last, start) or abort "Missing boundary: #{last}" + source[start...stop] +end +Dir.mktmpdir('tb-receiver-identity.') do |dir| + sources = File.join(dir, 'Sources/TargetBridge') + tests = File.join(dir, 'Tests/TargetBridgeTests') + FileUtils.mkdir_p([sources, tests]) + discovery = File.read(File.join(root, 'TBDisplaySender/TBReceiverDiscovery.swift')) + model = section(discovery, 'import Foundation', 'final class TBReceiverDiscovery:') + manager = File.read(File.join(root, 'TBDisplaySender/TBDisplaySenderManager.swift')) + transport = section(manager, 'enum TBTransportKind:', ' func title(') + "}\n" + automation = File.read(File.join(root, 'TBDisplaySender/TBDisplaySenderAutomation.swift')) + selectors = section(automation, ' static func automaticReceiver(', ' private static func selectConnectionPath(') + matching = section(automation, ' static func matches(', ' static func parseTransport(') + File.write(File.join(sources, 'Identity.swift'), model + transport + + "\nenum TBSenderAutomation {\n" + selectors + matching + "}\n") + FileUtils.cp(File.join(root, 'TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift'), tests) + parsing = File.read(File.join(root, 'TBDisplaySenderTests/TBSenderAutomationParsingTests.swift')) + cases = section(parsing, ' private func makeReceiver()', ' func testNoSessionParamTargetsAllSessionsWhenNotCreating()') + File.write(File.join(tests, 'SelectionTests.swift'), + "import XCTest\n@testable import TargetBridge\nfinal class SelectionTests: XCTestCase {\n" + cases + "}\n") + File.write(File.join(dir, 'Package.swift'), <<~'SWIFT') + // swift-tools-version: 5.9 + import PackageDescription + let package = Package(name: "ReceiverIdentityTests", platforms: [.macOS(.v14)], + targets: [.target(name: "TargetBridge"), + .testTarget(name: "TargetBridgeTests", dependencies: ["TargetBridge"])]) + SWIFT + abort 'Identity/selection tests failed' unless system('swift', 'test', '--package-path', dir, + '--scratch-path', File.join(dir, 'build'), '--cache-path', File.join(dir, 'cache'), + '--config-path', File.join(dir, 'config'), '--security-path', File.join(dir, 'security'), + '--disable-sandbox') + + # Exercise the exact persistence function in a unique disposable preferences + # domain, never com.targetbridge.receiver or the running app's preferences. + main = File.read(File.join(root, '../TargetBridge-Receiver/TBReceiverC/src/main.c')) + persist = section(main, 'static int tb_receiver_load_or_create_id(', 'static void on_bonjour_register(') + harness = <<~'C' + #include + #include + #include + #include + #include + #include + C + harness += persist + harness += <<~'C' + int main(void) { + CFStringRef domain = CFStringCreateWithFormat(NULL, NULL, + CFSTR("org.targetbridge.tests.identity.%d"), getpid()); + char first[64], second[64]; + assert(tb_receiver_load_or_create_id(first, sizeof(first), domain) == 0); + assert(strlen(first) == 36); + assert(tb_receiver_load_or_create_id(second, sizeof(second), domain) == 0); + assert(strcmp(first, second) == 0); + CFPreferencesSetAppValue(CFSTR("receiverID"), CFSTR("not-a-uuid"), domain); + assert(tb_receiver_load_or_create_id(second, sizeof(second), domain) == 0); + assert(strlen(second) == 36 && strcmp(first, second) != 0); + CFPreferencesSetAppValue(CFSTR("receiverID"), kCFBooleanTrue, domain); + assert(tb_receiver_load_or_create_id(first, sizeof(first), domain) == 0); + assert(strlen(first) == 36); + CFPreferencesSetAppValue(CFSTR("receiverID"), + CFSTR("a4e28721-22a7-42a9-89d7-70f3dbb0e906"), domain); + assert(tb_receiver_load_or_create_id(first, sizeof(first), domain) == 0); + assert(strcmp(first, "A4E28721-22A7-42A9-89D7-70F3DBB0E906") == 0); + assert(tb_receiver_load_or_create_id(second, 2, domain) == -1 && second[0] == 0); + assert(tb_receiver_load_or_create_id(NULL, 64, domain) == -1); + assert(tb_receiver_load_or_create_id(second, sizeof(second), NULL) == -1); + CFPreferencesSetAppValue(CFSTR("receiverID"), NULL, domain); + assert(CFPreferencesAppSynchronize(domain)); + CFRelease(domain); + puts("PASS: Receiver UUID creation, persistence, normalization, malformed/type recovery and argument guards"); + } + C + c_path = File.join(dir, 'identity.c') + binary = File.join(dir, 'identity-test') + File.write(c_path, harness) + abort 'Persistence test build failed' unless system('xcrun', 'clang', '-Wall', '-Wextra', '-Werror', + c_path, '-framework', 'CoreFoundation', '-o', binary) + abort 'Persistence test failed' unless system(binary) +end