Skip to content
Closed
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
45 changes: 45 additions & 0 deletions TargetBridge-Receiver/TBReceiverC/src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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') {
Expand Down
97 changes: 85 additions & 12 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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 = ""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 10 additions & 5 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down
46 changes: 41 additions & 5 deletions TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@ struct TBDiscoveredReceiver: Identifiable, Equatable {
init(
serviceName: String,
receiverName: String,
receiverID: String = "",
preferredIP: String,
thunderboltIP: String,
usbIP: String = "",
Expand All @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading