From 1037c81fd8a63130f491579a2e6d27cef3585fc0 Mon Sep 17 00:00:00 2001 From: Betafer Date: Sun, 30 Aug 2026 22:26:24 +0200 Subject: [PATCH 01/20] feat(discovery): remember receivers independently of IP --- TargetBridge-Receiver/TBReceiverC/src/main.c | 45 +++++++++ .../TBDisplaySenderAutomation.swift | 97 ++++++++++++++++--- .../TBDisplaySenderManager.swift | 12 +-- .../TBDisplaySenderService.swift | 15 ++- .../TBDisplaySender/TBReceiverDiscovery.swift | 46 ++++++++- .../TBReceiverDiscoveryModelTests.swift | 53 +++++++++- .../TBSenderAutomationParsingTests.swift | 76 +++++++++++++++ .../scripts/test_receiver_identity.rb | 89 +++++++++++++++++ 8 files changed, 400 insertions(+), 33 deletions(-) create mode 100644 TargetBridge-Sender/scripts/test_receiver_identity.rb diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index 377cf184..43b9edfd 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); @@ -1968,6 +2007,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 53bf1712..ec8c23b9 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1100,7 +1100,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 @@ -1774,7 +1774,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 @@ -1783,9 +1783,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 From a49ca1ae69bd22e6b1759a088a93a78dd6e40c25 Mon Sep 17 00:00:00 2001 From: Betafer Date: Fri, 21 Aug 2026 00:48:11 +0200 Subject: [PATCH 02/20] feat(sender): add safe per-receiver codec selection --- .../TBDisplaySenderContentView.swift | 26 ++++ .../TBDisplaySenderLocalization.swift | 22 +++ .../TBDisplaySenderManager.swift | 55 ++++++- .../TBDisplaySenderService.swift | 138 +++++++++++++++--- .../TBSenderAutomationParsingTests.swift | 92 ++++++++++++ TargetBridge-Shared/Languages/de.json | 8 + TargetBridge-Shared/Languages/en.json | 8 + TargetBridge-Shared/Languages/fr.json | 8 + TargetBridge-Shared/Languages/it.json | 8 + TargetBridge-Shared/Languages/zh.json | 8 + 10 files changed, 352 insertions(+), 21 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index de97d340..8ec0b314 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -615,6 +615,32 @@ private struct TBDisplaySenderSessionSettingsSheet: View { .disabled(session.isConnected || session.isStreaming) } + settingRow( + TBDisplaySenderL10n.videoCompression(service.language), + details: TBDisplaySenderL10n.codecPreferenceHint( + session.codecPreference, + language: service.language + ) + ) { + Picker( + TBDisplaySenderL10n.videoCompression(service.language), + selection: Binding( + get: { session.codecPreference }, + set: { service.setCodecPreference($0, for: session) } + ) + ) { + ForEach(TBDisplayCodecPreference.allCases) { preference in + Text(TBDisplaySenderL10n.codecPreferenceTitle( + preference, + language: service.language + )) + .tag(preference) + } + } + .pickerStyle(.menu) + .disabled(session.isConnected || session.isStreaming) + } + if session.captureSource == .extendedDesktop { settingRow(renderMatchingTitle, details: renderMatchingDetails) { Toggle("", isOn: $session.matchRenderToStream) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index 2f5fe16d..d32d2107 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -488,6 +488,28 @@ enum TBDisplaySenderL10n { ]) } + static func videoCompression(_ language: TBDisplaySenderLanguage) -> String { + text("sender.codec.label", language) + } + + static func codecPreferenceTitle( + _ preference: TBDisplayCodecPreference, + language: TBDisplaySenderLanguage + ) -> String { + text("sender.codec.\(preference.rawValue)", language) + } + + static func codecPreferenceHint( + _ preference: TBDisplayCodecPreference, + language: TBDisplaySenderLanguage + ) -> String { + text("sender.codec.hint.\(preference.rawValue)", language) + } + + static func codecUnavailable(_ language: TBDisplaySenderLanguage) -> String { + text("sender.codec.unavailable", language) + } + static func missingScreenRecordingPermission(language: TBDisplaySenderLanguage) -> String { text("sender.error.screen_recording_permission", language) } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index 15c35309..ed31c61a 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -222,7 +222,9 @@ final class TBDisplaySenderService: ObservableObject { receiverAddress: session.receiverIP.trimmingCharacters(in: .whitespacesAndNewlines), receiverProfileAvailable: session.receiverSupportsHEVCDecodeHint != nil, receiverSupportsHEVC: session.receiverSupportsHEVCDecodeHint, - requiresHEVC: session.capturePreset.codecName == "HEVC", + requiresHEVC: session.codecPreference == .hevc || ( + session.codecPreference == .automatic && session.capturePreset.codecName == "HEVC" + ), cableRate: session.cableTestResult, requiresSenderInputMonitoring: role == .senderMaster, senderInputMonitoringGranted: localInputMonitoringTrusted, @@ -317,6 +319,7 @@ final class TBDisplaySenderService: ObservableObject { private static let persistedSessionsKey = "fd.tbdisplaysender.sessions.v1" private static let receiverDisplayProfilesKey = "fd.tbdisplaysender.receiverDisplayProfiles.v1" + private static let receiverCodecPreferencesKey = "fd.tbdisplaysender.receiverCodecPreferences.v1" /// Earlier builds persisted `false` when the audio addon had not finished /// loading. Repair that ambiguous state exactly once without making future /// deliberate choices reversible. @@ -342,6 +345,7 @@ final class TBDisplaySenderService: ObservableObject { var inputControlRole: String? var inputBindings: [TBInputBinding]? var matchRenderToStream: Bool? + var codecPreference: String? } private var lastPersistedData: Data? @@ -376,7 +380,8 @@ final class TBDisplaySenderService: ObservableObject { volume: session.volume, inputControlRole: session.inputControlRole.rawValue, inputBindings: session.inputBindings, - matchRenderToStream: session.matchRenderToStream + matchRenderToStream: session.matchRenderToStream, + codecPreference: session.codecPreference.rawValue ) } guard let data = try? JSONEncoder().encode(configs) else { return } @@ -464,6 +469,9 @@ final class TBDisplaySenderService: ObservableObject { session.brightness = config.brightness session.volume = config.volume ?? 0.5 session.matchRenderToStream = config.matchRenderToStream ?? false + session.codecPreference = config.codecPreference + .flatMap(TBDisplayCodecPreference.init(rawValue:)) + ?? .automatic } func refreshLocalInterfaces() { @@ -477,6 +485,7 @@ final class TBDisplaySenderService: ObservableObject { session.selectedReceiverID = receiver.stableIdentity session.receiverIP = receiver.ip(for: session.transportKind) session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + restoreCodecPreference(for: receiver, to: session, defaultToAutomatic: true) if session.localInterfaceIP.isEmpty { session.localInterfaceIP = suggestedInterfaceForNewSession(transportKind: session.transportKind)?.ip ?? availableInterfaces(for: session.transportKind).first?.ip @@ -503,6 +512,48 @@ final class TBDisplaySenderService: ObservableObject { session.selectedReceiverID = receiver.stableIdentity session.receiverIP = receiver.ip(for: session.transportKind) session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + restoreCodecPreference(for: receiver, to: session, defaultToAutomatic: false) + } + } + + func setCodecPreference(_ preference: TBDisplayCodecPreference, for session: TBDisplaySenderSession) { + guard !session.isConnected, !session.isStreaming else { return } + session.codecPreference = preference + guard let key = receiverCodecPreferenceKey(for: session) else { return } + var preferences = persistedCodecPreferences + preferences[key] = preference.rawValue + UserDefaults.standard.set(preferences, forKey: Self.receiverCodecPreferencesKey) + } + + private var persistedCodecPreferences: [String: String] { + UserDefaults.standard.dictionary(forKey: Self.receiverCodecPreferencesKey) as? [String: String] ?? [:] + } + + private func receiverCodecPreferenceKey(for receiver: TBDiscoveredReceiver) -> String { + "service:\(receiver.serviceName)" + } + + private func receiverCodecPreferenceKey(for session: TBDisplaySenderSession) -> String? { + let selectedID = session.selectedReceiverID.trimmingCharacters(in: .whitespacesAndNewlines) + if let serviceName = selectedID.split(separator: "|", maxSplits: 1).first, + !serviceName.isEmpty { + return "service:\(serviceName)" + } + + let receiverIP = session.receiverIP.trimmingCharacters(in: .whitespacesAndNewlines) + return receiverIP.isEmpty ? nil : "ip:\(receiverIP)" + } + + private func restoreCodecPreference( + for receiver: TBDiscoveredReceiver, + to session: TBDisplaySenderSession, + defaultToAutomatic: Bool + ) { + let rawValue = persistedCodecPreferences[receiverCodecPreferenceKey(for: receiver)] + if let rawValue, let preference = TBDisplayCodecPreference(rawValue: rawValue) { + session.codecPreference = preference + } else if defaultToAutomatic { + session.codecPreference = .automatic } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index ec8c23b9..bf4e86d1 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -330,6 +330,19 @@ struct TBFrameRatePacer { } } +enum TBDisplayCodecPreference: String, CaseIterable, Identifiable, Codable { + case automatic + case h264 + case hevc + + var id: String { rawValue } +} + +struct TBDisplayCodecDecision: Equatable { + let codecType: CMVideoCodecType + let usedFallback: Bool +} + enum TBDisplayCaptureSource: String, CaseIterable, Identifiable { case desktopMirror case extendedDesktop @@ -1196,6 +1209,17 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } } + @Published var codecPreference: TBDisplayCodecPreference = .automatic { + didSet { + if !isStreaming { + streamResolutionText = TBDisplaySenderL10n.streamSummary( + preset: capturePreset, + source: captureSource, + language: language + ) + } + } + } @Published var captureSource: TBDisplayCaptureSource = .desktopMirror { didSet { if !isStreaming { @@ -1287,7 +1311,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u /// While a binding trigger key is held (matched), swallow its key-up so the /// raw trigger key never reaches the slave. private var suppressedTriggerKeyCode: UInt16? - private static var cachedSupportsHEVCHardwareEncode: Bool? + private static var cachedHardwareEncoderSupport: [String: Bool] = [:] private var receivedInputEventCount: UInt64 = 0 var onRemoteSwitchRequest: ((Int) -> Void)? var onRemoteDeactivateInputRequest: (() -> Void)? @@ -1339,9 +1363,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u statusText = state.text(language) } - private static func probeHEVCHardwareEncoderSupport() -> Bool { - if let cachedSupportsHEVCHardwareEncode { - return cachedSupportsHEVCHardwareEncode + private static func probeHardwareEncoderSupport( + codecType: CMVideoCodecType, + width: Int, + height: Int + ) -> Bool { + let cacheKey = "\(codecType)-\(width)x\(height)" + if let cached = cachedHardwareEncoderSupport[cacheKey] { + return cached } let encoderSpecification: CFDictionary = [ @@ -1352,9 +1381,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u var session: VTCompressionSession? let status = VTCompressionSessionCreate( allocator: kCFAllocatorDefault, - width: 1920, - height: 1080, - codecType: kCMVideoCodecType_HEVC, + width: Int32(width), + height: Int32(height), + codecType: codecType, encoderSpecification: encoderSpecification, imageBufferAttributes: nil, compressedDataAllocator: nil, @@ -1367,21 +1396,74 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } let supported = status == noErr - cachedSupportsHEVCHardwareEncode = supported + cachedHardwareEncoderSupport[cacheKey] = supported return supported } - private func resolvedCodecType(for preset: TBDisplayCapturePreset, profile: TBMonitorDisplayProfile?) -> CMVideoCodecType { - switch preset { - case .standard1440p, .smooth1440p60, .smooth1800p60: - let receiverSupportsHEVC = profile?.supportsHEVCDecode ?? receiverSupportsHEVCDecodeHint ?? false - if receiverSupportsHEVC, Self.probeHEVCHardwareEncoderSupport() { - return kCMVideoCodecType_HEVC - } - return kCMVideoCodecType_H264 - case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: - return preset.codecType + static func chooseCodec( + preference: TBDisplayCodecPreference, + preset: TBDisplayCapturePreset, + receiverSupportsHEVC: Bool, + senderSupportsH264: Bool, + senderSupportsHEVC: Bool + ) -> TBDisplayCodecDecision? { + let h264Available = senderSupportsH264 + let hevcAvailable = receiverSupportsHEVC && senderSupportsHEVC + let preferredCodec: CMVideoCodecType + + switch preference { + case .automatic: + preferredCodec = preset.codecType == kCMVideoCodecType_HEVC || hevcAvailable + ? kCMVideoCodecType_HEVC + : kCMVideoCodecType_H264 + case .h264: + preferredCodec = kCMVideoCodecType_H264 + case .hevc: + preferredCodec = kCMVideoCodecType_HEVC + } + + if preferredCodec == kCMVideoCodecType_HEVC, hevcAvailable { + return TBDisplayCodecDecision(codecType: kCMVideoCodecType_HEVC, usedFallback: false) } + if preferredCodec == kCMVideoCodecType_H264, h264Available { + return TBDisplayCodecDecision(codecType: kCMVideoCodecType_H264, usedFallback: false) + } + if preferredCodec == kCMVideoCodecType_HEVC, h264Available { + return TBDisplayCodecDecision(codecType: kCMVideoCodecType_H264, usedFallback: true) + } + if preferredCodec == kCMVideoCodecType_H264, hevcAvailable { + return TBDisplayCodecDecision(codecType: kCMVideoCodecType_HEVC, usedFallback: true) + } + + return nil + } + + private func resolvedCodecDecision( + for preset: TBDisplayCapturePreset, + profile: TBMonitorDisplayProfile? + ) -> TBDisplayCodecDecision? { + let receiverSupportsHEVC = profile?.supportsHEVCDecode + ?? receiverSupportsHEVCDecodeHint + ?? false + return Self.chooseCodec( + preference: codecPreference, + preset: preset, + receiverSupportsHEVC: receiverSupportsHEVC, + senderSupportsH264: Self.probeHardwareEncoderSupport( + codecType: kCMVideoCodecType_H264, + width: preset.width, + height: preset.height + ), + senderSupportsHEVC: Self.probeHardwareEncoderSupport( + codecType: kCMVideoCodecType_HEVC, + width: preset.width, + height: preset.height + ) + ) + } + + private func resolvedCodecType(for preset: TBDisplayCapturePreset, profile: TBMonitorDisplayProfile?) -> CMVideoCodecType { + resolvedCodecDecision(for: preset, profile: profile)?.codecType ?? preset.codecType } private func codecName(for codecType: CMVideoCodecType) -> String { @@ -2558,10 +2640,28 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let preset = capturePreset let usesRawNV12 = rawNV12Enabled(for: profile) - let codecType = resolvedCodecType(for: preset, profile: profile) + let codecDecision: TBDisplayCodecDecision + if usesRawNV12 { + codecDecision = TBDisplayCodecDecision(codecType: preset.codecType, usedFallback: false) + } else { + guard let resolvedDecision = resolvedCodecDecision(for: preset, profile: profile) else { + setStatus(.captureError(TBDisplaySenderL10n.codecUnavailable(language))) + TBLog.connection.error( + "capture: no compatible hardware codec preset=\(preset.rawValue, privacy: .public) preference=\(self.codecPreference.rawValue, privacy: .public)" + ) + return false + } + codecDecision = resolvedDecision + } + let codecType = codecDecision.codecType let codecName = usesRawNV12 ? "NV12 RAW" : codecName(for: codecType) activeCodecType = usesRawNV12 ? nil : codecType activeCodecName = codecName + if codecDecision.usedFallback { + TBLog.connection.notice( + "capture: codec fallback preference=\(self.codecPreference.rawValue, privacy: .public) selected=\(codecName, privacy: .public)" + ) + } guard let connection else { return false } // The encode/send pipeline runs entirely on its own serial queue, diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index 33f31b9c..98d3b348 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -70,6 +70,98 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertEqual(TBDisplayCapturePreset.native5k.captureRequestFrameRate, 96) } + func testAutomaticCodecUsesH264WhenHEVCIsUnavailable() { + let decision = TBDisplaySenderSession.chooseCodec( + preference: .automatic, + preset: .standard1440p, + receiverSupportsHEVC: false, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(decision?.codecType, kCMVideoCodecType_H264) + XCTAssertEqual(decision?.usedFallback, false) + } + + func testAutomaticCodecUsesHEVCWhenBothMacsSupportIt() { + for preset in [TBDisplayCapturePreset.standard1440p, .retina4k60] { + let decision = TBDisplaySenderSession.chooseCodec( + preference: .automatic, + preset: preset, + receiverSupportsHEVC: true, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(decision?.codecType, kCMVideoCodecType_HEVC) + XCTAssertEqual(decision?.usedFallback, false) + } + } + + func testAutomaticHighResolutionFallsBackToH264WithoutHEVC() { + let decision = TBDisplaySenderSession.chooseCodec( + preference: .automatic, + preset: .retina4k60, + receiverSupportsHEVC: false, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(decision?.codecType, kCMVideoCodecType_H264) + XCTAssertEqual(decision?.usedFallback, true) + } + + func testManualCodecChoicesAreHonouredWhenAvailable() { + let h264 = TBDisplaySenderSession.chooseCodec( + preference: .h264, + preset: .retina4k60, + receiverSupportsHEVC: true, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(h264?.codecType, kCMVideoCodecType_H264) + XCTAssertEqual(h264?.usedFallback, false) + + let hevc = TBDisplaySenderSession.chooseCodec( + preference: .hevc, + preset: .standard1440p, + receiverSupportsHEVC: true, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(hevc?.codecType, kCMVideoCodecType_HEVC) + XCTAssertEqual(hevc?.usedFallback, false) + } + + func testManualCodecFallsBackOnlyToACompatibleAlternative() { + let hevcToH264 = TBDisplaySenderSession.chooseCodec( + preference: .hevc, + preset: .standard1440p, + receiverSupportsHEVC: false, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(hevcToH264?.codecType, kCMVideoCodecType_H264) + XCTAssertEqual(hevcToH264?.usedFallback, true) + + let h264ToHEVC = TBDisplaySenderSession.chooseCodec( + preference: .h264, + preset: .native5k, + receiverSupportsHEVC: true, + senderSupportsH264: false, + senderSupportsHEVC: true + ) + XCTAssertEqual(h264ToHEVC?.codecType, kCMVideoCodecType_HEVC) + XCTAssertEqual(h264ToHEVC?.usedFallback, true) + } + + func testCodecSelectionFailsCleanlyWhenNoHardwarePathExists() { + XCTAssertNil(TBDisplaySenderSession.chooseCodec( + preference: .automatic, + preset: .native5k, + receiverSupportsHEVC: false, + senderSupportsH264: false, + senderSupportsHEVC: false + )) + } + func testFrameRatePacerSamples75HzInputAt60Hz() { var pacer = TBFrameRatePacer(maximumFrameRate: 60) let emitted = (0..<750).filter { frame in diff --git a/TargetBridge-Shared/Languages/de.json b/TargetBridge-Shared/Languages/de.json index 0075cd5b..ed4335e6 100644 --- a/TargetBridge-Shared/Languages/de.json +++ b/TargetBridge-Shared/Languages/de.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "Manuelle Eingabe", "sender.option.not_detected": "Nicht erkannt", "sender.group.stream_profile": "Stream-Profil", + "sender.codec.label": "Videokomprimierung", + "sender.codec.automatic": "Automatisch (empfohlen)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "Wählt den besten kompatiblen Codec für diesen Mac und den Anzeige-Mac.", + "sender.codec.hint.h264": "Bevorzugt H.264 und wechselt sicher, wenn es für die gewählte Auflösung nicht verfügbar ist.", + "sender.codec.hint.hevc": "Bevorzugt HEVC und wechselt sicher, wenn einer der Macs es nicht verwenden kann.", + "sender.codec.unavailable": "Für diese Auflösung ist kein kompatibler Hardware-Videocodec verfügbar.", "sender.group.display_profiles": "Anzeigeprofile", "sender.display_profiles.hint": "Wähle eine fertige Konfiguration. TargetBridge merkt sich die letzte Auswahl für diesen Empfänger.", "sender.display_profiles.work_5k": "Arbeit 5K", diff --git a/TargetBridge-Shared/Languages/en.json b/TargetBridge-Shared/Languages/en.json index e38314e8..781e04ca 100644 --- a/TargetBridge-Shared/Languages/en.json +++ b/TargetBridge-Shared/Languages/en.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "Manual entry", "sender.option.not_detected": "Not detected", "sender.group.stream_profile": "Stream profile", + "sender.codec.label": "Video compression", + "sender.codec.automatic": "Automatic (recommended)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "Chooses the best compatible codec for this Mac and display Mac.", + "sender.codec.hint.h264": "Prefers H.264 and switches safely if it is unavailable at the selected resolution.", + "sender.codec.hint.hevc": "Prefers HEVC and switches safely if either Mac cannot use it.", + "sender.codec.unavailable": "No compatible hardware video codec is available for this resolution.", "sender.group.display_profiles": "Display profiles", "sender.display_profiles.hint": "Apply a ready-to-use setup. TargetBridge remembers the last choice for this receiver.", "sender.display_profiles.work_5k": "Work 5K", diff --git a/TargetBridge-Shared/Languages/fr.json b/TargetBridge-Shared/Languages/fr.json index e18d886f..cededbdb 100644 --- a/TargetBridge-Shared/Languages/fr.json +++ b/TargetBridge-Shared/Languages/fr.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "Saisie manuelle", "sender.option.not_detected": "Non détecté", "sender.group.stream_profile": "Profil de flux", + "sender.codec.label": "Compression vidéo", + "sender.codec.automatic": "Automatique (recommandé)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "Choisit le meilleur codec compatible avec ce Mac et le Mac utilisé comme écran.", + "sender.codec.hint.h264": "Préfère H.264 et change de manière sûre s’il n’est pas disponible à la résolution choisie.", + "sender.codec.hint.hevc": "Préfère HEVC et change de manière sûre si l’un des deux Mac ne peut pas l’utiliser.", + "sender.codec.unavailable": "Aucun codec vidéo matériel compatible n’est disponible pour cette résolution.", "sender.group.display_profiles": "Profils d’affichage", "sender.display_profiles.hint": "Appliquez une configuration prête à l’emploi. TargetBridge mémorise le dernier choix pour ce receiver.", "sender.display_profiles.work_5k": "Travail 5K", diff --git a/TargetBridge-Shared/Languages/it.json b/TargetBridge-Shared/Languages/it.json index 37bc70f5..72a08d6d 100644 --- a/TargetBridge-Shared/Languages/it.json +++ b/TargetBridge-Shared/Languages/it.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "Inserimento manuale", "sender.option.not_detected": "Non rilevato", "sender.group.stream_profile": "Profilo stream", + "sender.codec.label": "Compressione video", + "sender.codec.automatic": "Automatica (consigliata)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "Sceglie il formato migliore compatibile con questo Mac e con il Mac usato come schermo.", + "sender.codec.hint.h264": "Preferisce H.264 e cambia in modo sicuro se non è disponibile alla risoluzione scelta.", + "sender.codec.hint.hevc": "Preferisce HEVC e cambia in modo sicuro se uno dei due Mac non può usarlo.", + "sender.codec.unavailable": "Nessun formato video hardware compatibile è disponibile per questa risoluzione.", "sender.group.display_profiles": "Profili display", "sender.display_profiles.hint": "Applica una configurazione pronta. TargetBridge ricorda l'ultima scelta per questo receiver.", "sender.display_profiles.work_5k": "Lavoro 5K", diff --git a/TargetBridge-Shared/Languages/zh.json b/TargetBridge-Shared/Languages/zh.json index 6cc99284..16632668 100644 --- a/TargetBridge-Shared/Languages/zh.json +++ b/TargetBridge-Shared/Languages/zh.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "手动输入", "sender.option.not_detected": "未检测到", "sender.group.stream_profile": "码流配置", + "sender.codec.label": "视频压缩", + "sender.codec.automatic": "自动(推荐)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "为此 Mac 和用作显示器的 Mac 选择最佳兼容编码。", + "sender.codec.hint.h264": "优先使用 H.264;若所选分辨率不可用,则安全切换。", + "sender.codec.hint.hevc": "优先使用 HEVC;若任一台 Mac 无法使用,则安全切换。", + "sender.codec.unavailable": "此分辨率没有可用的兼容硬件视频编码。", "sender.group.display_profiles": "显示配置", "sender.display_profiles.hint": "一键应用预设。TargetBridge 会记住此 receiver 的上次选择。", "sender.display_profiles.work_5k": "5K 工作", From dab7569ae704601df85c2d5d307c150d11dc2318 Mon Sep 17 00:00:00 2001 From: polmarinm Date: Sat, 15 Aug 2026 15:27:28 +0200 Subject: [PATCH 03/20] feat(display): render 1080p profiles at native 1x scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receiver advertises a fixed 5120x2880 panel with a 2560x1440 HiDPI mode and `"hiDPI":true`, regardless of the display it is actually attached to. The sender took those values as the virtual display geometry, so a 1080p stream was captured from a 1440p HiDPI desktop and rescaled twice on the way to a 1080p panel: the desktop arrived at the wrong size and paid for two non-integer resamples. Render matching could not fix this either. It sizes the backing store to the stream but keeps the strictly 2x HiDPI contract, so 1080p became a 960x540 desktop — capture is 1:1, but every control is drawn at 2x on a panel with no extra pixels to resolve it. Give `TBVirtualDisplayModeSize` an explicit `isHiDPI`, defaulting to the existing 2x behaviour, and let the capture profile choose. The 1080p profiles declare native scale and get a 1920x1080 1x mode; every other profile is untouched and still derives its mode and HiDPI flag from the receiver profile. The trigger is the profile rather than the receiver's reported panel size, because that value is a compile-time constant on the receiver and cannot distinguish a 1080p panel from a 5K one. Co-Authored-By: Claude Opus 5 --- .../ReceiverBackedVirtualDisplaySession.swift | 27 ++++++++-- .../TBDisplaySenderContentView.swift | 6 ++- .../TBDisplaySenderService.swift | 49 +++++++++++++++++-- .../TBSenderAutomationParsingTests.swift | 33 +++++++++++++ docs/Features.md | 7 +++ 5 files changed, 111 insertions(+), 11 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index d9564927..e58e55b7 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -8,12 +8,24 @@ extension CGVirtualDisplaySettings: @unchecked @retroactive Sendable {} /// Pixel size of the mode handed to CGVirtualDisplay. With `settings.hiDPI = true` /// macOS synthesises a strictly 2x backing store, so a mode of (w, h) renders the /// desktop into a (2w, 2h) framebuffer and reports "looks like w x h" in Displays. +/// +/// `isHiDPI == false` asks for a 1x mode instead: the framebuffer equals the mode, +/// and the desktop reports its true size. That is the right choice when the stream +/// already matches a non-Retina receiver panel pixel for pixel, where a HiDPI mode +/// would render every control at 2x on a panel that has no extra pixels to show it. struct TBVirtualDisplayModeSize: Equatable { let width: Int let height: Int + let isHiDPI: Bool - var backingWidth: Int { width * 2 } - var backingHeight: Int { height * 2 } + init(width: Int, height: Int, isHiDPI: Bool = true) { + self.width = width + self.height = height + self.isHiDPI = isHiDPI + } + + var backingWidth: Int { isHiDPI ? width * 2 : width } + var backingHeight: Int { isHiDPI ? height * 2 : height } } struct TBVirtualDisplayIdentity { @@ -84,7 +96,8 @@ final class ReceiverBackedVirtualDisplaySession { // stream exactly, so capture is 1:1 and only the panel-side scale remains. var resolvedMode = modeOverride ?? TBVirtualDisplayModeSize( width: profile.modeWidth, - height: profile.modeHeight + height: profile.modeHeight, + isHiDPI: profile.hiDPI ) // macOS refuses a HiDPI mode whose backing store exceeds the advertised panel. @@ -95,7 +108,11 @@ final class ReceiverBackedVirtualDisplaySession { resolvedMode.backingWidth, resolvedMode.backingHeight, profile.panelWidth, profile.panelHeight ) - resolvedMode = TBVirtualDisplayModeSize(width: profile.modeWidth, height: profile.modeHeight) + resolvedMode = TBVirtualDisplayModeSize( + width: profile.modeWidth, + height: profile.modeHeight, + isHiDPI: profile.hiDPI + ) } let descriptor = CGVirtualDisplayDescriptor() @@ -128,7 +145,7 @@ final class ReceiverBackedVirtualDisplaySession { } let settings = CGVirtualDisplaySettings() - settings.hiDPI = profile.hiDPI + settings.hiDPI = resolvedMode.isHiDPI guard let mode = CGVirtualDisplayMode( width: UInt(resolvedMode.width), height: UInt(resolvedMode.height), diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index 8ec0b314..33c08604 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -641,7 +641,11 @@ private struct TBDisplaySenderSessionSettingsSheet: View { .disabled(session.isConnected || session.isStreaming) } - if session.captureSource == .extendedDesktop { + // A native-scale profile already renders the desktop 1:1 with the + // stream, so render matching has nothing left to do and its HiDPI + // wording would be wrong. Hide the row rather than show a toggle + // that silently does nothing. + if session.captureSource == .extendedDesktop, !session.capturePreset.rendersAtNativeScale { settingRow(renderMatchingTitle, details: renderMatchingDetails) { Toggle("", isOn: $session.matchRenderToStream) .labelsHidden() diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index bf4e86d1..24639091 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -287,6 +287,33 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var renderMatchedDesktopDescription: String { "\(width / 2) × \(height / 2)" } + + /// Virtual display mode for a receiver whose panel already matches the stream + /// pixel for pixel. Rendering 1x keeps the desktop at its true size and makes + /// capture 1:1; the HiDPI variant would halve the logical desktop and draw every + /// control at 2x on a panel with no extra pixels to resolve it. + var nativeScaleDisplayMode: TBVirtualDisplayModeSize { + TBVirtualDisplayModeSize(width: width, height: height, isHiDPI: false) + } + + /// True for profiles that target a receiver panel of exactly this resolution, + /// with no extra pixels for a HiDPI backing store. + /// + /// The receiver advertises a fixed 5120x2880 panel and a 2560x1440 HiDPI mode + /// no matter what it is actually plugged into, so its profile cannot be used to + /// detect a non-Retina panel; the capture profile is the signal instead. A + /// 21.5-inch non-Retina iMac running the 1080p profiles wants a 1920x1080 1x + /// desktop: HiDPI would render the desktop at 960x540 and draw every control at + /// 2x, and the receiver's own 1440p default would rescale twice on the way out. + var rendersAtNativeScale: Bool { + switch self { + case .standard1080p, .smooth1080p60: + return true + case .standard1440p, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, + .native5k, .native5k60Experimental: + return false + } + } } /// A zero-buffer frame-rate ceiling for capture callbacks. Deadlines advance on @@ -2549,15 +2576,27 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u ? await self.fetchShareableDisplayIDs() : [] let receiverKey = self.extendedDisplayIdentityKey(for: profile) - let modeOverride: TBVirtualDisplayModeSize? = (self.matchRenderToStream && self.captureSource == .extendedDesktop) - ? self.capturePreset.renderMatchedDisplayMode - : nil + // A native-scale profile targets a panel with exactly this many pixels, so + // render the desktop 1x at the stream size: capture is 1:1 and the panel + // shows it at its true size. This takes priority over render matching, + // whose 2x mode would halve the logical desktop for no gain on a panel with + // no extra pixels to resolve it. + let modeOverride: TBVirtualDisplayModeSize? + if self.capturePreset.rendersAtNativeScale { + modeOverride = self.capturePreset.nativeScaleDisplayMode + } else if self.matchRenderToStream && self.captureSource == .extendedDesktop { + modeOverride = self.capturePreset.renderMatchedDisplayMode + } else { + modeOverride = nil + } if let modeOverride { NSLog( - "TargetBridge: render matching on, virtual display mode %dx%d (backing %dx%d) for %dx%d stream", + "TargetBridge: virtual display mode %dx%d %@ (backing %dx%d) for %dx%d stream, panel %dx%d", modeOverride.width, modeOverride.height, + modeOverride.isHiDPI ? "HiDPI" : "1x", modeOverride.backingWidth, modeOverride.backingHeight, - self.capturePreset.width, self.capturePreset.height + self.capturePreset.width, self.capturePreset.height, + profile.panelWidth, profile.panelHeight ) } guard self.session.create( diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index 98d3b348..18352353 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -56,6 +56,39 @@ final class TBSenderAutomationParsingTests: XCTestCase { ) } + func testNativeScaleAppliesOnlyToPanelMatchedProfiles() { + XCTAssertTrue(TBDisplayCapturePreset.standard1080p.rendersAtNativeScale) + XCTAssertTrue(TBDisplayCapturePreset.smooth1080p60.rendersAtNativeScale) + for preset in TBDisplayCapturePreset.allCases where preset.width > 1920 { + XCTAssertFalse( + preset.rendersAtNativeScale, + "\(preset.rawValue) targets a Retina panel and must keep its HiDPI mode" + ) + } + } + + /// A 1x mode must hand CGVirtualDisplay the full stream size with a framebuffer + /// to match, not the halved HiDPI point size. Getting this backwards renders the + /// desktop at 960x540 and draws every control at 2x on a 1080p panel. + func testNativeScaleModeRendersFullSizeWithoutHiDPIBacking() { + let mode = TBDisplayCapturePreset.standard1080p.nativeScaleDisplayMode + XCTAssertEqual(mode.width, 1920) + XCTAssertEqual(mode.height, 1080) + XCTAssertFalse(mode.isHiDPI) + XCTAssertEqual(mode.backingWidth, 1920) + XCTAssertEqual(mode.backingHeight, 1080) + } + + /// Render matching keeps its 2x contract for every non-native-scale profile. + func testRenderMatchedModeKeepsHiDPIBacking() { + let mode = TBDisplayCapturePreset.standard1440p.renderMatchedDisplayMode + XCTAssertEqual(mode.width, 1280) + XCTAssertEqual(mode.height, 720) + XCTAssertTrue(mode.isHiDPI) + XCTAssertEqual(mode.backingWidth, 2560) + XCTAssertEqual(mode.backingHeight, 1440) + } + func testHighFrameRatePresetsUseFiveCaptureSurfaces() { XCTAssertEqual(TBDisplayCapturePreset.standard1440p.queueDepth, 3) XCTAssertEqual(TBDisplayCapturePreset.smooth1440p60.queueDepth, 5) diff --git a/docs/Features.md b/docs/Features.md index 9fbb35d2..d2400983 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -13,6 +13,13 @@ Extended layouts can be arranged in macOS **System Settings -> Displays -> Arran If the receiver panel does not look correct, set the matching resolution on the TargetBridge display in macOS Display Settings. For 27-inch 5K iMac workflows, the `Crisp` or `5K` stream profiles usually pair best with the matching HiDPI arrangement. +The `Full HD` profiles are for non-Retina 1080p receivers, such as every 21.5-inch +iMac that is not a Retina 4K model. They render the virtual display at 1920 × 1080 +1x rather than as a HiDPI mode, so the desktop reaches the panel at its true size +and capture stays 1:1. Because those profiles already render the desktop to match +the stream, the **Match render to stream** toggle does not apply and is hidden when +one is selected. + Related reading: - [docs/QuickStart-EN.md](docs/QuickStart-EN.md) From b3375cec41fca74838a14a27dbcede9cc25c72cc Mon Sep 17 00:00:00 2001 From: swellweb Date: Sat, 3 Oct 2026 02:25:21 +0200 Subject: [PATCH 04/20] fix(display): complete 1080p profile integration --- .../TBDisplaySenderAutomation.swift | 2 + .../TBDisplaySenderLocalization.swift | 4 ++ .../TBDisplaySenderService.swift | 52 +++++++++++++++---- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 7892d379..c4fac75a 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -533,6 +533,8 @@ enum TBSenderAutomation { static func parsePreset(_ value: String) -> TBDisplayCapturePreset? { if let preset = TBDisplayCapturePreset(rawValue: value) { return preset } switch value.lowercased() { + case "1080p", "1080", "fullhd", "1920x1080": return .standard1080p + case "1080p60", "fullhd60", "smooth1080": return .smooth1080p60 case "1440p", "1440", "standard": return .standard1440p case "1440p60", "smooth", "smooth1440": return .smooth1440p60 case "1800p", "1800p60", "smooth1800": return .smooth1800p60 diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index d32d2107..fb9d20db 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -581,6 +581,10 @@ enum TBDisplaySenderL10n { extension TBDisplayCapturePreset { func title(_ language: TBDisplaySenderLanguage) -> String { switch self { + case .standard1080p: + return TBDisplaySenderL10n.text("sender.profile.full_hd", language) + case .smooth1080p60: + return TBDisplaySenderL10n.text("sender.profile.full_hd_60", language) case .standard1440p: return TBDisplaySenderL10n.text("sender.profile.standard", language) case .smooth1440p60: diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 24639091..323e3065 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -23,6 +23,8 @@ enum TBVideoQueueBudget { } enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { + case standard1080p + case smooth1080p60 case standard1440p case smooth1440p60 case smooth1800p60 @@ -35,6 +37,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var title: String { switch self { + case .standard1080p: + return "Full HD" + case .smooth1080p60: + return "Full HD 60" case .standard1440p: return "Standard" case .smooth1440p60: @@ -54,6 +60,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var description: String { switch self { + case .standard1080p: + return "1920 × 1080" + case .smooth1080p60: + return "1920 × 1080 @ 60" case .standard1440p: return "2560 × 1440" case .smooth1440p60: @@ -73,6 +83,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var width: Int { switch self { + case .standard1080p, .smooth1080p60: + return 1920 case .standard1440p, .smooth1440p60: return 2560 case .smooth1800p60: @@ -88,6 +100,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var height: Int { switch self { + case .standard1080p, .smooth1080p60: + return 1080 case .standard1440p, .smooth1440p60: return 1440 case .smooth1800p60: @@ -103,6 +117,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var averageBitRate: Int { switch self { + case .standard1080p: + return 20_000_000 + case .smooth1080p60: + return 30_000_000 case .standard1440p: return 36_000_000 case .smooth1440p60: @@ -122,7 +140,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var codecName: String { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return "H.264" case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return "HEVC" @@ -131,7 +149,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var codecType: CMVideoCodecType { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return kCMVideoCodecType_H264 case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return kCMVideoCodecType_HEVC @@ -143,9 +161,9 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return parsed } switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return 3 - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: // Five surfaces protect WindowServer from starvation during // high-frame-rate 4K capture while the serial pipeline prevents an @@ -156,6 +174,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var expectedFrameRate: Int { switch self { + case .standard1080p: + return 30 + case .smooth1080p60: + return 60 case .standard1440p: return 30 case .smooth1440p60: @@ -180,6 +202,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxKeyFrameInterval: Int { switch self { + case .standard1080p, .smooth1080p60: + return 60 case .standard1440p: return 60 case .smooth1440p60: @@ -197,6 +221,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxKeyFrameIntervalDuration: Int { switch self { + case .standard1080p: + return 2 + case .smooth1080p60: + return 1 case .standard1440p: return 2 case .smooth1440p60: @@ -210,9 +238,9 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var prioritizeSpeed: Bool { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -226,18 +254,18 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxFrameDelayCount: Int { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return 1 - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return 0 } } var dropsBeforeEncodeWhenBacklogged: Bool { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -251,7 +279,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var captureResolution: SCCaptureResolutionType { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return .nominal case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return .best @@ -260,6 +288,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var virtualDisplayRefreshRate: Double { switch self { + case .standard1080p, .smooth1080p60: + return 60 case .standard1440p: return 60 case .smooth1440p60, .smooth1800p60: From 2c7d1dcbc1e2696d70fa0f2b744d4952ce1b94c4 Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 19 Aug 2026 10:30:47 +0200 Subject: [PATCH 05/20] fix(sender): stream virtual display directly when headless --- .../TBDisplaySenderService.swift | 39 +++++++++++++++++-- .../TBSenderAutomationParsingTests.swift | 37 ++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 323e3065..6539f3ce 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1352,6 +1352,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var lastCheckedCursor: NSCursor? private var lastCheckedCursorType: Int = 0 private var baselineDisplayIDs = Set() + private var headlessMirrorUsesVirtualDisplay = false private var cursorDisplayID: CGDirectDisplayID = kCGNullDirectDisplay private var lastCursorPacket: TBMonitorCursor? private var injectedRemoteMouseLocation: CGPoint? @@ -1899,6 +1900,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u sentSnapshot = 0 sessionAckSent = false baselineDisplayIDs = [] + headlessMirrorUsesVirtualDisplay = false cursorDisplayID = kCGNullDirectDisplay lastCursorPacket = nil captureDisplayText = TBDisplaySenderL10n.captureDisplayNotAvailable(language) @@ -2602,6 +2604,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // Wake and hold the graphical session before virtual display setup. self.beginCaptureActivity() self.setStatus(.creatingVirtualDisplay) + self.headlessMirrorUsesVirtualDisplay = false self.baselineDisplayIDs = self.captureSource == .extendedDesktop ? await self.fetchShareableDisplayIDs() : [] @@ -2641,8 +2644,18 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } if self.captureSource == .desktopMirror { - if CGDisplayIsInMirrorSet(self.session.displayID) == 0 { - let displayReady = await self.waitForOnlineDisplay(self.session.displayID) + let displayReady = await self.waitForOnlineDisplay(self.session.displayID) + self.headlessMirrorUsesVirtualDisplay = displayReady && Self.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: self.session.displayID, + mainDisplayID: CGMainDisplayID(), + onlineDisplayIDs: self.onlineDisplayIDs() + ) + if self.headlessMirrorUsesVirtualDisplay { + NSLog( + "TargetBridge: headless mirror mode uses virtual display %u directly", + self.session.displayID + ) + } else if CGDisplayIsInMirrorSet(self.session.displayID) == 0 { let mirrorConfigured = displayReady && self.configureDesktopMirror(for: self.session.displayID) if !mirrorConfigured { NSLog( @@ -2686,7 +2699,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if self.captureSource == .extendedDesktop { self.scheduleExtendedDesktopRecovery(for: self.session.displayID) - } else if self.captureSource == .desktopMirror { + } else if self.captureSource == .desktopMirror && !self.headlessMirrorUsesVirtualDisplay { self.scheduleDesktopMirrorRecovery(for: self.session.displayID) } @@ -2755,7 +2768,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let display: SCDisplay if captureSource == .desktopMirror { - if let mirrorDisplay = try await resolveMirrorCaptureDisplay() { + if headlessMirrorUsesVirtualDisplay { + TBLog.connection.info("capture: headless virtual display uses direct stream id=\(self.session.displayID, privacy: .public)") + return startDirectDisplayStream(displayID: session.displayID, preset: preset) + } else if let mirrorDisplay = try await resolveMirrorCaptureDisplay() { display = mirrorDisplay } else if let fallbackDisplayID = directMirrorFallbackDisplayID() { TBLog.connection.warning("capture: no virtual ScreenCaptureKit display; using direct fallback id=\(fallbackDisplayID, privacy: .public)") @@ -3070,6 +3086,21 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return false } + static func shouldUseHeadlessVirtualDisplay( + virtualDisplayID: CGDirectDisplayID, + mainDisplayID: CGDirectDisplayID, + onlineDisplayIDs: [CGDirectDisplayID] + ) -> Bool { + guard virtualDisplayID != kCGNullDirectDisplay, + mainDisplayID == virtualDisplayID, + onlineDisplayIDs.contains(virtualDisplayID) + else { return false } + + return onlineDisplayIDs.allSatisfy { + $0 == kCGNullDirectDisplay || $0 == virtualDisplayID + } + } + private func scheduleExtendedDesktopRecovery(for virtualDisplayID: CGDirectDisplayID) { Task { @MainActor [weak self] in guard let self else { return } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index 18352353..80b506a6 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -8,6 +8,43 @@ import XCTest /// silently reroute automation traffic. @MainActor final class TBSenderAutomationParsingTests: XCTestCase { + func testHeadlessMirrorUsesOnlyOnlineVirtualDisplayDirectly() { + XCTAssertTrue( + TBDisplaySenderSession.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: 120, + mainDisplayID: 120, + onlineDisplayIDs: [120] + ) + ) + } + + func testHeadlessMirrorDoesNotBypassMirroringWithPhysicalDisplayOnline() { + XCTAssertFalse( + TBDisplaySenderSession.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: 120, + mainDisplayID: 120, + onlineDisplayIDs: [120, 1] + ) + ) + XCTAssertFalse( + TBDisplaySenderSession.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: 120, + mainDisplayID: 1, + onlineDisplayIDs: [1, 120] + ) + ) + } + + func testHeadlessMirrorRequiresVirtualDisplayToBeOnline() { + XCTAssertFalse( + TBDisplaySenderSession.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: 120, + mainDisplayID: 120, + onlineDisplayIDs: [] + ) + ) + } + func testReceiverControlKeepsNativeCursorWithoutLargeCursor() { XCTAssertFalse( TBInputControlRole.receiverMaster.usesLowLatencyCursorOverlay( From 0e3594fab787d118e5e04567bbb58c07d686be59 Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 19 Aug 2026 11:15:14 +0200 Subject: [PATCH 06/20] fix(sender): preserve audio in headless direct capture --- .../TBDisplaySenderService.swift | 73 ++++++++++++++++++- .../TBSenderAutomationParsingTests.swift | 21 ++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 6539f3ce..e6365120 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -2770,12 +2770,12 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if captureSource == .desktopMirror { if headlessMirrorUsesVirtualDisplay { TBLog.connection.info("capture: headless virtual display uses direct stream id=\(self.session.displayID, privacy: .public)") - return startDirectDisplayStream(displayID: session.displayID, preset: preset) + return await startDirectDisplayStream(displayID: session.displayID, preset: preset) } else if let mirrorDisplay = try await resolveMirrorCaptureDisplay() { display = mirrorDisplay } else if let fallbackDisplayID = directMirrorFallbackDisplayID() { TBLog.connection.warning("capture: no virtual ScreenCaptureKit display; using direct fallback id=\(fallbackDisplayID, privacy: .public)") - return startDirectDisplayStream(displayID: fallbackDisplayID, preset: preset) + return await startDirectDisplayStream(displayID: fallbackDisplayID, preset: preset) } else { return false } @@ -2902,7 +2902,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return nil } - private func startDirectDisplayStream(displayID: CGDirectDisplayID, preset: TBDisplayCapturePreset) -> Bool { + private func startDirectDisplayStream(displayID: CGDirectDisplayID, preset: TBDisplayCapturePreset) async -> Bool { guard let pipeline else { return false } let usesCursorOverlay = inputControlRole.usesLowLatencyCursorOverlay( largeCursorEnabled: largeCursor @@ -2923,6 +2923,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } directDisplayStream = directCapture + await startAuxiliaryAudioCapture(preferredDisplayID: displayID) captureDisplayText = TBDisplaySenderL10n.captureDisplayCGDisplayStream(language, id: displayID) isStreaming = true if usesCursorOverlay { startCursorUpdates(displayID: displayID) } @@ -2932,6 +2933,65 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return true } + /// CGDisplayStream is the most reliable video source when the virtual + /// display is the headless Mac's only online display, but it has no audio + /// output. Keep that low-latency video path and run an audio-only + /// ScreenCaptureKit stream beside it when audio relay is enabled. + private func startAuxiliaryAudioCapture(preferredDisplayID: CGDirectDisplayID) async { + guard Self.needsAuxiliaryAudioCapture( + usingDirectDisplayStream: true, + shouldRelayAudio: shouldRelayAudio + ) else { return } + + do { + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: false + ) + guard let display = content.displays.first(where: { $0.displayID == preferredDisplayID }) + ?? content.displays.first else { + TBLog.connection.warning("capture: auxiliary audio unavailable because ScreenCaptureKit exposed no display") + return + } + + let configuration = SCStreamConfiguration() + configuration.width = 2 + configuration.height = 2 + configuration.minimumFrameInterval = CMTime(value: 1, timescale: 1) + configuration.queueDepth = 1 + configuration.showsCursor = false + configuration.capturesAudio = true + configuration.excludesCurrentProcessAudio = true + configuration.sampleRate = 48000 + configuration.channelCount = 2 + + let delegate = CaptureDelegate() + delegate.onAudio = { [weak self] sampleBuffer in + self?.processAudio(sampleBuffer) + } + delegate.onError = { error in + NSLog("TargetBridge: auxiliary audio stream stopped: %@", error.localizedDescription) + } + + let filter = SCContentFilter(display: display, excludingWindows: []) + let stream = SCStream(filter: filter, configuration: configuration, delegate: delegate) + try stream.addStreamOutput( + delegate, + type: .audio, + sampleHandlerQueue: DispatchQueue( + label: "fd.tbmonitor.sender.direct-audio", + qos: .userInteractive + ) + ) + try await stream.startCapture() + captureDelegate = delegate + scStream = stream + TBLog.connection.info("capture: auxiliary audio stream active beside direct video") + } catch { + TBLog.connection.warning("capture: auxiliary audio unavailable: \(error.localizedDescription, privacy: .public)") + } + } + private func activityOptions() -> ProcessInfo.ActivityOptions { var options: ProcessInfo.ActivityOptions = [.userInitiated, .idleSystemSleepDisabled] if preventDisplaySleep { @@ -3101,6 +3161,13 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } + static func needsAuxiliaryAudioCapture( + usingDirectDisplayStream: Bool, + shouldRelayAudio: Bool + ) -> Bool { + usingDirectDisplayStream && shouldRelayAudio + } + private func scheduleExtendedDesktopRecovery(for virtualDisplayID: CGDirectDisplayID) { Task { @MainActor [weak self] in guard let self else { return } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index 80b506a6..8fe0fd36 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -45,6 +45,27 @@ final class TBSenderAutomationParsingTests: XCTestCase { ) } + func testDirectHeadlessVideoStartsAuxiliaryAudioOnlyWhenEnabled() { + XCTAssertTrue( + TBDisplaySenderSession.needsAuxiliaryAudioCapture( + usingDirectDisplayStream: true, + shouldRelayAudio: true + ) + ) + XCTAssertFalse( + TBDisplaySenderSession.needsAuxiliaryAudioCapture( + usingDirectDisplayStream: true, + shouldRelayAudio: false + ) + ) + XCTAssertFalse( + TBDisplaySenderSession.needsAuxiliaryAudioCapture( + usingDirectDisplayStream: false, + shouldRelayAudio: true + ) + ) + } + func testReceiverControlKeepsNativeCursorWithoutLargeCursor() { XCTAssertFalse( TBInputControlRole.receiverMaster.usesLowLatencyCursorOverlay( From 5ddee03dedce122ea3d3c62ad411e51158489573 Mon Sep 17 00:00:00 2001 From: swellweb Date: Sat, 3 Oct 2026 02:28:05 +0200 Subject: [PATCH 07/20] feat(sender): guide users through macOS permissions --- .../TBDisplaySenderContentView.swift | 20 +++++ .../TBDisplaySenderManager.swift | 38 ++++++++++ .../TBDisplaySenderPermissionAssistant.swift | 75 +++++++++++++++++++ .../TargetBridge.xcodeproj/project.pbxproj | 4 + 4 files changed, 137 insertions(+) create mode 100644 TargetBridge-Sender/TBDisplaySender/TBDisplaySenderPermissionAssistant.swift diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index 33c08604..a1d71141 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -27,12 +27,22 @@ struct TBDisplaySenderContentView: View { .background(Color.black.opacity(0.02)) .task { service.refreshLocalInterfaces() + service.presentPermissionAssistantIfNeeded() } .sheet(isPresented: $showingAbout) { TBDisplaySenderAboutView(service: service) } + .sheet(isPresented: $service.showingPermissionAssistant) { + TBDisplaySenderPermissionAssistant(service: service) + } .toolbar { ToolbarItemGroup(placement: .primaryAction) { + Button { + service.showingPermissionAssistant = true + } label: { + Label(permissionToolbarTitle, systemImage: "checkmark.shield") + } + Button { showingAbout = true } label: { @@ -179,6 +189,16 @@ struct TBDisplaySenderContentView: View { case .chinese: return "关于" } } + + private var permissionToolbarTitle: String { + switch service.language { + case .italian: return "Permessi" + case .english: return "Permissions" + case .german: return "Berechtigungen" + case .french: return "Autorisations" + case .chinese: return "权限" + } + } } private struct TBDisplaySenderSessionCard: View { diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index ed31c61a..9e2a49b3 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -49,6 +49,7 @@ final class TBDisplaySenderService: ObservableObject { /// Changes whenever the app returns from System Settings so permission cards /// re-evaluate their live TCC state instead of showing a stale warning. @Published private(set) var privacyPermissionsRevision = 0 + @Published var showingPermissionAssistant = false @Published var language: TBDisplaySenderLanguage = .load() { didSet { language.persist() @@ -146,6 +147,43 @@ final class TBDisplaySenderService: ObservableObject { privacyPermissionsRevision &+= 1 } + func presentPermissionAssistantIfNeeded() { + showingPermissionAssistant = !CGPreflightScreenCaptureAccess() + } + + func requestScreenRecordingPermission() { + _ = CGRequestScreenCaptureAccess() + refreshPrivacyPermissions() + } + + func requestAccessibilityPermission() { + let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + refreshPrivacyPermissions() + } + + func requestInputMonitoringPermission() { + _ = CGRequestListenEventAccess() + refreshPrivacyPermissions() + } + + func openScreenRecordingSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else { + return + } + NSWorkspace.shared.open(url) + } + + func restartAfterPermissionChange() { + let appURL = URL(fileURLWithPath: Bundle.main.bundlePath) + NSWorkspace.shared.openApplication(at: appURL, configuration: .init()) { _, error in + guard error == nil else { return } + DispatchQueue.main.async { + NSApp.terminate(nil) + } + } + } + var anyConnected: Bool { sessions.contains { $0.isConnected || $0.isStreaming } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderPermissionAssistant.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderPermissionAssistant.swift new file mode 100644 index 00000000..87319b29 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderPermissionAssistant.swift @@ -0,0 +1,75 @@ +import ApplicationServices +import SwiftUI + +struct TBDisplaySenderPermissionAssistant: View { + @ObservedObject var service: TBDisplaySenderService + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Label(copy.title, systemImage: "checkmark.shield") + .font(.title2.weight(.bold)) + + Text(copy.intro) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + permissionRow(copy.screenRecording, granted: CGPreflightScreenCaptureAccess()) { + service.requestScreenRecordingPermission() + } + permissionRow(copy.accessibility, granted: AXIsProcessTrusted()) { + service.requestAccessibilityPermission() + } + permissionRow(copy.inputMonitoring, granted: CGPreflightListenEventAccess()) { + service.requestInputMonitoringPermission() + } + + HStack { + Button(copy.openSettings) { service.openScreenRecordingSettings() } + Button(copy.restart) { service.restartAfterPermissionChange() } + .buttonStyle(.borderedProminent) + Spacer() + Button(copy.done) { service.showingPermissionAssistant = false } + } + } + .padding(24) + .frame(width: 560) + .onAppear { service.refreshPrivacyPermissions() } + } + + private func permissionRow(_ title: String, granted: Bool, request: @escaping () -> Void) -> some View { + HStack(spacing: 12) { + Image(systemName: granted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(granted ? .green : .yellow) + Text(title) + Spacer() + if granted { + Text(copy.granted).foregroundStyle(.secondary) + } else { + Button(copy.allow, action: request).buttonStyle(.bordered) + } + } + } + + private var copy: Copy { + switch service.language { + case .italian: return .init(title: "Permessi di TargetBridge", intro: "macOS richiede la tua conferma una sola volta per ogni app installata. Puoi autorizzare qui oppure aprire direttamente le Impostazioni.", screenRecording: "Registrazione schermo", accessibility: "Accessibilità", inputMonitoring: "Monitoraggio input", granted: "Attivo", allow: "Consenti", openSettings: "Apri Impostazioni", restart: "Riavvia TargetBridge", done: "Fine") + case .english: return .init(title: "TargetBridge Permissions", intro: "macOS requires your confirmation once for each installed app. Approve here or open the matching System Settings page.", screenRecording: "Screen Recording", accessibility: "Accessibility", inputMonitoring: "Input Monitoring", granted: "Granted", allow: "Allow", openSettings: "Open Settings", restart: "Restart TargetBridge", done: "Done") + case .german: return .init(title: "TargetBridge-Berechtigungen", intro: "macOS benötigt deine Bestätigung einmal für jede installierte App. Du kannst hier zustimmen oder die passende Systemeinstellung öffnen.", screenRecording: "Bildschirmaufnahme", accessibility: "Bedienungshilfen", inputMonitoring: "Eingabeüberwachung", granted: "Aktiv", allow: "Erlauben", openSettings: "Einstellungen öffnen", restart: "TargetBridge neu starten", done: "Fertig") + case .french: return .init(title: "Autorisations TargetBridge", intro: "macOS demande votre confirmation une fois pour chaque app installée. Autorisez ici ou ouvrez directement les Réglages correspondants.", screenRecording: "Enregistrement de l’écran", accessibility: "Accessibilité", inputMonitoring: "Surveillance des entrées", granted: "Autorisé", allow: "Autoriser", openSettings: "Ouvrir les réglages", restart: "Redémarrer TargetBridge", done: "Terminé") + case .chinese: return .init(title: "TargetBridge 权限", intro: "macOS 需要你为每个已安装的应用确认一次权限。可在此授权,或直接打开相应的系统设置页面。", screenRecording: "屏幕录制", accessibility: "辅助功能", inputMonitoring: "输入监控", granted: "已授权", allow: "允许", openSettings: "打开设置", restart: "重新启动 TargetBridge", done: "完成") + } + } + + private struct Copy { + let title: String + let intro: String + let screenRecording: String + let accessibility: String + let inputMonitoring: String + let granted: String + let allow: String + let openSettings: String + let restart: String + let done: String + } +} diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 5f896ae7..14887fb0 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 96DDF119DE60DFBA437D32D2 /* en.json in Resources */ = {isa = PBXBuildFile; fileRef = D386D0FCD0F93494220CC3DC /* en.json */; }; 9B07E096AE81E351B6D1E17C /* fr.json in Resources */ = {isa = PBXBuildFile; fileRef = 73EDC0FF3A4EA4DC1ED63528 /* fr.json */; }; A3FD74604472363535D63273 /* TBDisplaySenderSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */; }; + AB66DEF6790324891146EE6E /* TBDisplaySenderPermissionAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DBAD89D0448890049E949 /* TBDisplaySenderPermissionAssistant.swift */; }; B9FA5989F04108E9FBFA8D3B /* TBDisplaySenderApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD235429FA67D231F565C93E /* TBDisplaySenderApp.swift */; }; BB4D986A5A0F32BC1480BD4D /* TBDisplaySenderSurfaceViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */; }; BB61FDD4D950F325E9D2ADAC /* TBInjectedPointerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10EC09EEBA6EA9B2EA6ACD1F /* TBInjectedPointerEvent.swift */; }; @@ -111,6 +112,7 @@ A3F0782C970AA23871AE73A6 /* TBConfigurationDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConfigurationDiagnostics.swift; sourceTree = ""; }; A51B69D318A85FF3061D6ED4 /* input-dockstation.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "input-dockstation.json"; sourceTree = ""; }; A7086193EE5710E2C37F86F1 /* TBConfigurationDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConfigurationDiagnosticsTests.swift; sourceTree = ""; }; + AD2DBAD89D0448890049E949 /* TBDisplaySenderPermissionAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderPermissionAssistant.swift; sourceTree = ""; }; B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderSettingsView.swift; sourceTree = ""; }; C11B4EB862393E5CE95426D4 /* TBCaptureHealthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCaptureHealthTests.swift; sourceTree = ""; }; C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiverBackedVirtualDisplaySession.swift; sourceTree = ""; }; @@ -155,6 +157,7 @@ 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */, 9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */, 4644CFB05098C895378B648A /* TBDisplaySenderManager.swift */, + AD2DBAD89D0448890049E949 /* TBDisplaySenderPermissionAssistant.swift */, 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */, B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */, 6AC1AF99431D4889F947CE1B /* TBDisplaySenderStatusItemController.swift */, @@ -414,6 +417,7 @@ 5C0CB53005B6D67362F14719 /* TBDisplaySenderContentView.swift in Sources */, BBFD76A352FEDC54029B2158 /* TBDisplaySenderLocalization.swift in Sources */, F2274C924BAC479635B44A47 /* TBDisplaySenderManager.swift in Sources */, + AB66DEF6790324891146EE6E /* TBDisplaySenderPermissionAssistant.swift in Sources */, 6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */, A3FD74604472363535D63273 /* TBDisplaySenderSettingsView.swift in Sources */, E9F7A263085592067562F768 /* TBDisplaySenderStatusItemController.swift in Sources */, From adbf8bfb08dc65753dac20b61a55d6f63c9b66ac Mon Sep 17 00:00:00 2001 From: swellweb Date: Sat, 3 Oct 2026 02:31:34 +0200 Subject: [PATCH 08/20] release: prepare TargetBridge 3.6.0 candidate --- README.md | 32 ++++++++------ TargetBridge-Receiver/TBReceiverC/Makefile | 2 +- .../TBReceiverC/src/display.c | 2 +- .../scripts/build_tbreceiver_c_app.sh | 2 +- .../TBDisplaySenderBuildInfo.swift | 4 +- .../TargetBridge.xcodeproj/project.pbxproj | 10 ++--- TargetBridge-Sender/project.yml | 6 +-- docs/Automation.md | 4 +- docs/Features.md | 33 ++++++++++++++ docs/releases/3.6.0.md | 43 +++++++++++++++++++ 10 files changed, 111 insertions(+), 27 deletions(-) create mode 100644 docs/releases/3.6.0.md diff --git a/README.md b/README.md index 5ce5146c..085986d2 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,23 @@ and improves the project. A special thank-you for this release goes to: - [@preggocl](https://github.com/preggocl) for the physical Receiver display profile and the software decode fallback. - [@Betafer](https://github.com/Betafer) for input-queue safety, cursor work, idle audio/display handling, watchdog protection, monitor shielding, and build improvements. -## TargetBridge 3.5.2 +## TargetBridge 3.6.0 -TargetBridge 3.5.2 is a focused maintenance update to the Input Dockstation. -When the Receiver is the input master, Shift and Command now remain active for -multi-file selection even when the keyboard is connected to the Receiver and -the mouse or trackpad is used locally on the Sender. This fixes Finder range -selection and non-contiguous selection without changing ordinary local input. +TargetBridge 3.6.0 improves everyday connection setup and makes the display +pipeline more adaptable without changing the stable default workflows: -It also includes the video queue, capture-health and permission-status fixes -prepared for this maintenance line. For the wider 3.5.1 maintenance work, -including Receiver display profiles, idle resource handling, and compatibility -with older Macs: +- remembers a discovered Receiver by its stable identity instead of losing the + selection when its Thunderbolt or network IP changes +- offers per-Receiver H.264, HEVC, or automatic hardware codec selection with + a safe fallback when a selected codec is unavailable +- adds `Full HD` and `Full HD 60` 1:1 stream profiles for non-Retina 1080p + receivers +- uses direct virtual-display capture in a genuinely headless mirror session, + while keeping system-audio relay active +- adds a built-in permission assistant for Screen Recording, Accessibility, + and Input Monitoring + +The release also retains the 3.5 maintenance work: - preserve keyboard and mouse release events when the Receiver input queue is under pressure - preserve native macOS cursor shapes by default, including the screenshot crosshair; the custom overlay is reserved for the explicit large-cursor option @@ -61,8 +66,8 @@ with older Macs: - keep local notification banners behind the active monitor surface across display/fullscreen transitions - package SDL runtimes safely and support explicit build/version overrides -See the [full 3.5.2 release notes](docs/releases/3.5.2.md), the -[3.5.1 release notes](docs/releases/3.5.1.md), and the +See the [full 3.6.0 release notes](docs/releases/3.6.0.md), the +[3.5.2 release notes](docs/releases/3.5.2.md), and the [testing guide](docs/Testing.md), including the new monitor-shield lifecycle test. It also keeps the established multi-Mac workspace features: @@ -91,6 +96,8 @@ It also keeps the established multi-Mac workspace features: - Overview hub: [docs/Features.md](docs/Features.md) - Mirror mode and Extended Desktop: [docs/Features.md#display-modes](docs/Features.md#display-modes) - Guided configuration check: [docs/Features.md#guided-configuration-check](docs/Features.md#guided-configuration-check) +- Stream profiles and per-Receiver codecs: [docs/Features.md#stream-profiles-and-codecs](docs/Features.md#stream-profiles-and-codecs) +- Permission assistant: [docs/Features.md#permission-assistant](docs/Features.md#permission-assistant) - Multi-receiver layouts: [docs/Features.md#multi-receiver-workflows](docs/Features.md#multi-receiver-workflows) - Network Link (experimental): [docs/Features.md#network-link-experimental](docs/Features.md#network-link-experimental) - Audio Relay: [docs/Features.md#audio-relay](docs/Features.md#audio-relay) @@ -151,6 +158,7 @@ If you build from source, app outputs go into `build/` folder. - `Input Dockstation` may also require `Accessibility` and `Input Monitoring`, depending on the active role. - Receiver may require `Accessibility` or `Input Monitoring` when it participates in input relay. - In practice, `Input Dockstation` is a two-sided feature: one Mac captures input, the other injects it, so permissions may be needed on both Sender and Receiver. +- On the Sender, use the `Permissions` toolbar button to review live status and request the native macOS prompts. macOS still requires confirmation from the person using the Mac; TargetBridge cannot grant these permissions automatically. - See [docs/Addons.md#input-dockstation](docs/Addons.md#input-dockstation) for the permission matrix. ## Quick start diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 8660b5a5..9fb161d0 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -16,7 +16,7 @@ CC ?= clang PKG ?= pkgconf -APP_VERSION ?= 3.5.2 +APP_VERSION ?= 3.6.0 APP_BUILD ?= dev MACOSX_DEPLOYMENT_TARGET ?= 11.0 diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index e432ee95..2805f916 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -21,7 +21,7 @@ #include #ifndef TB_RECEIVER_VERSION -#define TB_RECEIVER_VERSION "3.5.2" +#define TB_RECEIVER_VERSION "3.6.0" #endif #ifndef TB_RECEIVER_BUILD diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index 289e46cd..af005e05 100755 --- a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh +++ b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh @@ -8,7 +8,7 @@ BUILD_DIR="${REPO_ROOT}/build" APP_DIR="${BUILD_DIR}/TargetBridge Receiver.app" BIN_NAME="TargetBridgeReceiver" APP_NAME="TargetBridge Receiver" -APP_VERSION="${APP_VERSION:-3.5.2}" +APP_VERSION="${APP_VERSION:-3.6.0}" STAMP="${APP_BUILD:-$(date +%Y%m%d%H%M%S)}" ARCH="$(uname -m)" ICONSET_DIR="$(mktemp -d)" diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift index b3ff066d..9dd1e6c9 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.5.2" - static let buildNumber = "20260810141154" + static let marketingVersion = "3.6.0" + static let buildNumber = "20261003023000" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 14887fb0..a8feb7d8 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -376,7 +376,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"${TB_SENDER_BUILD:-$(date +%Y%m%d%H%M%S)}\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" <`, `--mode mirror|extended`, `--preset `, `--path auto|wired|thunderbolt|usb|ethernet|wifi`, `--transport tb|net`, `--session N`, `--local-ip `, `--retry`. -Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `retina4k60`, +Presets: `standard1080p`, `smooth1080p60`, `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `retina4k60`, `native5k`, `native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, -`5k`, `5k60`). The `retina4k60` aliases `retina4k`, `4096x2304` and `imac4k` match +`5k`, `5k60`, `1080p`, `1080p60`). The `retina4k60` aliases `retina4k`, `4096x2304` and `imac4k` match the 4096 × 2304 panel in the 21.5-inch Retina 4K iMac without changing the existing `4k` alias for the 3840 × 2160 preset. `native5k60Experimental` is an opt-in HEVC test profile for 5K at 60 FPS; it does not diff --git a/docs/Features.md b/docs/Features.md index d2400983..2df9f0d3 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -11,6 +11,10 @@ TargetBridge supports two main display modes: Extended layouts can be arranged in macOS **System Settings -> Displays -> Arrange**. TargetBridge remembers the saved arrangement per receiver when possible and restores it on reconnect. +When a receiver is chosen from discovery, TargetBridge stores its stable receiver +identity rather than only its current IP. A Thunderbolt Bridge reconnect or a +new DHCP address therefore does not make the sender forget the selected Mac. + If the receiver panel does not look correct, set the matching resolution on the TargetBridge display in macOS Display Settings. For 27-inch 5K iMac workflows, the `Crisp` or `5K` stream profiles usually pair best with the matching HiDPI arrangement. The `Full HD` profiles are for non-Retina 1080p receivers, such as every 21.5-inch @@ -26,6 +30,35 @@ Related reading: - [docs/QuickStart-IT.md](docs/QuickStart-IT.md) - [docs/QuickStart-ZH.md](docs/QuickStart-ZH.md) +## Stream Profiles and Codecs + +Each session can select **Automatic**, **H.264**, or **HEVC** before it starts. +The choice is saved per discovered receiver. Automatic remains the recommended +option: it checks the receiver capability and the sender hardware encoder at +the selected resolution. If a requested codec is unavailable, TargetBridge +uses a compatible hardware fallback and records that choice in the log. + +For non-Retina 1080p receivers, use **Full HD** or **Full HD 60**. These +profiles create a 1920 × 1080 1x virtual display and avoid the double scaling +that a HiDPI mode would introduce on a 1080p panel. + +In a genuinely headless mirror session, where the virtual display is the only +online display, TargetBridge captures that display directly. A companion +audio-only ScreenCaptureKit stream keeps Audio Relay available on this path. + +## Permission Assistant + +The Sender toolbar includes **Permissions**. It shows the current state of +Screen Recording, Accessibility, and Input Monitoring, can request the native +macOS permission prompts, and opens Privacy & Security when needed. The state +refreshes after returning to TargetBridge from System Settings. + +macOS deliberately requires a user to approve each permission for each +installed application. The assistant streamlines that process but cannot grant +or retain permission on the user's behalf. For Input Dockstation, check the +required permissions on both the Sender and Receiver according to the active +role. + ## Display Profiles Each sender session offers three ready-to-use display profiles before connecting: diff --git a/docs/releases/3.6.0.md b/docs/releases/3.6.0.md new file mode 100644 index 00000000..2cd462c0 --- /dev/null +++ b/docs/releases/3.6.0.md @@ -0,0 +1,43 @@ +# TargetBridge 3.6.0 + +TargetBridge 3.6.0 is a feature release focused on resilient receiver setup, +adaptive streaming, and clearer macOS permission onboarding. + +## Highlights + +- Remember discovered receivers by stable identity, so reconnecting a + Thunderbolt or network interface does not discard the selected receiver. +- Choose Automatic, H.264, or HEVC compression per receiver. Automatic safely + falls back to compatible hardware when the requested codec is unavailable. +- Add Full HD and Full HD 60 stream profiles for non-Retina 1080p receivers. + They use a native 1920 × 1080 1x virtual display to avoid unnecessary scaling. +- Capture the virtual display directly in headless mirror sessions while keeping + Audio Relay available through an auxiliary audio capture. +- Add the Sender permission assistant for Screen Recording, Accessibility, and + Input Monitoring. It requests the macOS prompts and refreshes when returning + from Privacy & Security. + +## Upgrade + +Update both Sender and Receiver together. Replace older copies in Applications +rather than keeping multiple app copies with the same bundle identifier. +macOS permissions apply to the installed application and still require user +approval after a rebuilt or newly signed copy is installed. + +The Sender requires macOS 14 or later. The Receiver supports macOS 11 or later. +For Input Dockstation, check the permission matrix on both Macs. + +## Validation + +The release candidate passes 163 Sender unit tests, the stable receiver +identity test package, and 654 Receiver parser, input-queue, watchdog, and +profile checks. These automated checks do not replace a real two-Mac test of +the selected stream profile, audio relay, and input role. + +## Thanks + +Thank you to [@Betafer](https://github.com/Betafer) for the receiver identity, +codec selection, and headless capture contributions, and to +[@polmarinm](https://github.com/polmarinm) for the native-scale Full HD profile. +Thank you as well to everyone testing TargetBridge on real hardware and sharing +clear reproduction details. From 5f3836234999b265b7a1d167b986957704bc5dce Mon Sep 17 00:00:00 2001 From: swellweb Date: Sat, 3 Oct 2026 02:33:07 +0200 Subject: [PATCH 09/20] ci: validate pull requests targeting dev-3.6.0 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c14519ba..9916328a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI Build Verification on: push: - branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0 ] + branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0, dev-3.6.0 ] pull_request: - branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0 ] + branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0, dev-3.6.0 ] jobs: build-sender: From f1253eb73b6e8c010517429c839a1c58ffb6bab1 Mon Sep 17 00:00:00 2001 From: swellweb Date: Thu, 1 Oct 2026 13:22:47 +0200 Subject: [PATCH 10/20] fix(input): keep mouse relay off the logging hot path (cherry picked from commit 9ea26f54789ab12755f0a6dcbc5e0619bee71aa0) --- .../TBDisplaySenderService.swift | 18 ++++- .../TBDisplayShared/TBInputDebugLog.swift | 68 ++++++++++++++----- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index e6365120..24ccd111 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1356,6 +1356,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var cursorDisplayID: CGDirectDisplayID = kCGNullDirectDisplay private var lastCursorPacket: TBMonitorCursor? private var injectedRemoteMouseLocation: CGPoint? + private var cachedLocalInputEventSource: CGEventSource? private var injectedLeftClickTracker = TBInjectedClickStateTracker() private let localPointerModifierBridge = TBLocalPointerModifierBridge() private var injectedCommandDown = false @@ -2263,14 +2264,21 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func localInputEventSource() -> CGEventSource? { + if let cachedLocalInputEventSource { + return cachedLocalInputEventSource + } let source = CGEventSource(stateID: .hidSystemState) source?.localEventsSuppressionInterval = 0 + cachedLocalInputEventSource = source return source } private func logLocalInputInjectionStateIfNeeded(context: String) { - let trusted = AXIsProcessTrusted() - TBInputDebugLog.log("sender input injection state trusted=\(trusted) context=\(context)") + if context == "mouseMove" { + TBInputDebugLog.logInputEvent("sender input injection state trusted=\(AXIsProcessTrusted()) context=\(context)") + } else { + TBInputDebugLog.log("sender input injection state trusted=\(AXIsProcessTrusted()) context=\(context)") + } } private func postLocalMouseMove(dx: Int, dy: Int, type: CGEventType = .mouseMoved, button: CGMouseButton = .left) { @@ -2423,7 +2431,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func applyIncomingInputEvent(_ event: TBMonitorInputEvent, payload: Data) { - TBInputDebugLog.log("sender applying incoming event kind=\(event.kind)") + if event.kind == "move" || event.kind.hasSuffix("Drag") || event.kind == "scroll" { + TBInputDebugLog.logInputEvent("sender applying incoming event kind=\(event.kind)") + } else { + TBInputDebugLog.log("sender applying incoming event kind=\(event.kind)") + } switch event.kind { case "move": postLocalMouseMove(dx: event.dx ?? 0, dy: event.dy ?? 0) diff --git a/TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift b/TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift index acdef6af..0c6af063 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift @@ -1,37 +1,69 @@ import Foundation -@MainActor -enum TBInputDebugLog { - private static let fileManager = FileManager.default +private final class TBAsyncInputDebugLogWriter: @unchecked Sendable { + private let fileManager = FileManager.default + private let writeQueue = DispatchQueue(label: "fd.tbdisplaysender.input-debug-log", qos: .utility) - private static var logURL: URL { + func enqueue(_ message: String) { + writeQueue.async { [self] in + write(message) + } + } + + private func write(_ message: String) { let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true) - return base + let url = base .appendingPathComponent("TargetBridge", isDirectory: true) .appendingPathComponent("Logs", isDirectory: true) .appendingPathComponent("input-debug.log", isDirectory: false) - } - - static func log(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "\(timestamp) \(message)\n" - let url = logURL let directory = url.deletingLastPathComponent() try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - if let data = line.data(using: .utf8) { - if fileManager.fileExists(atPath: url.path) { - if let handle = try? FileHandle(forWritingTo: url) { - try? handle.seekToEnd() - try? handle.write(contentsOf: data) - try? handle.close() - } - } else { - try? data.write(to: url, options: .atomic) + guard let data = line.data(using: .utf8) else { return } + if fileManager.fileExists(atPath: url.path) { + if let handle = try? FileHandle(forWritingTo: url) { + try? handle.seekToEnd() + try? handle.write(contentsOf: data) + try? handle.close() } + } else { + try? data.write(to: url, options: .atomic) } } +} + +@MainActor +enum TBInputDebugLog { + private static let writer = TBAsyncInputDebugLogWriter() + private static var inputEventCount: UInt64 = 0 + private static let inputEventBurstLimit: UInt64 = 20 + private static let inputEventSampleInterval: UInt64 = 100 + + private static var logURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true) + return base + .appendingPathComponent("TargetBridge", isDirectory: true) + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent("input-debug.log", isDirectory: false) + } + + static func log(_ message: String) { + writer.enqueue(message) + } + + /// Mouse motion can arrive hundreds of times per second. Keep the first few + /// records plus a periodic sample, without putting file I/O on the input path. + static func logInputEvent(_ message: @autoclosure () -> String) { + inputEventCount &+= 1 + let count = inputEventCount + + guard count <= inputEventBurstLimit || count.isMultiple(of: inputEventSampleInterval) else { return } + log(message()) + } static var currentLogPath: String { logURL.path From 3c061d0adacbc7f50f5f3c0eaa395a020c631bd9 Mon Sep 17 00:00:00 2001 From: swellweb Date: Thu, 1 Oct 2026 13:24:16 +0200 Subject: [PATCH 11/20] fix(display): prefer HiDPI backing mode for render matching (cherry picked from commit b6f54248ab3681e605e66764e46129ba655801b5) --- .../ReceiverBackedVirtualDisplaySession.swift | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index e58e55b7..32dee85c 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -173,6 +173,7 @@ final class ReceiverBackedVirtualDisplaySession { activatePreferredMode(for: display.displayID, mode: resolvedMode, refreshRate: preferredRefreshRate, + hiDPI: profile.hiDPI, savedChoice: savedChoice) virtualDisplay = display @@ -200,13 +201,14 @@ final class ReceiverBackedVirtualDisplaySession { private func activatePreferredMode(for displayID: CGDirectDisplayID, mode: TBVirtualDisplayModeSize, refreshRate: Double, + hiDPI: Bool, savedChoice: TBVirtualDisplayModeMemory.Choice?) -> Bool { let timeout = Date().addingTimeInterval(2.0) while Date() < timeout { var success = false autoreleasepool { let chosenMode = savedChoice.flatMap { savedMode(for: displayID, choice: $0) } - ?? preferredMode(for: displayID, mode: mode, refreshRate: refreshRate) + ?? preferredMode(for: displayID, mode: mode, refreshRate: refreshRate, hiDPI: hiDPI) if let chosenMode { success = CGDisplaySetDisplayMode(displayID, chosenMode, nil) == .success } @@ -240,8 +242,16 @@ final class ReceiverBackedVirtualDisplaySession { return candidates.first } - private func preferredMode(for displayID: CGDirectDisplayID, mode: TBVirtualDisplayModeSize, refreshRate: Double) -> CGDisplayMode? { - guard let modesCF = CGDisplayCopyAllDisplayModes(displayID, nil) else { + /// Select the mode by its backing pixels as well as its logical size. A + /// virtual HiDPI display exposes both a Retina 2x and a low-resolution 1x + /// variant that can report the same logical 2560x1440 size. Choosing only + /// by points made Render Matching restore the blurry 1x variant. + private func preferredMode(for displayID: CGDirectDisplayID, + mode: TBVirtualDisplayModeSize, + refreshRate: Double, + hiDPI: Bool) -> CGDisplayMode? { + let options = [kCGDisplayShowDuplicateLowResolutionModes: kCFBooleanTrue] as CFDictionary + guard let modesCF = CGDisplayCopyAllDisplayModes(displayID, options) else { return nil } let modes = modesCF as? [CGDisplayMode] ?? [] @@ -250,10 +260,16 @@ final class ReceiverBackedVirtualDisplaySession { candidate.width == mode.width && candidate.height == mode.height }.sorted { $0.refreshRate > $1.refreshRate } - if let exactMatch = matchingModes.first(where: { abs($0.refreshRate - refreshRate) < 0.5 }) { + let expectedPixelWidth = hiDPI ? mode.backingWidth : mode.width + let expectedPixelHeight = hiDPI ? mode.backingHeight : mode.height + let backingMatches = matchingModes.filter { candidate in + candidate.pixelWidth == expectedPixelWidth && candidate.pixelHeight == expectedPixelHeight + } + + if let exactMatch = backingMatches.first(where: { abs($0.refreshRate - refreshRate) < 0.5 }) { return exactMatch } - return matchingModes.first + return backingMatches.first ?? matchingModes.first } } From f21f4a462bbe7a0d63838697c637129d9aa992c4 Mon Sep 17 00:00:00 2001 From: swellweb Date: Thu, 1 Oct 2026 13:25:29 +0200 Subject: [PATCH 12/20] fix(network): declare local network and Bonjour access (cherry picked from commit 3a74aad381ffdf4ad91203c8e668bebeb84c52ec) --- TargetBridge-Sender/TargetBridgeSupport/Info.plist | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/TargetBridge-Sender/TargetBridgeSupport/Info.plist b/TargetBridge-Sender/TargetBridgeSupport/Info.plist index 66460518..cc8a32cc 100644 --- a/TargetBridge-Sender/TargetBridgeSupport/Info.plist +++ b/TargetBridge-Sender/TargetBridgeSupport/Info.plist @@ -12,6 +12,12 @@ TargetBridge requires screen recording permission to capture the virtual display or mirror the desktop to the iMac. NSAppleEventsUsageDescription TargetBridge uses System Events to run shortcuts selected for Receiver is Master. + NSBonjourServices + + _targetbridge._tcp + + NSLocalNetworkUsageDescription + TargetBridge connects to your receiver over Thunderbolt Bridge or your local network. CFBundleURLTypes From 060fb80fbeef430bc10bdd1563417b2ef4d670dd Mon Sep 17 00:00:00 2001 From: swellweb Date: Mon, 5 Oct 2026 17:23:50 +0200 Subject: [PATCH 13/20] release: stamp TargetBridge 3.6.0 build --- .../TBDisplaySender/TBDisplaySenderBuildInfo.swift | 2 +- TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj | 4 ++-- TargetBridge-Sender/project.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift index 9dd1e6c9..1b64444c 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { static let marketingVersion = "3.6.0" - static let buildNumber = "20261003023000" + static let buildNumber = "20261005172051" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index a8feb7d8..f66b095e 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -455,7 +455,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20261003023000; + CURRENT_PROJECT_VERSION = 20261005172051; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TargetBridgeSupport/Info.plist; @@ -479,7 +479,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20261003023000; + CURRENT_PROJECT_VERSION = 20261005172051; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TargetBridgeSupport/Info.plist; diff --git a/TargetBridge-Sender/project.yml b/TargetBridge-Sender/project.yml index caee4ee8..6c1627f6 100644 --- a/TargetBridge-Sender/project.yml +++ b/TargetBridge-Sender/project.yml @@ -28,7 +28,7 @@ targets: # live in TargetBridgeSupport/Info.plist (Xcode drops INFOPLIST_KEY_* usage strings once # INFOPLIST_FILE is set; the file is the single source of truth for those + the URL scheme). MARKETING_VERSION: "3.6.0" - CURRENT_PROJECT_VERSION: "20261003023000" + CURRENT_PROJECT_VERSION: "20261005172051" SWIFT_VERSION: "6.0" ENABLE_HARDENED_RUNTIME: NO SWIFT_STRICT_CONCURRENCY: minimal From 252fb88fb0d3c8aef9011e788d5624e7531ab3a7 Mon Sep 17 00:00:00 2001 From: swellweb Date: Tue, 6 Oct 2026 21:03:55 +0200 Subject: [PATCH 14/20] fix(release): share build identifier across app bundles --- .github/workflows/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d693a54a..961dfeb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,9 @@ jobs: - name: Build Sender App run: | cd TargetBridge-Sender - ./scripts/build_targetbridge_sender_app.sh + TB_BUILD="$(grep -m1 'CURRENT_PROJECT_VERSION:' project.yml | sed -E 's/.*\"([^\"]+)\".*/\1/')" + test -n "$TB_BUILD" + TB_SENDER_BUILD="$TB_BUILD" ./scripts/build_targetbridge_sender_app.sh file ../build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Zip App Bundle @@ -73,7 +75,9 @@ jobs: - name: Build Receiver App run: | cd TargetBridge-Receiver - ./scripts/build_tbreceiver_c_app.sh + TB_BUILD="$(grep -m1 'CURRENT_PROJECT_VERSION:' ../TargetBridge-Sender/project.yml | sed -E 's/.*\"([^\"]+)\".*/\1/')" + test -n "$TB_BUILD" + APP_BUILD="$TB_BUILD" ./scripts/build_tbreceiver_c_app.sh - name: Zip App Bundle run: | From 82acad8444f7c4491ee74f74898e77299f669b46 Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 9 Sep 2026 17:21:45 +0200 Subject: [PATCH 15/20] fix(sender): require persistent signing identity for updates (cherry picked from commit cc8f2844282a9f058118f8f9c288ac73ff77cee4) --- .../scripts/build_targetbridge_sender_app.sh | 21 ++++- .../scripts/sign_targetbridge_sender_app.sh | 39 +++++++++ .../scripts/test_sender_signing.sh | 42 ++++++++++ .../scripts/test_sender_signing_guards.sh | 27 ++++++ .../scripts/verify_sender_update.sh | 19 +++++ docs/Sender-Signing.md | 84 +++++++++++++++++++ 6 files changed, 228 insertions(+), 4 deletions(-) create mode 100755 TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh create mode 100755 TargetBridge-Sender/scripts/test_sender_signing.sh create mode 100755 TargetBridge-Sender/scripts/test_sender_signing_guards.sh create mode 100755 TargetBridge-Sender/scripts/verify_sender_update.sh create mode 100644 docs/Sender-Signing.md diff --git a/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh b/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh index ed579e7d..3fc77263 100755 --- a/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh +++ b/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh @@ -10,6 +10,7 @@ BUILD_DIR="${DERIVED_DATA_DIR}/Build/Products/${CONFIGURATION}" SOURCE_APP="${BUILD_DIR}/TargetBridge.app" DEST_DIR="${REPO_ROOT}/build" DEST_APP="${DEST_DIR}/TargetBridge.app" +"$SCRIPT_DIR/sign_targetbridge_sender_app.sh" --check-identity cd "$ROOT" @@ -25,12 +26,24 @@ xcodebuild \ build mkdir -p "$DEST_DIR" -rm -rf "$DEST_APP" -ditto "$SOURCE_APP" "$DEST_APP" +STAGING_DIR=$(mktemp -d "$DEST_DIR/.sender-signing.XXXXXX") +STAGED_APP="$STAGING_DIR/TargetBridge.app" +ditto "$SOURCE_APP" "$STAGED_APP" echo "Cleaning extended attributes..." -xattr -cr "$DEST_APP" || true +xattr -cr "$STAGED_APP" echo "Signing sender application..." -codesign --force --deep --sign - "$DEST_APP" || true +"$SCRIPT_DIR/sign_targetbridge_sender_app.sh" "$STAGED_APP" +if [[ -e "$DEST_APP" ]]; then + # Retain the old artifact inside the staging directory for recovery. + mv "$DEST_APP" "$STAGING_DIR/Previous TargetBridge.app" +fi +if ! mv "$STAGED_APP" "$DEST_APP"; then + if [[ ! -e "$DEST_APP" && -e "$STAGING_DIR/Previous TargetBridge.app" ]]; then + mv "$STAGING_DIR/Previous TargetBridge.app" "$DEST_APP" + fi + echo "Could not activate the signed build; recovery files: $STAGING_DIR" >&2 + exit 1 +fi touch "$DEST_APP" echo "TargetBridge sender built: $DEST_APP" diff --git a/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh b/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh new file mode 100755 index 00000000..88ea4f2d --- /dev/null +++ b/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh @@ -0,0 +1,39 @@ +#!/bin/zsh +set -euo pipefail + +fail() { print -u2 -- "Sender signing: $*"; exit 1; } +APP="${1:-}" +[[ -n "$APP" && $# -eq 1 ]] || fail "Usage: $0 TargetBridge.app | --check-identity" + +CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt}" +IDENTITY="${TARGETBRIDGE_CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" && -f "$CONFIG" ]]; then + IDENTITY=$(<"$CONFIG") +fi +if [[ -z "$IDENTITY" ]]; then + fail "No persistent signing identity. Configure TARGETBRIDGE_CODESIGN_IDENTITY or $CONFIG. See docs/Sender-Signing.md. Refusing an implicit ad-hoc build." +fi +if [[ "$IDENTITY" == - ]]; then + [[ "${TARGETBRIDGE_ALLOW_ADHOC:-0}" == 1 ]] || fail "Ad-hoc signing requires TARGETBRIDGE_ALLOW_ADHOC=1; updates may lose privacy permissions." + print -u2 -- "WARNING: disposable ad-hoc development build; do not install over a persistent signed Sender." +else + [[ "$IDENTITY" =~ '^[[:xdigit:]]{40}$' ]] || fail "Use the exact 40-character certificate fingerprint, not a display name" + IDENTITIES=$(/usr/bin/security find-identity -v -p codesigning) + [[ "${(U)IDENTITIES}" == *" ${(U)IDENTITY} "* ]] || fail "Configured certificate is not a valid code-signing identity in this user's Keychain. Unlock/configure it through macOS; no fallback is allowed." +fi +[[ "$APP" != --check-identity ]] || { print -- 'Signing identity preflight passed'; exit 0; } +PLIST="$APP/Contents/Info.plist" +[[ -f "$PLIST" && ! -L "$APP" ]] || fail "Expected a real application bundle" +BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST") +[[ "$BUNDLE_ID" == com.targetbridge.sender ]] || fail "Wrong bundle identifier: $BUNDLE_ID" + +# Use the system-generated designated requirement. Never weaken it to just a +# bundle identifier: certificate-bound identity is what makes updates trustworthy. +/usr/bin/codesign --force --sign "$IDENTITY" "$APP" +/usr/bin/codesign --verify --deep --strict --all-architectures "$APP" +DETAILS=$(/usr/bin/codesign -dv "$APP" 2>&1) +if [[ "$IDENTITY" != - && "$DETAILS" == *"Signature=adhoc"* ]]; then + fail "A persistent signature was requested but the result is ad-hoc" +fi +/usr/bin/codesign -d -r- "$APP" 2>&1 +print -- "Sender signature verified: $APP" diff --git a/TargetBridge-Sender/scripts/test_sender_signing.sh b/TargetBridge-Sender/scripts/test_sender_signing.sh new file mode 100755 index 00000000..86567faa --- /dev/null +++ b/TargetBridge-Sender/scripts/test_sender_signing.sh @@ -0,0 +1,42 @@ +#!/bin/zsh +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/targetbridge-signing-test.XXXXXX") +BASE="${1:?Usage: test_sender_signing.sh signed-TargetBridge.app}" +SIGN="$SCRIPT_DIR/sign_targetbridge_sender_app.sh" +VERIFY="$SCRIPT_DIR/verify_sender_update.sh" +expect_reject() { + local title="$1"; shift + if "$@" >"$WORK/rejection.log" 2>&1; then + print -u2 -- "FAIL: accepted $title"; exit 1 + fi + print -- "PASS: rejected $title" +} +ditto "$BASE" "$WORK/A.app" +ditto "$BASE" "$WORK/B.app" +# Changing a bundle resource changes the sealed CodeDirectory, reproducing the +# identity risk without launching a capture process or requesting permissions. +/usr/libexec/PlistBuddy -c 'Set :CFBundleVersion 999999' "$WORK/B.app/Contents/Info.plist" +zsh "$SIGN" "$WORK/B.app" +zsh "$VERIFY" "$WORK/A.app" "$WORK/B.app" +HASH_A=$(/usr/bin/shasum -a 256 "$WORK/A.app/Contents/MacOS/TargetBridge") +HASH_B=$(/usr/bin/shasum -a 256 "$WORK/B.app/Contents/MacOS/TargetBridge") +[[ "${HASH_A%% *}" != "${HASH_B%% *}" ]] || { print -u2 'FAIL: fixtures have identical signed executables'; exit 1; } +print -- 'PASS: different signed executable hashes, stable identity' +expect_reject 'missing identity' env TARGETBRIDGE_CODESIGN_IDENTITY= TARGETBRIDGE_SIGNING_CONFIG="$WORK/missing" zsh "$SIGN" "$WORK/B.app" +expect_reject 'unavailable identity' env TARGETBRIDGE_CODESIGN_IDENTITY=0000000000000000000000000000000000000000 zsh "$SIGN" "$WORK/B.app" +expect_reject 'implicit ad-hoc signing' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=0 zsh "$SIGN" "$WORK/B.app" +ditto "$WORK/B.app" "$WORK/Adhoc.app" +TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +expect_reject 'ad-hoc update' zsh "$VERIFY" "$WORK/A.app" "$WORK/Adhoc.app" +expect_reject 'ad-hoc baseline' zsh "$VERIFY" "$WORK/Adhoc.app" "$WORK/B.app" +ditto "$WORK/B.app" "$WORK/WrongID.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier com.targetbridge.receiver' "$WORK/WrongID.app/Contents/Info.plist" +expect_reject 'wrong signing bundle ID' zsh "$SIGN" "$WORK/WrongID.app" +expect_reject 'wrong update bundle ID' zsh "$VERIFY" "$WORK/A.app" "$WORK/WrongID.app" +ditto "$WORK/B.app" "$WORK/Tampered.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleVersion 888888' "$WORK/Tampered.app/Contents/Info.plist" +expect_reject 'tampered candidate' zsh "$VERIFY" "$WORK/A.app" "$WORK/Tampered.app" +expect_reject 'tampered installed baseline' zsh "$VERIFY" "$WORK/Tampered.app" "$WORK/B.app" +zsh "$VERIFY" "$WORK/A.app" "$WORK/B.app" +print -- "All signing checks passed. Fixtures retained at $WORK; no installed app or TCC setting changed." diff --git a/TargetBridge-Sender/scripts/test_sender_signing_guards.sh b/TargetBridge-Sender/scripts/test_sender_signing_guards.sh new file mode 100755 index 00000000..6974d0b8 --- /dev/null +++ b/TargetBridge-Sender/scripts/test_sender_signing_guards.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +# Negative-path checks that do not need a trusted signing certificate. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/targetbridge-signing-guards.XXXXXX") +BASE="${1:?Usage: test_sender_signing_guards.sh TargetBridge.app}" +SIGN="$SCRIPT_DIR/sign_targetbridge_sender_app.sh" +VERIFY="$SCRIPT_DIR/verify_sender_update.sh" +reject_with() { + local expected="$1"; shift + if "$@" >"$WORK/rejection.log" 2>&1; then + print -u2 -- "FAIL: unexpectedly accepted $expected"; exit 1 + fi + /usr/bin/grep -Fq -- "$expected" "$WORK/rejection.log" || { /bin/cat "$WORK/rejection.log"; exit 1; } + print -- "PASS: $expected" +} +reject_with 'No persistent signing identity' env TARGETBRIDGE_CODESIGN_IDENTITY= TARGETBRIDGE_SIGNING_CONFIG="$WORK/missing" zsh "$SIGN" --check-identity +reject_with 'exact 40-character certificate fingerprint' env TARGETBRIDGE_CODESIGN_IDENTITY='unverified display name' zsh "$SIGN" --check-identity +reject_with 'not a valid code-signing identity' env TARGETBRIDGE_CODESIGN_IDENTITY=0000000000000000000000000000000000000000 zsh "$SIGN" --check-identity +reject_with 'Ad-hoc signing requires' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=0 zsh "$SIGN" --check-identity +ditto "$BASE" "$WORK/Adhoc.app" +TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +reject_with 'Ad-hoc identity cannot preserve' zsh "$VERIFY" "$WORK/Adhoc.app" "$WORK/Adhoc.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier com.targetbridge.receiver' "$WORK/Adhoc.app/Contents/Info.plist" +reject_with 'Wrong bundle identifier' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +reject_with 'Unexpected bundle ID' zsh "$VERIFY" "$WORK/Adhoc.app" "$BASE" +print -- "7 negative checks passed. Persistent-signature and TCC migration tests still require a valid identity and human consent. Fixtures: $WORK" diff --git a/TargetBridge-Sender/scripts/verify_sender_update.sh b/TargetBridge-Sender/scripts/verify_sender_update.sh new file mode 100755 index 00000000..58e825ff --- /dev/null +++ b/TargetBridge-Sender/scripts/verify_sender_update.sh @@ -0,0 +1,19 @@ +#!/bin/zsh +set -euo pipefail +fail() { print -u2 -- "Sender update rejected: $*"; exit 1; } +[[ $# -eq 2 ]] || fail "Usage: $0 installed.app candidate.app" +OLD="$1" +NEW="$2" +for APP in "$OLD" "$NEW"; do + [[ -f "$APP/Contents/Info.plist" && ! -L "$APP" ]] || fail "Invalid app: $APP" + ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist") + [[ "$ID" == com.targetbridge.sender ]] || fail "Unexpected bundle ID: $ID" + /usr/bin/codesign --verify --deep --strict --all-architectures "$APP" + DETAILS=$(/usr/bin/codesign -dv "$APP" 2>&1) + [[ "$DETAILS" != *"Signature=adhoc"* ]] || fail "Ad-hoc identity cannot preserve update permissions. Migrate once to a persistent signer and reapprove in macOS." +done +REQUIREMENT=$(/usr/bin/codesign -d -r- "$OLD" 2>&1 | /usr/bin/sed -nE 's/^#? ?designated => //p') +[[ -n "$REQUIREMENT" ]] || fail "Missing installed identity requirement" +/usr/bin/codesign --verify --deep --strict --all-architectures -R "$REQUIREMENT" "$NEW" || fail "Candidate does not satisfy the installed Sender identity" +print -- "PASS: candidate satisfies the installed Sender identity on every architecture." +print -- "This verifies signing continuity, not a TCC grant. Install at /Applications/TargetBridge.app without re-signing." diff --git a/docs/Sender-Signing.md b/docs/Sender-Signing.md new file mode 100644 index 00000000..91b6a9af --- /dev/null +++ b/docs/Sender-Signing.md @@ -0,0 +1,84 @@ +# Stable Sender identity and privacy permissions + +macOS recognises updates by the app's code-signing designated requirement, not +just its name or version. An ad-hoc signature binds that requirement to the +binary's hashes. Rebuilding changes those hashes and can invalidate Screen +Recording and Accessibility consent. + +## Build and sign + +Use one persistent certificate and keep `com.targetbridge.sender` as the bundle +identifier. Configure `TARGETBRIDGE_CODESIGN_IDENTITY` with the certificate's +fingerprint, or store that public fingerprint in: + +`~/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt` + +The regular Sender build now refuses to silently produce an ad-hoc artifact. +Signing errors and verification failures are fatal. It stages the candidate +before replacing the previous build, so a missing/locked signing key leaves the +previous artifact intact. + +For private local builds only, a persistent self-signed code-signing identity +can be configured using Apple's Certificate Assistant in Keychain Access. +Any change to its trust settings requires the Mac owner's explicit approval; +limit trust to Code Signing. These build scripts never create/trust certificates, +unlock a Keychain, export keys, or change TCC settings. Do not use an unrestricted +trust-root setup. Verify the chosen fingerprint against the actual certificate. + +Keep the signing Keychain and always sign on that Mac, even when compilation +happens elsewhere. Do not recreate the certificate for each build. A lost key +requires an intentional identity migration and new consent. A local certificate +is not a substitute for Developer ID and notarization for public distribution. + +For distribution, use Developer ID plus the required notarization workflow; +these scripts alone do not implement notarization. Do not mix local and upstream signers if preserving existing consent +is required. No Apple Developer membership is provisioned by these scripts. + +For a disposable development build only, explicitly opt in to both +`TARGETBRIDGE_CODESIGN_IDENTITY=-` and `TARGETBRIDGE_ALLOW_ADHOC=1`. Never install +it over a Sender that is already persistently signed. + +## Install/update gate + +Before replacing an installed, persistently signed Sender: + +```sh +scripts/verify_sender_update.sh /Applications/TargetBridge.app ../build/TargetBridge.app +``` + +The check rejects ad-hoc signatures, broken bundles, wrong bundle IDs and a +candidate that fails the installed app's designated requirement on any +architecture. Never re-sign, modify Info.plist or otherwise change the bundle +after that check. Preserve the signature with `ditto` and verify it again at the +destination. Keep a recoverable backup outside Applications. + +An initial migration from an ad-hoc app intentionally fails this gate: it is an +identity change, not a seamless update. Back up the installed app, replace it +once with the persistently signed app at `/Applications/TargetBridge.app`, and +approve Screen Recording and Accessibility there if macOS asks. Do not edit +TCC databases, run blanket permission resets, or disable system protections. + +## Validation + +Test two builds with different executable contents signed with the same +identity: both must pass signature verification and the second must satisfy the +first's designated requirement. Test that an ad-hoc update, a different signer, +wrong bundle ID and a tampered resource are rejected. + +`scripts/test_sender_signing.sh /path/to/signed/TargetBridge.app` covers signing +continuity with a changed sealed bundle, missing/unavailable identities, explicit +ad-hoc opt-in, bundle ID mismatch and tampering. Run it with a configured valid +identity; a refusal at preflight is not a passing integration test. Also test a +separate compiled revision and, when available, a second signer before release. + +Signing continuity is necessary, not proof that TCC granted permissions. The +final acceptance test is a real A-to-B installed update after human consent on +the Sender Mac, checking screen capture and receiver-controlled input without +granting them again. macOS can still ask for consent after a reset, identity +change, certificate loss, or OS privacy-policy change. + +References: +- https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements +- https://developer.apple.com/library/archive/technotes/tn2206/ +- https://developer.apple.com/forums/thread/819406 +- https://support.apple.com/guide/keychain-access/kyca8916/mac From 24b5d7354ccedf3ad9f2bb796f6eb8f3d160d353 Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 9 Sep 2026 23:22:22 +0200 Subject: [PATCH 16/20] fix(sender): pass update identity as a literal requirement (cherry picked from commit a17cdcae0666d26a83c7e25f0f8baab98e23fb65) --- TargetBridge-Sender/scripts/verify_sender_update.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TargetBridge-Sender/scripts/verify_sender_update.sh b/TargetBridge-Sender/scripts/verify_sender_update.sh index 58e825ff..aa6bcd12 100755 --- a/TargetBridge-Sender/scripts/verify_sender_update.sh +++ b/TargetBridge-Sender/scripts/verify_sender_update.sh @@ -14,6 +14,7 @@ for APP in "$OLD" "$NEW"; do done REQUIREMENT=$(/usr/bin/codesign -d -r- "$OLD" 2>&1 | /usr/bin/sed -nE 's/^#? ?designated => //p') [[ -n "$REQUIREMENT" ]] || fail "Missing installed identity requirement" -/usr/bin/codesign --verify --deep --strict --all-architectures -R "$REQUIREMENT" "$NEW" || fail "Candidate does not satisfy the installed Sender identity" +# '=' marks a literal requirement; without it codesign treats the text as a path. +/usr/bin/codesign --verify --deep --strict --all-architectures -R "=$REQUIREMENT" "$NEW" || fail "Candidate does not satisfy the installed Sender identity" print -- "PASS: candidate satisfies the installed Sender identity on every architecture." print -- "This verifies signing continuity, not a TCC grant. Install at /Applications/TargetBridge.app without re-signing." From 246f9f78225552c701302fc98c43a78a49e1bd7d Mon Sep 17 00:00:00 2001 From: Betafer Date: Sat, 12 Sep 2026 07:22:21 +0200 Subject: [PATCH 17/20] build(sender): retain unconfigured disposable CI builds (cherry picked from commit 0cc02f845e47b7914235b838cca16fd9bfd53bf8) --- .../scripts/build_targetbridge_sender_app.sh | 13 ++++++++++++- docs/Sender-Signing.md | 11 ++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh b/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh index 3fc77263..15f57dbd 100755 --- a/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh +++ b/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh @@ -10,7 +10,18 @@ BUILD_DIR="${DERIVED_DATA_DIR}/Build/Products/${CONFIGURATION}" SOURCE_APP="${BUILD_DIR}/TargetBridge.app" DEST_DIR="${REPO_ROOT}/build" DEST_APP="${DEST_DIR}/TargetBridge.app" -"$SCRIPT_DIR/sign_targetbridge_sender_app.sh" --check-identity +# Preserve the upstream disposable CI/development build when no signing setup +# was requested. Once configured (or explicitly required), never fall back. +SIGNING_CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt}" +if [[ -n "${TARGETBRIDGE_CODESIGN_IDENTITY:-}" || + -n "${TARGETBRIDGE_SIGNING_CONFIG:-}" || -e "$SIGNING_CONFIG" || + "${TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING:-0}" == 1 ]]; then + "$SCRIPT_DIR/sign_targetbridge_sender_app.sh" --check-identity +else + echo "WARNING: unconfigured disposable build; this ad-hoc app is not an identity-preserving update." >&2 + export TARGETBRIDGE_CODESIGN_IDENTITY=- + export TARGETBRIDGE_ALLOW_ADHOC=1 +fi cd "$ROOT" diff --git a/docs/Sender-Signing.md b/docs/Sender-Signing.md index 91b6a9af..23c7cbe1 100644 --- a/docs/Sender-Signing.md +++ b/docs/Sender-Signing.md @@ -13,8 +13,13 @@ fingerprint, or store that public fingerprint in: `~/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt` -The regular Sender build now refuses to silently produce an ad-hoc artifact. -Signing errors and verification failures are fatal. It stages the candidate +The regular Sender build preserves upstream's disposable ad-hoc CI/development +build when no signing configuration was requested, with a prominent warning. +Set `TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING=1` to require an update-safe build +even on an unconfigured machine. Configuring an identity or a configuration +file also enables strict preflight: a missing/invalid configured identity never +falls back to ad-hoc. Signing errors and verification failures are fatal. +The build stages the candidate before replacing the previous build, so a missing/locked signing key leaves the previous artifact intact. @@ -34,7 +39,7 @@ For distribution, use Developer ID plus the required notarization workflow; these scripts alone do not implement notarization. Do not mix local and upstream signers if preserving existing consent is required. No Apple Developer membership is provisioned by these scripts. -For a disposable development build only, explicitly opt in to both +When using the signing helper directly, a disposable development build must opt in to both `TARGETBRIDGE_CODESIGN_IDENTITY=-` and `TARGETBRIDGE_ALLOW_ADHOC=1`. Never install it over a Sender that is already persistently signed. From 39cb619b98b50d629cb1f47a41c0fd7aeddba57f Mon Sep 17 00:00:00 2001 From: Betafer Date: Sat, 12 Sep 2026 07:26:30 +0200 Subject: [PATCH 18/20] build(sender): package standalone executable for strict signing (cherry picked from commit 9bd4a0b8a696e9174d8bbda3e0907bd1413c979e) --- TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh | 3 +++ docs/Sender-Signing.md | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh b/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh index 15f57dbd..ef94da6d 100755 --- a/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh +++ b/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh @@ -27,6 +27,8 @@ cd "$ROOT" xcodegen generate +# Package a standalone app, not Xcode's preview executor plus unsigned debug +# dylibs. This also lets strict signing verify the same bundle on Xcode 16+. xcodebuild \ -scheme TBDisplaySender \ -configuration "$CONFIGURATION" \ @@ -34,6 +36,7 @@ xcodebuild \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ + ENABLE_DEBUG_DYLIB=NO \ build mkdir -p "$DEST_DIR" diff --git a/docs/Sender-Signing.md b/docs/Sender-Signing.md index 23c7cbe1..b0c275d6 100644 --- a/docs/Sender-Signing.md +++ b/docs/Sender-Signing.md @@ -23,6 +23,10 @@ The build stages the candidate before replacing the previous build, so a missing/locked signing key leaves the previous artifact intact. +The packaging build disables Xcode's debug-dylib/preview executor layout, so +the standalone app can be strictly signed without unsigned preview dylibs. +This does not change normal interactive builds in Xcode. + For private local builds only, a persistent self-signed code-signing identity can be configured using Apple's Certificate Assistant in Keychain Access. Any change to its trust settings requires the Mac owner's explicit approval; From 10f8ccd60b6e78504bab4a07fa5309da2df33c68 Mon Sep 17 00:00:00 2001 From: swellweb Date: Tue, 6 Oct 2026 21:23:34 +0200 Subject: [PATCH 19/20] fix(release): preserve macOS permission identity --- .../action.yml | 30 +++++++++++++ .github/workflows/release.yml | 22 +++++++++- README.md | 2 +- .../scripts/build_tbreceiver_c_app.sh | 20 ++++++--- .../scripts/sign_targetbridge_receiver_app.sh | 42 +++++++++++++++++++ docs/Sender-Signing.md | 28 +++++++++---- docs/releases/3.6.0.md | 6 ++- 7 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 .github/actions/import-macos-signing-certificate/action.yml create mode 100755 TargetBridge-Receiver/scripts/sign_targetbridge_receiver_app.sh diff --git a/.github/actions/import-macos-signing-certificate/action.yml b/.github/actions/import-macos-signing-certificate/action.yml new file mode 100644 index 00000000..4739548e --- /dev/null +++ b/.github/actions/import-macos-signing-certificate/action.yml @@ -0,0 +1,30 @@ +name: Import macOS signing certificate +description: Imports the release signing certificate and exports its persistent identity. +inputs: + certificate-base64: + required: true + certificate-password: + required: true +runs: + using: composite + steps: + - shell: bash + env: + CERTIFICATE_BASE64: ${{ inputs.certificate-base64 }} + CERTIFICATE_PASSWORD: ${{ inputs.certificate-password }} + run: | + set -euo pipefail + certificate_path="$RUNNER_TEMP/targetbridge-signing.p12" + keychain_path="$RUNNER_TEMP/targetbridge-signing.keychain-db" + keychain_password="$(uuidgen)" + printf '%s' "$CERTIFICATE_BASE64" | base64 -D > "$certificate_path" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" -k "$keychain_path" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security list-keychain -d user -s "$keychain_path" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" + identity="$(security find-identity -v -p codesigning "$keychain_path" | sed -nE 's/.* ([A-F0-9]{40}) .*/\1/p' | head -n1)" + test -n "$identity" + echo "TARGETBRIDGE_CODESIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "Imported TargetBridge signing identity ${identity:0:8}..." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 961dfeb2..3650bfb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,12 +29,21 @@ jobs: run: | brew install xcodegen + - name: Import release signing certificate + if: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 != '' }} + uses: ./.github/actions/import-macos-signing-certificate + with: + certificate-base64: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 }} + certificate-password: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_PASSWORD }} + - name: Build Sender App run: | cd TargetBridge-Sender TB_BUILD="$(grep -m1 'CURRENT_PROJECT_VERSION:' project.yml | sed -E 's/.*\"([^\"]+)\".*/\1/')" test -n "$TB_BUILD" - TB_SENDER_BUILD="$TB_BUILD" ./scripts/build_targetbridge_sender_app.sh + REQUIRE_PERSISTENT_SIGNING=0 + [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && REQUIRE_PERSISTENT_SIGNING=1 + TB_SENDER_BUILD="$TB_BUILD" TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING="$REQUIRE_PERSISTENT_SIGNING" ./scripts/build_targetbridge_sender_app.sh file ../build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Zip App Bundle @@ -72,12 +81,21 @@ jobs: run: | brew install ffmpeg sdl2 sdl3 dylibbundler + - name: Import release signing certificate + if: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 != '' }} + uses: ./.github/actions/import-macos-signing-certificate + with: + certificate-base64: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 }} + certificate-password: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_PASSWORD }} + - name: Build Receiver App run: | cd TargetBridge-Receiver TB_BUILD="$(grep -m1 'CURRENT_PROJECT_VERSION:' ../TargetBridge-Sender/project.yml | sed -E 's/.*\"([^\"]+)\".*/\1/')" test -n "$TB_BUILD" - APP_BUILD="$TB_BUILD" ./scripts/build_tbreceiver_c_app.sh + REQUIRE_PERSISTENT_SIGNING=0 + [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && REQUIRE_PERSISTENT_SIGNING=1 + APP_BUILD="$TB_BUILD" TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING="$REQUIRE_PERSISTENT_SIGNING" ./scripts/build_tbreceiver_c_app.sh - name: Zip App Bundle run: | diff --git a/README.md b/README.md index 085986d2..97e31c27 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ If you build from source, app outputs go into `build/` folder. - `Input Dockstation` may also require `Accessibility` and `Input Monitoring`, depending on the active role. - Receiver may require `Accessibility` or `Input Monitoring` when it participates in input relay. - In practice, `Input Dockstation` is a two-sided feature: one Mac captures input, the other injects it, so permissions may be needed on both Sender and Receiver. -- On the Sender, use the `Permissions` toolbar button to review live status and request the native macOS prompts. macOS still requires confirmation from the person using the Mac; TargetBridge cannot grant these permissions automatically. +- On the Sender, use the `Permissions` toolbar button to review live status and request the native macOS prompts. macOS still requires confirmation from the person using the Mac; TargetBridge cannot grant or overwrite these permissions automatically. Official updates keep a stable app identity so future releases can retain consent already granted to the app installed in `Applications`. See [Signing and permission continuity](docs/Sender-Signing.md). - See [docs/Addons.md#input-dockstation](docs/Addons.md#input-dockstation) for the permission matrix. ## Quick start diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index af005e05..025807ef 100755 --- a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh +++ b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh @@ -14,6 +14,19 @@ ARCH="$(uname -m)" ICONSET_DIR="$(mktemp -d)" ICON_FILE="${ROOT}/TargetBridgeAssets/Assets.xcassets/AppIcon.appiconset/icon_1024.png" ICNS_PATH="${APP_DIR}/Contents/Resources/TargetBridgeReceiver.icns" +SIGNING_CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/receiver-signing-identity.txt}" + +# Development builds remain possible without a certificate. A configured or +# required release build must never silently fall back to a new ad-hoc identity. +if [[ -n "${TARGETBRIDGE_CODESIGN_IDENTITY:-}" || + -n "${TARGETBRIDGE_SIGNING_CONFIG:-}" || -e "$SIGNING_CONFIG" || + "${TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING:-0}" == 1 ]]; then + "$SCRIPT_DIR/sign_targetbridge_receiver_app.sh" --check-identity +else + echo "WARNING: unconfigured disposable build; this ad-hoc app is not an identity-preserving update." >&2 + export TARGETBRIDGE_CODESIGN_IDENTITY=- + export TARGETBRIDGE_ALLOW_ADHOC=1 +fi cd "$ROOT/TBReceiverC" make clean @@ -108,11 +121,8 @@ cat > "$APP_DIR/Contents/Info.plist" < "$APP_DIR/Contents/PkgInfo" -# Sign each bundled dylib first, then the app -find "$APP_DIR/Contents/Frameworks" -name "*.dylib" | while read dylib; do - codesign --force --sign - "$dylib" >/dev/null 2>&1 || true -done -codesign --force --deep --sign - "$APP_DIR" >/dev/null 2>&1 || true +# Sign the complete bundle only after all runtime dependencies are present. +"$SCRIPT_DIR/sign_targetbridge_receiver_app.sh" "$APP_DIR" # A released bundle must never depend on the Homebrew installation used to # compile it. Check the executable and every bundled dynamic library after diff --git a/TargetBridge-Receiver/scripts/sign_targetbridge_receiver_app.sh b/TargetBridge-Receiver/scripts/sign_targetbridge_receiver_app.sh new file mode 100755 index 00000000..1f38eb09 --- /dev/null +++ b/TargetBridge-Receiver/scripts/sign_targetbridge_receiver_app.sh @@ -0,0 +1,42 @@ +#!/bin/zsh +set -euo pipefail + +fail() { print -u2 -- "Receiver signing: $*"; exit 1; } +APP="${1:-}" +[[ -n "$APP" && $# -eq 1 ]] || fail "Usage: $0 'TargetBridge Receiver.app' | --check-identity" + +CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/receiver-signing-identity.txt}" +IDENTITY="${TARGETBRIDGE_CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" && -f "$CONFIG" ]]; then + IDENTITY=$(<"$CONFIG") +fi +if [[ -z "$IDENTITY" ]]; then + fail "No persistent signing identity. Configure TARGETBRIDGE_CODESIGN_IDENTITY or $CONFIG. Refusing an implicit ad-hoc build." +fi +if [[ "$IDENTITY" == - ]]; then + [[ "${TARGETBRIDGE_ALLOW_ADHOC:-0}" == 1 ]] || fail "Ad-hoc signing requires TARGETBRIDGE_ALLOW_ADHOC=1; updates may lose privacy permissions." + print -u2 -- "WARNING: disposable ad-hoc development build; do not install over a persistently signed Receiver." +else + [[ "$IDENTITY" =~ '^[[:xdigit:]]{40}$' ]] || fail "Use the exact 40-character certificate fingerprint, not a display name" + IDENTITIES=$(/usr/bin/security find-identity -v -p codesigning) + [[ "${(U)IDENTITIES}" == *" ${(U)IDENTITY} "* ]] || fail "Configured certificate is not a valid code-signing identity in this user's Keychain." +fi +[[ "$APP" != --check-identity ]] || { print -- 'Signing identity preflight passed'; exit 0; } + +PLIST="$APP/Contents/Info.plist" +[[ -f "$PLIST" && ! -L "$APP" ]] || fail "Expected a real application bundle" +BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST") +[[ "$BUNDLE_ID" == com.targetbridge.receiver ]] || fail "Wrong bundle identifier: $BUNDLE_ID" + +# Frameworks must carry the same identity before sealing the containing app. +while IFS= read -r dylib; do + /usr/bin/codesign --force --sign "$IDENTITY" "$dylib" +done < <(/usr/bin/find "$APP/Contents/Frameworks" -type f -name '*.dylib' -print) +/usr/bin/codesign --force --sign "$IDENTITY" "$APP" +/usr/bin/codesign --verify --deep --strict --all-architectures "$APP" +DETAILS=$(/usr/bin/codesign -dv "$APP" 2>&1) +if [[ "$IDENTITY" != - && "$DETAILS" == *"Signature=adhoc"* ]]; then + fail "A persistent signature was requested but the result is ad-hoc" +fi +/usr/bin/codesign -d -r- "$APP" 2>&1 +print -- "Receiver signature verified: $APP" diff --git a/docs/Sender-Signing.md b/docs/Sender-Signing.md index b0c275d6..c38ceacb 100644 --- a/docs/Sender-Signing.md +++ b/docs/Sender-Signing.md @@ -1,9 +1,14 @@ -# Stable Sender identity and privacy permissions +# Stable application identity and privacy permissions macOS recognises updates by the app's code-signing designated requirement, not just its name or version. An ad-hoc signature binds that requirement to the binary's hashes. Rebuilding changes those hashes and can invalidate Screen -Recording and Accessibility consent. +Recording, Accessibility, and Input Monitoring consent. + +TargetBridge has two applications: Sender (`com.targetbridge.sender`) and +Receiver (`com.targetbridge.receiver`). Both must retain their respective +bundle ID, installation path, and signing identity. This matters whenever the +app has been granted a macOS privacy permission. ## Build and sign @@ -39,9 +44,15 @@ happens elsewhere. Do not recreate the certificate for each build. A lost key requires an intentional identity migration and new consent. A local certificate is not a substitute for Developer ID and notarization for public distribution. -For distribution, use Developer ID plus the required notarization workflow; -these scripts alone do not implement notarization. Do not mix local and upstream signers if preserving existing consent -is required. No Apple Developer membership is provisioned by these scripts. +For public distribution, use Developer ID plus the required notarization +workflow; these scripts alone do not implement notarization. Configure the +same certificate in the `TARGETBRIDGE_CODESIGN_P12_BASE64` and +`TARGETBRIDGE_CODESIGN_P12_PASSWORD` repository secrets before tagging a final +release. The release workflow refuses an exact `vX.Y.Z` release if that +persistent identity is unavailable; prerelease tags remain usable for +disposable CI builds. Do not mix local and upstream signers if preserving +existing consent is required. No Apple Developer membership is provisioned by +these scripts. When using the signing helper directly, a disposable development build must opt in to both `TARGETBRIDGE_CODESIGN_IDENTITY=-` and `TARGETBRIDGE_ALLOW_ADHOC=1`. Never install @@ -64,8 +75,11 @@ destination. Keep a recoverable backup outside Applications. An initial migration from an ad-hoc app intentionally fails this gate: it is an identity change, not a seamless update. Back up the installed app, replace it once with the persistently signed app at `/Applications/TargetBridge.app`, and -approve Screen Recording and Accessibility there if macOS asks. Do not edit -TCC databases, run blanket permission resets, or disable system protections. +approve any requested privacy permissions there. The Receiver follows the same +rule at `/Applications/TargetBridge Receiver.app`. Do not edit TCC databases, +run blanket permission resets, or disable system protections. Once the stable +identity is in place, subsequent signed updates preserve the identity macOS +uses to associate existing consent. ## Validation diff --git a/docs/releases/3.6.0.md b/docs/releases/3.6.0.md index 2cd462c0..f5c75312 100644 --- a/docs/releases/3.6.0.md +++ b/docs/releases/3.6.0.md @@ -21,8 +21,10 @@ adaptive streaming, and clearer macOS permission onboarding. Update both Sender and Receiver together. Replace older copies in Applications rather than keeping multiple app copies with the same bundle identifier. -macOS permissions apply to the installed application and still require user -approval after a rebuilt or newly signed copy is installed. +The first transition from an older ad-hoc build to a persistently signed release +may require macOS approval once. Subsequent official updates retain the stable +application identity macOS uses for existing permission consent; TargetBridge +never modifies the TCC permission database directly. The Sender requires macOS 14 or later. The Receiver supports macOS 11 or later. For Input Dockstation, check the permission matrix on both Macs. From 602c57eeae29bf9a5dad73a40bb204f61577aedc Mon Sep 17 00:00:00 2001 From: swellweb Date: Mon, 12 Oct 2026 12:59:57 +0200 Subject: [PATCH 20/20] fix(release): validate signing certificate conditions --- .github/workflows/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3650bfb7..0ab2c331 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ permissions: jobs: build-sender: name: Build Sender (${{ matrix.os }} - ${{ matrix.arch }}) + env: + TARGETBRIDGE_SIGNING_CONFIGURED: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 != '' }} strategy: matrix: include: @@ -30,7 +32,7 @@ jobs: brew install xcodegen - name: Import release signing certificate - if: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 != '' }} + if: ${{ env.TARGETBRIDGE_SIGNING_CONFIGURED == 'true' }} uses: ./.github/actions/import-macos-signing-certificate with: certificate-base64: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 }} @@ -66,6 +68,8 @@ jobs: build-receiver: name: Build Receiver (macOS ${{ matrix.arch }}) runs-on: ${{ matrix.os }} + env: + TARGETBRIDGE_SIGNING_CONFIGURED: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 != '' }} strategy: matrix: include: @@ -82,7 +86,7 @@ jobs: brew install ffmpeg sdl2 sdl3 dylibbundler - name: Import release signing certificate - if: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 != '' }} + if: ${{ env.TARGETBRIDGE_SIGNING_CONFIGURED == 'true' }} uses: ./.github/actions/import-macos-signing-certificate with: certificate-base64: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 }}