From ebcad4bba405d73f8124664e3c31e5dfa24a35bd Mon Sep 17 00:00:00 2001 From: Mike Freeman Date: Tue, 28 Jul 2026 11:51:53 +0100 Subject: [PATCH 01/40] fix(input): preserve receiver-master click count --- .../TBReceiverC/src/display.c | 2 + .../TBReceiverC/src/display.h | 1 + TargetBridge-Receiver/TBReceiverC/src/main.c | 77 ++++++++++++++++--- .../TBDisplaySenderService.swift | 28 ++++--- .../TBMonitorProtocolTests.swift | 34 ++++++++ .../TBDisplayShared/TBMonitorProtocol.swift | 5 ++ 6 files changed, 125 insertions(+), 22 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index 6a0790bf..99f6924b 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -1240,12 +1240,14 @@ unsigned int tb_disp_poll_actions(struct tb_display *d) { if (ev.button.button == SDL_BUTTON_LEFT) input_event.kind = TB_INPUT_EVENT_LEFT_DOWN; else if (ev.button.button == SDL_BUTTON_RIGHT) input_event.kind = TB_INPUT_EVENT_RIGHT_DOWN; else input_event.kind = TB_INPUT_EVENT_OTHER_DOWN; + input_event.click_count = ev.button.clicks; tb_disp_queue_input_event(d, &input_event); break; case SDL_MOUSEBUTTONUP: if (ev.button.button == SDL_BUTTON_LEFT) input_event.kind = TB_INPUT_EVENT_LEFT_UP; else if (ev.button.button == SDL_BUTTON_RIGHT) input_event.kind = TB_INPUT_EVENT_RIGHT_UP; else input_event.kind = TB_INPUT_EVENT_OTHER_UP; + input_event.click_count = ev.button.clicks; tb_disp_queue_input_event(d, &input_event); break; case SDL_KEYDOWN: diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.h b/TargetBridge-Receiver/TBReceiverC/src/display.h index 0052ab79..d5b3d382 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.h +++ b/TargetBridge-Receiver/TBReceiverC/src/display.h @@ -53,6 +53,7 @@ struct tb_input_event { int scroll_x; int scroll_y; uint16_t key_code; + int click_count; }; struct tb_display *tb_disp_create(int fullscreen); diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index 7bc721c6..cb2635f6 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -1265,6 +1265,41 @@ static void tb_receiver_send_input_event(struct app *a, (void)send_all(a->client_fd, pkt, 5 + (size_t)len); } +static void tb_receiver_send_input_button_event(struct app *a, + const char *kind, + int click_count) { + if (!a || a->client_fd < 0) return; + if (strcmp(a->input_control_mode, "receiverMaster") != 0) return; + + if (click_count < 1) click_count = 1; + if (click_count > 3) click_count = 3; + + char json[128]; + int len = snprintf(json, sizeof(json), + "{\"kind\":\"%s\",\"clickCount\":%d}", + kind ? kind : "", + click_count); + if (len <= 0 || (size_t)len >= sizeof(json)) return; + + uint8_t pkt[4 + 1 + sizeof(json)]; + write_be32(pkt, (uint32_t)(1 + len)); + pkt[4] = TB_PKT_INPUT_EVENT; + memcpy(pkt + 5, json, (size_t)len); + a->input_events_sent += 1; + if (tb_should_log_input_event(a->input_events_sent)) { + tb_receiver_input_log("[input][receiver->sender] send #%llu kind=%s dx=%d dy=%d sx=%d sy=%d key=%u mode=%s", + (unsigned long long)a->input_events_sent, + kind ? kind : "?", + 0, + 0, + 0, + 0, + 0, + a->input_control_mode); + } + (void)send_all(a->client_fd, pkt, 5 + (size_t)len); +} + static void tb_receiver_send_target_switch(struct app *a, int direction) { tb_receiver_send_input_event(a, direction < 0 ? "switchPrevTarget" : "switchNextTarget", @@ -1375,27 +1410,45 @@ static CGEventRef tb_receiver_input_tap_callback(CGEventTapProxy proxy, break; } case kCGEventLeftMouseDown: - tb_receiver_send_input_event(a, "leftDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "leftDown", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventLeftMouseUp: - tb_receiver_send_input_event(a, "leftUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "leftUp", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventRightMouseDown: - tb_receiver_send_input_event(a, "rightDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "rightDown", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventRightMouseUp: - tb_receiver_send_input_event(a, "rightUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "rightUp", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventOtherMouseDown: - tb_receiver_send_input_event(a, "otherDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "otherDown", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventOtherMouseUp: - tb_receiver_send_input_event(a, "otherUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "otherUp", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventScrollWheel: { @@ -1947,22 +2000,22 @@ int main(int argc, char **argv) { tb_receiver_send_input_event(&a, "scroll", 0, 0, 0, 0, 1, input_event.scroll_x, 1, input_event.scroll_y, 0, 0); break; case TB_INPUT_EVENT_LEFT_DOWN: - tb_receiver_send_input_event(&a, "leftDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "leftDown", input_event.click_count); break; case TB_INPUT_EVENT_LEFT_UP: - tb_receiver_send_input_event(&a, "leftUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "leftUp", input_event.click_count); break; case TB_INPUT_EVENT_RIGHT_DOWN: - tb_receiver_send_input_event(&a, "rightDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "rightDown", input_event.click_count); break; case TB_INPUT_EVENT_RIGHT_UP: - tb_receiver_send_input_event(&a, "rightUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "rightUp", input_event.click_count); break; case TB_INPUT_EVENT_OTHER_DOWN: - tb_receiver_send_input_event(&a, "otherDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "otherDown", input_event.click_count); break; case TB_INPUT_EVENT_OTHER_UP: - tb_receiver_send_input_event(&a, "otherUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "otherUp", input_event.click_count); break; case TB_INPUT_EVENT_KEY_DOWN: tb_receiver_send_input_event(&a, "keyDown", 0, 0, 0, 0, 0, 0, 0, 0, 1, input_event.key_code); diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index f90aa88b..60b8d656 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1799,7 +1799,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u releaseInjectedModifiersIfNeeded() onRemoteDeactivateInputRequest?() } else { - applyIncomingInputEvent(event) + applyIncomingInputEvent(event, payload: payload) } } case .heartbeat: @@ -1906,11 +1906,13 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } - private func postLocalMouseButton(type: CGEventType, button: CGMouseButton) { + private func postLocalMouseButton(type: CGEventType, button: CGMouseButton, clickCount: Int? = nil) { logLocalInputInjectionStateIfNeeded(context: "mouseButton") guard let current = injectedRemoteMouseLocation ?? currentLocalMouseLocation() else { return } guard let event = CGEvent(mouseEventSource: localInputEventSource(), mouseType: type, mouseCursorPosition: current, mouseButton: button) else { return } - if button == .left { + if let clickCount { + event.setIntegerValueField(.mouseEventClickState, value: Int64(min(max(clickCount, 1), 3))) + } else if button == .left { let clickState: Int if type == .leftMouseDown { clickState = injectedLeftClickTracker.registerClick( @@ -2022,7 +2024,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u controlUp.post(tap: .cghidEventTap) } - private func applyIncomingInputEvent(_ event: TBMonitorInputEvent) { + private func applyIncomingInputEvent(_ event: TBMonitorInputEvent, payload: Data) { TBInputDebugLog.log("sender applying incoming event kind=\(event.kind)") switch event.kind { case "move": @@ -2034,17 +2036,23 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u case "otherDrag": postLocalMouseMove(dx: event.dx ?? 0, dy: event.dy ?? 0, type: .otherMouseDragged, button: .center) case "leftDown": - postLocalMouseButton(type: .leftMouseDown, button: .left) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .leftMouseDown, button: .left, clickCount: buttonEvent?.clickCount) case "leftUp": - postLocalMouseButton(type: .leftMouseUp, button: .left) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .leftMouseUp, button: .left, clickCount: buttonEvent?.clickCount) case "rightDown": - postLocalMouseButton(type: .rightMouseDown, button: .right) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .rightMouseDown, button: .right, clickCount: buttonEvent?.clickCount) case "rightUp": - postLocalMouseButton(type: .rightMouseUp, button: .right) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .rightMouseUp, button: .right, clickCount: buttonEvent?.clickCount) case "otherDown": - postLocalMouseButton(type: .otherMouseDown, button: .center) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .otherMouseDown, button: .center, clickCount: buttonEvent?.clickCount) case "otherUp": - postLocalMouseButton(type: .otherMouseUp, button: .center) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .otherMouseUp, button: .center, clickCount: buttonEvent?.clickCount) case "scroll": postLocalScroll(scrollX: event.scrollX ?? 0, scrollY: event.scrollY ?? 0) case "keyDown": diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift index f2e32046..4dd88466 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -207,6 +207,40 @@ final class TBMonitorProtocolTests: XCTestCase { XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload)?.sequence, 42) } + func testInputButtonEventRoundTripPreservesClickCount() throws { + let buttonEvent = TBMonitorInputButtonEvent(kind: "leftDown", clickCount: 2) + guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .inputEvent, value: buttonEvent), + let (type, payload) = try TBMonitorProtocol.drainPacket(from: &buffer) + else { + XCTFail("button event did not encode and drain") + return + } + + XCTAssertEqual(type, .inputEvent) + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload)?.kind, "leftDown") + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload)?.clickCount, 2) + } + + func testInputButtonEventWithoutClickCountRemainsCompatible() { + let payload = Data(#"{"kind":"leftDown"}"#.utf8) + + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + XCTAssertEqual(buttonEvent?.kind, "leftDown") + XCTAssertNil(buttonEvent?.clickCount) + } + + func testGenericInputEventIgnoresButtonOnlyMetadata() { + let payload = Data(#"{"kind":"leftDown","clickCount":2}"#.utf8) + + let genericEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputEvent.self, from: payload) + XCTAssertEqual(genericEvent?.kind, "leftDown") + XCTAssertNil(genericEvent?.dx) + XCTAssertNil(genericEvent?.dy) + XCTAssertNil(genericEvent?.scrollX) + XCTAssertNil(genericEvent?.scrollY) + XCTAssertNil(genericEvent?.keyCode) + } + // MARK: - Hand-rolled input-event encoder parity // // `makeInputEventPacket` documents this invariant: "emits the same JSON shape diff --git a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift index fb3ff858..02885643 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift @@ -82,6 +82,11 @@ struct TBMonitorInputEvent: Codable { var keyCode: UInt16? } +struct TBMonitorInputButtonEvent: Codable { + var kind: String + var clickCount: Int? +} + struct TBMonitorInputControlMode: Codable { var mode: String } From 40af3c3cc754a0098437d5885a1cd53978ed3140 Mon Sep 17 00:00:00 2001 From: swellweb Date: Tue, 28 Jul 2026 17:50:34 +0200 Subject: [PATCH 02/40] Release 3.3.1 --- TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh | 2 +- .../TBDisplaySender/TBDisplaySenderBuildInfo.swift | 4 ++-- TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj | 6 +++--- TargetBridge-Sender/project.yml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index 17b46249..3186b2ec 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="3.3.0" +APP_VERSION="3.3.1" STAMP="$(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 0060966e..b4e4e4ef 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.3.0" - static let buildNumber = "20260728035336" + static let marketingVersion = "3.3.1" + static let buildNumber = "20260728175011" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 5e28fcb1..4e23b69c 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -346,7 +346,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"$(date +%Y%m%d%H%M%S)\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" < Date: Sun, 26 Jul 2026 20:23:32 +0200 Subject: [PATCH 03/40] feat(sender): add brightness & volume sliders to the menu bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces per-session Brightness and Volume sliders directly in the menu bar status menu, so the receiver's real brightness/volume can be adjusted without opening the main window. Each slider pushes its value live to the existing session.brightness / session.volume path (already forwarded to the receiver), so it works reliably regardless of keyboard or input routing — no event taps, no cursor/media-key handling. Sliders appear only for connected sessions; the volume slider only when audio is enabled. Also tidies the menu: the verbose interface/IP and per-session status lines move into a "Connection info" submenu, keeping the default menu focused on the status summary, sliders, and actions. Localized en/it/de/zh. Co-Authored-By: Claude Opus 4.8 --- .../TBDisplaySenderStatusItemController.swift | 129 ++++++++++++++++-- 1 file changed, 118 insertions(+), 11 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift index e86f08fd..bba6a61a 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift @@ -7,6 +7,9 @@ final class TBDisplaySenderStatusItemController: NSObject { nonisolated(unsafe) private var statusItem: NSStatusItem? private var cancellables = Set() private var hasActivated = false + // Retains the target objects for the current menu's sliders (NSSlider holds + // its target weakly). Cleared and rebuilt each time the menu opens. + private var sliderTargets: [TBMenuSliderTarget] = [] init(service: TBDisplaySenderService) { self.service = service @@ -94,6 +97,7 @@ final class TBDisplaySenderStatusItemController: NSObject { private func rebuildMenuItems(in menu: NSMenu) { menu.removeAllItems() + sliderTargets.removeAll() let titleItem = NSMenuItem(title: "TargetBridge", action: nil, keyEquivalent: "") titleItem.isEnabled = false @@ -103,17 +107,27 @@ final class TBDisplaySenderStatusItemController: NSObject { statusItem.isEnabled = false menu.addItem(statusItem) - if !service.localInterfaces.isEmpty { - let ipItem = NSMenuItem(title: TBDisplaySenderL10n.topBarIP(service.language, service.localInterfaceSummaryText), action: nil, keyEquivalent: "") - ipItem.isEnabled = false - menu.addItem(ipItem) - } - - for session in service.sessions { - let line = "\(service.sessionTitle(for: session)): \(session.statusText)" - let sessionItem = NSMenuItem(title: line, action: nil, keyEquivalent: "") - sessionItem.isEnabled = false - menu.addItem(sessionItem) + // Brightness / volume sliders for each connected session. Reliable and + // native-feeling: they drive the receiver directly via the existing + // brightness/volume path — no event taps, no cursor/keyboard routing. + let connectedSessions = service.sessions.filter { $0.isConnected } + if !connectedSessions.isEmpty { + menu.addItem(.separator()) + for session in connectedSessions { + if connectedSessions.count > 1 { + let header = NSMenuItem(title: service.sessionTitle(for: session), action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + } + menu.addItem(makeSliderItem(symbol: "sun.max", label: brightnessMenuLabel(), value: session.brightness) { [weak session] value in + session?.brightness = value + }) + if session.audioEnabled { + menu.addItem(makeSliderItem(symbol: "speaker.wave.2.fill", label: volumeMenuLabel(), value: session.volume) { [weak session] value in + session?.volume = value + }) + } + } } menu.addItem(.separator()) @@ -143,6 +157,26 @@ final class TBDisplaySenderStatusItemController: NSObject { stopAllItem.isEnabled = service.anyConnected menu.addItem(stopAllItem) + // The verbose connection/IP details live behind a submenu so the default + // menu stays focused on the useful actions. + if !service.localInterfaces.isEmpty || !service.sessions.isEmpty { + let infoItem = NSMenuItem(title: connectionInfoMenuLabel(), action: nil, keyEquivalent: "") + let infoSubmenu = NSMenu() + if !service.localInterfaces.isEmpty { + let ipItem = NSMenuItem(title: TBDisplaySenderL10n.topBarIP(service.language, service.localInterfaceSummaryText), action: nil, keyEquivalent: "") + ipItem.isEnabled = false + infoSubmenu.addItem(ipItem) + } + for session in service.sessions { + let line = "\(service.sessionTitle(for: session)): \(session.statusText)" + let sessionItem = NSMenuItem(title: line, action: nil, keyEquivalent: "") + sessionItem.isEnabled = false + infoSubmenu.addItem(sessionItem) + } + infoItem.submenu = infoSubmenu + menu.addItem(infoItem) + } + let hideItem = NSMenuItem( title: TBDisplaySenderL10n.hideMenuBarIcon(service.language), action: #selector(hideStatusItem), @@ -158,6 +192,64 @@ final class TBDisplaySenderStatusItemController: NSObject { menu.addItem(quitItem) } + /// Builds a menu row with an icon and a 0…1 slider whose changes are pushed + /// live to `onChange` (which sets `session.brightness`/`.volume`, forwarding + /// to the receiver). + private func makeSliderItem(symbol: String, label: String, value: Double, onChange: @escaping (Double) -> Void) -> NSMenuItem { + let width: CGFloat = 240 + let height: CGFloat = 24 + let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height)) + + let icon = NSImageView(frame: NSRect(x: 14, y: (height - 15) / 2, width: 15, height: 15)) + icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + icon.contentTintColor = .secondaryLabelColor + icon.imageScaling = .scaleProportionallyUpOrDown + container.addSubview(icon) + + let slider = NSSlider(frame: NSRect(x: 38, y: (height - 19) / 2, width: width - 38 - 14, height: 19)) + slider.minValue = 0 + slider.maxValue = 1 + slider.doubleValue = value + slider.isContinuous = true + let target = TBMenuSliderTarget(onChange) + slider.target = target + slider.action = #selector(TBMenuSliderTarget.changed(_:)) + sliderTargets.append(target) + container.addSubview(slider) + + let item = NSMenuItem() + item.view = container + item.toolTip = label + return item + } + + private func brightnessMenuLabel() -> String { + switch service.language { + case .italian: return "Luminosità" + case .english: return "Brightness" + case .german: return "Helligkeit" + case .chinese: return "亮度" + } + } + + private func volumeMenuLabel() -> String { + switch service.language { + case .italian: return "Volume" + case .english: return "Volume" + case .german: return "Lautstärke" + case .chinese: return "音量" + } + } + + private func connectionInfoMenuLabel() -> String { + switch service.language { + case .italian: return "Info connessione" + case .english: return "Connection info" + case .german: return "Verbindungsinfo" + case .chinese: return "连接信息" + } + } + // Menu-item handlers run while the menu is still dismissing. Doing work // synchronously here (activating the app, ordering windows front, mutating // observed session state) interrupts the menu window's fade-out: its alpha @@ -212,3 +304,18 @@ extension TBDisplaySenderStatusItemController: NSMenuDelegate { rebuildMenuItems(in: menu) } } + +/// Target for a menu slider. NSSlider holds its target weakly, so the controller +/// retains these for the lifetime of the open menu. +@MainActor +final class TBMenuSliderTarget: NSObject { + private let onChange: (Double) -> Void + + init(_ onChange: @escaping (Double) -> Void) { + self.onChange = onChange + } + + @objc func changed(_ sender: NSSlider) { + onChange(sender.doubleValue) + } +} From 7d097293124d162cb0a486df00683b68754de959 Mon Sep 17 00:00:00 2001 From: Aykut Alpgiray Ates Date: Wed, 29 Jul 2026 19:45:20 +0200 Subject: [PATCH 04/40] feat(menubar): Night Shift and True Tone for the receiver's display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Control-Center-style toggles to the menu bar for the receiver's Night Shift and True Tone, and drops the volume slider — with an audio device selected, macOS's own Sound slider already drives the receiver's hardware volume, so a second control here can only disagree with the system one. Two-way: changing either on the receiver itself (Control Centre, System Settings) propagates back, so the toggles never show a stale state. ## Notes Both live in the private CoreBrightness framework, reached by dlopen and the Objective-C runtime — the same approach this app already uses for DisplayServices brightness — so a missing class or renamed selector degrades to "unsupported" rather than breaking the build or crashing on a future macOS. The receiver advertises supportsNightShift / supportsTrueTone in its profile and the toggles appear only when it does. CBTrueToneClient exposes both activate/deactivate and setEnabled:. The first pair looks like the on/off switch and is not — measured against the live framework, after deactivate the panel is unchanged and `enabled` still reports 1. They activate the client *session*. setEnabled: is the real switch, with activate/deactivate kept only as a fallback. One deliberately fragile spot: CBBlueLightClient has no `enabled` getter, only getBlueLightStatus:, which fills a private struct. It is read into an oversized zeroed buffer so a larger struct on a future macOS cannot overflow, and a changed layout misreports the toggle rather than crashing. ## Panel styling The panel was ~240 pt against Control Center's ~340 and the symbols were wrong. Width now comes from one constant; note the row width and the visible panel width differ, because a menu draws chrome around the item. Symbols were checked by rendering every plausible SF Symbol and comparing with Control Center rather than picking by name. Apple's family is half-circle -> half-sun -> full-sun for Dark Mode, Night Shift and True Tone, so Night Shift is a *sun with a dark half*, not a moon. True Tone's real glyph has lines through the disc and no SF Symbol does — Control Center appears to use a private asset — so sun.max.fill is the closest public match. The slider track stays grey, and the comment now records why: trackFillColor is set and menus ignore it, and overriding NSSliderCell.drawBar produces the accent fill but opts the cell out of AppKit's modern rendering, costing the knob its pressed-state translucency. A control that feels wrong beats one that looks plain. Accessibility: the slider carries its name (previously only its decorative glyphs did), the toggles use the checkbox role with a value that follows the state, and the captions are out of the accessibility tree rather than read as extra elements. Includes #143, which this builds on, rebased onto current main — that also required filling in the French labels it predates. Co-Authored-By: Claude Opus 5 --- TargetBridge-Receiver/TBReceiverC/Makefile | 2 +- TargetBridge-Receiver/TBReceiverC/src/main.c | 67 ++++++- TargetBridge-Receiver/TBReceiverC/src/proto.h | 4 + .../TBReceiverC/src/tb_display_tweaks.h | 28 +++ .../TBReceiverC/src/tb_display_tweaks.m | 135 ++++++++++++++ .../TBDisplaySenderBuildInfo.swift | 2 +- .../TBDisplaySenderService.swift | 40 +++++ .../TBDisplaySenderStatusItemController.swift | 137 ++++++++++++-- .../TBDisplaySender/TBMenuMetrics.swift | 24 +++ .../TBDisplaySender/TBMenuToggleRowView.swift | 167 ++++++++++++++++++ .../TBDisplayShared/TBMonitorProtocol.swift | 10 ++ .../TargetBridge.xcodeproj/project.pbxproj | 8 + 12 files changed, 604 insertions(+), 20 deletions(-) create mode 100644 TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.h create mode 100644 TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.m create mode 100644 TargetBridge-Sender/TBDisplaySender/TBMenuMetrics.swift create mode 100644 TargetBridge-Sender/TBDisplaySender/TBMenuToggleRowView.swift diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 87051d0b..64ced78c 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -35,7 +35,7 @@ LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoun endif C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/tb_i18n.c -OBJC_SRC = src/tb_gesture_bridge.m +OBJC_SRC = src/tb_gesture_bridge.m src/tb_display_tweaks.m OBJ = $(C_SRC:.c=.o) $(OBJC_SRC:.m=.o) BIN = tbreceiver diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index 7bc721c6..d6227962 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -18,6 +18,7 @@ #include "display.h" #include "proto.h" #include "tb_gesture_bridge.h" +#include "tb_display_tweaks.h" #include "tb_i18n.h" #include @@ -64,6 +65,13 @@ struct app { uint64_t last_fps_tick_ms; uint64_t last_fps_count; uint64_t last_ip_check_ms; + /* Last display-tweak state reported to the sender, so changes made on this + * Mac (Control Center, System Settings) propagate back and the sender's + * toggles stay truthful. -1 = nothing sent yet. */ + int reported_night_shift; + int reported_true_tone; + uint64_t last_tweak_poll_ms; + uint64_t last_recv_ms; /* idle watchdog: last time the sender sent anything */ int close_requested; int have_video_frame; @@ -191,6 +199,7 @@ static void tb_receiver_set_clipboard_text(const char *text); static int tb_receiver_get_clipboard_text(char *dest, size_t size); static void tb_receiver_send_clipboard_if_changed(struct app *a); static void write_be32(uint8_t *dst, uint32_t value); +static void tb_receiver_send_display_tweaks_if_changed(struct app *a); static int tb_receiver_is_valid_language_pref(const char *language_pref) { return language_pref && @@ -1105,6 +1114,19 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud tb_disp_set_brightness(a->disp, level); } break; + case TB_PKT_DISPLAY_TWEAKS: + { + /* Absent fields are left alone rather than defaulted, so the sender + * can change one without disturbing the other. */ + int night = 0, tone = 0; + if (extract_json_bool_field(payload, len, "\"nightShift\"", &night)) { + tb_night_shift_set(night); + } + if (extract_json_bool_field(payload, len, "\"trueTone\"", &tone)) { + tb_true_tone_set(tone); + } + } + break; case TB_PKT_CLIPBOARD: { char text[4096]; @@ -1615,6 +1637,33 @@ static void tb_receiver_refresh_input_capture(struct app *a) { } } + +/* Report Night Shift / True Tone back to the sender when they change, so its + * menu reflects the panel's real state rather than only what it last asked for. */ +static void tb_receiver_send_display_tweaks_if_changed(struct app *a) { + if (a->client_fd < 0) return; + + const int night = tb_night_shift_supported() ? tb_night_shift_enabled() : 0; + const int tone = tb_true_tone_supported() ? tb_true_tone_enabled() : 0; + if (night == a->reported_night_shift && tone == a->reported_true_tone) return; + + a->reported_night_shift = night; + a->reported_true_tone = tone; + + char json[192]; + int len = snprintf(json, sizeof(json), + "{\"nightShift\":%s,\"trueTone\":%s}", + night ? "true" : "false", + tone ? "true" : "false"); + if (len <= 0 || (size_t)len >= sizeof(json)) return; + + uint8_t pkt[4 + 1 + sizeof(json)]; + write_be32(pkt, (uint32_t)(1 + len)); + pkt[4] = TB_PKT_DISPLAY_TWEAKS; + memcpy(pkt + 5, json, (size_t)len); + (void)send_all(a->client_fd, pkt, 5 + (size_t)len); +} + static void send_receiver_info(struct app *a) { struct tb_display_info info; if (tb_disp_get_info(a->disp, &info) < 0) return; @@ -1644,14 +1693,15 @@ static void send_receiver_info(struct app *a) { } escaped_name[out] = '\0'; - char json[768]; + char json[1024]; int json_len = snprintf( json, sizeof(json), "{\"receiverName\":\"%s\",\"panelWidth\":%u,\"panelHeight\":%u," "\"modeWidth\":%u,\"modeHeight\":%u,\"refreshRate\":60," "\"hiDPI\":true,\"captureWidth\":%u,\"captureHeight\":%u," - "\"supportsHEVCDecode\":%s,\"supportsRawNV12\":true,\"inputMonitoringTrusted\":%s,\"accessibilityTrusted\":%s}", + "\"supportsHEVCDecode\":%s,\"supportsRawNV12\":true,\"inputMonitoringTrusted\":%s,\"accessibilityTrusted\":%s," + "\"supportsNightShift\":%s,\"supportsTrueTone\":%s}", escaped_name, panel_w, panel_h, @@ -1661,8 +1711,9 @@ static void send_receiver_info(struct app *a) { capture_h, tb_dec_supports_hevc_hwdecode() ? "true" : "false", tb_receiver_input_monitoring_trusted() ? "true" : "false", - tb_receiver_accessibility_trusted() ? "true" : "false" - ); + tb_receiver_accessibility_trusted() ? "true" : "false", + tb_night_shift_supported() ? "true" : "false", + tb_true_tone_supported() ? "true" : "false"); if (json_len <= 0 || (size_t)json_len >= sizeof(json)) return; const size_t packet_len = 4 + 1 + (size_t)json_len; @@ -1878,6 +1929,8 @@ int main(int argc, char **argv) { a.client_fd = c; a.have_video_frame = 0; a.session_active = 0; + a.reported_night_shift = -1; /* force one report per session */ + a.reported_true_tone = -1; a.last_recv_ms = t; SDL_DisableScreenSaver(); fprintf(stderr, "[main] client connected\n"); @@ -1914,6 +1967,12 @@ int main(int argc, char **argv) { tb_receiver_poll_permissions(&a); } + /* Cheap enough to poll: two private-framework getters, twice a second. */ + if (t - a.last_tweak_poll_ms >= 500) { + a.last_tweak_poll_ms = t; + tb_receiver_send_display_tweaks_if_changed(&a); + } + if (a.client_fd < 0 || !a.session_active) { /* No client, or a connection that hasn't started a real streaming * session (e.g. a transient UI-language push during discovery): diff --git a/TargetBridge-Receiver/TBReceiverC/src/proto.h b/TargetBridge-Receiver/TBReceiverC/src/proto.h index f71c9f1a..649c829f 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/proto.h +++ b/TargetBridge-Receiver/TBReceiverC/src/proto.h @@ -55,6 +55,10 @@ #define TB_PKT_BRIGHTNESS 0x35 #define TB_PKT_CLIPBOARD 0x36 #define TB_PKT_VOLUME 0x37 +/* Display tweaks on the receiver's panel: JSON {"nightShift":bool,"trueTone":bool}. + * Both are private CoreBrightness features, so the receiver reports whether it + * can honour them in its display profile. */ +#define TB_PKT_DISPLAY_TWEAKS 0x38 #define TB_PKT_TEST_DATA 0x40 #define TB_HDR_BYTES 5 /* 4 length + 1 type */ diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.h b/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.h new file mode 100644 index 00000000..64e409e4 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.h @@ -0,0 +1,28 @@ +/* tb_display_tweaks.h — Night Shift / True Tone on the receiver's own panel. + * Implemented over the private CoreBrightness framework; every entry point is + * safe to call on a Mac that lacks the feature (returns unsupported / -1). */ + +#ifndef TB_DISPLAY_TWEAKS_H +#define TB_DISPLAY_TWEAKS_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* 1 if the panel can do it, 0 otherwise. */ +int tb_night_shift_supported(void); +int tb_true_tone_supported(void); + +/* Current state, so the sender's UI can follow changes made on this Mac. */ +int tb_true_tone_enabled(void); +int tb_night_shift_enabled(void); + +/* 0 on success, -1 when unavailable. */ +int tb_night_shift_set(int enabled); +int tb_true_tone_set(int enabled); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.m b/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.m new file mode 100644 index 00000000..316cbc77 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.m @@ -0,0 +1,135 @@ +/* tb_display_tweaks.m — Night Shift and True Tone on the receiver's panel. + * + * Both live in the private CoreBrightness framework, so they are reached the + * same way this app already reaches DisplayServices for brightness: dlopen the + * framework and go through the Objective-C runtime, so a missing class or a + * renamed selector degrades to "unsupported" instead of breaking the build or + * crashing on a future macOS. + * + * Night Shift is CBBlueLightClient (setEnabled:). + * True Tone is CBTrueToneClient, which has no setter — it is activate/deactivate. + */ + +#import +#import +#include + +#include "tb_display_tweaks.h" + +static void tb_load_core_brightness(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + dlopen("/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness", + RTLD_LAZY); + }); +} + +/* Call a no-argument BOOL getter, returning 0 if the selector is absent. */ +static int tb_bool_getter(id obj, SEL sel) { + if (!obj || ![obj respondsToSelector:sel]) return 0; + IMP imp = [obj methodForSelector:sel]; + if (!imp) return 0; + BOOL (*fn)(id, SEL) = (BOOL (*)(id, SEL))imp; + return fn(obj, sel) ? 1 : 0; +} + +/* ---- Night Shift ------------------------------------------------------- */ + +static id tb_blue_light_client(void) { + static id client = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + tb_load_core_brightness(); + Class cls = NSClassFromString(@"CBBlueLightClient"); + if (cls) client = [[cls alloc] init]; + }); + return client; +} + +int tb_night_shift_supported(void) { + return tb_bool_getter(tb_blue_light_client(), @selector(supported)); +} + +int tb_night_shift_enabled(void) { + id c = tb_blue_light_client(); + SEL sel = @selector(getBlueLightStatus:); + if (!c || ![c respondsToSelector:sel]) return 0; + IMP imp = [c methodForSelector:sel]; + if (!imp) return 0; + + /* CBBlueLightClient has no `enabled` getter — only getBlueLightStatus:, + * which fills a private struct whose first two bytes are BOOL active then + * BOOL enabled. The layout is not API, so read defensively: a generously + * oversized zeroed buffer means a larger struct on some macOS version + * cannot overflow, and a changed layout misreports the state rather than + * crashing. Worst case the menu shows a stale toggle. */ + unsigned char status[256]; + memset(status, 0, sizeof(status)); + BOOL (*fn)(id, SEL, void *) = (BOOL (*)(id, SEL, void *))imp; + if (!fn(c, sel, status)) return 0; + return status[1] ? 1 : 0; +} + +int tb_night_shift_set(int enabled) { + id c = tb_blue_light_client(); + SEL sel = @selector(setEnabled:); + if (!c || ![c respondsToSelector:sel]) return -1; + IMP imp = [c methodForSelector:sel]; + if (!imp) return -1; + BOOL (*fn)(id, SEL, BOOL) = (BOOL (*)(id, SEL, BOOL))imp; + fn(c, sel, enabled ? YES : NO); + return 0; +} + +/* ---- True Tone -------------------------------------------------------- */ + +static id tb_true_tone_client(void) { + static id client = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + tb_load_core_brightness(); + Class cls = NSClassFromString(@"CBTrueToneClient"); + if (cls) client = [[cls alloc] init]; + }); + return client; +} + +int tb_true_tone_supported(void) { + id c = tb_true_tone_client(); + if (!c) return 0; + /* `supported` means the hardware has it; `available` means it can be used + * right now — it goes unavailable under some display profiles. Both must + * hold before offering the toggle. */ + return tb_bool_getter(c, @selector(supported)) && tb_bool_getter(c, @selector(available)); +} + +int tb_true_tone_enabled(void) { + return tb_bool_getter(tb_true_tone_client(), @selector(enabled)); +} + +int tb_true_tone_set(int enabled) { + id c = tb_true_tone_client(); + if (!c) return -1; + + /* setEnabled: is the real switch. activate/deactivate look like the obvious + * pair and are not: measured on macOS 15, deactivate leaves `enabled` + * reporting 1 and the panel unchanged — they activate the *client session*, + * not the feature. Keep them only as a fallback for an OS that lacks the + * setter, so a missing selector degrades instead of failing outright. */ + SEL set = @selector(setEnabled:); + if ([c respondsToSelector:set]) { + IMP imp = [c methodForSelector:set]; + if (imp) { + BOOL (*fn)(id, SEL, BOOL) = (BOOL (*)(id, SEL, BOOL))imp; + return fn(c, set, enabled ? YES : NO) ? 0 : -1; + } + } + + SEL sel = enabled ? @selector(activate) : @selector(deactivate); + if (![c respondsToSelector:sel]) return -1; + IMP imp = [c methodForSelector:sel]; + if (!imp) return -1; + void (*fn)(id, SEL) = (void (*)(id, SEL))imp; + fn(c, sel); + return 0; +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift index 0060966e..89d12771 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { static let marketingVersion = "3.3.0" - static let buildNumber = "20260728035336" + static let buildNumber = "20260729194347" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index f90aa88b..b100c021 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1006,6 +1006,20 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u sendVolumeUpdate() } } + /// Night Shift / True Tone on the receiver's own panel. Only offered when + /// the receiver reports it can honour them (both are private CoreBrightness + /// features, and True Tone needs supporting hardware). + @Published var nightShiftEnabled = false { + didSet { if !adoptingReportedTweaks { sendDisplayTweaks() } } + } + @Published var trueToneEnabled = false { + didSet { if !adoptingReportedTweaks { sendDisplayTweaks() } } + } + /// Set while adopting state the receiver reported, so the didSet observers + /// above don't echo it back and start a loop. + private var adoptingReportedTweaks = false + @Published var receiverSupportsNightShift = false + @Published var receiverSupportsTrueTone = false var audioAddonAvailable = true var receiverSupportsHEVCDecodeHint: Bool? var receiverInputMonitoringTrustedHint: Bool? @@ -1701,6 +1715,22 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u send(packet) } + private func applyReportedDisplayTweaks(_ tweaks: TBMonitorDisplayTweaks) { + guard nightShiftEnabled != tweaks.nightShift || trueToneEnabled != tweaks.trueTone else { return } + adoptingReportedTweaks = true + nightShiftEnabled = tweaks.nightShift + trueToneEnabled = tweaks.trueTone + adoptingReportedTweaks = false + } + + private func sendDisplayTweaks() { + guard let packet = TBMonitorProtocol.makeJSONPacket( + type: .displayTweaks, + value: TBMonitorDisplayTweaks(nightShift: nightShiftEnabled, trueTone: trueToneEnabled) + ) else { return } + send(packet) + } + private func sendVolumeUpdate() { guard let packet = TBMonitorProtocol.makeJSONPacket( type: .volume, @@ -1808,6 +1838,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u setStatus(.receiverTerminatedSession) stop(resetStatusTo: nil) return + case .displayTweaks: + // Receiver reporting its real state (it may have been changed on + // that Mac directly). Adopt it without sending anything back — + // the didSet observers would otherwise bounce it straight to the + // receiver and the two could ping-pong. + if let tweaks = TBMonitorProtocol.decodeJSON(TBMonitorDisplayTweaks.self, from: payload) { + applyReportedDisplayTweaks(tweaks) + } case .clipboard: if let clipboard = TBMonitorProtocol.decodeJSON(TBMonitorClipboard.self, from: payload) { let pasteboard = NSPasteboard.general @@ -2169,6 +2207,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if let accessibilityTrusted = profile.accessibilityTrusted { receiverAccessibilityTrustedHint = accessibilityTrusted } + receiverSupportsNightShift = profile.supportsNightShift ?? false + receiverSupportsTrueTone = profile.supportsTrueTone ?? false receiverPanelText = TBDisplaySenderL10n.receiverSummary(profile, language: language) sendHello() sendInputControlModeUpdate() diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift index bba6a61a..d8ad05af 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift @@ -10,6 +10,7 @@ final class TBDisplaySenderStatusItemController: NSObject { // Retains the target objects for the current menu's sliders (NSSlider holds // its target weakly). Cleared and rebuilt each time the menu opens. private var sliderTargets: [TBMenuSliderTarget] = [] + private var toggleRows: [TBMenuToggleRowView] = [] init(service: TBDisplaySenderService) { self.service = service @@ -98,6 +99,7 @@ final class TBDisplaySenderStatusItemController: NSObject { private func rebuildMenuItems(in menu: NSMenu) { menu.removeAllItems() sliderTargets.removeAll() + toggleRows.removeAll() let titleItem = NSMenuItem(title: "TargetBridge", action: nil, keyEquivalent: "") titleItem.isEnabled = false @@ -119,13 +121,45 @@ final class TBDisplaySenderStatusItemController: NSObject { header.isEnabled = false menu.addItem(header) } - menu.addItem(makeSliderItem(symbol: "sun.max", label: brightnessMenuLabel(), value: session.brightness) { [weak session] value in + menu.addItem(makeSliderItem(symbol: "sun.min.fill", + trailingSymbol: "sun.max.fill", + label: brightnessMenuLabel(), + value: session.brightness) { [weak session] value in session?.brightness = value }) - if session.audioEnabled { - menu.addItem(makeSliderItem(symbol: "speaker.wave.2.fill", label: volumeMenuLabel(), value: session.volume) { [weak session] value in - session?.volume = value - }) + // No volume slider: with the TargetBridge audio device selected, + // macOS's own Sound slider and the F11/F12 keys already drive the + // receiver's hardware volume, so a second control here would just + // be a duplicate that can disagree with the system one. + var toggles: [TBMenuToggleSpec] = [] + if session.receiverSupportsNightShift { + toggles.append(TBMenuToggleSpec( + symbol: "sun.lefthalf.filled", + title: nightShiftMenuLabel(), + stateText: session.nightShiftEnabled ? onWord() : offWord(), + isOn: session.nightShiftEnabled) { [weak session] on in + session?.nightShiftEnabled = on + }) + } + if session.receiverSupportsTrueTone { + toggles.append(TBMenuToggleSpec( + symbol: "sun.max.fill", + title: trueToneMenuLabel(), + stateText: session.trueToneEnabled ? onWord() : offWord(), + isOn: session.trueToneEnabled) { [weak session] on in + session?.trueToneEnabled = on + }) + } + if !toggles.isEmpty { + let row = TBMenuToggleRowView(specs: toggles, + width: TBMenuMetrics.width, + leadingInset: TBMenuMetrics.inset) + row.onWord = onWord() + row.offWord = offWord() + let item = NSMenuItem() + item.view = row + menu.addItem(item) + toggleRows.append(row) } } } @@ -195,22 +229,54 @@ final class TBDisplaySenderStatusItemController: NSObject { /// Builds a menu row with an icon and a 0…1 slider whose changes are pushed /// live to `onChange` (which sets `session.brightness`/`.volume`, forwarding /// to the receiver). - private func makeSliderItem(symbol: String, label: String, value: Double, onChange: @escaping (Double) -> Void) -> NSMenuItem { - let width: CGFloat = 240 - let height: CGFloat = 24 + /// Brightness row: a small glyph, the system slider, and a larger glyph + /// closing the row. The trailing icon is what stops the track running to the + /// edge of the menu. + /// + /// Stock NSSlider, deliberately, after two attempts at Control Center's + /// accent-filled track. `trackFillColor` is set below and menus ignore it. + /// Overriding NSSliderCell.drawBar does produce the fill, but any drawing + /// override opts the cell out of AppKit's modern slider rendering and the + /// knob loses its pressed-state translucency — more noticeable than a grey + /// track, since it makes the control feel wrong rather than just look plain. + private func makeSliderItem(symbol: String, trailingSymbol: String, label: String, + value: Double, onChange: @escaping (Double) -> Void) -> NSMenuItem { + let width = TBMenuMetrics.width + let height: CGFloat = 28 + let inset = TBMenuMetrics.inset + let leadingIcon: CGFloat = 13 + let trailingIcon: CGFloat = 17 + let gap: CGFloat = 8 let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height)) - let icon = NSImageView(frame: NSRect(x: 14, y: (height - 15) / 2, width: 15, height: 15)) - icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) - icon.contentTintColor = .secondaryLabelColor - icon.imageScaling = .scaleProportionallyUpOrDown - container.addSubview(icon) + func glyph(_ name: String, size: CGFloat, x: CGFloat) -> NSImageView { + let view = NSImageView(frame: NSRect(x: x, y: (height - size) / 2, width: size, height: size)) + view.image = NSImage(systemSymbolName: name, accessibilityDescription: label) + // Decorative: the slider itself carries the name, so reading these + // as well would announce the row three times. + view.setAccessibilityElement(false) + view.contentTintColor = .secondaryLabelColor + view.imageScaling = .scaleProportionallyUpOrDown + return view + } - let slider = NSSlider(frame: NSRect(x: 38, y: (height - 19) / 2, width: width - 38 - 14, height: 19)) + container.addSubview(glyph(symbol, size: leadingIcon, x: inset)) + container.addSubview(glyph(trailingSymbol, size: trailingIcon, + x: width - inset - trailingIcon)) + + let sliderX = inset + leadingIcon + gap + let sliderWidth = width - sliderX - gap - trailingIcon - inset + let slider = NSSlider(frame: NSRect(x: sliderX, y: (height - 19) / 2, + width: sliderWidth, height: 19)) slider.minValue = 0 slider.maxValue = 1 slider.doubleValue = value slider.isContinuous = true + // Set even though menus currently ignore it: harmless, and it is the + // supported way to get an accent-filled track if that changes. + slider.trackFillColor = .controlAccentColor + slider.controlSize = .small + slider.setAccessibilityLabel(label) let target = TBMenuSliderTarget(onChange) slider.target = target slider.action = #selector(TBMenuSliderTarget.changed(_:)) @@ -226,15 +292,57 @@ final class TBDisplaySenderStatusItemController: NSObject { private func brightnessMenuLabel() -> String { switch service.language { case .italian: return "Luminosità" + case .french: return "Luminosité" case .english: return "Brightness" case .german: return "Helligkeit" case .chinese: return "亮度" } } + private func onWord() -> String { + switch service.language { + case .italian: return "Attivo" + case .english: return "On" + case .german: return "Ein" + case .chinese: return "开" + case .french: return "Activé" + } + } + + private func offWord() -> String { + switch service.language { + case .italian: return "Non attivo" + case .english: return "Off" + case .german: return "Aus" + case .chinese: return "关" + case .french: return "Désactivé" + } + } + + private func nightShiftMenuLabel() -> String { + switch service.language { + case .italian: return "Night Shift" + case .english: return "Night Shift" + case .german: return "Night Shift" + case .chinese: return "夜览" + case .french: return "Night Shift" + } + } + + private func trueToneMenuLabel() -> String { + switch service.language { + case .italian: return "True Tone" + case .english: return "True Tone" + case .german: return "True Tone" + case .chinese: return "原彩显示" + case .french: return "True Tone" + } + } + private func volumeMenuLabel() -> String { switch service.language { case .italian: return "Volume" + case .french: return "Volume" case .english: return "Volume" case .german: return "Lautstärke" case .chinese: return "音量" @@ -244,6 +352,7 @@ final class TBDisplaySenderStatusItemController: NSObject { private func connectionInfoMenuLabel() -> String { switch service.language { case .italian: return "Info connessione" + case .french: return "Infos de connexion" case .english: return "Connection info" case .german: return "Verbindungsinfo" case .chinese: return "连接信息" diff --git a/TargetBridge-Sender/TBDisplaySender/TBMenuMetrics.swift b/TargetBridge-Sender/TBDisplaySender/TBMenuMetrics.swift new file mode 100644 index 00000000..0bf7f158 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBMenuMetrics.swift @@ -0,0 +1,24 @@ +import AppKit + +/// Geometry shared by the menu-bar panel's custom rows. +/// +/// A menu takes the width of its widest item, so with only text items ours came +/// out around 240 pt — narrow enough to read as cramped beside the system's own +/// panels. Control Center's modules are ~340 pt, measured from the Sound and +/// Display panels on macOS 15. +/// +/// The content view is set below that, because a menu draws its own chrome +/// around the item — a 340 pt view produced a visibly wider panel than Apple's. +/// This is the width of the row, not of the panel the user sees; the value was +/// settled by comparing the two panels on screen rather than by calculation. +/// +/// Apple publishes no metrics for those panels — they are system UI, not a +/// component we can adopt — so this is deliberate imitation rather than a +/// documented standard. Two differences cannot be closed: menu items reserve a +/// leading column for checkmarks, so text rows sit further right than Control +/// Center's labels, and Control Center is a floating panel that draws its own +/// rounded background where a menu draws its own chrome. +enum TBMenuMetrics { + static let width: CGFloat = 300 + static let inset: CGFloat = 14 +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBMenuToggleRowView.swift b/TargetBridge-Sender/TBDisplaySender/TBMenuToggleRowView.swift new file mode 100644 index 00000000..755005ea --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBMenuToggleRowView.swift @@ -0,0 +1,167 @@ +import AppKit + +/// One circular toggle in the row. +struct TBMenuToggleSpec { + let symbol: String + let title: String + /// Shown under the title, e.g. "On" / "Off" — mirrors Control Center. + let stateText: String + let isOn: Bool + let onToggle: (Bool) -> Void +} + +/// A row of circular on/off buttons laid out like Control Center's display +/// panel: filled accent circle when on, muted circle when off, with the name and +/// current state stacked underneath. +/// +/// Built as a custom menu-item view rather than checkable NSMenuItems because +/// these are display *modes* the user flips repeatedly while looking at the +/// screen, and the Apple layout shows state at a glance without opening a +/// submenu. Menus don't lay out subviews for us, so all geometry is explicit. +final class TBMenuToggleRowView: NSView { + + private var buttons: [NSButton] = [] + private var stateFields: [NSTextField] = [] + private var specs: [TBMenuToggleSpec] = [] + + private enum Metrics { + static let circle: CGFloat = 38 + static let gap: CGFloat = 4 // circle -> title + static let titleHeight: CGFloat = 14 + static let stateHeight: CGFloat = 13 + static let topPadding: CGFloat = 6 + static let bottomPadding: CGFloat = 8 + static var height: CGFloat { + topPadding + circle + gap + titleHeight + stateHeight + bottomPadding + } + } + + static var preferredHeight: CGFloat { Metrics.height } + + init(specs: [TBMenuToggleSpec], width: CGFloat, leadingInset: CGFloat) { + self.specs = specs + super.init(frame: NSRect(x: 0, y: 0, width: width, height: Metrics.height)) + build(width: width, leadingInset: leadingInset) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + private func build(width: CGFloat, leadingInset: CGFloat) { + guard !specs.isEmpty else { return } + + let usable = width - leadingInset * 2 + let cellWidth = usable / CGFloat(specs.count) + + for (index, spec) in specs.enumerated() { + let cellX = leadingInset + cellWidth * CGFloat(index) + let centerX = cellX + cellWidth / 2 + + // Buttons are laid out from the top down; AppKit's origin is bottom + // left, so each y is measured back from the view's height. + let circleY = Metrics.height - Metrics.topPadding - Metrics.circle + + let button = NSButton(frame: NSRect(x: centerX - Metrics.circle / 2, + y: circleY, + width: Metrics.circle, + height: Metrics.circle)) + button.isBordered = false + button.bezelStyle = .regularSquare + button.title = "" + button.wantsLayer = true + button.layer?.cornerRadius = Metrics.circle / 2 + button.imagePosition = .imageOnly + button.setButtonType(.momentaryChange) + + let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium) + button.image = NSImage(systemSymbolName: spec.symbol, + accessibilityDescription: spec.title)? + .withSymbolConfiguration(config) + // A toggle, not a plain button: the checkbox role is what makes + // VoiceOver announce the on/off state, which is otherwise only + // conveyed by the caption underneath. + button.setAccessibilityLabel(spec.title) + button.setAccessibilityRole(.checkBox) + button.setAccessibilityValue(NSNumber(value: spec.isOn)) + + button.tag = index + button.target = self + button.action = #selector(tapped(_:)) + applyStyle(to: button, isOn: spec.isOn) + addSubview(button) + buttons.append(button) + + let titleY = circleY - Metrics.gap - Metrics.titleHeight + // The captions repeat what the button already announces, so keep + // them out of the accessibility tree rather than reading twice. + addSubview(label(spec.title, + frame: NSRect(x: cellX, y: titleY, width: cellWidth, height: Metrics.titleHeight), + font: .systemFont(ofSize: 11, weight: .medium), + color: .labelColor)) + + let stateField = label(spec.stateText, + frame: NSRect(x: cellX, y: titleY - Metrics.stateHeight, + width: cellWidth, height: Metrics.stateHeight), + font: .systemFont(ofSize: 10, weight: .regular), + color: .secondaryLabelColor) + addSubview(stateField) + stateFields.append(stateField) + } + } + + private func label(_ text: String, frame: NSRect, font: NSFont, color: NSColor) -> NSTextField { + let field = NSTextField(labelWithString: text) + field.setAccessibilityElement(false) + field.frame = frame + field.font = font + field.textColor = color + field.alignment = .center + field.lineBreakMode = .byTruncatingTail + return field + } + + private func applyStyle(to button: NSButton, isOn: Bool) { + // Accent fill + white glyph when on; muted fill + normal glyph when off, + // matching Control Center rather than inventing a colour scheme. + button.layer?.backgroundColor = isOn + ? NSColor.controlAccentColor.cgColor + : NSColor.quaternaryLabelColor.withAlphaComponent(0.22).cgColor + button.contentTintColor = isOn ? .white : .labelColor + } + + @objc private func tapped(_ sender: NSButton) { + let index = sender.tag + guard specs.indices.contains(index) else { return } + + let newState = !isOn(index) + states[index] = newState + applyStyle(to: sender, isOn: newState) + sender.setAccessibilityValue(NSNumber(value: newState)) + // Reflect the change immediately; the menu usually closes on click, but + // if it stays open the row should not show a stale state. + if let field = stateField(at: index) { + field.stringValue = newState ? onWord : offWord + } + specs[index].onToggle(newState) + } + + // MARK: - Live state + + private lazy var states: [Int: Bool] = { + var map: [Int: Bool] = [:] + for (i, s) in specs.enumerated() { map[i] = s.isOn } + return map + }() + + private func isOn(_ index: Int) -> Bool { + states[index] ?? specs[index].isOn + } + + /// Words used when a tap updates the state line. Set by the owner so the + /// row stays localised without knowing about the localisation layer. + var onWord = "On" + var offWord = "Off" + + private func stateField(at index: Int) -> NSTextField? { + stateFields.indices.contains(index) ? stateFields[index] : nil + } +} diff --git a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift index fb3ff858..1cff619b 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift @@ -17,6 +17,8 @@ enum TBMonitorPacketType: UInt8 { case brightness = 0x35 case clipboard = 0x36 case volume = 0x37 + /// Night Shift / True Tone on the receiver's panel. + case displayTweaks = 0x38 case testData = 0x40 } @@ -44,6 +46,9 @@ struct TBMonitorDisplayProfile: Codable { var supportsRawNV12: Bool? var inputMonitoringTrusted: Bool? var accessibilityTrusted: Bool? + /// Optional so older receivers still decode; absent means "cannot". + var supportsNightShift: Bool? + var supportsTrueTone: Bool? } struct TBMonitorCreateSessionAck: Codable { @@ -94,6 +99,11 @@ struct TBMonitorVolume: Codable { var level: Double } +struct TBMonitorDisplayTweaks: Codable { + var nightShift: Bool + var trueTone: Bool +} + struct TBMonitorClipboard: Codable { var text: String } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 5e28fcb1..f3c748ae 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */; }; 6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */; }; 7528751850893B52660870E4 /* TBConfigurationDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7086193EE5710E2C37F86F1 /* TBConfigurationDiagnosticsTests.swift */; }; + 762884127C8093D3E9797790 /* TBMenuToggleRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */; }; + C7A1D4E29B3F45A88C3D0052 /* TBMenuMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A1D4E29B3F45A88C3D0051 /* TBMenuMetrics.swift */; }; 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */; }; 7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; }; 7FB34AA1E75B01E7D1EC3E21 /* it.json in Resources */ = {isa = PBXBuildFile; fileRef = 036E14097FA59989FFC456FD /* it.json */; }; @@ -65,6 +67,8 @@ /* Begin PBXFileReference section */ 036E14097FA59989FFC456FD /* it.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = it.json; sourceTree = ""; }; + 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuToggleRowView.swift; sourceTree = ""; }; + C7A1D4E29B3F45A88C3D0051 /* TBMenuMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuMetrics.swift; sourceTree = ""; }; 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscovery.swift; sourceTree = ""; }; 06D011FF8AF7BB6D26EDC25B /* TBAddonStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonStore.swift; sourceTree = ""; }; 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderService.swift; sourceTree = ""; }; @@ -144,6 +148,8 @@ 4A72C42968FA2A6B2A0515B9 /* TBInputBinding.swift */, 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */, 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */, + 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */, + C7A1D4E29B3F45A88C3D0051 /* TBMenuMetrics.swift */, 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */, 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */, ); @@ -393,6 +399,8 @@ D5E6B5B7B49E740C92010C6F /* TBInputDebugLog.swift in Sources */, CBA7C2F0FA57CC475E2307AF /* TBInputRelayController.swift in Sources */, 8A729C68ECAC4642FA20A284 /* TBLocalizationStore.swift in Sources */, + 762884127C8093D3E9797790 /* TBMenuToggleRowView.swift in Sources */, + C7A1D4E29B3F45A88C3D0052 /* TBMenuMetrics.swift in Sources */, C2CCAB017BC3CAB1DCFA5CCC /* TBMonitorProtocol.swift in Sources */, 06E4F7B33C77762A5E2CB616 /* TBReceiverDiscovery.swift in Sources */, 58980FE95A48D8F8EDB42055 /* TBVirtualDisplayModeMemory.swift in Sources */, From be11162ca66e0d7ccdc730d062ca668e2fc5a007 Mon Sep 17 00:00:00 2001 From: swellweb Date: Fri, 31 Jul 2026 13:56:29 +0200 Subject: [PATCH 05/40] Fix receiver identity after wake and color metadata --- .../ReceiverBackedVirtualDisplaySession.swift | 10 ++++++ .../TBDisplaySenderService.swift | 31 +++++++++++++++++-- .../TBDisplaySender/TBReceiverDiscovery.swift | 4 +++ .../TBReceiverDiscoveryModelTests.swift | 8 +++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index 7f1281c0..d9564927 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -107,6 +107,16 @@ final class ReceiverBackedVirtualDisplaySession { descriptor.maxPixelsWide = UInt32(profile.panelWidth) descriptor.maxPixelsHigh = UInt32(profile.panelHeight) + // A virtual display without chromaticity metadata can receive a generic + // ColorSync profile. In mirror mode that makes macOS render the same + // desktop differently from the built-in Display P3 panel. Advertise the + // iMac's wide-gamut SDR space explicitly so capture is colour-managed + // before it enters the 8-bit NV12 video pipeline. + descriptor.whitePoint = CGPoint(x: 0.3125, y: 0.3291) // D65 + descriptor.redPrimary = CGPoint(x: 0.6797, y: 0.3203) + descriptor.greenPrimary = CGPoint(x: 0.2559, y: 0.6983) + descriptor.bluePrimary = CGPoint(x: 0.1494, y: 0.0557) + let ppi = 218.0 descriptor.sizeInMillimeters = CGSize( width: Double(profile.panelWidth) / ppi * 25.4, diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 60b8d656..70388cc5 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1599,8 +1599,35 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u /// (distinct per machine even when two identical iMacs report the same SDL /// display name), falling back to the receiver-reported name. private func receiverIdentityDiscriminator(for profile: TBMonitorDisplayProfile) -> String { - let trimmedIP = receiverIP.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedIP.isEmpty ? profile.receiverName : trimmedIP + // Bonjour keeps the service name stable across Thunderbolt link-local IP + // 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.preferredIP == receiverIP || + $0.thunderboltIP == receiverIP || + $0.networkIP == receiverIP + }) { + return receiver.stableIdentity + } + + let selectedID = selectedReceiverID.trimmingCharacters(in: .whitespacesAndNewlines) + if let serviceName = selectedID.split(separator: "|", maxSplits: 1).first, + !serviceName.isEmpty { + return "service:\(serviceName)" + } + + if let host = shortHostName?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty { + return "host:\(host)" + } + + let receiverName = profile.receiverName.trimmingCharacters(in: .whitespacesAndNewlines) + if !receiverName.isEmpty { + return "receiver:\(receiverName)" + } + + return "address:\(receiverIP.trimmingCharacters(in: .whitespacesAndNewlines))" } /// Key used to derive the extended-desktop virtual display identity. Shares diff --git a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift index 6b15edbd..bc6c67a9 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift @@ -13,6 +13,10 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { var id: String { "\(serviceName)|\(preferredIP)" } + /// 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 shortHostName: String? { guard let host = hostName, !host.isEmpty else { return nil } let stripped = host.hasSuffix(".") ? String(host.dropLast()) : host diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift index 3ed1c843..1c6428ba 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift @@ -60,6 +60,14 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { XCTAssertEqual(receiver.id, "TargetBridge Jonathans-iMac|192.168.1.64") } + 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") + + XCTAssertNotEqual(beforeWake.id, afterWake.id) + XCTAssertEqual(beforeWake.stableIdentity, afterWake.stableIdentity) + } + // MARK: - shortHostName func testShortHostNameStripsTrailingDotAndDomain() { From df57d7a9d52dfb82d2f6589aad069ec4d4deb880 Mon Sep 17 00:00:00 2001 From: AndyStuardo <174057230+preggocl@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:32:37 -0400 Subject: [PATCH 06/40] Build and publish Sender for x86_64 --- .github/workflows/ci.yml | 16 ++++++++++++---- .github/workflows/release.yml | 27 +++++++++++++++++++++------ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87ce4e77..2509c71f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,14 +2,21 @@ name: CI Build Verification on: push: - branches: [ main, 3.1-maint, 3.2-dev, dev-3.3.0 ] + branches: [ main, 3.1-maint, 3.2-dev, dev-3.3.0, dev-3.4.0 ] pull_request: - branches: [ main, 3.1-maint, 3.2-dev, dev-3.3.0 ] + branches: [ main, 3.1-maint, 3.2-dev, dev-3.3.0, dev-3.4.0 ] jobs: build-sender: - name: Build Sender (macOS 15 - Apple Silicon) - runs-on: macos-15 + name: Build Sender (${{ matrix.os }} - ${{ matrix.arch }}) + strategy: + matrix: + include: + - os: macos-15-intel + arch: x86_64 + - os: macos-15 + arch: arm64 + runs-on: ${{ matrix.os }} steps: - name: Checkout Code uses: actions/checkout@v6 @@ -26,6 +33,7 @@ jobs: run: | cd TargetBridge-Sender ./scripts/build_targetbridge_sender_app.sh + file build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Run Sender Unit Tests run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49fe24b6..13f7c8ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,8 +12,15 @@ permissions: jobs: build-sender: - name: Build Sender (Apple Silicon) - runs-on: macos-15 + name: Build Sender (${{ matrix.os }} - ${{ matrix.arch }}) + strategy: + matrix: + include: + - os: macos-15-intel + arch: x86_64 + - os: macos-15 + arch: arm64 + runs-on: ${{ matrix.os }} steps: - name: Checkout Code uses: actions/checkout@v6 @@ -26,22 +33,23 @@ jobs: run: | cd TargetBridge-Sender ./scripts/build_targetbridge_sender_app.sh + file build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Zip App Bundle run: | cd build - zip -r TargetBridge-arm64.app.zip TargetBridge.app + zip -r TargetBridge-${{ matrix.arch }}.app.zip TargetBridge.app - name: Generate build provenance attestation uses: actions/attest@v4 with: - subject-path: build/TargetBridge-arm64.app.zip + subject-path: build/TargetBridge-${{ matrix.arch }}.app.zip - name: Upload Sender Artifact uses: actions/upload-artifact@v7 with: - name: TargetBridge-arm64.app.zip - path: build/TargetBridge-arm64.app.zip + name: TargetBridge-${{ matrix.arch }}.app.zip + path: build/TargetBridge-${{ matrix.arch }}.app.zip retention-days: 1 build-receiver: @@ -93,6 +101,12 @@ jobs: uses: actions/checkout@v6 - name: Download Sender Artifact + uses: actions/download-artifact@v8 + with: + name: TargetBridge-x86_64.app.zip + path: ./artifacts + + - name: Download Sender Artifact (arm64) uses: actions/download-artifact@v8 with: name: TargetBridge-arm64.app.zip @@ -115,6 +129,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ github.ref_name }}" \ + ./artifacts/TargetBridge-x86_64.app.zip \ ./artifacts/TargetBridge-arm64.app.zip \ ./artifacts/TargetBridge-Receiver-x86_64.app.zip \ ./artifacts/TargetBridge-Receiver-arm64.app.zip \ From a9008268abf24a53db60324c3869db6237da3515 Mon Sep 17 00:00:00 2001 From: AndyStuardo <174057230+preggocl@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:13:22 -0400 Subject: [PATCH 07/40] Fix Sender architecture artifact paths --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2509c71f..db10f367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: run: | cd TargetBridge-Sender ./scripts/build_targetbridge_sender_app.sh - file build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" + file ../build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Run Sender Unit Tests run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13f7c8ed..d693a54a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: run: | cd TargetBridge-Sender ./scripts/build_targetbridge_sender_app.sh - file build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" + file ../build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Zip App Bundle run: | From 57c9228a0513308a31d8b75496c437520eaca38d Mon Sep 17 00:00:00 2001 From: swellweb Date: Sun, 2 Aug 2026 14:23:35 +0200 Subject: [PATCH 08/40] release: prepare v3.4.2 --- README.md | 28 +++++++++++++++---- TargetBridge-Receiver/TBReceiverC/Makefile | 2 +- .../scripts/build_tbreceiver_c_app.sh | 2 +- .../TBDisplaySenderBuildInfo.swift | 2 +- .../TargetBridge.xcodeproj/project.pbxproj | 6 ++-- TargetBridge-Sender/project.yml | 4 +-- docs/Features.md | 13 ++++++--- 7 files changed, 40 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2ad1c2e4..c46b7344 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,26 @@ contributions help keep the project moving forward. [![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github)](https://github.com/sponsors/swellweb) -## TargetBridge 3.3 +## Contributors -TargetBridge 3.3 improves the reliability of the display connection while -keeping the established multi-Mac workspace features: +Thank you to everyone who tests TargetBridge on real hardware, reports issues, +and improves the project. A special thank-you for this release goes to: + +- [@aalpgiray](https://github.com/aalpgiray) for receiver brightness, volume, Night Shift, and True Tone controls. +- [@preggocl](https://github.com/preggocl) for the Apple Silicon and Intel Sender release build coverage. + +## TargetBridge 3.4.2 + +TargetBridge 3.4.2 adds receiver controls that make the remote display feel +more like part of the sender workspace, improves reliability after wake, and +ships pre-built Sender and Receiver apps for both Apple Silicon and Intel Macs: + +- control receiver brightness and volume from the Sender menu +- toggle receiver Night Shift and True Tone from the Sender when the receiver hardware supports them +- retain receiver identity more reliably after wake and carry improved colour metadata through the session +- download separate Apple Silicon and Intel Sender builds, with CI validating the architecture of every release artifact + +It also keeps the established multi-Mac workspace features: - mirror mode and extended desktop mode - multiple receivers from one sender @@ -54,7 +70,7 @@ keeping the established multi-Mac workspace features: - 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) - Input Dockstation, clipboard sync, master/slave roles, and Receiver Master shortcuts: [docs/Features.md#input-dockstation](docs/Features.md#input-dockstation) -- Remote brightness control: [docs/Features.md#remote-brightness-control](docs/Features.md#remote-brightness-control) +- Receiver device controls (brightness, volume, Night Shift, True Tone): [docs/Features.md#receiver-device-controls](docs/Features.md#receiver-device-controls) - Remote connection & automation (URL scheme, launch args, SSH, login/wake): [docs/Automation.md](docs/Automation.md) - Shared translations (English, Italian, German, French, and Chinese): [docs/Features.md#shared-translations](docs/Features.md#shared-translations) - Thunderbolt networking extras (SSH/SFTP, file sharing, Internet Sharing): [docs/Features.md#thunderbolt-networking-extras](docs/Features.md#thunderbolt-networking-extras) @@ -66,6 +82,7 @@ keeping the established multi-Mac workspace features: - Stream profiles range from `2560 x 1440` to `5120 x 2880` with H.264/HEVC selection based on capability. `5K 60` is an experimental profile for recent Apple Silicon; `5K 48` remains recommended for reliable daily work. See [Display Modes](docs/Features.md#display-modes). - Receiver discovery is automatic over Bonjour. Extended-display arrangement is remembered per receiver when possible. See [Display Modes](docs/Features.md#display-modes). - Thunderbolt Bridge remains the primary low-latency path, with `Network Link` available as an experimental addon-gated transport. See [Network Link](docs/Features.md#network-link-experimental). +- The Sender menu can control a connected receiver's brightness, volume, Night Shift, and True Tone when supported. See [Receiver Device Controls](docs/Features.md#receiver-device-controls). ## Official Addons @@ -77,7 +94,7 @@ TargetBridge now has a conservative manifest-based addon system. Official manife ## Requirements -- Sender: Apple Silicon Mac (M1 or later), macOS 14 Sonoma or later +- Sender: Apple Silicon Mac (M1 or later) or Intel Mac, macOS 14 Sonoma or later. Apple Silicon remains the primary tested path; Intel Sender builds are available for broader testing. - Receiver: Intel or Apple Silicon Mac, macOS 11 Big Sur or later - Thunderbolt cable - See also [docs/Hardware.md](docs/Hardware.md) for hardware details, tested cables, adapters, and Thunderbolt networking ideas. @@ -87,6 +104,7 @@ TargetBridge now has a conservative manifest-based addon system. Official manife **[→ Download latest release (pre-built apps, no Xcode needed)](https://github.com/swellweb/targetBridge/releases/latest)** - `TargetBridge-arm64.app.zip` — Sender (for Apple Silicon Macs) +- `TargetBridge-x86_64.app.zip` — Sender (for Intel Macs) - `TargetBridge-Receiver-arm64.app.zip` — Apple Silicon Receiver (use machine as monitor for sender) - `TargetBridge-Receiver-x86_64.app.zip` — Intel Receiver (use machine as monitor for sender) diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 64ced78c..107529ab 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -16,7 +16,7 @@ CC ?= clang PKG ?= pkgconf -APP_VERSION ?= 3.2.0 +APP_VERSION ?= 3.4.2 APP_BUILD ?= dev MACOSX_DEPLOYMENT_TARGET ?= 11.0 diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index 3186b2ec..b4a12c72 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="3.3.1" +APP_VERSION="3.4.2" STAMP="$(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 b4e4e4ef..dcdb2aa7 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.3.1" + static let marketingVersion = "3.4.2" static let buildNumber = "20260728175011" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index ae8427b5..1a138605 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -352,7 +352,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"$(date +%Y%m%d%H%M%S)\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" < Date: Fri, 7 Aug 2026 07:52:55 +0200 Subject: [PATCH 09/40] feat: measure and select direct USB4 network paths --- TargetBridge-Receiver/TBReceiverC/Makefile | 8 +- TargetBridge-Receiver/TBReceiverC/src/main.c | 66 ++- TargetBridge-Receiver/TBReceiverC/src/net.c | 104 ++++- TargetBridge-Receiver/TBReceiverC/src/net.h | 14 + .../TBReceiverC/tests/test_net_parser.c | 11 + .../TBDisplaySenderAutomation.swift | 114 ++++- .../TBDisplaySender/TBReceiverDiscovery.swift | 117 ++++- .../TBConnectionDiagnosticsTests.swift | 160 +++++++ .../TBReceiverDiscoveryModelTests.swift | 48 +- .../TBSenderAutomationParsingTests.swift | 7 + .../TBConnectionDiagnostics.swift | 428 +++++++++++++++++- cli/targetbridge | 6 +- docs/Automation.md | 16 +- 13 files changed, 1062 insertions(+), 37 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 107529ab..5298b3de 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -31,7 +31,7 @@ LDFLAGS += $(shell $(PKG) --libs libavcodec libavutil sdl2) UNAME_S := $(shell uname -s) ifeq ($(UNAME_S),Darwin) -LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework IOSurface -framework CoreServices -framework CoreAudio +LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework IOSurface -framework CoreServices -framework CoreAudio -framework SystemConfiguration endif C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/tb_i18n.c @@ -56,10 +56,14 @@ clean: # Unit tests for the packet parser. net.c is pure POSIX, so this needs no # ffmpeg/SDL/pkgconf — it runs anywhere, including CI, with no hardware. TEST_CFLAGS = -O2 -g -Wall -Wextra -Wno-unused-parameter -std=c11 +TEST_LDFLAGS = +ifeq ($(UNAME_S),Darwin) +TEST_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration +endif TEST_BIN = test_net_parser $(TEST_BIN): tests/test_net_parser.c src/net.c src/net.h src/proto.h - $(CC) $(TEST_CFLAGS) -Isrc tests/test_net_parser.c src/net.c -o $@ + $(CC) $(TEST_CFLAGS) -Isrc tests/test_net_parser.c src/net.c $(TEST_LDFLAGS) -o $@ test: $(TEST_BIN) ./$(TEST_BIN) diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index ee87a780..ad54a5f1 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -82,7 +82,10 @@ struct app { char ip_text[64]; char tb_ip_text[64]; + char usb_ip_text[64]; char net_ip_text[64]; + char ethernet_ip_text[64]; + char wifi_ip_text[64]; char display_host[128]; /* short hostname (or hostname+IP), cached at startup */ char status_text[128]; char sender_text[128]; @@ -597,9 +600,18 @@ static void bonjour_update(struct app *a, uint16_t port) { if (a->tb_ip_text[0] != '\0') { TXTRecordSetValue(&txt, "tbIP", (uint8_t)strlen(a->tb_ip_text), a->tb_ip_text); } + if (a->usb_ip_text[0] != '\0') { + TXTRecordSetValue(&txt, "usbIP", (uint8_t)strlen(a->usb_ip_text), a->usb_ip_text); + } if (a->net_ip_text[0] != '\0') { TXTRecordSetValue(&txt, "netIP", (uint8_t)strlen(a->net_ip_text), a->net_ip_text); } + if (a->ethernet_ip_text[0] != '\0') { + TXTRecordSetValue(&txt, "ethernetIP", (uint8_t)strlen(a->ethernet_ip_text), a->ethernet_ip_text); + } + if (a->wifi_ip_text[0] != '\0') { + TXTRecordSetValue(&txt, "wifiIP", (uint8_t)strlen(a->wifi_ip_text), a->wifi_ip_text); + } TXTRecordSetValue(&txt, "panel", (uint8_t)strlen(a->panel_text), a->panel_text); TXTRecordSetValue(&txt, "version", (uint8_t)strlen(TB_RECEIVER_VERSION), TB_RECEIVER_VERSION); TXTRecordSetValue(&txt, "supportsHEVCDecode", 1, tb_dec_supports_hevc_hwdecode() ? "1" : "0"); @@ -1858,17 +1870,31 @@ int main(int argc, char **argv) { signal(SIGPIPE, SIG_IGN); char tb_ip[64] = {0}; + char usb_ip[64] = {0}; char net_ip[64] = {0}; + char ethernet_ip[64] = {0}; + char wifi_ip[64] = {0}; if (tb_net_get_tb_ip(tb_ip, sizeof(tb_ip)) == 0) { printf("TBReceiver: Thunderbolt Bridge IP = %s\n", tb_ip); } else { printf("TBReceiver: warning, no bridge IP detected (169.254.x.x)\n"); } + if (tb_net_get_link_local_ip(usb_ip, sizeof(usb_ip)) == 0) { + printf("TBReceiver: Direct USB-NCM IP = %s\n", usb_ip); + } else { + printf("TBReceiver: warning, no direct USB-NCM/link-local IP detected\n"); + } if (tb_net_get_lan_ip(net_ip, sizeof(net_ip)) == 0) { printf("TBReceiver: Local network IP = %s\n", net_ip); } else { printf("TBReceiver: warning, no LAN IP detected (RFC1918 IPv4)\n"); } + if (tb_net_get_ethernet_ip(ethernet_ip, sizeof(ethernet_ip)) == 0) { + printf("TBReceiver: Ethernet IP = %s\n", ethernet_ip); + } + if (tb_net_get_wifi_ip(wifi_ip, sizeof(wifi_ip)) == 0) { + printf("TBReceiver: Wi-Fi IP = %s\n", wifi_ip); + } printf("TBReceiver: listening on TCP port %d\n", TB_PORT); struct app a; @@ -1883,14 +1909,21 @@ int main(int argc, char **argv) { snprintf(a.bonjour_name, sizeof(a.bonjour_name), "TargetBridge %s", host); } snprintf(a.tb_ip_text, sizeof(a.tb_ip_text), "%s", tb_ip); + snprintf(a.usb_ip_text, sizeof(a.usb_ip_text), "%s", usb_ip); snprintf(a.net_ip_text, sizeof(a.net_ip_text), "%s", net_ip); - snprintf(a.ip_text, sizeof(a.ip_text), "%s", tb_ip[0] ? tb_ip : (net_ip[0] ? net_ip : tb_i18n_get("receiver.network.not_detected"))); + snprintf(a.ethernet_ip_text, sizeof(a.ethernet_ip_text), "%s", ethernet_ip); + snprintf(a.wifi_ip_text, sizeof(a.wifi_ip_text), "%s", wifi_ip); + snprintf(a.ip_text, sizeof(a.ip_text), "%s", + tb_ip[0] ? tb_ip + : (usb_ip[0] ? usb_ip + : (net_ip[0] ? net_ip : tb_i18n_get("receiver.network.not_detected")))); snprintf(a.language_pref, sizeof(a.language_pref), "%s", startup_language_pref); snprintf(a.input_control_mode, sizeof(a.input_control_mode), "%s", "off"); a.last_input_monitoring_trusted = -1; a.last_accessibility_trusted = -1; tb_refresh_idle_localized_strings(&a); - build_display_host(a.display_host, sizeof(a.display_host), a.ip_text, tb_ip[0] || net_ip[0]); + build_display_host(a.display_host, sizeof(a.display_host), a.ip_text, + tb_ip[0] || usb_ip[0] || net_ip[0]); tb_receiver_apply_language_preference(&a); tb_gesture_bridge_install(tb_receiver_space_switch_callback, &a); tb_gesture_bridge_set_active(0); @@ -1949,28 +1982,51 @@ int main(int argc, char **argv) { if (t - a.last_ip_check_ms >= 1000) { char refreshed_tb_ip[64] = {0}; + char refreshed_usb_ip[64] = {0}; char refreshed_net_ip[64] = {0}; + char refreshed_ethernet_ip[64] = {0}; + char refreshed_wifi_ip[64] = {0}; a.last_ip_check_ms = t; (void)tb_net_get_tb_ip(refreshed_tb_ip, sizeof(refreshed_tb_ip)); + (void)tb_net_get_link_local_ip(refreshed_usb_ip, sizeof(refreshed_usb_ip)); (void)tb_net_get_lan_ip(refreshed_net_ip, sizeof(refreshed_net_ip)); + (void)tb_net_get_ethernet_ip(refreshed_ethernet_ip, sizeof(refreshed_ethernet_ip)); + (void)tb_net_get_wifi_ip(refreshed_wifi_ip, sizeof(refreshed_wifi_ip)); - const int have_refreshed_ip = refreshed_tb_ip[0] || refreshed_net_ip[0]; + const int have_refreshed_ip = + refreshed_tb_ip[0] || refreshed_usb_ip[0] || refreshed_net_ip[0]; const char *preferred_ip = refreshed_tb_ip[0] ? refreshed_tb_ip - : (refreshed_net_ip[0] ? refreshed_net_ip - : tb_i18n_get("receiver.network.not_detected")); + : (refreshed_usb_ip[0] ? refreshed_usb_ip + : (refreshed_net_ip[0] ? refreshed_net_ip + : tb_i18n_get("receiver.network.not_detected"))); if (strcmp(a.tb_ip_text, refreshed_tb_ip) != 0 || + strcmp(a.usb_ip_text, refreshed_usb_ip) != 0 || strcmp(a.net_ip_text, refreshed_net_ip) != 0 || + strcmp(a.ethernet_ip_text, refreshed_ethernet_ip) != 0 || + strcmp(a.wifi_ip_text, refreshed_wifi_ip) != 0 || strcmp(a.ip_text, preferred_ip) != 0) { snprintf(a.tb_ip_text, sizeof(a.tb_ip_text), "%s", refreshed_tb_ip); + snprintf(a.usb_ip_text, sizeof(a.usb_ip_text), "%s", refreshed_usb_ip); snprintf(a.net_ip_text, sizeof(a.net_ip_text), "%s", refreshed_net_ip); + snprintf(a.ethernet_ip_text, sizeof(a.ethernet_ip_text), "%s", refreshed_ethernet_ip); + snprintf(a.wifi_ip_text, sizeof(a.wifi_ip_text), "%s", refreshed_wifi_ip); snprintf(a.ip_text, sizeof(a.ip_text), "%s", preferred_ip); build_display_host(a.display_host, sizeof(a.display_host), preferred_ip, have_refreshed_ip); if (refreshed_tb_ip[0] != '\0') { fprintf(stderr, "[main] Thunderbolt Bridge IP = %s\n", refreshed_tb_ip); } + if (refreshed_usb_ip[0] != '\0') { + fprintf(stderr, "[main] Direct USB-NCM IP = %s\n", refreshed_usb_ip); + } if (refreshed_net_ip[0] != '\0') { fprintf(stderr, "[main] Local network IP = %s\n", refreshed_net_ip); } + if (refreshed_ethernet_ip[0] != '\0') { + fprintf(stderr, "[main] Ethernet IP = %s\n", refreshed_ethernet_ip); + } + if (refreshed_wifi_ip[0] != '\0') { + fprintf(stderr, "[main] Wi-Fi IP = %s\n", refreshed_wifi_ip); + } bonjour_update(&a, TB_PORT); } } diff --git a/TargetBridge-Receiver/TBReceiverC/src/net.c b/TargetBridge-Receiver/TBReceiverC/src/net.c index 700b79ad..387d3b6f 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/net.c +++ b/TargetBridge-Receiver/TBReceiverC/src/net.c @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,11 @@ #include #include +#if defined(__APPLE__) +#include +#include +#endif + /* ---- Parser ----------------------------------------------------------- */ void tb_parser_init(struct tb_parser *p, tb_pkt_cb cb, void *ud) { @@ -160,6 +166,76 @@ static int is_rfc1918_ipv4(const char *host) { return a == 172 && b >= 16 && b <= 31; } +enum tb_lan_interface_kind { + TB_LAN_INTERFACE_ETHERNET = 1, + TB_LAN_INTERFACE_WIFI = 2 +}; + +static int interface_matches_kind(const char *name, enum tb_lan_interface_kind kind) { + if (!name || !*name) return 0; +#if defined(__APPLE__) + CFArrayRef interfaces = SCNetworkInterfaceCopyAll(); + if (!interfaces) return 0; + int matched = 0; + CFIndex count = CFArrayGetCount(interfaces); + for (CFIndex index = 0; index < count; index++) { + SCNetworkInterfaceRef interface = (SCNetworkInterfaceRef)CFArrayGetValueAtIndex(interfaces, index); + CFStringRef bsd_name = SCNetworkInterfaceGetBSDName(interface); + CFStringRef interface_type = SCNetworkInterfaceGetInterfaceType(interface); + if (!bsd_name || !interface_type) continue; + + char candidate_name[IFNAMSIZ] = {0}; + if (!CFStringGetCString(bsd_name, candidate_name, sizeof(candidate_name), kCFStringEncodingUTF8)) continue; + if (strcmp(candidate_name, name) != 0) continue; + + CFStringRef expected_type = kind == TB_LAN_INTERFACE_WIFI + ? kSCNetworkInterfaceTypeIEEE80211 + : kSCNetworkInterfaceTypeEthernet; + matched = CFEqual(interface_type, expected_type); + break; + } + CFRelease(interfaces); + return matched; +#else + if (kind == TB_LAN_INTERFACE_WIFI) { + return strncmp(name, "wlan", 4) == 0 || strncmp(name, "wifi", 4) == 0; + } + return strncmp(name, "en", 2) == 0 || strncmp(name, "eth", 3) == 0; +#endif +} + +static int get_lan_ip_for_kind(char *buf, size_t bufsz, enum tb_lan_interface_kind kind) { + struct ifaddrs *ifap = NULL; + if (!buf || bufsz == 0) return -1; + buf[0] = '\0'; + if (getifaddrs(&ifap) != 0) return -1; + + int ret = -1; + for (struct ifaddrs *p = ifap; p; p = p->ifa_next) { + if (!p->ifa_addr) continue; + if (p->ifa_addr->sa_family != AF_INET) continue; + if (!interface_matches_kind(p->ifa_name, kind)) continue; + + char host[NI_MAXHOST]; + if (getnameinfo(p->ifa_addr, sizeof(struct sockaddr_in), + host, sizeof(host), NULL, 0, NI_NUMERICHOST) == 0 && + is_rfc1918_ipv4(host)) { + snprintf(buf, bufsz, "%s", host); + ret = 0; + break; + } + } + freeifaddrs(ifap); + return ret; +} + +int tb_net_is_link_local_ipv4(const char *host) { + unsigned int a = 0, b = 0, c = 0, d = 0; + if (!host) return 0; + if (sscanf(host, "%u.%u.%u.%u", &a, &b, &c, &d) != 4) return 0; + return a == 169 && b == 254 && c <= 255 && d <= 255; +} + int tb_net_get_tb_ip(char *buf, size_t bufsz) { struct ifaddrs *ifap = NULL; if (getifaddrs(&ifap) != 0) return -1; @@ -184,8 +260,10 @@ int tb_net_get_tb_ip(char *buf, size_t bufsz) { return ret; } -int tb_net_get_lan_ip(char *buf, size_t bufsz) { +int tb_net_get_link_local_ip(char *buf, size_t bufsz) { struct ifaddrs *ifap = NULL; + if (!buf || bufsz == 0) return -1; + buf[0] = '\0'; if (getifaddrs(&ifap) != 0) return -1; int ret = -1; @@ -196,14 +274,26 @@ int tb_net_get_lan_ip(char *buf, size_t bufsz) { char host[NI_MAXHOST]; if (getnameinfo(p->ifa_addr, sizeof(struct sockaddr_in), - host, sizeof(host), NULL, 0, NI_NUMERICHOST) == 0) { - if (is_rfc1918_ipv4(host)) { - snprintf(buf, bufsz, "%s", host); - ret = 0; - break; - } + host, sizeof(host), NULL, 0, NI_NUMERICHOST) == 0 && + tb_net_is_link_local_ipv4(host)) { + snprintf(buf, bufsz, "%s", host); + ret = 0; + break; } } freeifaddrs(ifap); return ret; } + +int tb_net_get_lan_ip(char *buf, size_t bufsz) { + if (tb_net_get_ethernet_ip(buf, bufsz) == 0) return 0; + return tb_net_get_wifi_ip(buf, bufsz); +} + +int tb_net_get_ethernet_ip(char *buf, size_t bufsz) { + return get_lan_ip_for_kind(buf, bufsz, TB_LAN_INTERFACE_ETHERNET); +} + +int tb_net_get_wifi_ip(char *buf, size_t bufsz) { + return get_lan_ip_for_kind(buf, bufsz, TB_LAN_INTERFACE_WIFI); +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/net.h b/TargetBridge-Receiver/TBReceiverC/src/net.h index 80e730ad..09082acb 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/net.h +++ b/TargetBridge-Receiver/TBReceiverC/src/net.h @@ -34,8 +34,22 @@ int tb_net_accept (int server_fd); * buf size must be at least INET_ADDRSTRLEN (16) bytes. Returns 0 on success. */ int tb_net_get_tb_ip(char *buf, size_t bufsz); +/* Returns IP address of the first non-Thunderbolt enX/ethX interface using + * IPv4 link-local addressing (169.254/16). macOS exposes direct USB-NCM + * Mac-to-Mac links this way. Returns 0 on success. */ +int tb_net_get_link_local_ip(char *buf, size_t bufsz); + /* Returns IP address of a likely local LAN interface (for example en0/eth0, RFC1918 IPv4). * buf size must be at least INET_ADDRSTRLEN (16) bytes. Returns 0 on success. */ int tb_net_get_lan_ip(char *buf, size_t bufsz); +/* Returns RFC1918 IPv4 addresses for the physical Ethernet and Wi-Fi + * interfaces independently. Interface roles are resolved from macOS + * SystemConfiguration instead of assuming en0/en1 numbering. */ +int tb_net_get_ethernet_ip(char *buf, size_t bufsz); +int tb_net_get_wifi_ip(char *buf, size_t bufsz); + +/* Address classifier exposed for hardware-free unit tests. */ +int tb_net_is_link_local_ipv4(const char *host); + #endif diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_net_parser.c b/TargetBridge-Receiver/TBReceiverC/tests/test_net_parser.c index 18b0afaf..0c6f38d6 100644 --- a/TargetBridge-Receiver/TBReceiverC/tests/test_net_parser.c +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_net_parser.c @@ -73,6 +73,16 @@ static size_t build_packet(uint8_t *buf, uint8_t type, const void *payload, size /* ---- tests ------------------------------------------------------------- */ +static void test_link_local_ipv4_classifier(void) { + CHECK(tb_net_is_link_local_ipv4("169.254.189.3"), "USB-NCM link-local address"); + CHECK(tb_net_is_link_local_ipv4("169.254.0.1"), "link-local lower boundary"); + CHECK(tb_net_is_link_local_ipv4("169.254.255.254"), "link-local upper boundary"); + CHECK(!tb_net_is_link_local_ipv4("169.255.1.1"), "outside link-local prefix"); + CHECK(!tb_net_is_link_local_ipv4("10.77.77.2"), "private LAN is not link-local"); + CHECK(!tb_net_is_link_local_ipv4("not-an-ip"), "invalid address rejected"); + CHECK(!tb_net_is_link_local_ipv4(NULL), "NULL address rejected"); +} + static void test_single_packet_whole_feed(void) { struct tb_parser p; tb_parser_init(&p, capture_cb, NULL); @@ -213,6 +223,7 @@ static void test_large_payload_roundtrip(void) { } int main(void) { + test_link_local_ipv4_classifier(); test_single_packet_whole_feed(); test_byte_by_byte_feed(); test_two_contiguous_packets(); diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 8ecee060..f8808446 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -83,16 +83,52 @@ enum TBSenderAutomation { session.transportKind = parseTransport(transport) } + let rawPath = params["path"] ?? params["connection-path"] ?? params["connection"] + let pathPreference: TBConnectionPathPreference? + if let rawPath { + guard let parsed = TBConnectionPathPreference.parse(rawPath) else { + NSLog("[automation] unknown connection path '\(rawPath)'; aborting connect") + return + } + pathPreference = parsed + } else { + pathPreference = nil + } + let receiver = params["receiver"].flatMap { $0.isEmpty ? nil : $0 } ?? "auto" if receiver.lowercased() == "auto" { guard let discovered = await waitForReceiver(service) else { NSLog("[automation] no receivers discovered; aborting connect") return } - service.applyDiscoveredReceiver(discovered, to: session) + if let pathPreference { + guard let selected = await selectConnectionPath( + receiver: discovered, + preference: pathPreference, + requestedLocalIP: params["localip"] ?? params["local-ip"] + ) else { + NSLog("[automation] no working \(pathPreference.rawValue) path; aborting connect") + return + } + apply(selected, receiver: discovered, to: session) + } else { + service.applyDiscoveredReceiver(discovered, to: session) + } session.selectedReceiverID = discovered.id } else if let discovered = service.discoveredReceivers.first(where: { matches(receiver, $0) }) { - service.applyDiscoveredReceiver(discovered, to: session) + if let pathPreference { + guard let selected = await selectConnectionPath( + receiver: discovered, + preference: pathPreference, + requestedLocalIP: params["localip"] ?? params["local-ip"] + ) else { + NSLog("[automation] no working \(pathPreference.rawValue) path to \(receiver); aborting connect") + return + } + apply(selected, receiver: discovered, to: session) + } else { + service.applyDiscoveredReceiver(discovered, to: session) + } session.selectedReceiverID = discovered.id } else { // Treat as a raw IP / hostname (bypasses Bonjour). @@ -100,7 +136,9 @@ enum TBSenderAutomation { session.selectedReceiverID = "" } - if let localIP = (params["localip"] ?? params["local-ip"]), !localIP.isEmpty { + if pathPreference == nil, + let localIP = (params["localip"] ?? params["local-ip"]), + !localIP.isEmpty { session.localInterfaceIP = localIP } if session.localInterfaceIP.isEmpty { @@ -124,7 +162,7 @@ enum TBSenderAutomation { NSLog("[automation] no local interface for transport \(session.transportKind.rawValue); aborting connect") return } - NSLog("[automation] connecting to \(session.receiverIP) via \(session.transportKind.rawValue) — \(session.captureSource.rawValue)/\(session.capturePreset.rawValue)") + NSLog("[automation] connecting to \(session.receiverIP) from \(session.localInterfaceIP) via \(session.transportKind.rawValue) — \(session.captureSource.rawValue)/\(session.capturePreset.rawValue)") session.connect() } @@ -202,6 +240,70 @@ enum TBSenderAutomation { return service.discoveredReceivers.first } + private static func selectConnectionPath( + receiver: TBDiscoveredReceiver, + preference: TBConnectionPathPreference, + requestedLocalIP: String? + ) async -> TBConnectionMeasurement? { + let allInterfaces = TBConnectionDiagnostics.currentIPv4Interfaces() + let eligibleInterfaces: [TBConnectionDiagnostics.LocalInterface] + if let requestedLocalIP, !requestedLocalIP.isEmpty { + eligibleInterfaces = allInterfaces.filter { $0.ip == requestedLocalIP } + } else { + eligibleInterfaces = allInterfaces + } + + let allCandidates = TBConnectionDiagnostics.connectionCandidates( + receiver: receiver, + interfaces: eligibleInterfaces, + hardwareKinds: TBConnectionDiagnostics.hardwarePathKinds() + ) + let candidates = allCandidates.filter { preference.allows($0.kind) } + guard !candidates.isEmpty else { + NSLog("[automation] no candidate interfaces/endpoints for path \(preference.rawValue)") + return nil + } + + let measurements = await Task.detached(priority: .userInitiated) { + var values: [TBConnectionMeasurement] = [] + for candidate in candidates { + guard !Task.isCancelled else { break } + NSLog("[automation] probing \(candidate.kind.rawValue): \(candidate.localIP) (\(candidate.localInterfaceName)) -> \(candidate.receiverIP)") + do { + let measurement = try TBConnectionDiagnostics.probe(candidate) + NSLog( + "[automation] path \(candidate.kind.rawValue) measured \(String(format: "%.3f", measurement.throughputGbps)) Gbit/s, \(String(format: "%.2f", measurement.connectLatencyMilliseconds)) ms" + ) + values.append(measurement) + } catch { + NSLog("[automation] path \(candidate.kind.rawValue) unavailable: \(error.localizedDescription)") + } + usleep(100_000) + } + return values + }.value + + guard let selected = TBConnectionDiagnostics.selectBestMeasurement( + measurements, + preference: preference + ) else { return nil } + NSLog( + "[automation] selected \(selected.candidate.kind.rawValue) on \(selected.candidate.localInterfaceName) at \(String(format: "%.3f", selected.throughputGbps)) Gbit/s" + ) + return selected + } + + private static func apply( + _ measurement: TBConnectionMeasurement, + receiver: TBDiscoveredReceiver, + to session: TBDisplaySenderSession + ) { + session.transportKind = measurement.candidate.transportKind + session.localInterfaceIP = measurement.candidate.localIP + session.receiverIP = measurement.candidate.receiverIP + session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + } + // The pure parsing helpers below are `internal` (not `private`) so the // unit-test bundle can exercise them directly. static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { @@ -211,7 +313,11 @@ enum TBSenderAutomation { if let host = receiver.shortHostName?.lowercased(), host == needle { return true } return receiver.preferredIP.lowercased() == needle || receiver.thunderboltIP.lowercased() == needle + || receiver.usbIP.lowercased() == needle + || receiver.ethernetIP.lowercased() == needle + || receiver.wifiIP.lowercased() == needle || receiver.networkIP.lowercased() == needle + || receiver.resolvedIPv4Addresses.contains(where: { $0.lowercased() == needle }) } static func parseTransport(_ value: String) -> TBTransportKind { diff --git a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift index bc6c67a9..4e59de86 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift @@ -5,12 +5,46 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { let receiverName: String let preferredIP: String let thunderboltIP: String + let usbIP: String let networkIP: String + let ethernetIP: String + let wifiIP: String + let resolvedIPv4Addresses: [String] let panelSummary: String let version: String let supportsHEVCDecode: Bool let hostName: String? + init( + serviceName: String, + receiverName: String, + preferredIP: String, + thunderboltIP: String, + usbIP: String = "", + networkIP: String, + ethernetIP: String = "", + wifiIP: String = "", + resolvedIPv4Addresses: [String] = [], + panelSummary: String, + version: String, + supportsHEVCDecode: Bool, + hostName: String? + ) { + self.serviceName = serviceName + self.receiverName = receiverName + self.preferredIP = preferredIP + self.thunderboltIP = thunderboltIP + self.usbIP = usbIP + self.networkIP = networkIP + self.ethernetIP = ethernetIP + self.wifiIP = wifiIP + self.resolvedIPv4Addresses = resolvedIPv4Addresses + self.panelSummary = panelSummary + self.version = version + self.supportsHEVCDecode = supportsHEVCDecode + self.hostName = hostName + } + var id: String { "\(serviceName)|\(preferredIP)" } /// Bonjour service names survive link-local IP address changes after wake. @@ -25,26 +59,40 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { return String(first) } - func ip(for transportKind: TBTransportKind) -> String { + func ip(for transportKind: TBTransportKind, localInterfaceIP: String = "") -> String { switch transportKind { case .thunderboltBridge: return !thunderboltIP.isEmpty ? thunderboltIP : preferredIP case .networkLink: + if localInterfaceIP.hasPrefix("169.254."), !usbIP.isEmpty { + return usbIP + } return !networkIP.isEmpty ? networkIP : preferredIP } } var displayText: String { + var addresses: [(label: String, ip: String)] = [] + var seenIPs = Set() + func appendAddress(_ label: String, _ ip: String) { + guard !ip.isEmpty, seenIPs.insert(ip).inserted else { return } + addresses.append((label, ip)) + } + appendAddress("TB", thunderboltIP) + appendAddress("USB", usbIP) + appendAddress("ETH", ethernetIP) + appendAddress("Wi-Fi", wifiIP) + appendAddress("NET", networkIP) + let addressSummary: String - switch (thunderboltIP.isEmpty, networkIP.isEmpty) { - case (false, false): - addressSummary = "TB \(thunderboltIP) · NET \(networkIP)" - case (false, true): - addressSummary = thunderboltIP - case (true, false): - addressSummary = networkIP - case (true, true): + if addresses.isEmpty { addressSummary = preferredIP + } else if addresses.count == 1 { + addressSummary = addresses[0].ip + } else { + addressSummary = addresses + .map { "\($0.label) \($0.ip)" } + .joined(separator: " · ") } let name: String @@ -112,8 +160,18 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { let receiverName = stringValue("name").isEmpty ? service.name : stringValue("name") let receiverIP = stringValue("ip") let thunderboltIP = stringValue("tbIP") + let usbIP = stringValue("usbIP") let networkIP = stringValue("netIP") - let preferredIP = !receiverIP.isEmpty ? receiverIP : (!thunderboltIP.isEmpty ? thunderboltIP : networkIP) + let ethernetIP = stringValue("ethernetIP") + let wifiIP = stringValue("wifiIP") + let resolvedIPv4Addresses = resolvedIPv4Addresses(from: service) + let preferredIP = !receiverIP.isEmpty + ? receiverIP + : (!thunderboltIP.isEmpty + ? thunderboltIP + : (!usbIP.isEmpty + ? usbIP + : (!networkIP.isEmpty ? networkIP : (resolvedIPv4Addresses.first ?? "")))) guard !preferredIP.isEmpty else { return } let panelName = stringValue("panel") @@ -138,14 +196,18 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { receiverName: receiverName, preferredIP: preferredIP, thunderboltIP: thunderboltIP, + usbIP: usbIP, networkIP: networkIP, + ethernetIP: ethernetIP, + wifiIP: wifiIP, + resolvedIPv4Addresses: resolvedIPv4Addresses, panelSummary: panelSummary, version: version, supportsHEVCDecode: supportsHEVCDecode, hostName: service.hostName ) - if let index = receivers.firstIndex(where: { $0.id == receiver.id }) { + if let index = receivers.firstIndex(where: { $0.serviceName == receiver.serviceName }) { receivers[index] = receiver } else { receivers.append(receiver) @@ -158,6 +220,39 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { } } + private func resolvedIPv4Addresses(from service: NetService) -> [String] { + guard let addresses = service.addresses else { return [] } + var result: [String] = [] + var seen = Set() + + for addressData in addresses { + let address: String? = addressData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress, + rawBuffer.count >= MemoryLayout.size + else { return nil } + let socketAddress = baseAddress.assumingMemoryBound(to: sockaddr.self) + guard socketAddress.pointee.sa_family == UInt8(AF_INET) else { return nil } + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let length = socklen_t(min(rawBuffer.count, Int(socketAddress.pointee.sa_len))) + guard getnameinfo( + socketAddress, + length, + &host, + socklen_t(host.count), + nil, + 0, + NI_NUMERICHOST + ) == 0 else { return nil } + return String(cString: host) + } + if let address, seen.insert(address).inserted { + result.append(address) + } + } + + return result.sorted() + } + private func removeService(_ service: NetService) { services.removeValue(forKey: service.name) receivers.removeAll { $0.serviceName == service.name } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift index b35b2fff..d4adbee9 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift @@ -11,6 +11,7 @@ final class TBConnectionDiagnosticsTests: XCTestCase { private let interfaces: [Interface] = [ Interface(name: "en0", ip: "192.168.1.225"), Interface(name: "bridge0", ip: "169.254.109.86"), + Interface(name: "en8", ip: "169.254.190.84"), ] // MARK: - interfaceName(forLocalIP:) @@ -32,6 +33,20 @@ final class TBConnectionDiagnosticsTests: XCTestCase { XCTAssertNil(TBConnectionDiagnostics.interfaceName(forLocalIP: "169.254.109.86", in: [])) } + // MARK: - Direct USB-NCM interface classification + + func testDirectLinkInterfaceAcceptsUSBNCMLinkLocalAddress() { + XCTAssertTrue(TBConnectionDiagnostics.isDirectLinkInterface(name: "en8", ip: "169.254.190.84")) + XCTAssertTrue(TBConnectionDiagnostics.isDirectLinkInterface(name: "eth2", ip: "169.254.1.2")) + } + + func testDirectLinkInterfaceRejectsThunderboltLANAndMalformedAddresses() { + XCTAssertFalse(TBConnectionDiagnostics.isDirectLinkInterface(name: "bridge0", ip: "169.254.109.86")) + XCTAssertFalse(TBConnectionDiagnostics.isDirectLinkInterface(name: "en8", ip: "192.168.178.93")) + XCTAssertFalse(TBConnectionDiagnostics.isDirectLinkInterface(name: "en8", ip: "169.254.300.1")) + XCTAssertFalse(TBConnectionDiagnostics.isDirectLinkInterface(name: "en8", ip: "169.254.1")) + } + // MARK: - scopedReceiverHost /// The Thunderbolt Bridge case: both ends self-assign 169.254.x, the @@ -70,6 +85,17 @@ final class TBConnectionDiagnosticsTests: XCTestCase { ) } + func testUSBNCMLinkLocalReceiverIsNotGivenAnIPv4ZoneSuffix() { + XCTAssertEqual( + TBConnectionDiagnostics.scopedReceiverHost( + receiverIP: "169.254.189.3", + localIP: "169.254.190.84", + interfaces: interfaces + ), + "169.254.189.3" + ) + } + func testAlreadyScopedReceiverIsUnchanged() { XCTAssertEqual( TBConnectionDiagnostics.scopedReceiverHost( @@ -119,4 +145,138 @@ final class TBConnectionDiagnosticsTests: XCTestCase { XCTAssertFalse(iface.ip.hasPrefix("127."), "loopback must be excluded, found \(iface.ip) on \(iface.name)") } } + + // MARK: - Automatic path candidates and ranking + + func testPathPreferenceAcceptsUSB4AndUserFacingAliases() { + XCTAssertEqual(TBConnectionPathPreference.parse("auto"), .automatic) + XCTAssertEqual(TBConnectionPathPreference.parse("wired"), .wired) + XCTAssertEqual(TBConnectionPathPreference.parse("Thunderbolt Bridge"), .thunderbolt) + XCTAssertEqual(TBConnectionPathPreference.parse("USB4"), .usb) + XCTAssertEqual(TBConnectionPathPreference.parse("USB-C"), .usb) + XCTAssertEqual(TBConnectionPathPreference.parse("LAN"), .ethernet) + XCTAssertEqual(TBConnectionPathPreference.parse("wireless"), .wifi) + XCTAssertNil(TBConnectionPathPreference.parse("fibre-channel")) + } + + private func candidate( + _ kind: TBConnectionPathKind, + localIP: String, + receiverIP: String + ) -> TBConnectionCandidate { + TBConnectionCandidate( + kind: kind, + localInterfaceName: kind == .thunderbolt ? "bridge0" : "en8", + localIP: localIP, + receiverIP: receiverIP + ) + } + + func testCandidateDiscoverySeparatesThunderboltUSBEthernetAndWiFi() { + let receiver = TBDiscoveredReceiver( + serviceName: "TargetBridge iMac", + receiverName: "iMac", + preferredIP: "192.168.178.101", + thunderboltIP: "169.254.50.32", + usbIP: "169.254.190.85", + networkIP: "192.168.178.101", + ethernetIP: "10.77.77.2", + wifiIP: "192.168.178.101", + resolvedIPv4Addresses: ["169.254.50.32", "169.254.190.85", "10.77.77.2", "192.168.178.101"], + panelSummary: "iMac 4K", + version: "3.2.1", + supportsHEVCDecode: true, + hostName: "iMac.local." + ) + let local = [ + Interface(name: "bridge0", ip: "169.254.50.31"), + Interface(name: "en8", ip: "169.254.190.84"), + Interface(name: "en0", ip: "10.77.77.1"), + Interface(name: "en1", ip: "192.168.178.93"), + ] + let candidates = TBConnectionDiagnostics.connectionCandidates( + receiver: receiver, + interfaces: local, + hardwareKinds: ["en0": .ethernet, "en1": .wifi] + ) + + XCTAssertTrue(candidates.contains(candidate(.thunderbolt, localIP: "169.254.50.31", receiverIP: "169.254.50.32"))) + XCTAssertTrue(candidates.contains(candidate(.usb, localIP: "169.254.190.84", receiverIP: "169.254.190.85"))) + XCTAssertTrue(candidates.contains { + $0.kind == .ethernet && $0.localIP == "10.77.77.1" && $0.receiverIP == "10.77.77.2" + }) + XCTAssertTrue(candidates.contains { + $0.kind == .wifi && $0.localIP == "192.168.178.93" && $0.receiverIP == "192.168.178.101" + }) + } + + func testAutoSelectionLetsGigabitEthernetBeatSlowerUSB() { + let usb = TBConnectionMeasurement( + candidate: candidate(.usb, localIP: "169.254.1.1", receiverIP: "169.254.1.2"), + throughputGbps: 0.48, + connectLatencyMilliseconds: 0.20 + ) + let ethernet = TBConnectionMeasurement( + candidate: candidate(.ethernet, localIP: "10.77.77.1", receiverIP: "10.77.77.2"), + throughputGbps: 0.94, + connectLatencyMilliseconds: 0.35 + ) + XCTAssertEqual( + TBConnectionDiagnostics.selectBestMeasurement([usb, ethernet], preference: .automatic), + ethernet + ) + } + + func testAutoSelectionChoosesMeasuredFastestPhysicalPath() { + let thunderbolt = TBConnectionMeasurement( + candidate: candidate(.thunderbolt, localIP: "169.254.50.31", receiverIP: "169.254.50.32"), + throughputGbps: 4.2, + connectLatencyMilliseconds: 0.16 + ) + let ethernet = TBConnectionMeasurement( + candidate: candidate(.ethernet, localIP: "10.77.77.1", receiverIP: "10.77.77.2"), + throughputGbps: 0.94, + connectLatencyMilliseconds: 0.22 + ) + XCTAssertEqual( + TBConnectionDiagnostics.selectBestMeasurement([ethernet, thunderbolt], preference: .automatic), + thunderbolt + ) + } + + func testManualSelectionFiltersOutFasterOtherPaths() { + let usb = TBConnectionMeasurement( + candidate: candidate(.usb, localIP: "169.254.1.1", receiverIP: "169.254.1.2"), + throughputGbps: 0.42, + connectLatencyMilliseconds: 0.20 + ) + let thunderbolt = TBConnectionMeasurement( + candidate: candidate(.thunderbolt, localIP: "169.254.2.1", receiverIP: "169.254.2.2"), + throughputGbps: 7.0, + connectLatencyMilliseconds: 0.10 + ) + XCTAssertEqual( + TBConnectionDiagnostics.selectBestMeasurement([thunderbolt, usb], preference: .usb), + usb + ) + XCTAssertNil(TBConnectionDiagnostics.selectBestMeasurement([thunderbolt], preference: .wifi)) + } + + func testWiredModeNeverFallsBackToFasterWiFi() { + let ethernet = TBConnectionMeasurement( + candidate: candidate(.ethernet, localIP: "10.77.77.1", receiverIP: "10.77.77.2"), + throughputGbps: 0.94, + connectLatencyMilliseconds: 0.22 + ) + let wifi = TBConnectionMeasurement( + candidate: candidate(.wifi, localIP: "192.168.178.93", receiverIP: "192.168.178.101"), + throughputGbps: 1.20, + connectLatencyMilliseconds: 0.15 + ) + XCTAssertEqual( + TBConnectionDiagnostics.selectBestMeasurement([wifi, ethernet], preference: .wired), + ethernet + ) + XCTAssertNil(TBConnectionDiagnostics.selectBestMeasurement([wifi], preference: .wired)) + } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift index 1c6428ba..0e90d79a 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift @@ -12,6 +12,7 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { receiverName: String = "Test-iMac", preferredIP: String = "192.168.1.64", thunderboltIP: String = "", + usbIP: String = "", networkIP: String = "", panelSummary: String = "", version: String = "3.1.0", @@ -23,6 +24,7 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { receiverName: receiverName, preferredIP: preferredIP, thunderboltIP: thunderboltIP, + usbIP: usbIP, networkIP: networkIP, panelSummary: panelSummary, version: version, @@ -53,6 +55,30 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { XCTAssertEqual(receiver.ip(for: .networkLink), "169.254.89.80") } + func testNetworkTransportUsesUSBIPForLinkLocalLocalInterface() { + let receiver = makeReceiver( + preferredIP: "169.254.189.3", + usbIP: "169.254.189.3", + networkIP: "192.168.178.101" + ) + XCTAssertEqual( + receiver.ip(for: .networkLink, localInterfaceIP: "169.254.190.84"), + "169.254.189.3" + ) + } + + func testNetworkTransportKeepsLANIPForLANLocalInterface() { + let receiver = makeReceiver( + preferredIP: "169.254.189.3", + usbIP: "169.254.189.3", + networkIP: "192.168.178.101" + ) + XCTAssertEqual( + receiver.ip(for: .networkLink, localInterfaceIP: "192.168.178.93"), + "192.168.178.101" + ) + } + // MARK: - Identity func testIDCombinesServiceNameAndPreferredIP() { @@ -85,10 +111,11 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { func testDisplayTextShowsBothTransportsWhenAvailable() { let receiver = makeReceiver( thunderboltIP: "169.254.89.80", + usbIP: "169.254.189.3", networkIP: "192.168.1.64", hostName: "Jonathans-iMac.local." ) - XCTAssertEqual(receiver.displayText, "Jonathans-iMac (TB 169.254.89.80 · NET 192.168.1.64)") + XCTAssertEqual(receiver.displayText, "Jonathans-iMac (TB 169.254.89.80 · USB 169.254.189.3 · NET 192.168.1.64)") } func testDisplayTextSingleTransportOnly() { @@ -105,4 +132,23 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { let receiver = makeReceiver(networkIP: "192.168.1.64", panelSummary: "iMac 5K (5120x2880)") XCTAssertEqual(receiver.displayText, "192.168.1.64 · iMac 5K (5120x2880)") } + + func testDisplayTextDistinguishesEthernetAndWiFiWhenAdvertised() { + let receiver = TBDiscoveredReceiver( + serviceName: "TargetBridge iMac", + receiverName: "iMac", + preferredIP: "192.168.178.101", + thunderboltIP: "", + usbIP: "", + networkIP: "192.168.178.101", + ethernetIP: "10.77.77.2", + wifiIP: "192.168.178.101", + resolvedIPv4Addresses: ["10.77.77.2", "192.168.178.101"], + panelSummary: "", + version: "3.2.1", + supportsHEVCDecode: true, + hostName: "iMac.local." + ) + XCTAssertEqual(receiver.displayText, "iMac (ETH 10.77.77.2 · Wi-Fi 192.168.178.101)") + } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index be5cf09e..eecae873 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -101,7 +101,11 @@ final class TBSenderAutomationParsingTests: XCTestCase { receiverName: "Jonathans-iMac", preferredIP: "192.168.1.64", thunderboltIP: "169.254.89.80", + usbIP: "169.254.189.3", networkIP: "192.168.1.64", + ethernetIP: "10.77.77.2", + wifiIP: "192.168.1.64", + resolvedIPv4Addresses: ["172.20.10.2"], panelSummary: "iMac 5K", version: "3.1.0", supportsHEVCDecode: true, @@ -121,6 +125,9 @@ final class TBSenderAutomationParsingTests: XCTestCase { func testMatchesByAnyAdvertisedIP() { XCTAssertTrue(TBSenderAutomation.matches("192.168.1.64", makeReceiver()), "preferred/network IP") XCTAssertTrue(TBSenderAutomation.matches("169.254.89.80", makeReceiver()), "thunderbolt IP") + XCTAssertTrue(TBSenderAutomation.matches("169.254.189.3", makeReceiver()), "direct USB IP") + XCTAssertTrue(TBSenderAutomation.matches("10.77.77.2", makeReceiver()), "Ethernet IP") + XCTAssertTrue(TBSenderAutomation.matches("172.20.10.2", makeReceiver()), "resolved Bonjour IP") } func testMatchesByID() { diff --git a/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift b/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift index dd9f0265..0cc279ab 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift @@ -1,6 +1,98 @@ import Foundation +import Darwin import Network import os +import SystemConfiguration + +enum TBConnectionPathPreference: String, CaseIterable { + case automatic + case wired + case thunderbolt + case usb + case ethernet + case wifi + + static func parse(_ value: String?) -> TBConnectionPathPreference? { + guard let value else { return nil } + switch value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") { + case "auto", "automatic", "automatico", "best", "alwaysavailable", "sempredisponibile": return .automatic + case "wired", "cable", "cabled", "cableonly", "solocavo", "onlycable": return .wired + case "tb", "thunderbolt", "thunderboltbridge", "bridge": return .thunderbolt + case "usb", "usb4", "usbc", "directusb": return .usb + case "ethernet", "eth", "lan": return .ethernet + case "wifi", "wireless", "wlan": return .wifi + default: return nil + } + } + + func allows(_ kind: TBConnectionPathKind) -> Bool { + switch self { + case .automatic: + return true + case .wired: + return kind != .wifi + case .thunderbolt, .usb, .ethernet, .wifi: + return kind.rawValue == rawValue + } + } +} + +enum TBConnectionPathKind: String, CaseIterable { + case thunderbolt + case usb + case ethernet + case wifi + + var transportKind: TBTransportKind { + self == .thunderbolt ? .thunderboltBridge : .networkLink + } + + fileprivate var tieBreakPriority: Int { + switch self { + case .thunderbolt: return 4 + case .usb: return 3 + case .ethernet: return 2 + case .wifi: return 1 + } + } +} + +struct TBConnectionCandidate: Hashable { + let kind: TBConnectionPathKind + let localInterfaceName: String + let localIP: String + let receiverIP: String + + var transportKind: TBTransportKind { kind.transportKind } + var id: String { "\(kind.rawValue)|\(localInterfaceName)|\(localIP)|\(receiverIP)" } +} + +struct TBConnectionMeasurement: Equatable { + let candidate: TBConnectionCandidate + let throughputGbps: Double + let connectLatencyMilliseconds: Double +} + +enum TBConnectionProbeError: LocalizedError { + case invalidAddress(String) + case interfaceUnavailable(String) + case socketFailure(String) + case timeout + + var errorDescription: String? { + switch self { + case .invalidAddress(let value): return "Invalid IPv4 address: \(value)" + case .interfaceUnavailable(let value): return "Interface unavailable: \(value)" + case .socketFailure(let value): return value + case .timeout: return "Connection-path probe timed out" + } + } +} /// Unified-logging entry points for the sender. `log stream --predicate /// 'subsystem == "com.targetbridge.sender"'` (or Console.app) shows the @@ -32,9 +124,338 @@ enum TBConnectionDiagnostics { return interfaces.first(where: { $0.ip == localIP })?.name } - /// For a link-local (`169.254.x`) receiver address, returns - /// `"%"` so the dial is scoped to the interface that owns - /// `localIP`. Everything else is returned unchanged. + /// Direct Mac-to-Mac USB connections are exposed by macOS as USB-NCM + /// Ethernet interfaces (normally enX) with an IPv4 link-local address. + /// Keep bridgeX reserved for the Thunderbolt Bridge transport. + static func isDirectLinkInterface(name: String, ip: String) -> Bool { + guard name.hasPrefix("en") || name.hasPrefix("eth") else { return false } + let octets = ip.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4, + let first = Int(octets[0]), + let second = Int(octets[1]), + octets.dropFirst(2).allSatisfy({ component in + guard let value = Int(component) else { return false } + return (0...255).contains(value) + }) + else { + return false + } + return first == 169 && second == 254 + } + + static func isIPv4LinkLocal(_ ip: String) -> Bool { + let octets = ipv4Octets(ip) + return octets?.count == 4 && octets?[0] == 169 && octets?[1] == 254 + } + + static func isPrivateIPv4(_ ip: String) -> Bool { + guard let octets = ipv4Octets(ip) else { return false } + return octets[0] == 10 + || (octets[0] == 172 && (16...31).contains(octets[1])) + || (octets[0] == 192 && octets[1] == 168) + } + + private static func ipv4Octets(_ ip: String) -> [Int]? { + let components = ip.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 4 else { return nil } + let octets = components.compactMap { Int($0) } + guard octets.count == 4, octets.allSatisfy({ (0...255).contains($0) }) else { return nil } + return octets + } + + /// Maps BSD interface names to their physical role using SystemConfiguration. + /// This avoids assuming that Wi-Fi is always en0/en1, which changes between Macs. + static func hardwarePathKinds() -> [String: TBConnectionPathKind] { + guard let all = SCNetworkInterfaceCopyAll() as? [SCNetworkInterface] else { return [:] } + var result: [String: TBConnectionPathKind] = [:] + for interface in all { + guard let bsdNameValue = SCNetworkInterfaceGetBSDName(interface), + let type = SCNetworkInterfaceGetInterfaceType(interface) + else { continue } + let bsdName = bsdNameValue as String + if CFEqual(type, kSCNetworkInterfaceTypeIEEE80211) { + result[bsdName] = .wifi + } else if CFEqual(type, kSCNetworkInterfaceTypeEthernet) { + result[bsdName] = .ethernet + } + } + return result + } + + static func pathKind( + for interface: LocalInterface, + hardwareKinds: [String: TBConnectionPathKind] + ) -> TBConnectionPathKind? { + if interface.name.hasPrefix("bridge"), isIPv4LinkLocal(interface.ip) { + return .thunderbolt + } + if isDirectLinkInterface(name: interface.name, ip: interface.ip) { + return .usb + } + if hardwareKinds[interface.name] == .wifi, isPrivateIPv4(interface.ip) { + return .wifi + } + if hardwareKinds[interface.name] == .ethernet, isPrivateIPv4(interface.ip) { + return .ethernet + } + // Some third-party USB Ethernet adapters are absent from the SC inventory. + if (interface.name.hasPrefix("en") || interface.name.hasPrefix("eth")), isPrivateIPv4(interface.ip) { + return .ethernet + } + return nil + } + + /// Builds every plausible local/remote pair. Real probing decides which pair + /// is alive; a cable type is never presumed to be faster merely from its name. + static func connectionCandidates( + receiver: TBDiscoveredReceiver, + interfaces: [LocalInterface], + hardwareKinds: [String: TBConnectionPathKind] + ) -> [TBConnectionCandidate] { + let allReceiverIPs = uniqueIPv4Addresses([ + receiver.thunderboltIP, + receiver.usbIP, + receiver.ethernetIP, + receiver.wifiIP, + receiver.networkIP, + receiver.preferredIP, + ] + receiver.resolvedIPv4Addresses) + + var candidates: [TBConnectionCandidate] = [] + var seen = Set() + for interface in interfaces { + guard let kind = pathKind(for: interface, hardwareKinds: hardwareKinds) else { continue } + let explicitIP: String + switch kind { + case .thunderbolt: explicitIP = receiver.thunderboltIP + case .usb: explicitIP = receiver.usbIP + case .ethernet: explicitIP = receiver.ethernetIP + case .wifi: explicitIP = receiver.wifiIP + } + + let addressPool: [String] + if kind == .thunderbolt || kind == .usb { + addressPool = uniqueIPv4Addresses([explicitIP] + allReceiverIPs.filter(isIPv4LinkLocal)) + } else { + let privateAddresses = allReceiverIPs.filter(isPrivateIPv4) + let sameSubnet = privateAddresses.filter { likelySameIPv4Subnet(interface.ip, $0) } + // Prefer a same-/24 endpoint. If Bonjour only exposes a different + // subnet, retain it as a probe candidate so routed LANs still work. + addressPool = uniqueIPv4Addresses( + [explicitIP] + (sameSubnet.isEmpty ? privateAddresses : sameSubnet) + ) + } + + for receiverIP in addressPool where receiverIP != interface.ip { + let candidate = TBConnectionCandidate( + kind: kind, + localInterfaceName: interface.name, + localIP: interface.ip, + receiverIP: receiverIP + ) + if seen.insert(candidate.id).inserted { + candidates.append(candidate) + } + } + } + + return candidates.sorted { + if $0.kind.tieBreakPriority != $1.kind.tieBreakPriority { + return $0.kind.tieBreakPriority > $1.kind.tieBreakPriority + } + if $0.localInterfaceName != $1.localInterfaceName { + return $0.localInterfaceName < $1.localInterfaceName + } + return $0.receiverIP < $1.receiverIP + } + } + + static func selectBestMeasurement( + _ measurements: [TBConnectionMeasurement], + preference: TBConnectionPathPreference + ) -> TBConnectionMeasurement? { + let eligible: [TBConnectionMeasurement] + eligible = measurements.filter { preference.allows($0.candidate.kind) } + guard let fastest = eligible.max(by: { $0.throughputGbps < $1.throughputGbps }) else { return nil } + + // Results within 10% are effectively tied for a short startup probe. + // In that narrow band prefer lower latency, then the more direct medium. + let competitive = eligible.filter { + $0.throughputGbps >= fastest.throughputGbps * 0.90 + } + return competitive.sorted { + let latencyDifference = abs($0.connectLatencyMilliseconds - $1.connectLatencyMilliseconds) + if latencyDifference > 0.25 { + return $0.connectLatencyMilliseconds < $1.connectLatencyMilliseconds + } + if $0.candidate.kind.tieBreakPriority != $1.candidate.kind.tieBreakPriority { + return $0.candidate.kind.tieBreakPriority > $1.candidate.kind.tieBreakPriority + } + return $0.throughputGbps > $1.throughputGbps + }.first + } + + /// Sends a bounded stream of protocol-valid TEST_DATA packets through one + /// explicitly bound interface. The receiver already discards this packet + /// type, so the probe measures the real path without starting video capture. + static func probe( + _ candidate: TBConnectionCandidate, + port: UInt16 = TBMonitorProtocol.port, + payloadBytes: Int = 16 * 1024 * 1024, + timeout: TimeInterval = 3.0 + ) throws -> TBConnectionMeasurement { + let fd = Darwin.socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) + guard fd >= 0 else { throw socketError("socket") } + defer { Darwin.close(fd) } + + var noSignal: Int32 = 1 + guard setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size(ofValue: noSignal))) == 0 else { + throw socketError("setsockopt(SO_NOSIGPIPE)") + } + + let interfaceIndex = if_nametoindex(candidate.localInterfaceName) + guard interfaceIndex != 0 else { throw TBConnectionProbeError.interfaceUnavailable(candidate.localInterfaceName) } + var boundIndex = interfaceIndex + guard setsockopt(fd, IPPROTO_IP, IP_BOUND_IF, &boundIndex, socklen_t(MemoryLayout.size(ofValue: boundIndex))) == 0 else { + throw socketError("setsockopt(IP_BOUND_IF)") + } + + var localAddress = try socketAddress(ip: candidate.localIP, port: 0) + let bindResult = withUnsafePointer(to: &localAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { throw socketError("bind") } + + let originalFlags = fcntl(fd, F_GETFL, 0) + guard originalFlags >= 0, fcntl(fd, F_SETFL, originalFlags | O_NONBLOCK) == 0 else { + throw socketError("fcntl") + } + + var remoteAddress = try socketAddress(ip: candidate.receiverIP, port: port) + let connectStarted = DispatchTime.now().uptimeNanoseconds + let connectResult = withUnsafePointer(to: &remoteAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + if connectResult != 0 { + guard errno == EINPROGRESS else { throw socketError("connect") } + try waitForSocket(fd, events: Int16(POLLOUT), timeout: timeout) + var socketStatus: Int32 = 0 + var socketStatusLength = socklen_t(MemoryLayout.size(ofValue: socketStatus)) + guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketStatus, &socketStatusLength) == 0 else { + throw socketError("getsockopt(SO_ERROR)") + } + guard socketStatus == 0 else { + throw TBConnectionProbeError.socketFailure("connect: \(String(cString: strerror(socketStatus)))") + } + } + let connectedAt = DispatchTime.now().uptimeNanoseconds + + let chunkBytes = min(256 * 1024, max(1, payloadBytes)) + let payload = Data(repeating: 0xA5, count: chunkBytes) + let fullPacket = TBMonitorProtocol.makePacket(type: .testData, payload: payload) + let sendStarted = DispatchTime.now().uptimeNanoseconds + let deadline = sendStarted + UInt64(timeout * 1_000_000_000) + var sentPayloadBytes = 0 + + while sentPayloadBytes < payloadBytes { + let remaining = payloadBytes - sentPayloadBytes + let packet = remaining >= chunkBytes + ? fullPacket + : TBMonitorProtocol.makePacket(type: .testData, payload: Data(repeating: 0xA5, count: remaining)) + try sendAll(packet, socket: fd, deadline: deadline) + sentPayloadBytes += min(chunkBytes, remaining) + } + let finishedAt = DispatchTime.now().uptimeNanoseconds + let sendSeconds = max(Double(finishedAt - sendStarted) / 1_000_000_000.0, 0.000_001) + let throughput = (Double(sentPayloadBytes) * 8.0) / 1_000_000_000.0 / sendSeconds + let latency = Double(connectedAt - connectStarted) / 1_000_000.0 + return TBConnectionMeasurement( + candidate: candidate, + throughputGbps: throughput, + connectLatencyMilliseconds: latency + ) + } + + private static func uniqueIPv4Addresses(_ values: [String]) -> [String] { + var result: [String] = [] + var seen = Set() + for value in values where ipv4Octets(value) != nil && seen.insert(value).inserted { + result.append(value) + } + return result + } + + private static func likelySameIPv4Subnet(_ lhs: String, _ rhs: String) -> Bool { + guard let left = ipv4Octets(lhs), let right = ipv4Octets(rhs) else { return false } + return left[0] == right[0] && left[1] == right[1] && left[2] == right[2] + } + + private static func socketAddress(ip: String, port: UInt16) throws -> sockaddr_in { + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + guard ip.withCString({ inet_pton(AF_INET, $0, &address.sin_addr) }) == 1 else { + throw TBConnectionProbeError.invalidAddress(ip) + } + return address + } + + private static func waitForSocket(_ fd: Int32, events: Int16, timeout: TimeInterval) throws { + var descriptor = pollfd(fd: fd, events: events, revents: 0) + let milliseconds = Int32(max(1, min(timeout * 1000.0, Double(Int32.max)))) + while true { + let result = Darwin.poll(&descriptor, 1, milliseconds) + if result > 0 { + guard (descriptor.revents & (Int16(POLLERR) | Int16(POLLHUP) | Int16(POLLNVAL))) == 0 else { + throw TBConnectionProbeError.socketFailure("socket became unavailable") + } + return + } + if result == 0 { throw TBConnectionProbeError.timeout } + if errno != EINTR { throw socketError("poll") } + } + } + + private static func sendAll(_ data: Data, socket fd: Int32, deadline: UInt64) throws { + try data.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { return } + var offset = 0 + while offset < buffer.count { + let now = DispatchTime.now().uptimeNanoseconds + if now >= deadline { + throw TBConnectionProbeError.timeout + } + let sent = Darwin.send(fd, baseAddress.advanced(by: offset), buffer.count - offset, 0) + if sent > 0 { + offset += sent + } else if sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { + let waitStarted = DispatchTime.now().uptimeNanoseconds + guard waitStarted < deadline else { throw TBConnectionProbeError.timeout } + let remainingSeconds = Double(deadline - waitStarted) / 1_000_000_000.0 + try waitForSocket(fd, events: Int16(POLLOUT), timeout: max(0.001, remainingSeconds)) + } else if sent < 0 && errno == EINTR { + continue + } else { + throw socketError("send") + } + } + } + } + + private static func socketError(_ operation: String) -> TBConnectionProbeError { + TBConnectionProbeError.socketFailure("\(operation): \(String(cString: strerror(errno)))") + } + + /// For a link-local (`169.254.x`) receiver reached through bridgeX, + /// returns `"%"` so the Thunderbolt dial is scoped to the + /// interface that owns `localIP`. Direct USB-NCM enX links remain + /// unscoped: macOS installs a host route for the USB peer and Network.framework + /// does not reliably accept an IPv4 `%enX` zone suffix. /// /// Why: macOS keeps a single routing-table entry for all of /// 169.254.0.0/16, pointing at the primary interface (usually Wi-Fi). A @@ -49,6 +470,7 @@ enum TBConnectionDiagnostics { ) -> String { guard receiverIP.hasPrefix("169.254."), !receiverIP.contains("%") else { return receiverIP } guard let name = interfaceName(forLocalIP: localIP, in: interfaces) else { return receiverIP } + guard name.hasPrefix("bridge") else { return receiverIP } return "\(receiverIP)%\(name)" } diff --git a/cli/targetbridge b/cli/targetbridge index 12cd2703..75bbcf75 100755 --- a/cli/targetbridge +++ b/cli/targetbridge @@ -9,12 +9,13 @@ set -euo pipefail # # Usage: # targetbridge connect [--receiver auto|] [--mode mirror|extended] -# [--preset ] [--transport tb|net] [--session N] [--local-ip ] +# [--preset ] [--path auto|wired|thunderbolt|usb|ethernet|wifi] +# [--transport tb|net] [--session N] [--local-ip ] # targetbridge disconnect [--session N] # # Examples: # targetbridge connect # auto receiver, app defaults -# targetbridge connect --receiver auto --mode mirror --preset 1440p +# targetbridge connect --receiver auto --mode mirror --preset 1440p --path auto # targetbridge connect --receiver 169.254.0.2 --mode extended --preset 5k # targetbridge disconnect # @@ -61,6 +62,7 @@ while [ $# -gt 0 ]; do --receiver) add receiver "${2:-}"; shift 2 ;; --mode) add mode "${2:-}"; shift 2 ;; --preset) add preset "${2:-}"; shift 2 ;; + --path) add path "${2:-}"; shift 2 ;; --transport) add transport "${2:-}"; shift 2 ;; --session) add session "${2:-}"; shift 2 ;; --local-ip) add localIP "${2:-}"; shift 2 ;; diff --git a/docs/Automation.md b/docs/Automation.md index e5b5fca8..d8ecf1cd 100644 --- a/docs/Automation.md +++ b/docs/Automation.md @@ -17,19 +17,31 @@ install -m 0755 cli/targetbridge /usr/local/bin/targetbridge # or ~/bin, etc. ```bash targetbridge connect # auto-pick receiver, app defaults -targetbridge connect --receiver auto --mode mirror --preset 1440p +targetbridge connect --receiver auto --mode mirror --preset 1440p --path auto targetbridge connect --receiver 169.254.0.2 --mode extended --preset 5k targetbridge disconnect ``` Options: `--receiver auto|`, `--mode mirror|extended`, `--preset `, -`--transport tb|net`, `--session N`, `--local-ip `. +`--path auto|wired|thunderbolt|usb|ethernet|wifi`, `--transport tb|net`, `--session N`, +`--local-ip `. Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `native5k`, `native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, `5k`, `5k60`). `native5k60Experimental` is an opt-in HEVC test profile for 5K at 60 FPS; it does not replace the stable 5K 48 FPS profile. A receiver of `auto` waits briefly for Bonjour discovery and uses the first receiver found; a raw IP/hostname bypasses discovery. +With `--path auto`, the Sender discovers the receiver addresses advertised for +Thunderbolt Bridge, direct USB/USB4 networking, Ethernet and Wi-Fi, then performs a +short bounded transfer over each working route and selects the measured fastest one. +Use `--path wired` to exclude Wi-Fi, or choose a specific path to force it. + +On macOS a direct USB-C/USB4 connection between two Macs is exposed as a USB-NCM +network interface, usually with a self-assigned `169.254.x.x` address. TargetBridge +continues to send its encoded network stream over that interface: this does not turn +the iMac into a native USB-C/DisplayPort video sink, and the reported USB4 link speed +is not the same as the application-level video throughput. + It launches the Sender on demand and works whether the app is already running or not. ## 2. URL scheme From 6a382e1146b0b8a78e089ffb4a24141e50bcf805 Mon Sep 17 00:00:00 2001 From: Betafer Date: Fri, 7 Aug 2026 08:06:38 +0200 Subject: [PATCH 10/40] perf: add stable Retina 4K 60 fps capture --- .../TBDisplaySenderAutomation.swift | 1 + .../TBDisplaySenderLocalization.swift | 2 + .../TBDisplaySenderService.swift | 207 ++++++++++++++---- .../TBSenderAutomationParsingTests.swift | 56 +++++ TargetBridge-Shared/Languages/de.json | 1 + TargetBridge-Shared/Languages/en.json | 1 + TargetBridge-Shared/Languages/fr.json | 1 + TargetBridge-Shared/Languages/it.json | 1 + TargetBridge-Shared/Languages/zh.json | 1 + cli/targetbridge | 2 +- docs/Automation.md | 7 +- 11 files changed, 238 insertions(+), 42 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 8ecee060..61bcbff9 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -241,6 +241,7 @@ enum TBSenderAutomation { case "1440p60", "smooth", "smooth1440": return .smooth1440p60 case "1800p", "1800p60", "smooth1800": return .smooth1800p60 case "2160p", "2160p60", "4k", "crisp": return .crisp2160p60 + case "retina4k", "retina4k60", "4096x2304", "imac4k": return .retina4k60 case "5k60", "native5k60": return .native5k60Experimental case "5k", "native", "5120x2880": return .native5k default: return nil diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index 73a1ef3c..2f5fe16d 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -567,6 +567,8 @@ extension TBDisplayCapturePreset { return TBDisplaySenderL10n.text("sender.profile.smooth_plus", language) case .crisp2160p60: return TBDisplaySenderL10n.text("sender.profile.crisp", language) + case .retina4k60: + return TBDisplaySenderL10n.text("sender.profile.retina_4k", language) case .native5k: return TBDisplaySenderL10n.text("sender.profile.native_5k", language) case .native5k60Experimental: diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index edd8a20e..a061a43f 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -15,6 +15,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { case smooth1440p60 case smooth1800p60 case crisp2160p60 + case retina4k60 case native5k case native5k60Experimental @@ -30,6 +31,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return "Smooth+" case .crisp2160p60: return "Crisp" + case .retina4k60: + return "Retina 4K" case .native5k: return "5K" case .native5k60Experimental: @@ -47,6 +50,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return "3200 × 1800 @ 60" case .crisp2160p60: return "3840 × 2160 @ 60" + case .retina4k60: + return "4096 × 2304 @ 60" case .native5k: return "5120 × 2880 @ 48" case .native5k60Experimental: @@ -62,6 +67,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 3200 case .crisp2160p60: return 3840 + case .retina4k60: + return 4096 case .native5k, .native5k60Experimental: return 5120 } @@ -75,6 +82,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 1800 case .crisp2160p60: return 2160 + case .retina4k60: + return 2304 case .native5k, .native5k60Experimental: return 2880 } @@ -90,6 +99,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 78_000_000 case .crisp2160p60: return 105_000_000 + case .retina4k60: + return 120_000_000 case .native5k: return 120_000_000 case .native5k60Experimental: @@ -101,7 +112,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p, .smooth1440p60, .smooth1800p60: return "H.264" - case .crisp2160p60, .native5k, .native5k60Experimental: + case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return "HEVC" } } @@ -110,7 +121,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p, .smooth1440p60, .smooth1800p60: return kCMVideoCodecType_H264 - case .crisp2160p60, .native5k, .native5k60Experimental: + case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return kCMVideoCodecType_HEVC } } @@ -119,7 +130,16 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { if let envVal = ProcessInfo.processInfo.environment["QD"], let parsed = Int(envVal) { return parsed } - return 2 + switch self { + case .standard1440p: + return 3 + case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, + .native5k, .native5k60Experimental: + // Five surfaces protect WindowServer from starvation during + // high-frame-rate 4K capture while the serial pipeline prevents an + // application-side frame backlog. + return 5 + } } var expectedFrameRate: Int { @@ -130,7 +150,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 60 case .smooth1800p60: return 60 - case .crisp2160p60: + case .crisp2160p60, .retina4k60: return 60 case .native5k: return 48 @@ -139,6 +159,13 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { } } + /// ScreenCaptureKit treats `minimumFrameInterval` as a throttle rather than + /// a target cadence. Requesting headroom avoids losing refreshes to scheduler + /// tolerance; `TBFrameRatePacer` applies the exact output ceiling later. + var captureRequestFrameRate: Int { + expectedFrameRate >= 48 ? expectedFrameRate * 2 : expectedFrameRate + } + var maxKeyFrameInterval: Int { switch self { case .standard1440p: @@ -147,7 +174,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 60 case .smooth1800p60: return 60 - case .crisp2160p60: + case .crisp2160p60, .retina4k60: return 60 case .native5k: return 48 @@ -162,7 +189,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 2 case .smooth1440p60: return 1 - case .smooth1800p60, .crisp2160p60: + case .smooth1800p60, .crisp2160p60, .retina4k60: return 1 case .native5k, .native5k60Experimental: return 1 @@ -173,7 +200,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .native5k, .native5k60Experimental: + case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -189,7 +216,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p: return 1 - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .native5k, .native5k60Experimental: + case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return 0 } } @@ -198,7 +225,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .native5k, .native5k60Experimental: + case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -214,7 +241,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p, .smooth1440p60, .smooth1800p60: return .nominal - case .crisp2160p60, .native5k, .native5k60Experimental: + case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return .best } } @@ -225,7 +252,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 60 case .smooth1440p60, .smooth1800p60: return 60 - case .crisp2160p60: + case .crisp2160p60, .retina4k60: return 60 case .native5k: return 48 @@ -250,6 +277,47 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { } } +/// A zero-buffer frame-rate ceiling for capture callbacks. Deadlines advance on +/// the ideal output timeline, allowing 75 Hz input to be sampled evenly at 60 Hz. +/// Long idle gaps reset the cadence instead of accumulating burst credit. +struct TBFrameRatePacer { + private let frameInterval: CMTime + private let deadlineTolerance = CMTime(value: 1, timescale: 10_000) + private var nextDeadline: CMTime? + + init(maximumFrameRate: Int) { + frameInterval = maximumFrameRate > 0 + ? CMTime(value: 1, timescale: CMTimeScale(maximumFrameRate)) + : .invalid + } + + mutating func shouldEmit(presentationTime: CMTime) -> Bool { + guard frameInterval.isValid, presentationTime.isValid, + !presentationTime.isIndefinite else { return true } + + guard let deadline = nextDeadline else { + nextDeadline = CMTimeAdd(presentationTime, frameInterval) + return true + } + + if CMTimeCompare(presentationTime, CMTimeSubtract(deadline, frameInterval)) < 0 { + nextDeadline = CMTimeAdd(presentationTime, frameInterval) + return true + } + + guard CMTimeCompare(CMTimeAdd(presentationTime, deadlineTolerance), deadline) >= 0 else { + return false + } + + if CMTimeCompare(CMTimeSubtract(presentationTime, deadline), frameInterval) > 0 { + nextDeadline = CMTimeAdd(presentationTime, frameInterval) + } else { + nextDeadline = CMTimeAdd(deadline, frameInterval) + } + return true + } +} + enum TBDisplayCaptureSource: String, CaseIterable, Identifiable { case desktopMirror case extendedDesktop @@ -385,12 +453,17 @@ private final class TBVideoPipeline: @unchecked Sendable { private var inFlightEncodeFrames = 0 private var displayStreamFrameSequence: CMTimeValue = 0 private var lastEncodedDisplayPTS: CMTime? + private var frameRatePacer: TBFrameRatePacer private var ackSent: Bool private var running = false // Read from the main thread (fps timer / watchdog); guarded by `lock`. private let lock = NSLock() private var _sentFrames = 0 + private var capturedFrames = 0 + private var droppedByFrameRatePacer = 0 + private var droppedBeforeEncodeFrames = 0 + private var droppedAfterEncodeFrames = 0 private var _lastCaptureFrameAt = Date() init(preset: TBDisplayCapturePreset, @@ -407,6 +480,7 @@ private final class TBVideoPipeline: @unchecked Sendable { self.displayName = displayName self.displayID = displayID self.usesRawNV12 = usesRawNV12 + self.frameRatePacer = TBFrameRatePacer(maximumFrameRate: preset.expectedFrameRate) self.ackSent = ackAlreadySent self.onFirstFrame = onFirstFrame } @@ -452,11 +526,22 @@ private final class TBVideoPipeline: @unchecked Sendable { return _lastCaptureFrameAt } - func diagnosticsSnapshot() -> (pending: Int, inFlight: Int, ptsSeq: CMTimeValue) { - queue.sync { (pending: pendingVideoPackets, inFlight: inFlightEncodeFrames, ptsSeq: displayStreamFrameSequence) } + func diagnosticsSnapshot() -> (pending: Int, inFlight: Int, ptsSeq: CMTimeValue, captured: Int, droppedPacing: Int, droppedPre: Int, droppedPost: Int) { + queue.sync { + ( + pending: pendingVideoPackets, + inFlight: inFlightEncodeFrames, + ptsSeq: displayStreamFrameSequence, + captured: capturedFrames, + droppedPacing: droppedByFrameRatePacer, + droppedPre: droppedBeforeEncodeFrames, + droppedPost: droppedAfterEncodeFrames + ) + } } private func markCaptureFrame() { + capturedFrames += 1 lock.lock(); _lastCaptureFrameAt = Date(); lock.unlock() } @@ -533,6 +618,11 @@ private final class TBVideoPipeline: @unchecked Sendable { /// SCStream capture path. Must be dispatched onto `queue` by the caller. func encode(_ sampleBuffer: CMSampleBuffer) { markCaptureFrame() + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + guard frameRatePacer.shouldEmit(presentationTime: pts) else { + droppedByFrameRatePacer += 1 + return + } if usesRawNV12 { sendRawFrame(sampleBuffer) return @@ -543,9 +633,9 @@ private final class TBVideoPipeline: @unchecked Sendable { if preset.dropsBeforeEncodeWhenBacklogged, (pendingVideoPackets >= preset.maxPendingVideoPackets || inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { + droppedBeforeEncodeFrames += 1 return } - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) encode(pixelBuffer: pixelBuffer, presentationTimeStamp: pts, using: encoder) } @@ -554,9 +644,22 @@ private final class TBVideoPipeline: @unchecked Sendable { func encodeDisplaySurface(_ surface: IOSurfaceRef, displayTime: UInt64) { markCaptureFrame() guard running, let encoder = vtEncoder else { return } + + var pts = displayTime != 0 + ? CMClockMakeHostTimeFromSystemUnits(displayTime) + : CMClockGetTime(CMClockGetHostTimeClock()) + if let last = lastEncodedDisplayPTS, CMTimeCompare(pts, last) <= 0 { + // VTCompressionSession requires strictly increasing PTS. + pts = CMTimeAdd(last, CMTime(value: 1, timescale: 600)) + } + guard frameRatePacer.shouldEmit(presentationTime: pts) else { + droppedByFrameRatePacer += 1 + return + } if preset.dropsBeforeEncodeWhenBacklogged, (pendingVideoPackets >= preset.maxPendingVideoPackets || inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { + droppedBeforeEncodeFrames += 1 return } @@ -583,13 +686,6 @@ private final class TBVideoPipeline: @unchecked Sendable { // frame-counter PTS would drift away from real wall-clock time over a // long session and pace the receiver progressively wrong. displayTime is // in mach-absolute units, the same host clock the SCStream path uses. - var pts = displayTime != 0 - ? CMClockMakeHostTimeFromSystemUnits(displayTime) - : CMClockGetTime(CMClockGetHostTimeClock()) - if let last = lastEncodedDisplayPTS, CMTimeCompare(pts, last) <= 0 { - // VTCompressionSession requires strictly increasing PTS. - pts = CMTimeAdd(last, CMTime(value: 1, timescale: 600)) - } lastEncodedDisplayPTS = pts encode(pixelBuffer: pixelBuffer, presentationTimeStamp: pts, using: encoder) } @@ -631,6 +727,7 @@ private final class TBVideoPipeline: @unchecked Sendable { let isKeyframe = !notSync if !isKeyframe, pendingVideoPackets >= preset.maxPendingVideoPackets { + droppedAfterEncodeFrames += 1 return } @@ -661,7 +758,10 @@ private final class TBVideoPipeline: @unchecked Sendable { let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } // Backpressure: never pile frames on top of a network that can't keep up. - if pendingVideoPackets >= preset.maxPendingVideoPackets { return } + if pendingVideoPackets >= preset.maxPendingVideoPackets { + droppedBeforeEncodeFrames += 1 + return + } guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2 else { return } CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) @@ -1249,7 +1349,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return kCMVideoCodecType_HEVC } return kCMVideoCodecType_H264 - case .crisp2160p60, .native5k, .native5k60Experimental: + case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return preset.codecType } } @@ -2367,10 +2467,19 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let display: SCDisplay if captureSource == .desktopMirror { let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) - guard let mainDisplay = content.displays.first(where: { $0.displayID == CGMainDisplayID() }) else { + // In mirror mode both surfaces carry the same desktop. Prefer the + // receiver-native virtual surface to avoid rescaling the physical + // display to the Retina stream dimensions. + if session.displayID != kCGNullDirectDisplay, + let virtualDisplay = content.displays.first(where: { $0.displayID == session.displayID }) { + display = virtualDisplay + TBLog.connection.info("capture: using receiver-native mirrored display id=\(virtualDisplay.displayID, privacy: .public)") + } else if let mainDisplay = content.displays.first(where: { $0.displayID == CGMainDisplayID() }) { + display = mainDisplay + TBLog.connection.info("capture: receiver display unavailable; using main display id=\(mainDisplay.displayID, privacy: .public)") + } else { return false } - display = mainDisplay } else { let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) if session.displayID != kCGNullDirectDisplay, @@ -2384,13 +2493,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let configuration = SCStreamConfiguration() configuration.width = preset.width configuration.height = preset.height - configuration.minimumFrameInterval = CMTime(value: 1, timescale: Int32(preset.expectedFrameRate)) + configuration.minimumFrameInterval = CMTime(value: 1, timescale: Int32(preset.captureRequestFrameRate)) configuration.queueDepth = preset.queueDepth configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + configuration.shouldBeOpaque = true configuration.showsCursor = !largeCursor configuration.scalesToFit = true configuration.captureResolution = preset.captureResolution - configuration.capturesAudio = true + configuration.capturesAudio = audioEnabled configuration.excludesCurrentProcessAudio = true configuration.sampleRate = 48000 configuration.channelCount = 2 @@ -2404,7 +2514,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let delegate = CaptureDelegate() delegate.onFrame = { sampleBuffer in - pipeline.queue.async { pipeline.encode(sampleBuffer) } + // ScreenCaptureKit already invokes this closure on pipeline.queue. + // Encode immediately so its IOSurface returns to WindowServer + // without an extra dispatch hop. + pipeline.encode(sampleBuffer) } delegate.onAudio = { [weak self] sampleBuffer in self?.processAudio(sampleBuffer) @@ -2424,13 +2537,15 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u try stream.addStreamOutput( delegate, type: .screen, - sampleHandlerQueue: DispatchQueue(label: "fd.tbmonitor.sender.capture", qos: .userInteractive) - ) - try stream.addStreamOutput( - delegate, - type: .audio, - sampleHandlerQueue: DispatchQueue(label: "fd.tbmonitor.sender.audio", qos: .userInteractive) + sampleHandlerQueue: pipeline.queue ) + if audioEnabled { + try stream.addStreamOutput( + delegate, + type: .audio, + sampleHandlerQueue: DispatchQueue(label: "fd.tbmonitor.sender.audio", qos: .userInteractive) + ) + } try await stream.startCapture() scStream = stream isStreaming = true @@ -2491,7 +2606,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func waitForCaptureDisplay() async throws -> SCDisplay { - let targetDisplayID = (captureSource == .desktopMirror) ? CGMainDisplayID() : session.displayID + let targetDisplayID = session.displayID != kCGNullDirectDisplay + ? session.displayID + : CGMainDisplayID() return try await waitForVirtualDisplay( matching: targetDisplayID, baselineDisplayIDs: baselineDisplayIDs @@ -3022,16 +3139,28 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u guard verboseDisplayLogging else { return } let online = onlineDisplayIDs() let virtualOnline = online.contains(session.displayID) - let diag = pipeline?.diagnosticsSnapshot() ?? (pending: 0, inFlight: 0, ptsSeq: 0) + let diag = pipeline?.diagnosticsSnapshot() ?? ( + pending: 0, + inFlight: 0, + ptsSeq: 0, + captured: 0, + droppedPacing: 0, + droppedPre: 0, + droppedPost: 0 + ) NSLog( - "TargetBridge: stream snapshot streaming=%@ fps=%d virtualID=%u online=%@ pendingPackets=%d inFlightEncode=%d ptsSeq=%lld", + "TargetBridge: stream snapshot streaming=%@ fps=%d virtualID=%u online=%@ pendingPackets=%d inFlightEncode=%d ptsSeq=%lld captured=%d droppedPacing=%d droppedPre=%d droppedPost=%d", isStreaming ? "yes" : "no", liveMetrics.senderFPS, session.displayID, virtualOnline ? "yes" : "no", diag.pending, diag.inFlight, - diag.ptsSeq + diag.ptsSeq, + diag.captured, + diag.droppedPacing, + diag.droppedPre, + diag.droppedPost ) } @@ -3153,7 +3282,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u guard isStreaming, !sessionAckSent else { return } let sentFrames = self.pipeline?.sentFramesSnapshot ?? 0 TBLog.connection.error("capture: first-frame timeout preset=\(self.capturePreset.rawValue, privacy: .public) source=\(String(describing: self.captureSource), privacy: .public) connected=\(self.isConnected, privacy: .public) sentFrames=\(sentFrames, privacy: .public)") - if self.capturePreset == .native5k || self.capturePreset == .native5k60Experimental { + if self.capturePreset == .retina4k60 || self.capturePreset == .native5k || self.capturePreset == .native5k60Experimental { setStatus(.hevcNoFrames) } else { setStatus(.noFirstFrame) diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index be5cf09e..b0f90e78 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -1,3 +1,4 @@ +import CoreMedia import XCTest @testable import TargetBridge @@ -7,6 +8,44 @@ import XCTest /// silently reroute automation traffic. @MainActor final class TBSenderAutomationParsingTests: XCTestCase { + func testHighFrameRatePresetsUseFiveCaptureSurfaces() { + XCTAssertEqual(TBDisplayCapturePreset.standard1440p.queueDepth, 3) + XCTAssertEqual(TBDisplayCapturePreset.smooth1440p60.queueDepth, 5) + XCTAssertEqual(TBDisplayCapturePreset.retina4k60.queueDepth, 5) + XCTAssertEqual(TBDisplayCapturePreset.native5k60Experimental.queueDepth, 5) + } + + func testHighFrameRatePresetsRequestCaptureHeadroom() { + XCTAssertEqual(TBDisplayCapturePreset.standard1440p.captureRequestFrameRate, 30) + XCTAssertEqual(TBDisplayCapturePreset.smooth1440p60.captureRequestFrameRate, 120) + XCTAssertEqual(TBDisplayCapturePreset.retina4k60.captureRequestFrameRate, 120) + XCTAssertEqual(TBDisplayCapturePreset.native5k.captureRequestFrameRate, 96) + } + + func testFrameRatePacerSamples75HzInputAt60Hz() { + var pacer = TBFrameRatePacer(maximumFrameRate: 60) + let emitted = (0..<750).filter { frame in + pacer.shouldEmit(presentationTime: CMTime(seconds: Double(frame) / 75.0, preferredTimescale: 60_000)) + } + XCTAssertEqual(emitted.count, 600) + } + + func testFrameRatePacerPreservesInputsAtOrBelowCeiling() { + for inputRate in [30, 60] { + var pacer = TBFrameRatePacer(maximumFrameRate: 60) + let emitted = (0..<(inputRate * 10)).filter { frame in + pacer.shouldEmit(presentationTime: CMTime(seconds: Double(frame) / Double(inputRate), preferredTimescale: 60_000)) + } + XCTAssertEqual(emitted.count, inputRate * 10) + } + } + + func testFrameRatePacerDoesNotAccumulateBurstCreditAfterIdleGap() { + var pacer = TBFrameRatePacer(maximumFrameRate: 60) + XCTAssertTrue(pacer.shouldEmit(presentationTime: CMTime(seconds: 0, preferredTimescale: 60_000))) + XCTAssertTrue(pacer.shouldEmit(presentationTime: CMTime(seconds: 1, preferredTimescale: 60_000))) + XCTAssertFalse(pacer.shouldEmit(presentationTime: CMTime(seconds: 1 + 1.0 / 75.0, preferredTimescale: 60_000))) + } // MARK: - parseTransport @@ -55,6 +94,7 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1440p60"), .smooth1440p60) XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1800p60"), .smooth1800p60) XCTAssertEqual(TBSenderAutomation.parsePreset("crisp2160p60"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("retina4k60"), .retina4k60) XCTAssertEqual(TBSenderAutomation.parsePreset("native5k"), .native5k) XCTAssertEqual(TBSenderAutomation.parsePreset("native5k60Experimental"), .native5k60Experimental) } @@ -67,6 +107,10 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertEqual(TBSenderAutomation.parsePreset("1800p"), .smooth1800p60) XCTAssertEqual(TBSenderAutomation.parsePreset("4k"), .crisp2160p60) XCTAssertEqual(TBSenderAutomation.parsePreset("crisp"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("retina4k"), .retina4k60) + XCTAssertEqual(TBSenderAutomation.parsePreset("RETINA4K60"), .retina4k60) + XCTAssertEqual(TBSenderAutomation.parsePreset("4096x2304"), .retina4k60) + XCTAssertEqual(TBSenderAutomation.parsePreset("imac4k"), .retina4k60) XCTAssertEqual(TBSenderAutomation.parsePreset("5k60"), .native5k60Experimental) XCTAssertEqual(TBSenderAutomation.parsePreset("native5k60"), .native5k60Experimental) XCTAssertEqual(TBSenderAutomation.parsePreset("5k"), .native5k) @@ -93,6 +137,18 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertEqual(preset.averageBitRate, 150_000_000) } + func testRetina4KPresetMatches215InchIMacPanel() { + let preset = TBDisplayCapturePreset.retina4k60 + + XCTAssertEqual(preset.width, 4096) + XCTAssertEqual(preset.height, 2304) + XCTAssertEqual(preset.expectedFrameRate, 60) + XCTAssertEqual(preset.virtualDisplayRefreshRate, 60) + XCTAssertEqual(preset.averageBitRate, 120_000_000) + XCTAssertEqual(preset.codecName, "HEVC") + XCTAssertEqual(preset.renderMatchedDesktopDescription, "2048 × 1152") + } + // MARK: - matches (receiver selection for --receiver ) private func makeReceiver() -> TBDiscoveredReceiver { diff --git a/TargetBridge-Shared/Languages/de.json b/TargetBridge-Shared/Languages/de.json index c46f289c..0075cd5b 100644 --- a/TargetBridge-Shared/Languages/de.json +++ b/TargetBridge-Shared/Languages/de.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", "sender.profile.crisp": "Crisp", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60 (Experimentell)", "sender.capture_display.na": "n/a", diff --git a/TargetBridge-Shared/Languages/en.json b/TargetBridge-Shared/Languages/en.json index 02d725dd..e38314e8 100644 --- a/TargetBridge-Shared/Languages/en.json +++ b/TargetBridge-Shared/Languages/en.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", "sender.profile.crisp": "Crisp", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60 (Experimental)", "sender.capture_display.na": "n/a", diff --git a/TargetBridge-Shared/Languages/fr.json b/TargetBridge-Shared/Languages/fr.json index ad603af7..e18d886f 100644 --- a/TargetBridge-Shared/Languages/fr.json +++ b/TargetBridge-Shared/Languages/fr.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Fluide", "sender.profile.smooth_plus": "Fluide+", "sender.profile.crisp": "Net", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60 (Expérimental)", "sender.capture_display.na": "n/d", diff --git a/TargetBridge-Shared/Languages/it.json b/TargetBridge-Shared/Languages/it.json index 8849e096..37bc70f5 100644 --- a/TargetBridge-Shared/Languages/it.json +++ b/TargetBridge-Shared/Languages/it.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", "sender.profile.crisp": "Crisp", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60 (Sperimentale)", "sender.capture_display.na": "n/a", diff --git a/TargetBridge-Shared/Languages/zh.json b/TargetBridge-Shared/Languages/zh.json index c575f1b8..6cc99284 100644 --- a/TargetBridge-Shared/Languages/zh.json +++ b/TargetBridge-Shared/Languages/zh.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", "sender.profile.crisp": "Crisp", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60(实验性)", "sender.capture_display.na": "n/a", diff --git a/cli/targetbridge b/cli/targetbridge index 12cd2703..fbf569ee 100755 --- a/cli/targetbridge +++ b/cli/targetbridge @@ -18,7 +18,7 @@ set -euo pipefail # targetbridge connect --receiver 169.254.0.2 --mode extended --preset 5k # targetbridge disconnect # -# Presets: standard1440p | smooth1440p60 | smooth1800p60 | crisp2160p60 | native5k +# Presets: standard1440p | smooth1440p60 | smooth1800p60 | crisp2160p60 | retina4k60 | native5k # (aliases: 1440p, 1440p60, 1800p, 4k, 5k) # # Remote / SSH: run it on the sender over SSH (the sender must be at a logged-in diff --git a/docs/Automation.md b/docs/Automation.md index e5b5fca8..9fb374d7 100644 --- a/docs/Automation.md +++ b/docs/Automation.md @@ -24,8 +24,11 @@ targetbridge disconnect Options: `--receiver auto|`, `--mode mirror|extended`, `--preset `, `--transport tb|net`, `--session N`, `--local-ip `. -Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `native5k`, -`native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, `5k`, `5k60`). +Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `retina4k60`, +`native5k`, `native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, +`5k`, `5k60`). 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 replace the stable 5K 48 FPS profile. A receiver of `auto` waits briefly for Bonjour discovery and uses the first receiver found; a raw IP/hostname bypasses discovery. From 62c9790524857d7c1e2ace58be6e33ee9080daf1 Mon Sep 17 00:00:00 2001 From: Betafer Date: Fri, 7 Aug 2026 08:20:10 +0200 Subject: [PATCH 11/40] feat: make monitor sessions headless and stoppable --- .../TBDisplaySender/TBDisplaySenderApp.swift | 17 +- .../TBDisplaySenderAutomation.swift | 129 +++++++++- .../TBDisplaySenderManager.swift | 42 +++- .../TBDisplaySenderService.swift | 224 +++++++++++++++--- .../TBDisplaySenderStatusItemController.swift | 26 +- .../TBSenderAutomationParsingTests.swift | 63 +++++ cli/targetbridge | 2 +- docs/Automation.md | 9 +- 8 files changed, 452 insertions(+), 60 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift index b91bac48..5457c13a 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI @main @@ -5,19 +6,33 @@ struct TBDisplaySenderApp: App { @StateObject private var service = TBDisplaySenderService.shared private let statusItemController = TBDisplaySenderStatusItemController(service: TBDisplaySenderService.shared) + @MainActor + init() { + // LaunchAgent automation must not depend on SwiftUI restoring or showing + // the main window on a headless Mac. + TBSenderAutomation.handleLaunchArguments(CommandLine.arguments) + } + var body: some Scene { WindowGroup("TargetBridge", id: "main") { TBDisplaySenderContentView(service: service) .frame(minWidth: 540) .task { statusItemController.activate() - TBSenderAutomation.handleLaunchArguments(CommandLine.arguments) } .onOpenURL { url in TBSenderAutomation.handle(url: url) } } .defaultSize(width: 860, height: 860) + .commands { + CommandGroup(replacing: .appTermination) { + Button(TBDisplaySenderL10n.quitApp(service.language)) { + service.quitAfterUserRequest() + } + .keyboardShortcut("q") + } + } Settings { TBDisplaySenderSettingsView(service: service) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 8ecee060..abd485ae 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -17,6 +17,66 @@ import Foundation @MainActor enum TBSenderAutomation { private static var didHandleLaunchArguments = false + private static var continuousConnectTask: Task? + + static func senderEnabledFlagURL( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + homeDirectory + .appendingPathComponent("Library/Application Support/TargetBridge/Sender", isDirectory: true) + .appendingPathComponent("enabled", isDirectory: false) + } + + static func suspendAutomaticReconnectAfterUserStop( + enabledFlagURL: URL? = nil, + fileManager: FileManager = .default + ) { + suspendAutomaticReconnect( + enabledFlagURL: enabledFlagURL, + fileManager: fileManager, + logReason: "user stopped session" + ) + } + + static func suspendAutomaticReconnectForRequiredPermission( + enabledFlagURL: URL? = nil, + fileManager: FileManager = .default + ) { + suspendAutomaticReconnect( + enabledFlagURL: enabledFlagURL, + fileManager: fileManager, + logReason: "screen recording permission required" + ) + } + + static func suspendAutomaticReconnectAfterCaptureFailure( + enabledFlagURL: URL? = nil, + fileManager: FileManager = .default + ) { + suspendAutomaticReconnect( + enabledFlagURL: enabledFlagURL, + fileManager: fileManager, + logReason: "capture produced no frames" + ) + } + + private static func suspendAutomaticReconnect( + enabledFlagURL: URL?, + fileManager: FileManager, + logReason: String + ) { + continuousConnectTask?.cancel() + continuousConnectTask = nil + let marker = enabledFlagURL ?? senderEnabledFlagURL() + do { + if fileManager.fileExists(atPath: marker.path) { + try fileManager.removeItem(at: marker) + } + NSLog("[automation] \(logReason); automatic reconnect suspended") + } catch { + NSLog("[automation] unable to clear automatic reconnect marker: \(error.localizedDescription)") + } + } /// Handle a `targetbridge://` URL (from `.onOpenURL`). static func handle(url: URL) { @@ -65,17 +125,54 @@ enum TBSenderAutomation { private static func run(action: String, params: [String: String]) { switch action { case "connect": - Task { await connect(params) } + continuousConnectTask?.cancel() + if flagEnabled(params["retry"] ?? params["autoreconnect"] ?? params["auto-reconnect"]) { + continuousConnectTask = Task { await connectContinuously(params) } + } else { + continuousConnectTask = nil + Task { _ = await connect(params) } + } case "disconnect": + continuousConnectTask?.cancel() + continuousConnectTask = nil disconnect(params) default: NSLog("[automation] unknown action '\(action)' (expected connect|disconnect)") } } - private static func connect(_ params: [String: String]) async { + private static func connectContinuously(_ params: [String: String]) async { + var attempt = 0 + while !Task.isCancelled { + attempt += 1 + NSLog("[automation] automatic connection attempt \(attempt)") + + if let session = await connect(params) { + if await waitForConnection(session) { + NSLog("[automation] automatic connection active; monitoring link") + while !Task.isCancelled && (session.isConnected || session.isStreaming) { + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + guard !Task.isCancelled else { return } + NSLog("[automation] connection lost; retrying") + } else { + NSLog("[automation] connection attempt did not become active; retrying") + } + + if !session.isConnected && !session.isStreaming { + session.stopForAutomaticReconnect() + } + } + + guard !Task.isCancelled else { return } + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + } + + @discardableResult + private static func connect(_ params: [String: String]) async -> TBDisplaySenderSession? { let service = TBDisplaySenderService.shared - guard let session = resolveSession(service, params["session"]) else { return } + guard let session = resolveSession(service, params["session"]) else { return nil } service.refreshLocalInterfaces() @@ -87,7 +184,7 @@ enum TBSenderAutomation { if receiver.lowercased() == "auto" { guard let discovered = await waitForReceiver(service) else { NSLog("[automation] no receivers discovered; aborting connect") - return + return nil } service.applyDiscoveredReceiver(discovered, to: session) session.selectedReceiverID = discovered.id @@ -118,14 +215,15 @@ enum TBSenderAutomation { guard !session.receiverIP.isEmpty else { NSLog("[automation] no receiver IP resolved; aborting connect") - return + return nil } guard !session.localInterfaceIP.isEmpty else { NSLog("[automation] no local interface for transport \(session.transportKind.rawValue); aborting connect") - return + return nil } NSLog("[automation] connecting to \(session.receiverIP) via \(session.transportKind.rawValue) — \(session.captureSource.rawValue)/\(session.capturePreset.rawValue)") session.connect() + return session } private static func disconnect(_ params: [String: String]) { @@ -202,6 +300,25 @@ enum TBSenderAutomation { return service.discoveredReceivers.first } + private static func waitForConnection(_ session: TBDisplaySenderSession) async -> Bool { + for _ in 0..<40 { + if session.isConnected || session.isStreaming { return true } + guard !Task.isCancelled else { return false } + try? await Task.sleep(nanoseconds: 250_000_000) + } + return session.isConnected || session.isStreaming + } + + static func flagEnabled(_ value: String?) -> Bool { + guard let value else { return false } + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "0", "false", "no", "off": + return false + default: + return true + } + } + // The pure parsing helpers below are `internal` (not `private`) so the // unit-test bundle can exercise them directly. static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index d8ee01ea..24f75d80 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -102,6 +102,7 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } } + private var userQuitPending = false private var sessionCancellables: [UUID: AnyCancellable] = [:] private let receiverDiscovery = TBReceiverDiscovery() private let addonStore = TBAddonStore.shared @@ -271,9 +272,44 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } - func stopAll() { - sessions.forEach { $0.persistExtendedDisplayArrangementSnapshot() } - sessions.forEach { $0.stop(persistArrangement: false) } + func stopAll(completion: (@MainActor @Sendable () -> Void)? = nil) { + // A manual stop is an operator decision, not a transient disconnect. + TBSenderAutomation.suspendAutomaticReconnectAfterUserStop() + let sessionsToStop = sessions + sessionsToStop.forEach { $0.persistExtendedDisplayArrangementSnapshot() } + guard !sessionsToStop.isEmpty else { + completion?() + return + } + + var remaining = sessionsToStop.count + sessionsToStop.forEach { session in + session.stop(persistArrangement: false) { + remaining -= 1 + if remaining == 0 { + completion?() + } + } + } + } + + func quitAfterUserRequest() { + guard !userQuitPending else { return } + userQuitPending = true + + // Allow the final teardown reason to reach the Receiver before quitting. + stopAll { [weak self] in + self?.finishUserRequestedQuit() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.finishUserRequestedQuit() + } + } + + private func finishUserRequestedQuit() { + guard userQuitPending else { return } + userQuitPending = false + NSApp.terminate(nil) } // MARK: - Session persistence diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index edd8a20e..bdbf4103 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -6,6 +6,7 @@ import Darwin import Foundation import AVFoundation import IOSurface +import IOKit.pwr_mgt import Network @preconcurrency import ScreenCaptureKit import VideoToolbox @@ -1110,6 +1111,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var sentSnapshot = 0 private var sessionAckSent = false + private var captureBlockedByScreenRecordingPermission = false private var fpsTimer: Timer? private var heartbeatTimer: Timer? private var firstFrameTimer: Timer? @@ -1125,6 +1127,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var heartbeatSequence: UInt64 = 0 private var statusState: TBDisplaySenderStatusState = .ready private var streamingActivity: NSObjectProtocol? + private var displayWakeAssertionID = IOPMAssertionID(0) private var lastCheckedCursor: NSCursor? private var lastCheckedCursorType: Int = 0 private var baselineDisplayIDs = Set() @@ -1534,19 +1537,43 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } - func stop(persistArrangement: Bool = true) { - stop(resetStatusTo: .stopped, persistArrangement: persistArrangement) + func stop( + persistArrangement: Bool = true, + completion: (@MainActor @Sendable () -> Void)? = nil + ) { + TBSenderAutomation.suspendAutomaticReconnectAfterUserStop() + stop( + resetStatusTo: .stopped, + persistArrangement: persistArrangement, + teardownReason: "sender_user_stop", + teardownCompletion: completion + ) + } + + /// Internal retry stop. Unlike a GUI Stop, this must not disable the + /// LaunchAgent marker that represents the user's monitor-mode choice. + func stopForAutomaticReconnect() { + stop( + resetStatusTo: .stopped, + persistArrangement: false, + teardownReason: "sender_internal_stop" + ) } func persistExtendedDisplayArrangementSnapshot() { persistExtendedDisplayArrangementIfNeeded() } - private func stop(resetStatusTo status: TBDisplaySenderStatusState?, persistArrangement: Bool = true) { + private func stop( + resetStatusTo status: TBDisplaySenderStatusState?, + persistArrangement: Bool = true, + teardownReason: String = "sender_internal_stop", + teardownCompletion: (@MainActor @Sendable () -> Void)? = nil + ) { + let connectionToClose = connection if persistArrangement { persistExtendedDisplayArrangementIfNeeded() } - sendTeardown(reason: "sender_stop") connectTimeoutWorkItem?.cancel() connectTimeoutWorkItem = nil heartbeatTimer?.invalidate() @@ -1571,19 +1598,20 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u scStream = nil } captureDelegate = nil - if let activity = streamingActivity { - ProcessInfo.processInfo.endActivity(activity) - streamingActivity = nil - } + endCaptureActivity() pipeline?.stop() pipeline = nil releaseInjectedModifiersIfNeeded() remoteHeldModifierKeyCodes.removeAll() injectedLeftClickTracker.reset() suppressedTriggerKeyCode = nil - connection?.stateUpdateHandler = nil - connection?.cancel() + connectionToClose?.stateUpdateHandler = nil connection = nil + sendTeardownAndClose( + reason: teardownReason, + over: connectionToClose, + completion: teardownCompletion + ) let currentSession = session Task { @MainActor in currentSession.destroy() @@ -1783,12 +1811,39 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u send(packet) } - private func sendTeardown(reason: String) { + private func sendTeardownAndClose( + reason: String, + over connection: NWConnection?, + completion: (@MainActor @Sendable () -> Void)? = nil + ) { + guard let connection else { + completion?() + return + } guard let packet = TBMonitorProtocol.makeJSONPacket( type: .teardown, value: TBMonitorTeardown(reason: reason) - ) else { return } - send(packet) + ) else { + connection.cancel() + completion?() + return + } + + // Preserve the explicit user/internal reason before closing the socket. + // Otherwise the Receiver cannot distinguish Stop from a dropped cable. + connection.send( + content: packet, + contentContext: .finalMessage, + isComplete: true, + completion: .contentProcessed { _ in + connection.cancel() + guard let completion else { return } + Task { @MainActor in completion() } + } + ) + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) { + connection.cancel() + } } private func receiveLoop(on connection: NWConnection) { @@ -2267,8 +2322,13 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } + // A headless Sender may still expose a sleeping physical display. + // Wake and hold the graphical session before virtual display setup. + self.beginCaptureActivity() self.setStatus(.creatingVirtualDisplay) - self.baselineDisplayIDs = await self.fetchShareableDisplayIDs() + self.baselineDisplayIDs = self.captureSource == .extendedDesktop + ? await self.fetchShareableDisplayIDs() + : [] let receiverKey = self.extendedDisplayIdentityKey(for: profile) let modeOverride: TBVirtualDisplayModeSize? = (self.matchRenderToStream && self.captureSource == .extendedDesktop) ? self.capturePreset.renderMatchedDisplayMode @@ -2293,13 +2353,15 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } if self.captureSource == .desktopMirror { - let displayReady = await self.waitForOnlineDisplay(self.session.displayID) - let mirrorConfigured = displayReady && self.configureDesktopMirror(for: self.session.displayID) - if !mirrorConfigured { - NSLog( - "TargetBridge: unable to enable mirror mode for virtual display %u on first attempt; scheduling retry", - self.session.displayID - ) + if CGDisplayIsInMirrorSet(self.session.displayID) == 0 { + let displayReady = await self.waitForOnlineDisplay(self.session.displayID) + let mirrorConfigured = displayReady && self.configureDesktopMirror(for: self.session.displayID) + if !mirrorConfigured { + NSLog( + "TargetBridge: unable to enable mirror mode for virtual display %u on first attempt; scheduling retry", + self.session.displayID + ) + } } } self.virtualDisplayText = TBDisplaySenderL10n.virtualDisplaySummary( @@ -2316,10 +2378,21 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // armed against a session that has already delivered frames — it then // tears down a healthy stream ~4s in. See onFirstFrame wiring below. self.sessionAckSent = false + self.captureBlockedByScreenRecordingPermission = false self.setStatus(.startingCapture(self.capturePreset.description, self.captureSource)) let started = await self.startCapture(for: profile) guard started else { - self.stop(resetStatusTo: nil) + if self.captureBlockedByScreenRecordingPermission { + self.captureBlockedByScreenRecordingPermission = false + TBSenderAutomation.suspendAutomaticReconnectForRequiredPermission() + self.stop( + resetStatusTo: nil, + persistArrangement: false, + teardownReason: "sender_user_stop" + ) + } else { + self.stop(resetStatusTo: nil) + } return } @@ -2336,6 +2409,16 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func startCapture(for profile: TBMonitorDisplayProfile) async -> Bool { do { + guard CGPreflightScreenCaptureAccess() else { + captureBlockedByScreenRecordingPermission = true + _ = CGRequestScreenCaptureAccess() + setStatus(.captureDesktopError( + TBDisplaySenderL10n.missingScreenRecordingPermission(language: language) + )) + TBLog.connection.error("capture: screen recording permission missing; automatic reconnect suspended") + return false + } + let preset = capturePreset let usesRawNV12 = rawNV12Enabled(for: profile) let codecType = resolvedCodecType(for: preset, profile: profile) @@ -2366,11 +2449,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let display: SCDisplay if captureSource == .desktopMirror { - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) - guard let mainDisplay = content.displays.first(where: { $0.displayID == CGMainDisplayID() }) 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) + } else { return false } - display = mainDisplay } else { let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) if session.displayID != kCGNullDirectDisplay, @@ -2435,10 +2521,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u scStream = stream isStreaming = true if largeCursor { startCursorUpdates(displayID: display.displayID) } - streamingActivity = ProcessInfo.processInfo.beginActivity( - options: activityOptions(), - reason: "TargetBridge streaming active" - ) + beginCaptureActivity(wakeDisplay: false) startFPSTimer() startCaptureWatchdog() return true @@ -2452,6 +2535,39 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } + /// Waking a headless graphical session is asynchronous. Poll briefly for a + /// receiver-native ScreenCaptureKit surface before using the direct fallback. + private func resolveMirrorCaptureDisplay() async throws -> SCDisplay? { + for attempt in 0..<30 { + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) + if session.displayID != kCGNullDirectDisplay, + let virtualDisplay = content.displays.first(where: { $0.displayID == session.displayID }) { + TBLog.connection.info("capture: using receiver-native mirrored display id=\(virtualDisplay.displayID, privacy: .public)") + return virtualDisplay + } + if let mainDisplay = content.displays.first(where: { $0.displayID == CGMainDisplayID() }) { + TBLog.connection.info("capture: using main display id=\(mainDisplay.displayID, privacy: .public)") + return mainDisplay + } + if attempt == 0 { + TBLog.connection.info("capture: graphical session is waking; waiting for a shareable display") + } + try await Task.sleep(nanoseconds: 100_000_000) + } + return nil + } + + private func directMirrorFallbackDisplayID() -> CGDirectDisplayID? { + if session.displayID != kCGNullDirectDisplay, CGDisplayIsOnline(session.displayID) != 0 { + return session.displayID + } + let mainDisplayID = CGMainDisplayID() + if mainDisplayID != kCGNullDirectDisplay, CGDisplayIsOnline(mainDisplayID) != 0 { + return mainDisplayID + } + return nil + } + private func startDirectDisplayStream(displayID: CGDirectDisplayID, preset: TBDisplayCapturePreset) -> Bool { guard let pipeline else { return false } let codecName = activeCodecName ?? codecName(for: activeCodecType ?? preset.codecType) @@ -2473,10 +2589,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u captureDisplayText = TBDisplaySenderL10n.captureDisplayCGDisplayStream(language, id: displayID) isStreaming = true if largeCursor { startCursorUpdates(displayID: displayID) } - streamingActivity = ProcessInfo.processInfo.beginActivity( - options: activityOptions(), - reason: "TargetBridge streaming active" - ) + beginCaptureActivity(wakeDisplay: false) startFPSTimer() startCaptureWatchdog() return true @@ -2490,6 +2603,38 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return options } + private func beginCaptureActivity(wakeDisplay: Bool = true) { + if streamingActivity == nil { + streamingActivity = ProcessInfo.processInfo.beginActivity( + options: activityOptions(), + reason: "TargetBridge streaming active" + ) + } + guard wakeDisplay, preventDisplaySleep else { return } + + let result = IOPMAssertionDeclareUserActivity( + "TargetBridge capture requested" as CFString, + kIOPMUserActiveLocal, + &displayWakeAssertionID + ) + if result == kIOReturnSuccess { + TBLog.connection.info("capture: requested graphical-session wake") + } else { + TBLog.connection.error("capture: unable to wake graphical session result=\(result, privacy: .public)") + } + } + + private func endCaptureActivity() { + if displayWakeAssertionID != 0 { + IOPMAssertionRelease(displayWakeAssertionID) + displayWakeAssertionID = 0 + } + if let activity = streamingActivity { + ProcessInfo.processInfo.endActivity(activity) + streamingActivity = nil + } + } + private func waitForCaptureDisplay() async throws -> SCDisplay { let targetDisplayID = (captureSource == .desktopMirror) ? CGMainDisplayID() : session.displayID return try await waitForVirtualDisplay( @@ -3088,10 +3233,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u scStream = nil } captureDelegate = nil - if let activity = streamingActivity { - ProcessInfo.processInfo.endActivity(activity) - streamingActivity = nil - } + endCaptureActivity() pipeline?.stop() pipeline = nil isStreaming = false @@ -3101,6 +3243,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u cursorDisplayID = kCGNullDirectDisplay lastCursorPacket = nil + beginCaptureActivity() let started = await startCapture(for: profile) if !started { NSLog("TargetBridge: soft restart after wake failed — falling back to full stop") @@ -3158,7 +3301,12 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } else { setStatus(.noFirstFrame) } - stop(resetStatusTo: nil) + TBSenderAutomation.suspendAutomaticReconnectAfterCaptureFailure() + stop( + resetStatusTo: nil, + persistArrangement: false, + teardownReason: "sender_user_stop" + ) } } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift index d8ad05af..353892bb 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift @@ -7,6 +7,7 @@ final class TBDisplaySenderStatusItemController: NSObject { nonisolated(unsafe) private var statusItem: NSStatusItem? private var cancellables = Set() private var hasActivated = false + private var pendingMenuAction: (@MainActor @Sendable () -> Void)? // Retains the target objects for the current menu's sliders (NSSlider holds // its target weakly). Cleared and rebuilt each time the menu opens. private var sliderTargets: [TBMenuSliderTarget] = [] @@ -359,14 +360,13 @@ final class TBDisplaySenderStatusItemController: NSObject { } } - // Menu-item handlers run while the menu is still dismissing. Doing work - // synchronously here (activating the app, ordering windows front, mutating - // observed session state) interrupts the menu window's fade-out: its alpha - // animates to 0 but the window is never closed, leaving an invisible - // menu-layer window that swallows clicks at the menu's footprint. Deferring - // to the next runloop tick lets the menu fully tear down first. - private func runAfterMenuDismissal(_ action: @escaping () -> Void) { - DispatchQueue.main.async(execute: action) + // AppKit's menu uses a nested tracking loop. Run state changes only after it + // positively reports closure, avoiding an invisible menu window that can + // remain above the streamed desktop and swallow clicks. + private func runAfterMenuDismissal( + _ action: @escaping @MainActor @Sendable () -> Void + ) { + pendingMenuAction = action } @objc @@ -402,8 +402,8 @@ final class TBDisplaySenderStatusItemController: NSObject { @objc private func quitApp() { - runAfterMenuDismissal { - NSApp.terminate(nil) + runAfterMenuDismissal { [service] in + service.quitAfterUserRequest() } } } @@ -412,6 +412,12 @@ extension TBDisplaySenderStatusItemController: NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { rebuildMenuItems(in: menu) } + + func menuDidClose(_ menu: NSMenu) { + guard let action = pendingMenuAction else { return } + pendingMenuAction = nil + DispatchQueue.main.async(execute: action) + } } /// Target for a menu slider. NSSlider holds its target weakly, so the controller diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index be5cf09e..61a32d2a 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -7,6 +7,69 @@ import XCTest /// silently reroute automation traffic. @MainActor final class TBSenderAutomationParsingTests: XCTestCase { + func testSenderEnabledFlagUsesSelectedHomeDirectory() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("targetbridge-automation-path-test", isDirectory: true) + XCTAssertEqual( + TBSenderAutomation.senderEnabledFlagURL(homeDirectory: root).path, + root.appendingPathComponent( + "Library/Application Support/TargetBridge/Sender/enabled", + isDirectory: false + ).path + ) + } + + func testUserStopRemovesAutomaticReconnectMarker() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("targetbridge-user-stop-\(UUID().uuidString)", isDirectory: true) + let marker = root.appendingPathComponent("enabled") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + XCTAssertTrue(FileManager.default.createFile(atPath: marker.path, contents: Data())) + + TBSenderAutomation.suspendAutomaticReconnectAfterUserStop(enabledFlagURL: marker) + + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + try? FileManager.default.removeItem(at: root) + } + + func testRequiredPermissionRemovesAutomaticReconnectMarker() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("targetbridge-permission-stop-\(UUID().uuidString)", isDirectory: true) + let marker = root.appendingPathComponent("enabled") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + XCTAssertTrue(FileManager.default.createFile(atPath: marker.path, contents: Data())) + + TBSenderAutomation.suspendAutomaticReconnectForRequiredPermission(enabledFlagURL: marker) + + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + try? FileManager.default.removeItem(at: root) + } + + func testCaptureFailureRemovesAutomaticReconnectMarker() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("targetbridge-capture-stop-\(UUID().uuidString)", isDirectory: true) + let marker = root.appendingPathComponent("enabled") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + XCTAssertTrue(FileManager.default.createFile(atPath: marker.path, contents: Data())) + + TBSenderAutomation.suspendAutomaticReconnectAfterCaptureFailure(enabledFlagURL: marker) + + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + try? FileManager.default.removeItem(at: root) + } + + func testAutomationFlagsEnableOnPresenceOrTruthyValues() { + for value in ["", "1", "true", "yes", "on", "unexpected"] { + XCTAssertTrue(TBSenderAutomation.flagEnabled(value), "value \(value)") + } + } + + func testAutomationFlagsDisableOnMissingOrExplicitFalseValues() { + XCTAssertFalse(TBSenderAutomation.flagEnabled(nil)) + for value in ["0", "false", "FALSE", "no", "off", " Off "] { + XCTAssertFalse(TBSenderAutomation.flagEnabled(value), "value \(value)") + } + } // MARK: - parseTransport diff --git a/cli/targetbridge b/cli/targetbridge index 12cd2703..69e61e66 100755 --- a/cli/targetbridge +++ b/cli/targetbridge @@ -9,7 +9,7 @@ set -euo pipefail # # Usage: # targetbridge connect [--receiver auto|] [--mode mirror|extended] -# [--preset ] [--transport tb|net] [--session N] [--local-ip ] +# [--preset ] [--transport tb|net] [--session N] [--local-ip ] [--retry] # targetbridge disconnect [--session N] # # Examples: diff --git a/docs/Automation.md b/docs/Automation.md index e5b5fca8..6fd9c7c7 100644 --- a/docs/Automation.md +++ b/docs/Automation.md @@ -23,7 +23,7 @@ targetbridge disconnect ``` Options: `--receiver auto|`, `--mode mirror|extended`, `--preset `, -`--transport tb|net`, `--session N`, `--local-ip `. +`--transport tb|net`, `--session N`, `--local-ip `, `--retry`. Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `native5k`, `native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, `5k`, `5k60`). `native5k60Experimental` is an opt-in HEVC test profile for 5K at 60 FPS; it does not @@ -56,6 +56,13 @@ handy for a Login Item or LaunchAgent: open -a TargetBridge --args --connect --receiver auto --mode mirror --preset 1440p ``` +Add `--retry` for monitor mode: the Sender keeps watching the active session and retries +after transient startup, cable or Receiver failures. An explicit GUI Stop, Command-Q or +Quit from the menu bar cancels that loop and removes +`~/Library/Application Support/TargetBridge/Sender/enabled`. A LaunchAgent can use that +file as a `PathState` marker so crash recovery remains automatic while a user Stop remains +stopped. Packaging such a LaunchAgent is intentionally outside this change. + ## Recipes **Connect from another Mac over SSH** — the Sender must be at a logged-in desktop with From 11a10af1a0458b3c8e071d47132b3f85cedbdd1c Mon Sep 17 00:00:00 2001 From: Betafer Date: Fri, 7 Aug 2026 08:25:42 +0200 Subject: [PATCH 12/40] fix(receiver): keep fullscreen video above notifications --- .../TBReceiverC/src/display.c | 6 +- .../TBReceiverC/src/tb_gesture_bridge.h | 4 ++ .../TBReceiverC/src/tb_gesture_bridge.m | 71 ++++++++++++++++--- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index 99f6924b..3708ef53 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -496,13 +496,17 @@ static void tb_disp_rebuild_status_texture(struct tb_display *d, static void tb_disp_refresh_window_mode(struct tb_display *d) { if (!d || !d->win) return; - if ((d->is_connected || d->is_connecting) && d->preferred_fullscreen) { + const int monitor_shield_active = + (d->is_connected || d->is_connecting) && d->preferred_fullscreen; + + if (monitor_shield_active) { SDL_SetWindowFullscreen(d->win, SDL_WINDOW_FULLSCREEN_DESKTOP); } else { SDL_SetWindowFullscreen(d->win, 0); SDL_SetWindowSize(d->win, 980, 620); SDL_SetWindowPosition(d->win, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED); } + tb_receiver_set_monitor_shield(monitor_shield_active); const int should_hide_cursor = ((d->is_connected || d->is_connecting) && d->preferred_fullscreen) || diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h index 5ad9e151..c5b7dfdf 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h @@ -10,4 +10,8 @@ void tb_gesture_bridge_set_active(int active); * Space (or can't be determined), 0 if it is on a different Space. */ int tb_window_on_active_space(void *sdl_window); +/* Keep the fullscreen monitor surface above Notification Center while a + * session is active, restoring the normal window level on Stop/disconnect. */ +void tb_receiver_set_monitor_shield(int active); + #endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m index a5577316..7d3876ed 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m @@ -3,6 +3,64 @@ #import #include +static NSWindow *tb_receiver_content_window(void) { + NSWindow *content = nil; + CGFloat best_area = 0.0; + for (NSWindow *window in NSApp.windows) { + if (!window.isVisible) continue; + NSSize size = window.frame.size; + CGFloat area = size.width * size.height; + if (area < 200.0 * 200.0) continue; + if (area > best_area) { + best_area = area; + content = window; + } + } + return content; +} + +static BOOL g_monitor_shield_active = NO; + +static void tb_apply_monitor_shield(void) { + NSWindow *content = tb_receiver_content_window(); + if (!content) return; + + /* The public status-window level keeps ordinary local banners and the menu + * bar behind the active monitor surface without using invasive private or + * screen-saver window levels. */ + NSWindowLevel desired = g_monitor_shield_active + ? (NSWindowLevel)CGWindowLevelForKey(kCGStatusWindowLevelKey) + : NSNormalWindowLevel; + if (content.level != desired) { + content.level = desired; + } +} + +void tb_receiver_set_monitor_shield(int active) { + @autoreleasepool { + BOOL normalized = active ? YES : NO; + BOOL changed = normalized != g_monitor_shield_active; + g_monitor_shield_active = normalized; + tb_apply_monitor_shield(); + + /* SDL completes part of its native fullscreen transition after the + * call returns. Reapply the latest desired state once afterward. */ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 300 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + tb_apply_monitor_shield(); + }); + + if (changed) { + fprintf(stderr, + "[display] monitor shield=%d level=%ld\n", + normalized ? 1 : 0, + (long)(normalized + ? CGWindowLevelForKey(kCGStatusWindowLevelKey) + : NSNormalWindowLevel)); + } + } +} + int tb_window_on_active_space(void *sdl_window) { /* Whether the receiver's content window is on the Space the user is * currently viewing. Used to gate receiverMaster forwarding so local work @@ -17,16 +75,9 @@ int tb_window_on_active_space(void *sdl_window) { * remains the active application on another Space. */ (void)sdl_window; - NSWindow *content = nil; - CGFloat best_area = 0.0; - NSArray *windows = [NSApp windows]; - for (NSWindow *w in windows) { - if (!w.isVisible) continue; - NSSize s = w.frame.size; - CGFloat area = s.width * s.height; - if (area < 200.0 * 200.0) continue; /* skip tiny status/aux windows */ - if (area > best_area) { best_area = area; content = w; } - } + NSWindow *content = tb_receiver_content_window(); + CGFloat best_area = content ? content.frame.size.width * content.frame.size.height : 0.0; + NSArray *windows = NSApp.windows; /* Fail open (forward) only when we genuinely can't find a content window. */ int on_active = content ? (content.isOnActiveSpace ? 1 : 0) : 1; From 434a3edb207ee74415ed8e7b5792de5ecc7b65ad Mon Sep 17 00:00:00 2001 From: swellweb Date: Sun, 9 Aug 2026 14:06:21 +0200 Subject: [PATCH 13/40] fix(sender): refresh discovered receiver addresses --- .../TBDisplaySenderManager.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index d8ee01ea..6ca9856a 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -116,6 +116,7 @@ final class TBDisplaySenderService: ObservableObject { discoveryCancellable = receiverDiscovery.$receivers.sink { [weak self] receivers in guard let self else { return } discoveredReceivers = receivers + reconcileDiscoveredReceivers(receivers) pushLanguageUpdateToDiscoveredReceivers() objectWillChange.send() } @@ -432,6 +433,29 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } + /// Bonjour can rediscover the same receiver with a new address after a + /// cable reconnect or wake. Keep selected receivers on their active + /// transport address, while leaving manually entered addresses untouched. + private func reconcileDiscoveredReceivers(_ receivers: [TBDiscoveredReceiver]) { + for session in sessions where + !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 + }) else { + continue + } + + session.selectedReceiverID = receiver.id + session.receiverIP = receiver.ip(for: session.transportKind) + session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + } + } + func applyDisplayProfile(_ profile: TBDisplayProfile, to session: TBDisplaySenderSession) { guard !session.isConnected, !session.isStreaming else { return } From 6d7da90a676a8f5ac264016b6122ccb5a2cf4d6f Mon Sep 17 00:00:00 2001 From: swellweb Date: Sun, 9 Aug 2026 14:36:20 +0200 Subject: [PATCH 14/40] fix(audio): support interleaved screen capture buffers --- .../TBDisplaySenderService.swift | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index a061a43f..9b0ad49d 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -3428,17 +3428,26 @@ private final class SBAudioConverter: Sendable { guard status == noErr else { return nil } - let firstBufferPtr = withUnsafeMutablePointer(to: &ablPointer.pointee.mBuffers) { $0 } - let buffers = UnsafeBufferPointer(start: firstBufferPtr, count: channelCount) + let sourceBuffers = UnsafeMutableAudioBufferListPointer(ablPointer) + let destinationBuffers = UnsafeMutableAudioBufferListPointer(inputBuffer.mutableAudioBufferList) + guard sourceBuffers.count == destinationBuffers.count else { return nil } + + // ScreenCaptureKit may supply either one interleaved buffer or one + // buffer per channel. Copy the actual AudioBufferList layout rather + // than assuming a non-interleaved Float32 input. + for index in 0.. 0 else { return nil } + memcpy(destinationData, sourceData, byteCount) + if byteCount < destinationByteCount { + memset(destinationData.advanced(by: byteCount), 0, destinationByteCount - byteCount) } } From b5c49b9ed986261881927b277e127a1fae8d3a3b Mon Sep 17 00:00:00 2001 From: swellweb Date: Sun, 9 Aug 2026 17:12:39 +0200 Subject: [PATCH 15/40] chore: prepare 3.4.3 Intel test build --- TargetBridge-Receiver/TBReceiverC/Makefile | 2 +- TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh | 2 +- .../TBDisplaySender/TBDisplaySenderBuildInfo.swift | 4 ++-- .../TargetBridge.xcodeproj/project.pbxproj | 8 ++++---- TargetBridge-Sender/project.yml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 107529ab..a853da98 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -16,7 +16,7 @@ CC ?= clang PKG ?= pkgconf -APP_VERSION ?= 3.4.2 +APP_VERSION ?= 3.4.3 APP_BUILD ?= dev MACOSX_DEPLOYMENT_TARGET ?= 11.0 diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index b4a12c72..e85bb2c0 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="3.4.2" +APP_VERSION="3.4.3" STAMP="$(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 dcdb2aa7..8dbef70f 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.4.2" - static let buildNumber = "20260728175011" + static let marketingVersion = "3.4.3" + static let buildNumber = "20260809170522" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 1a138605..1404b6af 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -68,7 +68,6 @@ /* Begin PBXFileReference section */ 036E14097FA59989FFC456FD /* it.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = it.json; sourceTree = ""; }; 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuToggleRowView.swift; sourceTree = ""; }; - C7A1D4E29B3F45A88C3D0051 /* TBMenuMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuMetrics.swift; sourceTree = ""; }; 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscovery.swift; sourceTree = ""; }; 06D011FF8AF7BB6D26EDC25B /* TBAddonStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonStore.swift; sourceTree = ""; }; 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderService.swift; sourceTree = ""; }; @@ -113,6 +112,7 @@ DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TBDisplaySenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBSenderAutomationParsingTests.swift; sourceTree = ""; }; EF17070AC1C6F9E9F3B4DFDF /* TBInjectedClickStateTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedClickStateTrackerTests.swift; sourceTree = ""; }; + C7A1D4E29B3F45A88C3D0051 /* TBMenuMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuMetrics.swift; sourceTree = ""; }; FD6EB41D253E67E350F5E25D /* TBConnectionDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConnectionDiagnostics.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -352,7 +352,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"$(date +%Y%m%d%H%M%S)\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" < Date: Mon, 10 Aug 2026 12:07:30 +0200 Subject: [PATCH 16/40] fix(audio): preserve relay preference across profiles --- .../TBDisplaySender/TBDisplayProfile.swift | 9 +++-- .../TBDisplaySenderManager.swift | 35 ++++++++++++++----- .../TBDisplaySenderService.swift | 10 ++++-- .../TBDisplayProfileTests.swift | 12 +++++-- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplayProfile.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplayProfile.swift index 225990c7..e0f4f33b 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplayProfile.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplayProfile.swift @@ -4,7 +4,10 @@ struct TBDisplayProfileSettings: Equatable { let captureSource: TBDisplayCaptureSource let capturePreset: TBDisplayCapturePreset let matchRenderToStream: Bool - let audioEnabled: Bool + /// `nil` keeps the user's existing audio preference when a display profile + /// is applied. A display profile should tune video without silently + /// disabling audio for future sessions. + let audioEnabled: Bool? } enum TBDisplayProfile: String, CaseIterable, Identifiable, Codable { @@ -21,14 +24,14 @@ enum TBDisplayProfile: String, CaseIterable, Identifiable, Codable { captureSource: .extendedDesktop, capturePreset: .native5k, matchRenderToStream: true, - audioEnabled: false + audioEnabled: nil ) case .lowLatency: return TBDisplayProfileSettings( captureSource: .desktopMirror, capturePreset: .smooth1440p60, matchRenderToStream: false, - audioEnabled: false + audioEnabled: nil ) case .presentation: return TBDisplayProfileSettings( diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index 6ca9856a..e40c4ebd 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -241,14 +241,14 @@ final class TBDisplaySenderService: ObservableObject { largeCursor: largeCursor, preventDisplaySleep: preventDisplaySleep, autoRestartOnWake: autoRestartOnWake, - audioEnabled: audioEnabled && audioRelayAvailable, + audioEnabled: audioEnabled, verboseDisplayLogging: verboseDisplayLogging ) if let previous = sessions.last { session.capturePreset = previous.capturePreset session.captureSource = previous.captureSource session.transportKind = previous.transportKind - session.audioEnabled = audioRelayAvailable && previous.audioEnabled + session.audioEnabled = previous.audioEnabled session.inputGestureMode = previous.inputGestureMode } session.audioAddonAvailable = audioRelayAvailable @@ -281,6 +281,10 @@ final class TBDisplaySenderService: ObservableObject { private static let persistedSessionsKey = "fd.tbdisplaysender.sessions.v1" private static let receiverDisplayProfilesKey = "fd.tbdisplaysender.receiverDisplayProfiles.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. + private static let audioPreferenceRepairKey = "fd.tbdisplaysender.audioPreferenceRepaired.v2" /// Snapshot of the user-configurable settings for a single session. Transient /// runtime state (connection, FPS, …) is intentionally excluded — only the @@ -355,6 +359,7 @@ final class TBDisplaySenderService: ObservableObject { return } + let repairPending = !UserDefaults.standard.bool(forKey: Self.audioPreferenceRepairKey) lastPersistedData = data for config in configs { let session = TBDisplaySenderSession( @@ -362,7 +367,7 @@ final class TBDisplaySenderService: ObservableObject { largeCursor: largeCursor, preventDisplaySleep: preventDisplaySleep, autoRestartOnWake: autoRestartOnWake, - audioEnabled: audioEnabled && audioRelayAvailable, + audioEnabled: audioEnabled, verboseDisplayLogging: verboseDisplayLogging ) apply(config, to: session) @@ -370,6 +375,14 @@ final class TBDisplaySenderService: ObservableObject { attachSession(session) sessions.append(session) } + if repairPending { + UserDefaults.standard.set(true, forKey: Self.audioPreferenceRepairKey) + // The repaired value was applied before the session observer was + // attached, so persist it explicitly rather than waiting for a + // later user action or letting the next launch restore `false`. + lastPersistedData = nil + persistSessions() + } // Enforce the single-master invariant: only one session may hold a // non-`off` input role. (Persisted data should already satisfy this, but // restore sets roles directly, so guard against stale/edited defaults.) @@ -408,7 +421,10 @@ final class TBDisplaySenderService: ObservableObject { session.receiverIP = config.receiverIP session.selectedReceiverID = config.selectedReceiverID session.localInterfaceIP = config.localInterfaceIP - session.audioEnabled = config.audioEnabled && audioRelayAvailable + session.audioEnabled = Self.restoredAudioEnabled( + from: config.audioEnabled, + repairPending: !UserDefaults.standard.bool(forKey: Self.audioPreferenceRepairKey) + ) session.brightness = config.brightness session.volume = config.volume ?? 0.5 session.matchRenderToStream = config.matchRenderToStream ?? false @@ -463,7 +479,9 @@ final class TBDisplaySenderService: ObservableObject { session.captureSource = settings.captureSource session.capturePreset = settings.capturePreset session.matchRenderToStream = settings.matchRenderToStream - session.audioEnabled = settings.audioEnabled && audioRelayAvailable + if let audioEnabled = settings.audioEnabled { + session.audioEnabled = audioEnabled + } guard let receiverKey = receiverProfileKey(for: session) else { return } var profiles = persistedDisplayProfiles @@ -644,9 +662,6 @@ final class TBDisplaySenderService: ObservableObject { for session in sessions { session.audioAddonAvailable = audioEnabled - if !audioEnabled { - session.audioEnabled = false - } if !networkLinkEnabled, session.transportKind == .networkLink { session.transportKind = .thunderboltBridge } @@ -661,6 +676,10 @@ final class TBDisplaySenderService: ObservableObject { sessions.forEach { $0.updateInputControlMode() } } + nonisolated static func restoredAudioEnabled(from persistedValue: Bool, repairPending: Bool) -> Bool { + repairPending && !persistedValue ? true : persistedValue + } + private func updateInputRelayController() { guard inputDockstationAvailable, let session = sessions.first(where: { $0.inputControlRole == .senderMaster }) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 9b0ad49d..291f2548 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -2500,7 +2500,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u configuration.showsCursor = !largeCursor configuration.scalesToFit = true configuration.captureResolution = preset.captureResolution - configuration.capturesAudio = audioEnabled + configuration.capturesAudio = shouldRelayAudio configuration.excludesCurrentProcessAudio = true configuration.sampleRate = 48000 configuration.channelCount = 2 @@ -2539,7 +2539,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u type: .screen, sampleHandlerQueue: pipeline.queue ) - if audioEnabled { + if shouldRelayAudio { try stream.addStreamOutput( delegate, type: .audio, @@ -3331,12 +3331,16 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func processAudio(_ sampleBuffer: CMSampleBuffer) { - guard audioEnabled else { return } + guard shouldRelayAudio else { return } guard let data = audioConverter.convert(sampleBuffer: sampleBuffer) else { return } let packet = TBMonitorProtocol.makePacket(type: .audioFrame, payload: data) send(packet) } + private var shouldRelayAudio: Bool { + audioEnabled && audioAddonAvailable + } + private func send(_ packet: Data) { connection?.send(content: packet, completion: .contentProcessed({ _ in })) } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBDisplayProfileTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBDisplayProfileTests.swift index bba96370..eeebfcf1 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBDisplayProfileTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBDisplayProfileTests.swift @@ -8,7 +8,7 @@ final class TBDisplayProfileTests: XCTestCase { XCTAssertEqual(settings.captureSource, .extendedDesktop) XCTAssertEqual(settings.capturePreset, .native5k) XCTAssertTrue(settings.matchRenderToStream) - XCTAssertFalse(settings.audioEnabled) + XCTAssertNil(settings.audioEnabled) } func testLowLatencyPrioritizesSmoothVideoWithoutAudio() { @@ -17,7 +17,7 @@ final class TBDisplayProfileTests: XCTestCase { XCTAssertEqual(settings.captureSource, .desktopMirror) XCTAssertEqual(settings.capturePreset, .smooth1440p60) XCTAssertFalse(settings.matchRenderToStream) - XCTAssertFalse(settings.audioEnabled) + XCTAssertNil(settings.audioEnabled) } func testPresentationUsesACompatibleMirrorProfileWithAudio() { @@ -25,6 +25,12 @@ final class TBDisplayProfileTests: XCTestCase { XCTAssertEqual(settings.captureSource, .desktopMirror) XCTAssertEqual(settings.capturePreset, .standard1440p) - XCTAssertTrue(settings.audioEnabled) + XCTAssertEqual(settings.audioEnabled, true) + } + + func testAudioPreferenceRepairRestoresOnlyTheAmbiguousDisabledState() { + XCTAssertTrue(TBDisplaySenderService.restoredAudioEnabled(from: false, repairPending: true)) + XCTAssertFalse(TBDisplaySenderService.restoredAudioEnabled(from: false, repairPending: false)) + XCTAssertTrue(TBDisplaySenderService.restoredAudioEnabled(from: true, repairPending: false)) } } From b59d68facc00ce661a601f3a992cfbe9d50725e4 Mon Sep 17 00:00:00 2001 From: swellweb Date: Mon, 10 Aug 2026 14:12:22 +0200 Subject: [PATCH 17/40] chore: prepare 3.5.0 release --- README.md | 18 ++++++++++-------- TargetBridge-Receiver/TBReceiverC/Makefile | 2 +- .../scripts/build_tbreceiver_c_app.sh | 2 +- .../TBDisplaySenderBuildInfo.swift | 4 ++-- .../TargetBridge.xcodeproj/project.pbxproj | 6 +++--- TargetBridge-Sender/project.yml | 4 ++-- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c46b7344..7878f5a1 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,18 @@ and improves the project. A special thank-you for this release goes to: - [@aalpgiray](https://github.com/aalpgiray) for receiver brightness, volume, Night Shift, and True Tone controls. - [@preggocl](https://github.com/preggocl) for the Apple Silicon and Intel Sender release build coverage. +- [@Betafer](https://github.com/Betafer) for measured connection-path selection and Sender automation improvements. -## TargetBridge 3.4.2 +## TargetBridge 3.5.0 -TargetBridge 3.4.2 adds receiver controls that make the remote display feel -more like part of the sender workspace, improves reliability after wake, and -ships pre-built Sender and Receiver apps for both Apple Silicon and Intel Macs: +TargetBridge 3.5.0 makes automated connections more reliable while giving +scripts a controlled way to choose the best available network route: -- control receiver brightness and volume from the Sender menu -- toggle receiver Night Shift and True Tone from the Sender when the receiver hardware supports them -- retain receiver identity more reliably after wake and carry improved colour metadata through the session -- download separate Apple Silicon and Intel Sender builds, with CI validating the architecture of every release artifact +- use `--path auto` to measure compatible Thunderbolt, USB/USB4, Ethernet, and Wi-Fi paths and choose the fastest one +- force a preferred route with `--path wired`, `thunderbolt`, `usb`, `ethernet`, or `wifi` +- run a Sender connection in monitor mode with `--retry`, including safe recovery after a temporary Receiver or cable interruption +- an explicit Stop or Quit cancels automatic retry, preventing unwanted reconnects +- keep duplicate-desktop startup resilient when macOS delays publishing a ScreenCaptureKit display It also keeps the established multi-Mac workspace features: @@ -72,6 +73,7 @@ It also keeps the established multi-Mac workspace features: - Input Dockstation, clipboard sync, master/slave roles, and Receiver Master shortcuts: [docs/Features.md#input-dockstation](docs/Features.md#input-dockstation) - Receiver device controls (brightness, volume, Night Shift, True Tone): [docs/Features.md#receiver-device-controls](docs/Features.md#receiver-device-controls) - Remote connection & automation (URL scheme, launch args, SSH, login/wake): [docs/Automation.md](docs/Automation.md) +- Measured connection-path selection (Thunderbolt, USB/USB4, Ethernet, Wi-Fi): [docs/Automation.md#1-targetbridge-cli](docs/Automation.md#1-targetbridge-cli) - Shared translations (English, Italian, German, French, and Chinese): [docs/Features.md#shared-translations](docs/Features.md#shared-translations) - Thunderbolt networking extras (SSH/SFTP, file sharing, Internet Sharing): [docs/Features.md#thunderbolt-networking-extras](docs/Features.md#thunderbolt-networking-extras) diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 95483fc7..a7d22e97 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -16,7 +16,7 @@ CC ?= clang PKG ?= pkgconf -APP_VERSION ?= 3.4.3 +APP_VERSION ?= 3.5.0 APP_BUILD ?= dev MACOSX_DEPLOYMENT_TARGET ?= 11.0 diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index e85bb2c0..8c745a87 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="3.4.3" +APP_VERSION="3.5.0" STAMP="$(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 8dbef70f..88e2b002 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.4.3" - static let buildNumber = "20260809170522" + static let marketingVersion = "3.5.0" + static let buildNumber = "20260810141154" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 1404b6af..b081f2a1 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -352,7 +352,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"$(date +%Y%m%d%H%M%S)\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" < Date: Mon, 10 Aug 2026 17:30:39 +0200 Subject: [PATCH 18/40] fix(receiver): preserve input releases under queue pressure --- .gitignore | 1 + TargetBridge-Receiver/TBReceiverC/Makefile | 11 +- .../TBReceiverC/src/display.c | 16 +-- .../TBReceiverC/src/display.h | 34 +---- .../TBReceiverC/src/input_queue.c | 68 ++++++++++ .../TBReceiverC/src/input_queue.h | 54 ++++++++ .../TBReceiverC/tests/test_input_queue.c | 121 ++++++++++++++++++ 7 files changed, 257 insertions(+), 48 deletions(-) create mode 100644 TargetBridge-Receiver/TBReceiverC/src/input_queue.c create mode 100644 TargetBridge-Receiver/TBReceiverC/src/input_queue.h create mode 100644 TargetBridge-Receiver/TBReceiverC/tests/test_input_queue.c diff --git a/.gitignore b/.gitignore index ee2bb9bf..0219bb95 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ tbreceiver # C test outputs test_net_parser +test_input_queue *.dSYM/ diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index a7d22e97..84fc6192 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -34,7 +34,7 @@ ifeq ($(UNAME_S),Darwin) LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework IOSurface -framework CoreServices -framework CoreAudio -framework SystemConfiguration endif -C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/tb_i18n.c +C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/input_queue.c src/tb_i18n.c OBJC_SRC = src/tb_gesture_bridge.m src/tb_display_tweaks.m OBJ = $(C_SRC:.c=.o) $(OBJC_SRC:.m=.o) BIN = tbreceiver @@ -51,7 +51,7 @@ $(BIN): $(OBJ) $(CC) $(CFLAGS) -fobjc-arc -c -o $@ $< clean: - rm -f $(OBJ) $(BIN) $(TEST_BIN) + rm -f $(OBJ) $(BIN) $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) # Unit tests for the packet parser. net.c is pure POSIX, so this needs no # ffmpeg/SDL/pkgconf — it runs anywhere, including CI, with no hardware. @@ -61,11 +61,16 @@ ifeq ($(UNAME_S),Darwin) TEST_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration endif TEST_BIN = test_net_parser +TEST_INPUT_QUEUE_BIN = test_input_queue $(TEST_BIN): tests/test_net_parser.c src/net.c src/net.h src/proto.h $(CC) $(TEST_CFLAGS) -Isrc tests/test_net_parser.c src/net.c $(TEST_LDFLAGS) -o $@ -test: $(TEST_BIN) +$(TEST_INPUT_QUEUE_BIN): tests/test_input_queue.c src/input_queue.c src/input_queue.h + $(CC) $(TEST_CFLAGS) -Isrc tests/test_input_queue.c src/input_queue.c -o $@ + +test: $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) ./$(TEST_BIN) + ./$(TEST_INPUT_QUEUE_BIN) .PHONY: all clean test diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index 3708ef53..4b52287c 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -40,9 +40,7 @@ struct tb_display { int is_connecting; int input_capture_active; int input_intercept_active; - struct tb_input_event input_events[128]; - int input_head; - int input_tail; + struct tb_input_queue input_queue; uint32_t last_target_switch_tick; uint32_t last_space_switch_tick; uint32_t last_space_gesture_tick; @@ -182,12 +180,7 @@ static uint16_t tb_disp_mac_keycode_for_sdl_scancode(SDL_Scancode scancode) { static void tb_disp_queue_input_event(struct tb_display *d, const struct tb_input_event *event) { if (!d || !event) return; - int next = (d->input_head + 1) % 128; - if (next == d->input_tail) { - d->input_tail = (d->input_tail + 1) % 128; - } - d->input_events[d->input_head] = *event; - d->input_head = next; + (void)tb_input_queue_push(&d->input_queue, event); } static void tb_disp_destroy_status_texture(struct tb_display *d) { @@ -1313,10 +1306,7 @@ unsigned int tb_disp_poll_actions(struct tb_display *d) { int tb_disp_pop_input_event(struct tb_display *d, struct tb_input_event *out) { if (!d || !out) return 0; - if (d->input_tail == d->input_head) return 0; - *out = d->input_events[d->input_tail]; - d->input_tail = (d->input_tail + 1) % 128; - return 1; + return tb_input_queue_pop(&d->input_queue, out); } int tb_disp_get_info(struct tb_display *d, struct tb_display_info *info) { diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.h b/TargetBridge-Receiver/TBReceiverC/src/display.h index d5b3d382..7670adb1 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.h +++ b/TargetBridge-Receiver/TBReceiverC/src/display.h @@ -6,6 +6,8 @@ #include #include +#include "input_queue.h" + struct tb_display; struct tb_display_info { @@ -24,38 +26,6 @@ enum tb_display_action { TB_DISP_ACTION_CYCLE_LANGUAGE = 1 << 1 }; -enum tb_input_event_kind { - TB_INPUT_EVENT_NONE = 0, - TB_INPUT_EVENT_MOVE, - TB_INPUT_EVENT_LEFT_DRAG, - TB_INPUT_EVENT_RIGHT_DRAG, - TB_INPUT_EVENT_OTHER_DRAG, - TB_INPUT_EVENT_SCROLL, - TB_INPUT_EVENT_LEFT_DOWN, - TB_INPUT_EVENT_LEFT_UP, - TB_INPUT_EVENT_RIGHT_DOWN, - TB_INPUT_EVENT_RIGHT_UP, - TB_INPUT_EVENT_OTHER_DOWN, - TB_INPUT_EVENT_OTHER_UP, - TB_INPUT_EVENT_KEY_DOWN, - TB_INPUT_EVENT_KEY_UP, - TB_INPUT_EVENT_SWITCH_PREV_TARGET, - TB_INPUT_EVENT_SWITCH_NEXT_TARGET, - TB_INPUT_EVENT_SWITCH_PREV_SPACE, - TB_INPUT_EVENT_SWITCH_NEXT_SPACE, - TB_INPUT_EVENT_DEACTIVATE_CONTROL -}; - -struct tb_input_event { - enum tb_input_event_kind kind; - int dx; - int dy; - int scroll_x; - int scroll_y; - uint16_t key_code; - int click_count; -}; - struct tb_display *tb_disp_create(int fullscreen); void tb_disp_destroy(struct tb_display *d); void tb_disp_set_connection_state(struct tb_display *d, int connected); diff --git a/TargetBridge-Receiver/TBReceiverC/src/input_queue.c b/TargetBridge-Receiver/TBReceiverC/src/input_queue.c new file mode 100644 index 00000000..cfe27ca0 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/input_queue.c @@ -0,0 +1,68 @@ +#include "input_queue.h" + +#include + +static int tb_input_event_is_lossy(enum tb_input_event_kind kind) { + return kind == TB_INPUT_EVENT_MOVE || + kind == TB_INPUT_EVENT_LEFT_DRAG || + kind == TB_INPUT_EVENT_RIGHT_DRAG || + kind == TB_INPUT_EVENT_OTHER_DRAG || + kind == TB_INPUT_EVENT_SCROLL; +} + +static size_t tb_input_queue_index(const struct tb_input_queue *queue, + size_t logical_index) { + return (queue->head + logical_index) % TB_INPUT_QUEUE_CAPACITY; +} + +static int tb_input_queue_remove_oldest_lossy(struct tb_input_queue *queue) { + size_t lossy_index = queue->count; + for (size_t i = 0; i < queue->count; i++) { + if (tb_input_event_is_lossy(queue->events[tb_input_queue_index(queue, i)].kind)) { + lossy_index = i; + break; + } + } + if (lossy_index == queue->count) return 0; + + for (size_t i = lossy_index; i + 1 < queue->count; i++) { + queue->events[tb_input_queue_index(queue, i)] = + queue->events[tb_input_queue_index(queue, i + 1)]; + } + queue->count--; + return 1; +} + +int tb_input_queue_push(struct tb_input_queue *queue, + const struct tb_input_event *event) { + if (!queue || !event) return 0; + + if (queue->count == TB_INPUT_QUEUE_CAPACITY) { + if (!tb_input_queue_remove_oldest_lossy(queue)) { + if (tb_input_event_is_lossy(event->kind)) { + return 0; + } + + /* A queue made entirely of lifecycle events cannot safely discard + * an arbitrary key/button transition. Collapse it to the existing + * fail-safe command so the Sender releases every held input. */ + memset(queue, 0, sizeof(*queue)); + queue->events[0].kind = TB_INPUT_EVENT_DEACTIVATE_CONTROL; + queue->count = 1; + return -1; + } + } + + queue->events[tb_input_queue_index(queue, queue->count)] = *event; + queue->count++; + return 1; +} + +int tb_input_queue_pop(struct tb_input_queue *queue, + struct tb_input_event *event) { + if (!queue || !event || queue->count == 0) return 0; + *event = queue->events[queue->head]; + queue->head = (queue->head + 1) % TB_INPUT_QUEUE_CAPACITY; + queue->count--; + return 1; +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/input_queue.h b/TargetBridge-Receiver/TBReceiverC/src/input_queue.h new file mode 100644 index 00000000..a771cd44 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/input_queue.h @@ -0,0 +1,54 @@ +#ifndef TB_INPUT_QUEUE_H +#define TB_INPUT_QUEUE_H + +#include +#include + +#define TB_INPUT_QUEUE_CAPACITY 128 + +enum tb_input_event_kind { + TB_INPUT_EVENT_NONE = 0, + TB_INPUT_EVENT_MOVE, + TB_INPUT_EVENT_LEFT_DRAG, + TB_INPUT_EVENT_RIGHT_DRAG, + TB_INPUT_EVENT_OTHER_DRAG, + TB_INPUT_EVENT_SCROLL, + TB_INPUT_EVENT_LEFT_DOWN, + TB_INPUT_EVENT_LEFT_UP, + TB_INPUT_EVENT_RIGHT_DOWN, + TB_INPUT_EVENT_RIGHT_UP, + TB_INPUT_EVENT_OTHER_DOWN, + TB_INPUT_EVENT_OTHER_UP, + TB_INPUT_EVENT_KEY_DOWN, + TB_INPUT_EVENT_KEY_UP, + TB_INPUT_EVENT_SWITCH_PREV_TARGET, + TB_INPUT_EVENT_SWITCH_NEXT_TARGET, + TB_INPUT_EVENT_SWITCH_PREV_SPACE, + TB_INPUT_EVENT_SWITCH_NEXT_SPACE, + TB_INPUT_EVENT_DEACTIVATE_CONTROL +}; + +struct tb_input_event { + enum tb_input_event_kind kind; + int dx; + int dy; + int scroll_x; + int scroll_y; + uint16_t key_code; + int click_count; +}; + +struct tb_input_queue { + struct tb_input_event events[TB_INPUT_QUEUE_CAPACITY]; + size_t head; + size_t count; +}; + +/* Returns 1 when the event was queued, 0 when a lossy motion event was + * discarded, and -1 when overload forced a safe control deactivation. */ +int tb_input_queue_push(struct tb_input_queue *queue, + const struct tb_input_event *event); +int tb_input_queue_pop(struct tb_input_queue *queue, + struct tb_input_event *event); + +#endif diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_input_queue.c b/TargetBridge-Receiver/TBReceiverC/tests/test_input_queue.c new file mode 100644 index 00000000..4bc6778e --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_input_queue.c @@ -0,0 +1,121 @@ +#include "input_queue.h" + +#include +#include + +static int checks = 0; +static int failures = 0; + +#define CHECK(condition, message) do { \ + checks++; \ + if (!(condition)) { \ + fprintf(stderr, "FAIL: %s\n", message); \ + failures++; \ + } \ +} while (0) + +static struct tb_input_event event(enum tb_input_event_kind kind, int value) { + struct tb_input_event result; + memset(&result, 0, sizeof(result)); + result.kind = kind; + result.dx = value; + result.key_code = (uint16_t)value; + return result; +} + +int main(void) { + struct tb_input_queue queue = {0}; + struct tb_input_event out; + int saw_down = 0; + int saw_up = 0; + + struct tb_input_event first = event(TB_INPUT_EVENT_KEY_DOWN, 12); + struct tb_input_event second = event(TB_INPUT_EVENT_KEY_UP, 12); + CHECK(tb_input_queue_push(&queue, &first) == 1, "first event queued"); + CHECK(tb_input_queue_push(&queue, &second) == 1, "second event queued"); + CHECK(tb_input_queue_pop(&queue, &out) == 1 && out.kind == TB_INPUT_EVENT_KEY_DOWN, + "queue preserves FIFO order"); + CHECK(tb_input_queue_pop(&queue, &out) == 1 && out.kind == TB_INPUT_EVENT_KEY_UP, + "queue preserves key release"); + + /* Exercise the same overflow policy after the circular head has wrapped. */ + memset(&queue, 0, sizeof(queue)); + for (int i = 0; i < 16; i++) { + struct tb_input_event warmup = event(TB_INPUT_EVENT_MOVE, i); + CHECK(tb_input_queue_push(&queue, &warmup) == 1, "wrap warmup queued"); + } + for (int i = 0; i < 16; i++) { + CHECK(tb_input_queue_pop(&queue, &out) == 1, "wrap warmup popped"); + } + for (int i = 0; i < TB_INPUT_QUEUE_CAPACITY - 1; i++) { + struct tb_input_event wrapped_move = event(TB_INPUT_EVENT_MOVE, 1000 + i); + CHECK(tb_input_queue_push(&queue, &wrapped_move) == 1, "wrapped motion queued"); + } + CHECK(tb_input_queue_push(&queue, &first) == 1, "wrapped key down fills queue"); + CHECK(tb_input_queue_push(&queue, &second) == 1, + "wrapped overflow preserves key release"); + while (tb_input_queue_pop(&queue, &out)) { + if (out.kind == TB_INPUT_EVENT_KEY_DOWN) saw_down++; + if (out.kind == TB_INPUT_EVENT_KEY_UP) saw_up++; + } + CHECK(saw_down == 1 && saw_up == 1, + "wrapped eviction preserves lifecycle order"); + + memset(&queue, 0, sizeof(queue)); + for (int i = 0; i < TB_INPUT_QUEUE_CAPACITY - 1; i++) { + struct tb_input_event move = event(TB_INPUT_EVENT_MOVE, i); + CHECK(tb_input_queue_push(&queue, &move) == 1, "motion burst queued"); + } + CHECK(tb_input_queue_push(&queue, &first) == 1, "key down fills queue"); + CHECK(tb_input_queue_push(&queue, &second) == 1, + "key up evicts motion instead of being discarded"); + saw_down = 0; + saw_up = 0; + while (tb_input_queue_pop(&queue, &out)) { + if (out.kind == TB_INPUT_EVENT_KEY_DOWN) saw_down++; + if (out.kind == TB_INPUT_EVENT_KEY_UP) saw_up++; + } + CHECK(saw_down == 1, "key down survives motion overflow"); + CHECK(saw_up == 1, "key up survives motion overflow"); + + memset(&queue, 0, sizeof(queue)); + for (int i = 0; i < TB_INPUT_QUEUE_CAPACITY - 1; i++) { + struct tb_input_event move = event(TB_INPUT_EVENT_MOVE, i); + CHECK(tb_input_queue_push(&queue, &move) == 1, "mouse motion burst queued"); + } + struct tb_input_event left_down = event(TB_INPUT_EVENT_LEFT_DOWN, 0); + struct tb_input_event left_up = event(TB_INPUT_EVENT_LEFT_UP, 0); + CHECK(tb_input_queue_push(&queue, &left_down) == 1, "mouse down fills queue"); + CHECK(tb_input_queue_push(&queue, &left_up) == 1, + "mouse up evicts motion instead of being discarded"); + saw_down = 0; + saw_up = 0; + while (tb_input_queue_pop(&queue, &out)) { + if (out.kind == TB_INPUT_EVENT_LEFT_DOWN) saw_down++; + if (out.kind == TB_INPUT_EVENT_LEFT_UP) saw_up++; + } + CHECK(saw_down == 1, "mouse down survives motion overflow"); + CHECK(saw_up == 1, "mouse up survives motion overflow"); + + memset(&queue, 0, sizeof(queue)); + for (int i = 0; i < TB_INPUT_QUEUE_CAPACITY; i++) { + struct tb_input_event critical = event(TB_INPUT_EVENT_KEY_DOWN, i); + CHECK(tb_input_queue_push(&queue, &critical) == 1, "critical event queued"); + } + struct tb_input_event move = event(TB_INPUT_EVENT_MOVE, 5); + CHECK(tb_input_queue_push(&queue, &move) == 0, + "lossy motion is discarded before a lifecycle event"); + CHECK(queue.count == TB_INPUT_QUEUE_CAPACITY, + "discarded motion leaves critical queue intact"); + + CHECK(tb_input_queue_push(&queue, &second) == -1, + "critical-only overflow requests safe deactivation"); + CHECK(queue.count == 1, "unsafe backlog is collapsed"); + CHECK(tb_input_queue_pop(&queue, &out) == 1 && + out.kind == TB_INPUT_EVENT_DEACTIVATE_CONTROL, + "overflow emits the existing release-all command"); + CHECK(tb_input_queue_pop(&queue, &out) == 0, "queue is empty after fail-safe"); + + printf("input queue tests: %d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} From 47de0a10b22e1d6d90b61ae7756429a70ccad1c8 Mon Sep 17 00:00:00 2001 From: Betafer Date: Mon, 10 Aug 2026 17:37:21 +0200 Subject: [PATCH 19/40] fix(sender): avoid duplicate receiver-master cursor --- .../TBDisplaySenderService.swift | 38 +++++++++++++-- .../TBSenderAutomationParsingTests.swift | 48 +++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 4d1b83d2..4f5b033e 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -350,6 +350,18 @@ enum TBInputControlRole: String, CaseIterable, Identifiable { case receiverMaster var id: String { rawValue } + + func usesLowLatencyCursorOverlay(largeCursorEnabled: Bool) -> Bool { + largeCursorEnabled || self == .receiverMaster + } + + func changesCursorCaptureMode( + from previousRole: TBInputControlRole, + largeCursorEnabled: Bool + ) -> Bool { + usesLowLatencyCursorOverlay(largeCursorEnabled: largeCursorEnabled) + != previousRole.usesLowLatencyCursorOverlay(largeCursorEnabled: largeCursorEnabled) + } } enum TBInputGestureMode: String, CaseIterable, Identifiable { @@ -1179,6 +1191,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } @Published var inputControlRole: TBInputControlRole = .off { didSet { + let cursorCaptureModeChanged = inputControlRole.changesCursorCaptureMode( + from: oldValue, + largeCursorEnabled: largeCursor + ) inputRelayActive = (inputControlRole == .senderMaster) if inputControlRole != .receiverMaster { injectedRemoteMouseLocation = nil @@ -1187,6 +1203,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u remoteHeldModifierKeyCodes.removeAll() suppressedTriggerKeyCode = nil } + if cursorCaptureModeChanged, isStreaming { + restartCaptureNow() + } } } @Published var inputGestureMode: TBInputGestureMode = .native @@ -2567,6 +2586,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } + let usesCursorOverlay = inputControlRole.usesLowLatencyCursorOverlay( + largeCursorEnabled: largeCursor + ) let configuration = SCStreamConfiguration() configuration.width = preset.width configuration.height = preset.height @@ -2574,7 +2596,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u configuration.queueDepth = preset.queueDepth configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange configuration.shouldBeOpaque = true - configuration.showsCursor = !largeCursor + configuration.showsCursor = !usesCursorOverlay configuration.scalesToFit = true configuration.captureResolution = preset.captureResolution configuration.capturesAudio = shouldRelayAudio @@ -2626,7 +2648,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u try await stream.startCapture() scStream = stream isStreaming = true - if largeCursor { startCursorUpdates(displayID: display.displayID) } + if usesCursorOverlay { startCursorUpdates(displayID: display.displayID) } beginCaptureActivity(wakeDisplay: false) startFPSTimer() startCaptureWatchdog() @@ -2676,6 +2698,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func startDirectDisplayStream(displayID: CGDirectDisplayID, preset: TBDisplayCapturePreset) -> Bool { guard let pipeline else { return false } + let usesCursorOverlay = inputControlRole.usesLowLatencyCursorOverlay( + largeCursorEnabled: largeCursor + ) let codecName = activeCodecName ?? codecName(for: activeCodecType ?? preset.codecType) streamResolutionText = TBDisplaySenderL10n.streamSummary( preset: preset, @@ -2687,14 +2712,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // Deliver frames straight onto the pipeline's own queue — the handler // runs there, so encode happens off the main thread with no extra hop. let directCapture = TBDirectDisplayStreamCapture(pipeline: pipeline, queue: pipeline.queue) - guard directCapture.start(displayID: displayID, preset: preset, showCursor: !largeCursor) else { + guard directCapture.start(displayID: displayID, preset: preset, showCursor: !usesCursorOverlay) else { return false } directDisplayStream = directCapture captureDisplayText = TBDisplaySenderL10n.captureDisplayCGDisplayStream(language, id: displayID) isStreaming = true - if largeCursor { startCursorUpdates(displayID: displayID) } + if usesCursorOverlay { startCursorUpdates(displayID: displayID) } beginCaptureActivity(wakeDisplay: false) startFPSTimer() startCaptureWatchdog() @@ -3076,7 +3101,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } - guard largeCursor, isStreaming, cursorDisplayID != kCGNullDirectDisplay else { return } + let usesCursorOverlay = inputControlRole.usesLowLatencyCursorOverlay( + largeCursorEnabled: largeCursor + ) + guard usesCursorOverlay, isStreaming, cursorDisplayID != kCGNullDirectDisplay else { return } startCursorUpdates(displayID: cursorDisplayID) } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index de161cf9..6375d053 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -8,6 +8,54 @@ import XCTest /// silently reroute automation traffic. @MainActor final class TBSenderAutomationParsingTests: XCTestCase { + func testReceiverControlUsesSeparateCursorOverlayWithoutLargeCursor() { + XCTAssertTrue( + TBInputControlRole.receiverMaster.usesLowLatencyCursorOverlay( + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.senderMaster.usesLowLatencyCursorOverlay( + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.off.usesLowLatencyCursorOverlay( + largeCursorEnabled: false + ) + ) + XCTAssertTrue( + TBInputControlRole.off.usesLowLatencyCursorOverlay( + largeCursorEnabled: true + ) + ) + + XCTAssertTrue( + TBInputControlRole.receiverMaster.changesCursorCaptureMode( + from: .off, + largeCursorEnabled: false + ) + ) + XCTAssertTrue( + TBInputControlRole.off.changesCursorCaptureMode( + from: .receiverMaster, + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.senderMaster.changesCursorCaptureMode( + from: .off, + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.receiverMaster.changesCursorCaptureMode( + from: .off, + largeCursorEnabled: true + ) + ) + } + func testHighFrameRatePresetsUseFiveCaptureSurfaces() { XCTAssertEqual(TBDisplayCapturePreset.standard1440p.queueDepth, 3) XCTAssertEqual(TBDisplayCapturePreset.smooth1440p60.queueDepth, 5) From 0ce99bf2ad288a2c66813379dc5d0d2b5326a495 Mon Sep 17 00:00:00 2001 From: AndyStuardo <174057230+preggocl@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:03:04 -0400 Subject: [PATCH 20/40] fix(receiver): advertise the physical display profile --- TargetBridge-Receiver/TBReceiverC/Makefile | 12 ++-- .../TBReceiverC/src/display.c | 23 ++++--- .../TBReceiverC/src/display.h | 2 + TargetBridge-Receiver/TBReceiverC/src/main.c | 38 ++++++------ .../TBReceiverC/src/receiver_profile.c | 24 ++++++++ .../TBReceiverC/src/receiver_profile.h | 27 ++++++++ .../TBReceiverC/src/tb_gesture_bridge.h | 6 ++ .../TBReceiverC/src/tb_gesture_bridge.m | 23 +++++++ .../TBReceiverC/tests/test_receiver_profile.c | 61 +++++++++++++++++++ 9 files changed, 184 insertions(+), 32 deletions(-) create mode 100644 TargetBridge-Receiver/TBReceiverC/src/receiver_profile.c create mode 100644 TargetBridge-Receiver/TBReceiverC/src/receiver_profile.h create mode 100644 TargetBridge-Receiver/TBReceiverC/tests/test_receiver_profile.c diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index a7d22e97..f2447bf2 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -34,7 +34,7 @@ ifeq ($(UNAME_S),Darwin) LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework IOSurface -framework CoreServices -framework CoreAudio -framework SystemConfiguration endif -C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/tb_i18n.c +C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/receiver_profile.c src/tb_i18n.c OBJC_SRC = src/tb_gesture_bridge.m src/tb_display_tweaks.m OBJ = $(C_SRC:.c=.o) $(OBJC_SRC:.m=.o) BIN = tbreceiver @@ -60,12 +60,16 @@ TEST_LDFLAGS = ifeq ($(UNAME_S),Darwin) TEST_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration endif -TEST_BIN = test_net_parser +TEST_BIN = test_net_parser test_receiver_profile -$(TEST_BIN): tests/test_net_parser.c src/net.c src/net.h src/proto.h +test_net_parser: tests/test_net_parser.c src/net.c src/net.h src/proto.h $(CC) $(TEST_CFLAGS) -Isrc tests/test_net_parser.c src/net.c $(TEST_LDFLAGS) -o $@ test: $(TEST_BIN) - ./$(TEST_BIN) + ./test_net_parser + ./test_receiver_profile + +test_receiver_profile: tests/test_receiver_profile.c src/receiver_profile.c src/receiver_profile.h + $(CC) $(TEST_CFLAGS) -Isrc tests/test_receiver_profile.c src/receiver_profile.c -o $@ .PHONY: all clean test diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index 3708ef53..3a8b88ad 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -1329,8 +1329,10 @@ int tb_disp_get_info(struct tb_display *d, struct tb_display_info *info) { SDL_DisplayMode mode; if (SDL_GetCurrentDisplayMode(display_index, &mode) == 0) { - info->active_w = (uint32_t)mode.w; - info->active_h = (uint32_t)mode.h; + info->logical_w = (uint32_t)mode.w; + info->logical_h = (uint32_t)mode.h; + info->active_w = info->logical_w; + info->active_h = info->logical_h; } int window_w = 0, window_h = 0; @@ -1341,12 +1343,17 @@ int tb_disp_get_info(struct tb_display *d, struct tb_display_info *info) { info->drawable_h = (uint32_t)drawable_h; } - /* On Retina/5K macOS displays SDL_GetCurrentDisplayMode may report the - * logical desktop size (for example 2560x1440) while the renderer - * drawable exposes the true backing pixel size (for example 5120x2880). - * For the TB stream UI we want the actual panel pixel size. */ - if (info->drawable_w > info->active_w) info->active_w = info->drawable_w; - if (info->drawable_h > info->active_h) info->active_h = info->drawable_h; + /* SDL reports a logical desktop mode on Retina displays. Query the + * receiver window's NSScreen for native pixels instead of using its + * transient drawable, which is only window-sized before fullscreen. */ + uint32_t native_w = 0, native_h = 0; + if (tb_receiver_content_display_pixels(&native_w, &native_h) == 0) { + info->active_w = native_w; + info->active_h = native_h; + } else { + if (info->drawable_w > info->active_w) info->active_w = info->drawable_w; + if (info->drawable_h > info->active_h) info->active_h = info->drawable_h; + } info->window_w = (uint32_t)window_w; info->window_h = (uint32_t)window_h; diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.h b/TargetBridge-Receiver/TBReceiverC/src/display.h index d5b3d382..92fcc019 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.h +++ b/TargetBridge-Receiver/TBReceiverC/src/display.h @@ -9,6 +9,8 @@ struct tb_display; struct tb_display_info { + uint32_t logical_w; + uint32_t logical_h; uint32_t active_w; uint32_t active_h; uint32_t window_w; diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index ad54a5f1..de2503b7 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -16,6 +16,7 @@ #include "net.h" #include "decoder.h" #include "display.h" +#include "receiver_profile.h" #include "proto.h" #include "tb_gesture_bridge.h" #include "tb_display_tweaks.h" @@ -1733,17 +1734,12 @@ static void send_receiver_info(struct app *a) { struct tb_display_info info; if (tb_disp_get_info(a->disp, &info) < 0) return; - /* Always advertise the intended iMac target panel, not the transient - * SDL window/debug drawable size. Using the drawable here breaks the - * sender's virtual display creation path when running windowed or on - * scaled desktops because macOS rejects a HiDPI mode larger than the - * advertised backing panel. */ - const uint32_t panel_w = 5120; - const uint32_t panel_h = 2880; - const uint32_t mode_w = 2560; - const uint32_t mode_h = 1440; - const uint32_t capture_w = 2560; - const uint32_t capture_h = 1440; + struct tb_receiver_profile profile; + if (tb_receiver_profile_from_dimensions(info.logical_w, info.logical_h, + info.active_w, info.active_h, + &profile) < 0) { + return; + } char escaped_name[256]; size_t out = 0; @@ -1764,16 +1760,17 @@ static void send_receiver_info(struct app *a) { sizeof(json), "{\"receiverName\":\"%s\",\"panelWidth\":%u,\"panelHeight\":%u," "\"modeWidth\":%u,\"modeHeight\":%u,\"refreshRate\":60," - "\"hiDPI\":true,\"captureWidth\":%u,\"captureHeight\":%u," + "\"hiDPI\":%s,\"captureWidth\":%u,\"captureHeight\":%u," "\"supportsHEVCDecode\":%s,\"supportsRawNV12\":true,\"inputMonitoringTrusted\":%s,\"accessibilityTrusted\":%s," "\"supportsNightShift\":%s,\"supportsTrueTone\":%s}", escaped_name, - panel_w, - panel_h, - mode_w, - mode_h, - capture_w, - capture_h, + profile.panel_w, + profile.panel_h, + profile.mode_w, + profile.mode_h, + profile.hi_dpi ? "true" : "false", + profile.capture_w, + profile.capture_h, tb_dec_supports_hevc_hwdecode() ? "true" : "false", tb_receiver_input_monitoring_trusted() ? "true" : "false", tb_receiver_accessibility_trusted() ? "true" : "false", @@ -1791,8 +1788,9 @@ static void send_receiver_info(struct app *a) { if (send_all(a->client_fd, pkt, packet_len) == 0) { fprintf(stderr, - "[main] sent display profile: panel=%ux%u mode=%ux%u hidpi name=%s\n", - panel_w, panel_h, mode_w, mode_h, info.name); + "[main] sent display profile: panel=%ux%u mode=%ux%u hidpi=%d name=%s\n", + profile.panel_w, profile.panel_h, profile.mode_w, profile.mode_h, + profile.hi_dpi, info.name); } free(pkt); } diff --git a/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.c b/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.c new file mode 100644 index 00000000..4313fab9 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.c @@ -0,0 +1,24 @@ +#include "receiver_profile.h" + +#include + +int tb_receiver_profile_from_dimensions(uint32_t logical_w, + uint32_t logical_h, + uint32_t panel_w, + uint32_t panel_h, + struct tb_receiver_profile *out) { + if (!out || !panel_w || !panel_h) return -1; + + memset(out, 0, sizeof(*out)); + out->panel_w = panel_w; + out->panel_h = panel_h; + out->capture_w = panel_w; + out->capture_h = panel_h; + + const int hi_dpi = logical_w && logical_h && + panel_w >= logical_w * 2 && panel_h >= logical_h * 2; + out->hi_dpi = hi_dpi; + out->mode_w = hi_dpi ? logical_w : panel_w; + out->mode_h = hi_dpi ? logical_h : panel_h; + return 0; +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.h b/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.h new file mode 100644 index 00000000..76039fc2 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.h @@ -0,0 +1,27 @@ +/* receiver_profile.h — sender-facing geometry derived from the receiver panel. */ + +#ifndef TB_RECEIVER_PROFILE_H +#define TB_RECEIVER_PROFILE_H + +#include + +struct tb_receiver_profile { + uint32_t panel_w; + uint32_t panel_h; + uint32_t mode_w; + uint32_t mode_h; + uint32_t capture_w; + uint32_t capture_h; + int hi_dpi; +}; + +/* Build the DISPLAY_PROFILE dimensions from the receiver's logical desktop + * mode and its native panel pixels. A Retina panel is advertised as HiDPI + * only when its backing dimensions are at least 2x the logical mode. */ +int tb_receiver_profile_from_dimensions(uint32_t logical_w, + uint32_t logical_h, + uint32_t panel_w, + uint32_t panel_h, + struct tb_receiver_profile *out); + +#endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h index c5b7dfdf..37cde0eb 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h @@ -1,6 +1,8 @@ #ifndef TB_GESTURE_BRIDGE_H #define TB_GESTURE_BRIDGE_H +#include + typedef void (*tb_gesture_space_switch_callback)(int direction, void *context); void tb_gesture_bridge_install(tb_gesture_space_switch_callback callback, void *context); @@ -14,4 +16,8 @@ int tb_window_on_active_space(void *sdl_window); * session is active, restoring the normal window level on Stop/disconnect. */ void tb_receiver_set_monitor_shield(int active); +/* Return the native pixel dimensions of the screen that hosts the receiver + * window. This avoids treating a windowed SDL drawable as the panel size. */ +int tb_receiver_content_display_pixels(uint32_t *width, uint32_t *height); + #endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m index 7d3876ed..ea539ccd 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m @@ -1,6 +1,7 @@ #import "tb_gesture_bridge.h" #import +#import #include static NSWindow *tb_receiver_content_window(void) { @@ -19,6 +20,28 @@ return content; } +int tb_receiver_content_display_pixels(uint32_t *width, uint32_t *height) { + if (!width || !height) return -1; + *width = 0; + *height = 0; + + @autoreleasepool { + NSWindow *content = tb_receiver_content_window(); + NSScreen *screen = content.screen ?: NSScreen.mainScreen; + NSNumber *screen_number = screen.deviceDescription[@"NSScreenNumber"]; + if (!screen_number) return -1; + + CGDirectDisplayID display = (CGDirectDisplayID)screen_number.unsignedIntValue; + const size_t pixels_w = CGDisplayPixelsWide(display); + const size_t pixels_h = CGDisplayPixelsHigh(display); + if (!pixels_w || !pixels_h) return -1; + + *width = (uint32_t)pixels_w; + *height = (uint32_t)pixels_h; + return 0; + } +} + static BOOL g_monitor_shield_active = NO; static void tb_apply_monitor_shield(void) { diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_receiver_profile.c b/TargetBridge-Receiver/TBReceiverC/tests/test_receiver_profile.c new file mode 100644 index 00000000..7241d961 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_receiver_profile.c @@ -0,0 +1,61 @@ +#include "../src/receiver_profile.h" + +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(condition, message) do { \ + checks++; \ + if (!(condition)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d — %s\\n", __FILE__, __LINE__, message); \ + } \ +} while (0) + +static void test_retina_5k(void) { + struct tb_receiver_profile p; + CHECK(tb_receiver_profile_from_dimensions(2560, 1440, 5120, 2880, &p) == 0, + "5K profile builds"); + CHECK(p.hi_dpi, "5K panel is HiDPI"); + CHECK(p.mode_w == 2560 && p.mode_h == 1440, "5K uses logical mode"); + CHECK(p.capture_w == 5120 && p.capture_h == 2880, "5K capture uses native pixels"); +} + +static void test_retina_4k(void) { + struct tb_receiver_profile p; + CHECK(tb_receiver_profile_from_dimensions(2048, 1152, 4096, 2304, &p) == 0, + "4K profile builds"); + CHECK(p.hi_dpi, "4K panel is HiDPI"); + CHECK(p.mode_w == 2048 && p.mode_h == 1152, "4K uses logical mode"); +} + +static void test_non_retina_1080p(void) { + struct tb_receiver_profile p; + CHECK(tb_receiver_profile_from_dimensions(1920, 1080, 1920, 1080, &p) == 0, + "1080p profile builds"); + CHECK(!p.hi_dpi, "1080p panel is not HiDPI"); + CHECK(p.mode_w == 1920 && p.mode_h == 1080, "1080p uses native mode"); + CHECK(p.capture_w == 1920 && p.capture_h == 1080, "1080p capture uses native pixels"); +} + +static void test_invalid_panel(void) { + struct tb_receiver_profile p; + CHECK(tb_receiver_profile_from_dimensions(1920, 1080, 0, 1080, &p) < 0, + "zero panel width is rejected"); + CHECK(tb_receiver_profile_from_dimensions(1920, 1080, 1920, 1080, NULL) < 0, + "NULL output is rejected"); +} + +int main(void) { + test_retina_5k(); + test_retina_4k(); + test_non_retina_1080p(); + test_invalid_panel(); + if (!failures) { + printf("receiver profile tests: %d checks passed\n", checks); + return 0; + } + fprintf(stderr, "receiver profile tests: %d/%d checks FAILED\n", failures, checks); + return 1; +} From ee3e17b542cf706f2ce826d82043831101444f35 Mon Sep 17 00:00:00 2001 From: AndyStuardo <174057230+preggocl@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:09:18 -0400 Subject: [PATCH 21/40] fix(receiver): fall back to software video decode --- TargetBridge-Receiver/TBReceiverC/Makefile | 4 +- .../TBReceiverC/src/decoder.c | 71 ++++++++++++++----- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index a7d22e97..dd400abf 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -23,11 +23,11 @@ MACOSX_DEPLOYMENT_TARGET ?= 11.0 export MACOSX_DEPLOYMENT_TARGET CFLAGS += -O2 -g -Wall -Wextra -Wno-unused-parameter -std=c11 -CFLAGS += $(shell $(PKG) --cflags libavcodec libavutil sdl2) +CFLAGS += $(shell $(PKG) --cflags libavcodec libavutil libswscale sdl2) CFLAGS += -DTB_RECEIVER_VERSION=\"$(APP_VERSION)\" -DTB_RECEIVER_BUILD=\"$(APP_BUILD)\" CFLAGS += -DTB_LANGUAGE_SOURCE_DIR=\"$(abspath ../../TargetBridge-Shared/Languages)\" -LDFLAGS += $(shell $(PKG) --libs libavcodec libavutil sdl2) +LDFLAGS += $(shell $(PKG) --libs libavcodec libavutil libswscale sdl2) UNAME_S := $(shell uname -s) ifeq ($(UNAME_S),Darwin) diff --git a/TargetBridge-Receiver/TBReceiverC/src/decoder.c b/TargetBridge-Receiver/TBReceiverC/src/decoder.c index f320f347..dd078d0c 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/decoder.c +++ b/TargetBridge-Receiver/TBReceiverC/src/decoder.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -30,7 +31,11 @@ struct tb_decoder { AVBufferRef *hw_dev; AVFrame *hw_frame; AVFrame *sw_frame; + AVFrame *nv12_frame; AVPacket *pkt; + struct SwsContext *sws; + enum AVPixelFormat hw_pix_fmt; + int using_software_decode; uint8_t *extradata; int extradata_size; @@ -58,15 +63,26 @@ static enum AVHWDeviceType pick_hwdev(void) { #endif } -static enum AVPixelFormat hw_pix_fmt; - static enum AVPixelFormat get_hw_format(AVCodecContext *ctx, const enum AVPixelFormat *pix_fmts) { - (void)ctx; + struct tb_decoder *d = (struct tb_decoder *)ctx->opaque; for (const enum AVPixelFormat *p = pix_fmts; *p != -1; p++) { - if (*p == hw_pix_fmt) return *p; + if (d && *p == d->hw_pix_fmt) return *p; + } + + /* VideoToolbox can advertise a codec but still reject an individual + * stream on older GPUs. FFmpeg calls get_format again with software + * formats in that case; accepting one keeps the session usable. */ + if (pix_fmts && *pix_fmts != AV_PIX_FMT_NONE) { + if (d && !d->using_software_decode) { + d->using_software_decode = 1; + fprintf(stderr, "[dec] hardware decode unavailable for this stream; using software (%s)\n", + av_get_pix_fmt_name(*pix_fmts)); + } + return *pix_fmts; } - fprintf(stderr, "[dec] no HW pix fmt available\n"); + + fprintf(stderr, "[dec] no compatible pixel format available\n"); return AV_PIX_FMT_NONE; } @@ -77,8 +93,9 @@ struct tb_decoder *tb_dec_create(tb_frame_cb cb, void *ud) { d->ud = ud; d->hw_frame = av_frame_alloc(); d->sw_frame = av_frame_alloc(); + d->nv12_frame = av_frame_alloc(); d->pkt = av_packet_alloc(); - if (!d->hw_frame || !d->sw_frame || !d->pkt) { + if (!d->hw_frame || !d->sw_frame || !d->nv12_frame || !d->pkt) { tb_dec_destroy(d); return NULL; } return d; @@ -129,7 +146,9 @@ void tb_dec_destroy(struct tb_decoder *d) { if (d->hw_dev) av_buffer_unref(&d->hw_dev); if (d->hw_frame) av_frame_free(&d->hw_frame); if (d->sw_frame) av_frame_free(&d->sw_frame); + if (d->nv12_frame) av_frame_free(&d->nv12_frame); if (d->pkt) av_packet_free(&d->pkt); + if (d->sws) sws_freeContext(d->sws); av_free(d->extradata); /* allocated with av_malloc */ free(d->scratch); free(d); @@ -202,21 +221,22 @@ static int open_decoder(struct tb_decoder *d) { /* find HW pix fmt that the codec advertises */ enum AVHWDeviceType type = pick_hwdev(); - hw_pix_fmt = AV_PIX_FMT_NONE; + d->hw_pix_fmt = AV_PIX_FMT_NONE; + d->using_software_decode = 0; for (int i = 0;; i++) { const AVCodecHWConfig *cfg = avcodec_get_hw_config(d->codec, i); if (!cfg) break; if ((cfg->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) && cfg->device_type == type) { - hw_pix_fmt = cfg->pix_fmt; + d->hw_pix_fmt = cfg->pix_fmt; break; } } - if (hw_pix_fmt == AV_PIX_FMT_NONE) { + if (d->hw_pix_fmt == AV_PIX_FMT_NONE) { fprintf(stderr, "[dec] WARNING: no HW pix fmt for codec, using SW decode (slow!)\n"); } else { fprintf(stderr, "[dec] HW decode pix_fmt=%s device=%s\n", - av_get_pix_fmt_name(hw_pix_fmt), + av_get_pix_fmt_name(d->hw_pix_fmt), av_hwdevice_get_type_name(type)); } @@ -224,7 +244,7 @@ static int open_decoder(struct tb_decoder *d) { d->ctx = avcodec_alloc_context3(d->codec); if (!d->ctx) return -1; - if (hw_pix_fmt != AV_PIX_FMT_NONE) { + if (d->hw_pix_fmt != AV_PIX_FMT_NONE) { if (!d->hw_dev) { if (av_hwdevice_ctx_create(&d->hw_dev, type, NULL, NULL, 0) < 0) { fprintf(stderr, "[dec] av_hwdevice_ctx_create failed\n"); @@ -233,6 +253,7 @@ static int open_decoder(struct tb_decoder *d) { if (d->hw_dev) { d->ctx->hw_device_ctx = av_buffer_ref(d->hw_dev); d->ctx->get_format = get_hw_format; + d->ctx->opaque = d; } } @@ -345,7 +366,7 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { * access to the IOSurface-backed NV12 planes without the * GPU→staging→CPU copy that av_hwframe_transfer_data performs. * Major win on Intel iMac + Radeon (~6× faster in practice). */ - if (d->hw_frame->format == hw_pix_fmt && d->hw_frame->data[3]) { + if (d->hw_frame->format == d->hw_pix_fmt && d->hw_frame->data[3]) { CVPixelBufferRef pb = (CVPixelBufferRef)d->hw_frame->data[3]; if (CVPixelBufferLockBaseAddress(pb, kCVPixelBufferLock_ReadOnly) == 0) { uint8_t *y = (uint8_t *)CVPixelBufferGetBaseAddressOfPlane(pb, 0); @@ -364,7 +385,7 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { /* Generic path: HW transfer (Linux VAAPI / Windows D3D11VA / SW). */ AVFrame *out = d->hw_frame; - if (d->hw_frame->format == hw_pix_fmt) { + if (d->hw_frame->format == d->hw_pix_fmt) { d->sw_frame->format = AV_PIX_FMT_NV12; if (av_hwframe_transfer_data(d->sw_frame, d->hw_frame, 0) < 0) { fprintf(stderr, "[dec] hwframe_transfer failed\n"); @@ -374,13 +395,31 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { out = d->sw_frame; } - if (out->format != AV_PIX_FMT_NV12) { - fprintf(stderr, "[dec] unexpected pix fmt %d\n", out->format); - } else { + if (out->format == AV_PIX_FMT_NV12) { d->cb(out->data[0], out->linesize[0], out->data[1], out->linesize[1], out->width, out->height, d->ud); + } else { + d->sws = sws_getCachedContext(d->sws, + out->width, out->height, (enum AVPixelFormat)out->format, + out->width, out->height, AV_PIX_FMT_NV12, + SWS_BILINEAR, NULL, NULL, NULL); + av_frame_unref(d->nv12_frame); + d->nv12_frame->format = AV_PIX_FMT_NV12; + d->nv12_frame->width = out->width; + d->nv12_frame->height = out->height; + if (!d->sws || av_frame_get_buffer(d->nv12_frame, 32) < 0) { + fprintf(stderr, "[dec] could not allocate NV12 software frame\n"); + } else if (sws_scale(d->sws, (const uint8_t * const *)out->data, out->linesize, + 0, out->height, d->nv12_frame->data, d->nv12_frame->linesize) <= 0) { + fprintf(stderr, "[dec] software frame conversion failed\n"); + } else { + d->cb(d->nv12_frame->data[0], d->nv12_frame->linesize[0], + d->nv12_frame->data[1], d->nv12_frame->linesize[1], + d->nv12_frame->width, d->nv12_frame->height, d->ud); + } } + av_frame_unref(d->nv12_frame); av_frame_unref(d->sw_frame); av_frame_unref(d->hw_frame); } From 13f8fd3f251bc8dad835b218d25020fd39ff7c47 Mon Sep 17 00:00:00 2001 From: swellweb Date: Tue, 18 Aug 2026 03:08:56 +0200 Subject: [PATCH 22/40] ci: validate maintenance 3.5 patches --- .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 db10f367..f74a389f 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, 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 ] pull_request: - branches: [ main, 3.1-maint, 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 ] jobs: build-sender: From ec96fa35424276447e53f2582d9d3fa344da7a61 Mon Sep 17 00:00:00 2001 From: swellweb Date: Wed, 19 Aug 2026 02:04:07 +0200 Subject: [PATCH 23/40] chore: prepare 3.5.1 maintenance release --- README.md | 19 +++++++++---------- TargetBridge-Receiver/TBReceiverC/Makefile | 2 +- .../scripts/build_tbreceiver_c_app.sh | 2 +- .../TBDisplaySenderBuildInfo.swift | 2 +- .../TargetBridge.xcodeproj/project.pbxproj | 14 +++++++------- TargetBridge-Sender/project.yml | 4 ++-- 6 files changed, 21 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7878f5a1..d240d40c 100644 --- a/README.md +++ b/README.md @@ -27,19 +27,18 @@ Thank you to everyone who tests TargetBridge on real hardware, reports issues, and improves the project. A special thank-you for this release goes to: - [@aalpgiray](https://github.com/aalpgiray) for receiver brightness, volume, Night Shift, and True Tone controls. -- [@preggocl](https://github.com/preggocl) for the Apple Silicon and Intel Sender release build coverage. -- [@Betafer](https://github.com/Betafer) for measured connection-path selection and Sender automation improvements. +- [@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 and the low-latency Receiver Master cursor overlay. -## TargetBridge 3.5.0 +## TargetBridge 3.5.1 -TargetBridge 3.5.0 makes automated connections more reliable while giving -scripts a controlled way to choose the best available network route: +TargetBridge 3.5.1 is a maintenance release focused on safer input control, +accurate Receiver display profiles, and improved compatibility with older Macs: -- use `--path auto` to measure compatible Thunderbolt, USB/USB4, Ethernet, and Wi-Fi paths and choose the fastest one -- force a preferred route with `--path wired`, `thunderbolt`, `usb`, `ethernet`, or `wifi` -- run a Sender connection in monitor mode with `--retry`, including safe recovery after a temporary Receiver or cable interruption -- an explicit Stop or Quit cancels automatic retry, preventing unwanted reconnects -- keep duplicate-desktop startup resilient when macOS delays publishing a ScreenCaptureKit display +- preserve keyboard and mouse release events when the Receiver input queue is under pressure +- use the low-latency cursor overlay in Receiver Master mode to avoid duplicate cursors +- advertise the Receiver's actual panel geometry instead of always reporting a fixed 5K profile +- fall back safely to software video decode when VideoToolbox cannot decode a stream on older hardware It also keeps the established multi-Mac workspace features: diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 85c68fd3..26ca2075 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -16,7 +16,7 @@ CC ?= clang PKG ?= pkgconf -APP_VERSION ?= 3.5.0 +APP_VERSION ?= 3.5.1 APP_BUILD ?= dev MACOSX_DEPLOYMENT_TARGET ?= 11.0 diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index 8c745a87..798c89bc 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="3.5.0" +APP_VERSION="3.5.1" STAMP="$(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 88e2b002..60401058 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.5.0" + static let marketingVersion = "3.5.1" static let buildNumber = "20260810141154" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index b081f2a1..6f8a79ab 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -27,9 +27,9 @@ 5C0CB53005B6D67362F14719 /* TBDisplaySenderContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */; }; 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */; }; 6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */; }; + 6DB5F29F60382A0CA97568F3 /* TBMenuMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08C140ECFEDAE11AD68D12B /* TBMenuMetrics.swift */; }; 7528751850893B52660870E4 /* TBConfigurationDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7086193EE5710E2C37F86F1 /* TBConfigurationDiagnosticsTests.swift */; }; 762884127C8093D3E9797790 /* TBMenuToggleRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */; }; - C7A1D4E29B3F45A88C3D0052 /* TBMenuMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A1D4E29B3F45A88C3D0051 /* TBMenuMetrics.swift */; }; 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */; }; 7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; }; 7FB34AA1E75B01E7D1EC3E21 /* it.json in Resources */ = {isa = PBXBuildFile; fileRef = 036E14097FA59989FFC456FD /* it.json */; }; @@ -112,7 +112,7 @@ DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TBDisplaySenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBSenderAutomationParsingTests.swift; sourceTree = ""; }; EF17070AC1C6F9E9F3B4DFDF /* TBInjectedClickStateTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedClickStateTrackerTests.swift; sourceTree = ""; }; - C7A1D4E29B3F45A88C3D0051 /* TBMenuMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuMetrics.swift; sourceTree = ""; }; + F08C140ECFEDAE11AD68D12B /* TBMenuMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuMetrics.swift; sourceTree = ""; }; FD6EB41D253E67E350F5E25D /* TBConnectionDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConnectionDiagnostics.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -148,8 +148,8 @@ 4A72C42968FA2A6B2A0515B9 /* TBInputBinding.swift */, 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */, 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */, + F08C140ECFEDAE11AD68D12B /* TBMenuMetrics.swift */, 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */, - C7A1D4E29B3F45A88C3D0051 /* TBMenuMetrics.swift */, 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */, 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */, ); @@ -352,7 +352,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"$(date +%Y%m%d%H%M%S)\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" < Date: Wed, 19 Aug 2026 02:20:50 +0200 Subject: [PATCH 24/40] fix(input): keep receiver master cursor normal by default --- .../TBReceiverC/src/display.c | 9 ++++++-- .../TBReceiverC/src/display.h | 3 ++- TargetBridge-Receiver/TBReceiverC/src/main.c | 6 ++++-- .../TBDisplaySenderService.swift | 9 +++++--- .../TBMonitorProtocolTests.swift | 21 +++++++++++++++++++ .../TBDisplayShared/TBMonitorProtocol.swift | 1 + 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index 5bb0721e..80bdf1d8 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -49,6 +49,7 @@ struct tb_display { int cursor_source_w, cursor_source_h; int cursor_visible; int cursor_type; + int cursor_large; uint32_t last_video_frame_time; int system_cursor_hidden; @@ -743,7 +744,9 @@ static void tb_disp_draw_cursor(struct tb_display *d) { const double sy = (double)out_h / (double)d->cursor_source_h; const int x = (int)((double)d->cursor_x * sx); const int y = (int)((double)d->cursor_y * sy); - const int size = out_w >= 5000 ? 58 : 44; + const int size = d->cursor_large + ? (out_w >= 5000 ? 58 : 44) + : (out_w >= 5000 ? 32 : 24); SDL_BlendMode old_blend = SDL_BLENDMODE_NONE; (void)SDL_GetRenderDrawBlendMode(d->ren, &old_blend); @@ -1140,7 +1143,8 @@ void tb_disp_set_cursor(struct tb_display *d, int x, int y, int source_w, int source_h, int visible, - int type) { + int type, + int large) { if (!d) return; d->cursor_x = x; d->cursor_y = y; @@ -1148,6 +1152,7 @@ void tb_disp_set_cursor(struct tb_display *d, d->cursor_source_h = source_h > 0 ? source_h : 1; d->cursor_visible = visible; d->cursor_type = type; + d->cursor_large = large; uint32_t now = SDL_GetTicks(); if (now - d->last_video_frame_time > 40) { diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.h b/TargetBridge-Receiver/TBReceiverC/src/display.h index 619a2348..901a1e01 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.h +++ b/TargetBridge-Receiver/TBReceiverC/src/display.h @@ -53,7 +53,8 @@ void tb_disp_set_cursor(struct tb_display *d, int x, int y, int source_w, int source_h, int visible, - int type); + int type, + int large); void tb_disp_set_brightness(struct tb_display *d, double level); diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index de2503b7..f3344137 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -1111,13 +1111,15 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud int h = 0; int visible = 0; int type = 0; + int large = 0; (void)extract_json_int_field(payload, len, "\"x\"", &x); (void)extract_json_int_field(payload, len, "\"y\"", &y); (void)extract_json_int_field(payload, len, "\"width\"", &w); (void)extract_json_int_field(payload, len, "\"height\"", &h); (void)extract_json_bool_field(payload, len, "\"visible\"", &visible); (void)extract_json_int_field(payload, len, "\"type\"", &type); - tb_disp_set_cursor(a->disp, x, y, w, h, visible, type); + (void)extract_json_bool_field(payload, len, "\"large\"", &large); + tb_disp_set_cursor(a->disp, x, y, w, h, visible, type, large); } break; case TB_PKT_BRIGHTNESS: @@ -1805,7 +1807,7 @@ static void close_client(struct app *a) { SDL_EnableScreenSaver(); tb_receiver_refresh_input_capture(a); tb_disp_set_connection_state(a->disp, 0); - tb_disp_set_cursor(a->disp, 0, 0, 1, 1, 0, 0); + tb_disp_set_cursor(a->disp, 0, 0, 1, 1, 0, 0, 0); tb_refresh_idle_localized_strings(a); a->last_clipboard_text[0] = '\0'; tb_parser_free(&a->parser); diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 4f5b033e..6de74124 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -3085,7 +3085,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u width: capturePreset.width, height: capturePreset.height, visible: false, - type: 0 + type: 0, + large: false ) lastCursorPacket = cursor if let packet = TBMonitorProtocol.makeJSONPacket(type: .cursor, value: cursor) { @@ -3169,7 +3170,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u width: capturePreset.width, height: capturePreset.height, visible: visible, - type: getCurrentCursorType() + type: getCurrentCursorType(), + large: largeCursor ) if !force, let previous = lastCursorPacket { @@ -3178,7 +3180,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u previous.visible == cursor.visible, previous.width == cursor.width, previous.height == cursor.height, - previous.type == cursor.type { + previous.type == cursor.type, + previous.large == cursor.large { return } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift index 4dd88466..011d57a3 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -207,6 +207,27 @@ final class TBMonitorProtocolTests: XCTestCase { XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload)?.sequence, 42) } + func testCursorPayloadPreservesLargeCursorPreference() throws { + let cursor = TBMonitorCursor( + x: 120, + y: 80, + width: 2560, + height: 1440, + visible: true, + type: 0, + large: false + ) + guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .cursor, value: cursor), + let (type, payload) = try TBMonitorProtocol.drainPacket(from: &buffer) + else { + XCTFail("cursor did not encode and drain") + return + } + + XCTAssertEqual(type, .cursor) + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorCursor.self, from: payload)?.large, false) + } + func testInputButtonEventRoundTripPreservesClickCount() throws { let buttonEvent = TBMonitorInputButtonEvent(kind: "leftDown", clickCount: 2) guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .inputEvent, value: buttonEvent), diff --git a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift index 6b07928f..78dce26c 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift @@ -76,6 +76,7 @@ struct TBMonitorCursor: Codable { var height: Int var visible: Bool var type: Int + var large: Bool } struct TBMonitorInputEvent: Codable { From c842ade0ee647c624f5dc17c221e034e541391be Mon Sep 17 00:00:00 2001 From: swellweb Date: Wed, 19 Aug 2026 09:53:29 +0200 Subject: [PATCH 25/40] fix(display): preserve native 5K profile and cursor shapes --- .../TBReceiverC/src/tb_gesture_bridge.m | 10 ++++++++-- .../TBDisplaySender/TBDisplaySenderService.swift | 5 ++++- .../TBSenderAutomationParsingTests.swift | 8 ++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m index ea539ccd..319d696c 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m @@ -32,8 +32,14 @@ int tb_receiver_content_display_pixels(uint32_t *width, uint32_t *height) { if (!screen_number) return -1; CGDirectDisplayID display = (CGDirectDisplayID)screen_number.unsignedIntValue; - const size_t pixels_w = CGDisplayPixelsWide(display); - const size_t pixels_h = CGDisplayPixelsHigh(display); + CGDisplayModeRef mode = CGDisplayCopyDisplayMode(display); + const size_t pixels_w = mode + ? CGDisplayModeGetPixelWidth(mode) + : CGDisplayPixelsWide(display); + const size_t pixels_h = mode + ? CGDisplayModeGetPixelHeight(mode) + : CGDisplayPixelsHigh(display); + if (mode) CFRelease(mode); if (!pixels_w || !pixels_h) return -1; *width = (uint32_t)pixels_w; diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 6de74124..44793461 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -352,7 +352,10 @@ enum TBInputControlRole: String, CaseIterable, Identifiable { var id: String { rawValue } func usesLowLatencyCursorOverlay(largeCursorEnabled: Bool) -> Bool { - largeCursorEnabled || self == .receiverMaster + // ScreenCaptureKit preserves every native macOS cursor shape, including + // temporary system cursors such as the screenshot crosshair. The custom + // overlay is reserved for the explicit large-cursor accessibility option. + largeCursorEnabled } func changesCursorCaptureMode( diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index 6375d053..36d8c9c2 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -8,8 +8,8 @@ import XCTest /// silently reroute automation traffic. @MainActor final class TBSenderAutomationParsingTests: XCTestCase { - func testReceiverControlUsesSeparateCursorOverlayWithoutLargeCursor() { - XCTAssertTrue( + func testReceiverControlKeepsNativeCursorWithoutLargeCursor() { + XCTAssertFalse( TBInputControlRole.receiverMaster.usesLowLatencyCursorOverlay( largeCursorEnabled: false ) @@ -30,13 +30,13 @@ final class TBSenderAutomationParsingTests: XCTestCase { ) ) - XCTAssertTrue( + XCTAssertFalse( TBInputControlRole.receiverMaster.changesCursorCaptureMode( from: .off, largeCursorEnabled: false ) ) - XCTAssertTrue( + XCTAssertFalse( TBInputControlRole.off.changesCursorCaptureMode( from: .receiverMaster, largeCursorEnabled: false From b761ee6cb536d6c2e32826676b05125eaff0f8eb Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 19 Aug 2026 14:58:48 +0200 Subject: [PATCH 26/40] feat: expose privacy permission probes --- TargetBridge-Receiver/TBReceiverC/src/main.c | 8 +++++++ .../TBDisplaySender/TBDisplaySenderApp.swift | 22 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index f3344137..4d23345c 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -1855,6 +1855,14 @@ static void build_display_host(char *buf, size_t bufsz, const char *ip_fallback, int main(int argc, char **argv) { int fullscreen = 1; for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--permission-status") == 0) { + printf( + "TB_PERMISSION_STATUS:screen_recording=0:accessibility=%d:input_monitoring=%d\n", + tb_receiver_accessibility_trusted(), + tb_receiver_input_monitoring_trusted() + ); + return 0; + } if (strcmp(argv[i], "--windowed") == 0) fullscreen = 0; } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift index 5457c13a..a96a66a4 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift @@ -1,13 +1,31 @@ import AppKit +import ApplicationServices +import Darwin import SwiftUI @main struct TBDisplaySenderApp: App { - @StateObject private var service = TBDisplaySenderService.shared - private let statusItemController = TBDisplaySenderStatusItemController(service: TBDisplaySenderService.shared) + @StateObject private var service: TBDisplaySenderService + private let statusItemController: TBDisplaySenderStatusItemController @MainActor init() { + // This probe is intentionally handled before constructing the shared + // service. The Setup Assistant can inspect the exact installed binary + // without opening a window, starting capture or attaching a display. + if CommandLine.arguments.contains("--permission-status") { + let screenRecording = CGPreflightScreenCaptureAccess() ? 1 : 0 + let accessibility = AXIsProcessTrusted() ? 1 : 0 + let inputMonitoring = CGPreflightListenEventAccess() ? 1 : 0 + print("TB_PERMISSION_STATUS:screen_recording=\(screenRecording):accessibility=\(accessibility):input_monitoring=\(inputMonitoring)") + fflush(stdout) + Darwin.exit(EXIT_SUCCESS) + } + + let sharedService = TBDisplaySenderService.shared + _service = StateObject(wrappedValue: sharedService) + statusItemController = TBDisplaySenderStatusItemController(service: sharedService) + // LaunchAgent automation must not depend on SwiftUI restoring or showing // the main window on a headless Mac. TBSenderAutomation.handleLaunchArguments(CommandLine.arguments) From 42eee3b5c05ae6fb067583ea4e35d735c18c8823 Mon Sep 17 00:00:00 2001 From: Marco Caciotti Date: Sat, 29 Aug 2026 17:11:15 +0200 Subject: [PATCH 27/40] fix(build): package SDL runtimes safely (#189) --- .../scripts/build_tbreceiver_c_app.sh | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index 798c89bc..7cd04d09 100755 --- a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh +++ b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh @@ -28,9 +28,10 @@ chmod +x "$APP_DIR/Contents/MacOS/$BIN_NAME" cp "$REPO_ROOT/TargetBridge-Shared/Languages/"*.json "$APP_DIR/Contents/Resources/Languages/" # Bundle dylib dependencies (ffmpeg and SDL) inside the .app. -# Homebrew's SDL2 is currently sdl2-compat, which loads SDL3 at runtime via -# dlopen. dylibbundler cannot discover that dynamic dependency, so copy it -# explicitly to keep the released receiver self-contained. +# Some Homebrew installations provide sdl2-compat, which loads SDL3 at runtime +# through dlopen. dylibbundler cannot discover that dependency, while genuine +# SDL2 does not need SDL3 at all. Inspect the bundled SDL2 artifact instead of +# assuming which formula Homebrew resolved on the build Mac. mkdir -p "$APP_DIR/Contents/Frameworks" if ! command -v dylibbundler &>/dev/null; then echo "Installing dylibbundler..." @@ -42,12 +43,21 @@ dylibbundler -od -b \ -p @executable_path/../Frameworks/ \ >/dev/null 2>&1 -SDL3_DYLIB="$(brew --prefix sdl3)/lib/libSDL3.dylib" -if [[ ! -f "$SDL3_DYLIB" ]]; then - echo "SDL3 runtime library not found: $SDL3_DYLIB" >&2 - exit 1 +SDL2_IN_APP="$(find "$APP_DIR/Contents/Frameworks" -type f -name 'libSDL2*.dylib' -print -quit)" +if [[ -n "$SDL2_IN_APP" ]] && strings -a "$SDL2_IN_APP" | grep -q 'libSDL3'; then + SDL3_PREFIX="$(brew --prefix sdl3 2>/dev/null || true)" + SDL3_DYLIB="${SDL3_PREFIX}/lib/libSDL3.dylib" + if [[ -z "$SDL3_PREFIX" || ! -f "$SDL3_DYLIB" ]]; then + echo "SDL3 runtime library is required by bundled sdl2-compat but was not found" >&2 + exit 1 + fi + cp -L "$SDL3_DYLIB" "$APP_DIR/Contents/Frameworks/libSDL3.dylib" + install_name_tool \ + -id "@executable_path/../Frameworks/libSDL3.dylib" \ + "$APP_DIR/Contents/Frameworks/libSDL3.dylib" +else + echo "Bundled SDL2 is native; SDL3 runtime is not required." fi -cp -L "$SDL3_DYLIB" "$APP_DIR/Contents/Frameworks/libSDL3.dylib" if [[ -f "$ICON_FILE" ]]; then mkdir -p "${ICONSET_DIR}/TargetBridgeReceiver.iconset" @@ -103,6 +113,24 @@ 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 + +# A released bundle must never depend on the Homebrew installation used to +# compile it. Check the executable and every bundled dynamic library after +# dylibbundler and the optional SDL3 copy have completed. +MACHO_FILES=( + "$APP_DIR/Contents/MacOS/$BIN_NAME" + "$APP_DIR/Contents/Frameworks/"*.dylib(N) +) +EXTERNAL_HOMEBREW_REFS="$( + otool -L "${MACHO_FILES[@]}" 2>/dev/null | + awk '$1 ~ /^\/(usr\/local|opt\/homebrew)\// { print }' +)" +if [[ -n "$EXTERNAL_HOMEBREW_REFS" ]]; then + echo "Receiver bundle still contains external Homebrew references:" >&2 + echo "$EXTERNAL_HOMEBREW_REFS" >&2 + exit 1 +fi + xattr -cr "$APP_DIR" >/dev/null 2>&1 || true rm -rf "$ICONSET_DIR" From 762011383bfcb2fe252c5b4ca82d6f58f2721633 Mon Sep 17 00:00:00 2001 From: Betafer Date: Fri, 21 Aug 2026 21:54:12 +0200 Subject: [PATCH 28/40] fix(receiver): allow display sleep before first session (cherry picked from commit 5212fb88b87f491c9758adc1c9cdc87577bdb203) --- TargetBridge-Receiver/TBReceiverC/src/main.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index f3344137..65a331a4 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -1931,6 +1931,12 @@ int main(int argc, char **argv) { a.disp = tb_disp_create(fullscreen); if (!a.disp) { fprintf(stderr, "tb_disp_create failed\n"); return 1; } + /* SDL starts with screen-saver inhibition enabled. A Receiver that has not + * accepted a client yet never reaches close_client(), so release the + * assertion immediately while it is waiting for its first session. */ + SDL_EnableScreenSaver(); + fprintf(stderr, "[main] display sleep enabled while receiver is idle\n"); + /* Open SDL Audio Device */ SDL_AudioSpec spec; SDL_zero(spec); From 563d0f454424f31cd2ca3f1f96cffb735820e4e2 Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 19 Aug 2026 08:10:11 +0200 Subject: [PATCH 29/40] fix(receiver): release audio device while idle (cherry picked from commit ea95d92add4b7aaebfc3b3de074b9cefc0bf211e) --- TargetBridge-Receiver/TBReceiverC/src/main.c | 96 ++++++++++++++------ 1 file changed, 67 insertions(+), 29 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index 65a331a4..bb6ad3b8 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -108,6 +108,7 @@ struct app { int input_tap_consumes_events; SDL_AudioDeviceID audio_device; + uint64_t audio_retry_after_ms; uint8_t audio_buf[AUDIO_BUF_CAP]; int audio_buf_head; @@ -979,6 +980,62 @@ static void audio_callback(void *userdata, Uint8 *stream, int len) { } } +/* Keep the output device closed while Receiver is waiting for a Sender. On + * macOS an open SDL output device is enough for coreaudiod to assert + * PreventUserIdleSystemSleep, even when the callback only produces silence. + * Opening lazily also means sessions with audio disabled never touch the + * system output device. */ +static int tb_audio_open_for_frame(struct app *a) { + if (!a) return 0; + if (a->audio_device != 0) return 1; + + uint64_t now = now_ms(); + if (now < a->audio_retry_after_ms) return 0; + + SDL_AudioSpec spec; + SDL_zero(spec); + spec.freq = 48000; + spec.format = AUDIO_S16LSB; + spec.channels = 2; + spec.samples = 1024; + spec.callback = audio_callback; + spec.userdata = a; + + SDL_AudioSpec obtained; + SDL_zero(obtained); + a->audio_device = SDL_OpenAudioDevice(NULL, 0, &spec, &obtained, 0); + if (a->audio_device == 0) { + /* Audio packets arrive frequently. Avoid retrying (and logging) for + * every packet if the output device is temporarily unavailable. */ + a->audio_retry_after_ms = now + 5000; + fprintf(stderr, "[main] warning: SDL_OpenAudioDevice failed: %s; retrying in 5s\n", + SDL_GetError()); + return 0; + } + + a->audio_retry_after_ms = 0; + fprintf(stderr, + "[main] SDL audio device opened on first audio frame: " + "48000Hz stereo 16-bit PCM (obtained %d samples)\n", + obtained.samples); + return 1; +} + +static void tb_audio_close_for_idle(struct app *a) { + if (!a) return; + + if (a->audio_device != 0) { + SDL_CloseAudioDevice(a->audio_device); + a->audio_device = 0; + fprintf(stderr, "[main] SDL audio device closed for idle Receiver\n"); + } + + a->audio_buf_head = 0; + a->audio_buf_tail = 0; + a->audio_buf_size = 0; + a->audio_retry_after_ms = 0; +} + /* Drive the receiver's master output volume knob (with the system volume HUD). * level is clamped to 0.0..1.0. Sets the default output device's scalar volume, * preferring the master element and falling back to per-channel when a device @@ -1157,7 +1214,10 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud } break; case TB_PKT_AUDIO_FRAME: - if (a->audio_device != 0) { + { + int opened_for_frame = a->audio_device == 0; + if (!tb_audio_open_for_frame(a)) break; + SDL_LockAudioDevice(a->audio_device); // Limit audio backlog to 150ms (150 * 192 = 28800 bytes) to cushion @@ -1183,6 +1243,10 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud } SDL_UnlockAudioDevice(a->audio_device); + + /* SDL opens devices paused. Queue the first packet before + * starting playback so the callback does not begin with silence. */ + if (opened_for_frame) SDL_PauseAudioDevice(a->audio_device, 0); } break; case TB_PKT_INPUT_EVENT: @@ -1813,13 +1877,7 @@ static void close_client(struct app *a) { tb_parser_free(&a->parser); tb_parser_init(&a->parser, on_packet, a); tb_dec_reset(a->dec); /* fresh decoder for next session */ - if (a->audio_device != 0) { - SDL_LockAudioDevice(a->audio_device); - a->audio_buf_head = 0; - a->audio_buf_tail = 0; - a->audio_buf_size = 0; - SDL_UnlockAudioDevice(a->audio_device); - } + tb_audio_close_for_idle(a); fprintf(stderr, "[main] client disconnected\n"); } @@ -1937,24 +1995,6 @@ int main(int argc, char **argv) { SDL_EnableScreenSaver(); fprintf(stderr, "[main] display sleep enabled while receiver is idle\n"); - /* Open SDL Audio Device */ - SDL_AudioSpec spec; - SDL_zero(spec); - spec.freq = 48000; - spec.format = AUDIO_S16LSB; // 16-bit signed, little-endian PCM - spec.channels = 2; // Stereo - spec.samples = 1024; // Buffer size (approx 21.3ms) - spec.callback = audio_callback; - spec.userdata = &a; - SDL_AudioSpec obtained; - a.audio_device = SDL_OpenAudioDevice(NULL, 0, &spec, &obtained, 0); - if (a.audio_device != 0) { - SDL_PauseAudioDevice(a.audio_device, 0); // Start playing (unpaused) - fprintf(stderr, "[main] SDL audio device opened: 48000Hz stereo 16-bit PCM (obtained %d samples)\n", obtained.samples); - } else { - fprintf(stderr, "[main] warning: SDL_OpenAudioDevice failed: %s\n", SDL_GetError()); - } - struct tb_display_info boot_info; if (tb_disp_get_info(a.disp, &boot_info) == 0) { snprintf(a.panel_text, sizeof(a.panel_text), "%u x %u px (%s)", @@ -2187,9 +2227,7 @@ int main(int argc, char **argv) { bonjour_deinit(&a); tb_parser_free(&a.parser); tb_dec_destroy(a.dec); - if (a.audio_device != 0) { - SDL_CloseAudioDevice(a.audio_device); - } + tb_audio_close_for_idle(&a); tb_disp_destroy(a.disp); fprintf(stderr, "[main] bye\n"); return 0; From 60555c32a86aaf0f803a731a4d0653f86a8e6bba Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 19 Aug 2026 08:12:53 +0200 Subject: [PATCH 30/40] fix(receiver): guard idle watchdog clock underflow (cherry picked from commit 1e16d4c93ab96623e0b39cc80f40e7a12f6ccc36) --- TargetBridge-Receiver/TBReceiverC/Makefile | 11 +++-- .../TBReceiverC/src/idle_watchdog.c | 19 ++++++++ .../TBReceiverC/src/idle_watchdog.h | 20 +++++++++ TargetBridge-Receiver/TBReceiverC/src/main.c | 37 +++++++++++----- .../TBReceiverC/tests/test_idle_watchdog.c | 44 +++++++++++++++++++ 5 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.c create mode 100644 TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.h create mode 100644 TargetBridge-Receiver/TBReceiverC/tests/test_idle_watchdog.c diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 26ca2075..4eca060e 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -34,7 +34,7 @@ ifeq ($(UNAME_S),Darwin) LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework IOSurface -framework CoreServices -framework CoreAudio -framework SystemConfiguration endif -C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/input_queue.c src/receiver_profile.c src/tb_i18n.c +C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/input_queue.c src/idle_watchdog.c src/receiver_profile.c src/tb_i18n.c OBJC_SRC = src/tb_gesture_bridge.m src/tb_display_tweaks.m OBJ = $(C_SRC:.c=.o) $(OBJC_SRC:.m=.o) BIN = tbreceiver @@ -51,7 +51,7 @@ $(BIN): $(OBJ) $(CC) $(CFLAGS) -fobjc-arc -c -o $@ $< clean: - rm -f $(OBJ) $(BIN) $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_RECEIVER_PROFILE_BIN) + rm -f $(OBJ) $(BIN) $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_IDLE_WATCHDOG_BIN) $(TEST_RECEIVER_PROFILE_BIN) # Unit tests for the packet parser. net.c is pure POSIX, so this needs no # ffmpeg/SDL/pkgconf — it runs anywhere, including CI, with no hardware. @@ -62,6 +62,7 @@ TEST_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration endif TEST_BIN = test_net_parser TEST_INPUT_QUEUE_BIN = test_input_queue +TEST_IDLE_WATCHDOG_BIN = test_idle_watchdog TEST_RECEIVER_PROFILE_BIN = test_receiver_profile test_net_parser: tests/test_net_parser.c src/net.c src/net.h src/proto.h @@ -70,12 +71,16 @@ test_net_parser: tests/test_net_parser.c src/net.c src/net.h src/proto.h $(TEST_INPUT_QUEUE_BIN): tests/test_input_queue.c src/input_queue.c src/input_queue.h $(CC) $(TEST_CFLAGS) -Isrc tests/test_input_queue.c src/input_queue.c -o $@ +$(TEST_IDLE_WATCHDOG_BIN): tests/test_idle_watchdog.c src/idle_watchdog.c src/idle_watchdog.h + $(CC) $(TEST_CFLAGS) -Isrc tests/test_idle_watchdog.c src/idle_watchdog.c -o $@ + $(TEST_RECEIVER_PROFILE_BIN): tests/test_receiver_profile.c src/receiver_profile.c src/receiver_profile.h $(CC) $(TEST_CFLAGS) -Isrc tests/test_receiver_profile.c src/receiver_profile.c -o $@ -test: $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_RECEIVER_PROFILE_BIN) +test: $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_IDLE_WATCHDOG_BIN) $(TEST_RECEIVER_PROFILE_BIN) ./$(TEST_BIN) ./$(TEST_INPUT_QUEUE_BIN) + ./$(TEST_IDLE_WATCHDOG_BIN) ./$(TEST_RECEIVER_PROFILE_BIN) .PHONY: all clean test diff --git a/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.c b/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.c new file mode 100644 index 00000000..04c24d6e --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.c @@ -0,0 +1,19 @@ +#include "idle_watchdog.h" + +enum tb_idle_watchdog_result tb_idle_watchdog_check(uint64_t now_ms, + uint64_t *last_activity_ms, + uint64_t timeout_ms, + uint64_t *elapsed_ms) { + if (!last_activity_ms || !elapsed_ms) return TB_IDLE_WATCHDOG_OK; + + if (now_ms < *last_activity_ms) { + *last_activity_ms = now_ms; + *elapsed_ms = 0; + return TB_IDLE_WATCHDOG_CLOCK_REWOUND; + } + + *elapsed_ms = now_ms - *last_activity_ms; + return *elapsed_ms >= timeout_ms + ? TB_IDLE_WATCHDOG_EXPIRED + : TB_IDLE_WATCHDOG_OK; +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.h b/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.h new file mode 100644 index 00000000..3597316b --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.h @@ -0,0 +1,20 @@ +#ifndef TB_IDLE_WATCHDOG_H +#define TB_IDLE_WATCHDOG_H + +#include + +enum tb_idle_watchdog_result { + TB_IDLE_WATCHDOG_OK = 0, + TB_IDLE_WATCHDOG_EXPIRED, + TB_IDLE_WATCHDOG_CLOCK_REWOUND +}; + +/* Compare timestamps without allowing an unsigned subtraction to turn a + * small backward jump into an immediate timeout. A backward jump rebases the + * last-activity timestamp so a genuinely stale connection can still expire. */ +enum tb_idle_watchdog_result tb_idle_watchdog_check(uint64_t now_ms, + uint64_t *last_activity_ms, + uint64_t timeout_ms, + uint64_t *elapsed_ms); + +#endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index bb6ad3b8..2bdb0446 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -16,6 +16,7 @@ #include "net.h" #include "decoder.h" #include "display.h" +#include "idle_watchdog.h" #include "receiver_profile.h" #include "proto.h" #include "tb_gesture_bridge.h" @@ -2100,19 +2101,33 @@ int main(int argc, char **argv) { close_client(&a); } else { socket_activity = drain_result; - if (drain_result > 0) a.last_recv_ms = t; + if (drain_result > 0) a.last_recv_ms = now_ms(); if (a.close_requested) { close_client(&a); - } else if (t - a.last_recv_ms >= TB_SENDER_IDLE_TIMEOUT_MS) { - /* The sender streams frames continuously and heartbeats - * every 2s. Total silence means it died without a FIN - * (crash, pulled cable, force sleep). Without this reap, - * the dead fd is held forever and — because the receiver - * is single-client — every future connect is locked out - * until the app is restarted. */ - fprintf(stderr, "[main] no data from sender for %llu ms; closing stale session\n", - (unsigned long long)(t - a.last_recv_ms)); - close_client(&a); + } else { + const uint64_t watchdog_now_ms = now_ms(); + uint64_t idle_ms = 0; + enum tb_idle_watchdog_result watchdog_result = + tb_idle_watchdog_check(watchdog_now_ms, + &a.last_recv_ms, + TB_SENDER_IDLE_TIMEOUT_MS, + &idle_ms); + if (watchdog_result == TB_IDLE_WATCHDOG_CLOCK_REWOUND) { + fprintf(stderr, + "[main] monotonic clock moved backwards; " + "rebasing sender idle watchdog\n"); + } else if (watchdog_result == TB_IDLE_WATCHDOG_EXPIRED) { + /* The sender streams frames continuously and heartbeats + * every 2s. Total silence means it died without a FIN + * (crash, pulled cable, force sleep). Without this reap, + * the dead fd is held forever and — because the receiver + * is single-client — every future connect is locked out + * until the app is restarted. */ + fprintf(stderr, + "[main] no data from sender for %llu ms; closing stale session\n", + (unsigned long long)idle_ms); + close_client(&a); + } } } } diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_idle_watchdog.c b/TargetBridge-Receiver/TBReceiverC/tests/test_idle_watchdog.c new file mode 100644 index 00000000..3defc418 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_idle_watchdog.c @@ -0,0 +1,44 @@ +#include "idle_watchdog.h" + +#include +#include + +static int checks; +static int failures; + +#define CHECK(condition) do { \ + checks++; \ + if (!(condition)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + } \ +} while (0) + +int main(void) { + uint64_t last = 1000; + uint64_t elapsed = UINT64_MAX; + + CHECK(tb_idle_watchdog_check(10999, &last, 10000, &elapsed) == TB_IDLE_WATCHDOG_OK); + CHECK(elapsed == 9999); + CHECK(last == 1000); + + CHECK(tb_idle_watchdog_check(11000, &last, 10000, &elapsed) == TB_IDLE_WATCHDOG_EXPIRED); + CHECK(elapsed == 10000); + + /* Regression for issue #156: 52 ms backwards must not become + * UINT64_MAX - 51 and close an active session. */ + last = 5000; + elapsed = UINT64_MAX; + CHECK(tb_idle_watchdog_check(4948, &last, 10000, &elapsed) == + TB_IDLE_WATCHDOG_CLOCK_REWOUND); + CHECK(elapsed == 0); + CHECK(last == 4948); + + CHECK(tb_idle_watchdog_check(14947, &last, 10000, &elapsed) == TB_IDLE_WATCHDOG_OK); + CHECK(elapsed == 9999); + CHECK(tb_idle_watchdog_check(14948, &last, 10000, &elapsed) == TB_IDLE_WATCHDOG_EXPIRED); + CHECK(elapsed == 10000); + + printf("idle watchdog tests: %d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} From 53cfc7ec052ac3af7171b752bbc3f41ca4f5de8d Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 19 Aug 2026 13:50:04 +0200 Subject: [PATCH 31/40] build: support reproducible app variants (cherry picked from commit b526d097e171b36e959dd420345efb71e90ba54e) --- TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh | 4 ++-- TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj | 2 +- TargetBridge-Sender/project.yml | 2 +- TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index 7cd04d09..a14975ad 100755 --- a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh +++ b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh @@ -8,8 +8,8 @@ BUILD_DIR="${REPO_ROOT}/build" APP_DIR="${BUILD_DIR}/TargetBridge Receiver.app" BIN_NAME="TargetBridgeReceiver" APP_NAME="TargetBridge Receiver" -APP_VERSION="3.5.1" -STAMP="$(date +%Y%m%d%H%M%S)" +APP_VERSION="${APP_VERSION:-3.5.1}" +STAMP="${APP_BUILD:-$(date +%Y%m%d%H%M%S)}" ARCH="$(uname -m)" ICONSET_DIR="$(mktemp -d)" ICON_FILE="${ROOT}/TargetBridgeAssets/Assets.xcassets/AppIcon.appiconset/icon_1024.png" diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 6f8a79ab..93239001 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -352,7 +352,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"$(date +%Y%m%d%H%M%S)\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" < Date: Fri, 21 Aug 2026 22:42:40 +0200 Subject: [PATCH 32/40] fix(receiver): keep local login alerts behind monitor (cherry picked from commit a1e551946918ae7c0d8b66d570e22d114cfcbcee) --- .../TBReceiverC/src/tb_gesture_bridge.m | 107 ++++++++++++++++-- 1 file changed, 95 insertions(+), 12 deletions(-) diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m index 319d696c..44fbc6e9 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m @@ -49,20 +49,96 @@ int tb_receiver_content_display_pixels(uint32_t *width, uint32_t *height) { } static BOOL g_monitor_shield_active = NO; +static uint64_t g_monitor_shield_generation = 0; +static NSMutableArray *g_monitor_shield_observers = nil; + +static NSWindowLevel tb_monitor_shield_level(void) { + NSWindowLevel status_level = + (NSWindowLevel)CGWindowLevelForKey(kCGStatusWindowLevelKey); + NSWindowLevel popup_level = + (NSWindowLevel)CGWindowLevelForKey(kCGPopUpMenuWindowLevelKey); + + /* Notification Center banners use the status-window level. A banner that + * arrives after the Receiver can therefore cover it when both windows + * share that level. Use the first free level above status windows, while + * deliberately staying below pop-up menus and the screen saver/lock UI. */ + return status_level + 1 < popup_level ? status_level + 1 : status_level; +} static void tb_apply_monitor_shield(void) { NSWindow *content = tb_receiver_content_window(); if (!content) return; - /* The public status-window level keeps ordinary local banners and the menu - * bar behind the active monitor surface without using invasive private or - * screen-saver window levels. */ + /* Keep local banners and the menu bar behind the active monitor surface + * without using invasive private or screen-saver window levels. */ NSWindowLevel desired = g_monitor_shield_active - ? (NSWindowLevel)CGWindowLevelForKey(kCGStatusWindowLevelKey) + ? tb_monitor_shield_level() : NSNormalWindowLevel; - if (content.level != desired) { + BOOL level_changed = content.level != desired; + if (level_changed) { content.level = desired; } + if (g_monitor_shield_active && level_changed) { + /* Do not make the Receiver key, but keep it first within its level if + * macOS or SDL has reordered windows during a fullscreen transition. */ + [content orderFrontRegardless]; + } +} + +static void tb_schedule_monitor_shield_reapply(uint64_t generation, + int64_t delay_ms) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay_ms * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + if (g_monitor_shield_active && + generation == g_monitor_shield_generation) { + tb_apply_monitor_shield(); + } + }); +} + +static void tb_install_monitor_shield_observers(void) { + if (g_monitor_shield_observers) return; + + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + NSArray *names = @[ + NSWindowDidEnterFullScreenNotification, + NSWindowDidExitFullScreenNotification, + NSWindowDidChangeScreenNotification, + NSWindowDidChangeBackingPropertiesNotification, + NSApplicationDidChangeScreenParametersNotification, + ]; + g_monitor_shield_observers = [NSMutableArray arrayWithCapacity:names.count]; + + for (NSNotificationName name in names) { + id token = [center addObserverForName:name + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification *notification) { + if (!g_monitor_shield_active) return; + + id object = notification.object; + if ([object isKindOfClass:NSWindow.class] && + object != tb_receiver_content_window()) { + return; + } + + /* Apply after AppKit finishes dispatching the transition event, + * then once more after its final window-server reconciliation. */ + uint64_t generation = g_monitor_shield_generation; + tb_schedule_monitor_shield_reapply(generation, 0); + tb_schedule_monitor_shield_reapply(generation, 250); + }]; + [g_monitor_shield_observers addObject:token]; + } +} + +static void tb_remove_monitor_shield_observers(void) { + if (!g_monitor_shield_observers) return; + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + for (id token in g_monitor_shield_observers) { + [center removeObserver:token]; + } + g_monitor_shield_observers = nil; } void tb_receiver_set_monitor_shield(int active) { @@ -70,21 +146,28 @@ void tb_receiver_set_monitor_shield(int active) { BOOL normalized = active ? YES : NO; BOOL changed = normalized != g_monitor_shield_active; g_monitor_shield_active = normalized; + uint64_t generation = ++g_monitor_shield_generation; + if (normalized) { + tb_install_monitor_shield_observers(); + } else { + tb_remove_monitor_shield_observers(); + } tb_apply_monitor_shield(); - /* SDL completes part of its native fullscreen transition after the - * call returns. Reapply the latest desired state once afterward. */ - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 300 * NSEC_PER_MSEC), - dispatch_get_main_queue(), ^{ - tb_apply_monitor_shield(); - }); + /* SDL can return before AppKit and the window server finish the Space + * transition. Use bounded retries; no polling remains after 2.5 s. */ + if (normalized) { + tb_schedule_monitor_shield_reapply(generation, 250); + tb_schedule_monitor_shield_reapply(generation, 1000); + tb_schedule_monitor_shield_reapply(generation, 2500); + } if (changed) { fprintf(stderr, "[display] monitor shield=%d level=%ld\n", normalized ? 1 : 0, (long)(normalized - ? CGWindowLevelForKey(kCGStatusWindowLevelKey) + ? tb_monitor_shield_level() : NSNormalWindowLevel)); } } From 9d04f39607bdad04941ccd012d89fa73157d1320 Mon Sep 17 00:00:00 2001 From: swellweb Date: Sat, 5 Sep 2026 18:26:24 +0200 Subject: [PATCH 33/40] test(receiver): cover monitor shield lifecycle and repeated activation --- .gitignore | 3 ++ TargetBridge-Receiver/TBReceiverC/Makefile | 11 +++- .../TBReceiverC/src/tb_gesture_bridge.m | 5 ++ .../TBReceiverC/tests/test_monitor_shield.m | 54 +++++++++++++++++++ docs/Testing.md | 14 +++++ 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 TargetBridge-Receiver/TBReceiverC/tests/test_monitor_shield.m diff --git a/.gitignore b/.gitignore index 0219bb95..59b762c8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,7 @@ tbreceiver # C test outputs test_net_parser test_input_queue +test_idle_watchdog +test_receiver_profile +test_monitor_shield *.dSYM/ diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 4eca060e..9e852873 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -51,7 +51,7 @@ $(BIN): $(OBJ) $(CC) $(CFLAGS) -fobjc-arc -c -o $@ $< clean: - rm -f $(OBJ) $(BIN) $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_IDLE_WATCHDOG_BIN) $(TEST_RECEIVER_PROFILE_BIN) + rm -f $(OBJ) $(BIN) $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_IDLE_WATCHDOG_BIN) $(TEST_RECEIVER_PROFILE_BIN) test_monitor_shield # Unit tests for the packet parser. net.c is pure POSIX, so this needs no # ffmpeg/SDL/pkgconf — it runs anywhere, including CI, with no hardware. @@ -83,4 +83,11 @@ test: $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_IDLE_WATCHDOG_BIN) $(TEST_RECEI ./$(TEST_IDLE_WATCHDOG_BIN) ./$(TEST_RECEIVER_PROFILE_BIN) -.PHONY: all clean test +# AppKit lifecycle coverage requires a logged-in macOS GUI session. +test_monitor_shield: tests/test_monitor_shield.m src/tb_gesture_bridge.m src/tb_gesture_bridge.h + $(CC) -fobjc-arc -framework AppKit -framework CoreGraphics tests/test_monitor_shield.m -o $@ + +test-monitor-shield: test_monitor_shield + ./test_monitor_shield + +.PHONY: all clean test test-monitor-shield diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m index 44fbc6e9..250272c7 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m @@ -145,6 +145,11 @@ void tb_receiver_set_monitor_shield(int active) { @autoreleasepool { BOOL normalized = active ? YES : NO; BOOL changed = normalized != g_monitor_shield_active; + // Repeated input-state refreshes must not invalidate pending retries. + if (!changed) { + tb_apply_monitor_shield(); + return; + } g_monitor_shield_active = normalized; uint64_t generation = ++g_monitor_shield_generation; if (normalized) { diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_monitor_shield.m b/TargetBridge-Receiver/TBReceiverC/tests/test_monitor_shield.m new file mode 100644 index 00000000..5544bc75 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_monitor_shield.m @@ -0,0 +1,54 @@ +#import "../src/tb_gesture_bridge.m" +#include + +static void pump(double seconds) { + NSDate *end = [NSDate dateWithTimeIntervalSinceNow:seconds]; + while ([end timeIntervalSinceNow] > 0) { + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:end]; + } +} + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + NSWindow *window = [[NSWindow alloc] + initWithContentRect:NSMakeRect(0, 0, 320, 240) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered defer:NO]; + window.releasedWhenClosed = NO; + [window orderFrontRegardless]; + + tb_receiver_set_monitor_shield(1); + uint64_t generation = g_monitor_shield_generation; + assert(g_monitor_shield_observers.count == 5); + assert(window.level == tb_monitor_shield_level()); + for (int i = 0; i < 20; i++) tb_receiver_set_monitor_shield(1); + assert(generation == g_monitor_shield_generation); + assert(g_monitor_shield_observers.count == 5); + + window.level = NSNormalWindowLevel; + pump(0.35); + assert(window.level == tb_monitor_shield_level()); + + tb_receiver_set_monitor_shield(0); + assert(g_monitor_shield_observers == nil); + assert(window.level == NSNormalWindowLevel); + pump(2.3); + assert(window.level == NSNormalWindowLevel); + + tb_receiver_set_monitor_shield(1); + pump(2.6); + window.level = NSNormalWindowLevel; + [[NSNotificationCenter defaultCenter] + postNotificationName:NSWindowDidChangeBackingPropertiesNotification + object:window]; + pump(0.35); + assert(window.level == tb_monitor_shield_level()); + tb_receiver_set_monitor_shield(0); + assert(window.level == NSNormalWindowLevel); + assert(g_monitor_shield_observers == nil); + [window close]; + puts("monitor shield lifecycle: passed"); + } + return 0; +} diff --git a/docs/Testing.md b/docs/Testing.md index 5a437b8c..085f3d1d 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -84,6 +84,20 @@ open build/TargetBridge.app # sender: pick the LAN interface, enter the Mac's This exercises the true capture → encode → decode → render path minus the Thunderbolt link itself. +## 6. Monitor shield lifecycle (macOS GUI session) + +```bash +cd TargetBridge-Receiver/TBReceiverC +make test-monitor-shield +``` + +Opens a small test window and checks repeated activation, delayed window-level +restoration, backing-property notifications, observer cleanup, and cancellation +of pending retries after stopping. Requires AppKit and a logged-in GUI session, +but no screen recording or input permissions. This is separate from `make test` +so the portable test suite remains usable without a GUI. It does not replace +live verification of notification suppression during a real stream. + ## Debugging a live connection The sender logs its connection lifecycle (dial target, interface, waiting/ From 25a3374fef2d7bcc73a3c942fcfb15776bfa5f12 Mon Sep 17 00:00:00 2001 From: swellweb Date: Sat, 5 Sep 2026 18:42:04 +0200 Subject: [PATCH 34/40] docs: prepare complete 3.5.1 maintenance release notes --- README.md | 15 +++++++++++--- docs/releases/3.5.1.md | 44 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 docs/releases/3.5.1.md diff --git a/README.md b/README.md index d240d40c..04ee09a6 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,26 @@ and improves the project. A special thank-you for this release goes to: - [@aalpgiray](https://github.com/aalpgiray) for receiver brightness, volume, Night Shift, and True Tone controls. - [@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 and the low-latency Receiver Master cursor overlay. +- [@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.1 TargetBridge 3.5.1 is a maintenance release focused on safer input control, -accurate Receiver display profiles, and improved compatibility with older Macs: +accurate Receiver display profiles, idle resource handling, and improved compatibility with older Macs: - preserve keyboard and mouse release events when the Receiver input queue is under pressure -- use the low-latency cursor overlay in Receiver Master mode to avoid duplicate cursors +- preserve native macOS cursor shapes by default, including the screenshot crosshair; the custom overlay is reserved for the explicit large-cursor option - advertise the Receiver's actual panel geometry instead of always reporting a fixed 5K profile +- preserve native 5K panel dimensions when macOS uses a scaled HiDPI desktop - fall back safely to software video decode when VideoToolbox cannot decode a stream on older hardware +- allow display sleep while the Receiver is waiting, including before the first session +- open audio output only when audio arrives and release it when the session ends +- handle watchdog clock underflow without incorrectly dropping the connection +- 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.1 release notes](docs/releases/3.5.1.md) and +[testing guide](docs/Testing.md), including the new monitor-shield lifecycle test. It also keeps the established multi-Mac workspace features: diff --git a/docs/releases/3.5.1.md b/docs/releases/3.5.1.md new file mode 100644 index 00000000..4946a968 --- /dev/null +++ b/docs/releases/3.5.1.md @@ -0,0 +1,44 @@ +# TargetBridge 3.5.1 + +A maintenance update to 3.5.0, developed on `maint-3.5` without changing `main`. +Update both Sender and Receiver for the complete set of fixes. + +## Display and input + +- 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 used only when the large-cursor option is enabled. +- Report the physical Receiver panel geometry and preserve native 5K dimensions when the desktop is scaled with HiDPI. +- Fall back to software decoding when VideoToolbox cannot decode a stream on older hardware. Performance depends on the Mac and stream profile. + +## Receiver lifecycle + +- Allow display sleep while waiting for a Sender, including before the first connection (#171). +- Open audio output only when an audio frame arrives and close it when the session ends, avoiding an idle audio device that prevents sleep (#172). +- Guard the idle watchdog against clock underflow (#173). +- Keep local notification banners behind the active monitor surface during fullscreen and display transitions (#186). Repeated activation preserves pending retries; teardown removes observers and invalidates stale retries. + +This release does not change the active-session display-sleep policy. The separate active-session sleep proposal is not included. + +## Packaging and validation + +- Package SDL runtimes and their dependencies safely (#189, incorporating #181 and #188). +- Support explicit version/build overrides and configurable Sender build configuration (#180). +- Add a documented AppKit monitor-shield lifecycle test: [Testing](../Testing.md#6-monitor-shield-lifecycle-macos-gui-session). +- Keep existing shared translations validated for English, Italian, German, French, and Chinese. + +The integration passed Sender and Receiver CI on Apple Silicon and Intel, 111 local Sender unit tests, and 654 Receiver checks on each architecture. Monitor-shield lifecycle tests passed on both Macs. A real Thunderbolt 5K session, stop/reconnect, idle resource release, watchdog timeout and malformed/partial-packet recovery were tested. The maintainer also reported that the installed test build appeared to work correctly in normal use. These checks are not a guarantee of constant FPS or compatibility with every Mac. + +## Downloads and upgrade + +- `TargetBridge-arm64.app.zip`: Sender for Apple Silicon. +- `TargetBridge-x86_64.app.zip`: Sender for Intel. +- `TargetBridge-Receiver-arm64.app.zip`: Receiver for Apple Silicon. +- `TargetBridge-Receiver-x86_64.app.zip`: Receiver for Intel. + +Sender requires macOS 14 or later; Receiver requires macOS 11 or later. Replace the existing app in Applications rather than keeping multiple copies. If macOS no longer recognizes a permission after the update, re-enable it for that installed copy and reopen the app. Screen Recording is needed on the Sender; Input Dockstation permissions must be checked on both Macs according to their roles. See [Input Dockstation](../Features.md#input-dockstation). + +## Thanks + +Thanks to [@Betafer](https://github.com/Betafer) for the focused input, lifecycle and build improvements, and [@preggocl](https://github.com/preggocl) for the Receiver display-profile and decoding compatibility work. Thanks also to everyone testing on real Macs, reporting problems, contributing code and supporting the project. + +[Full comparison with 3.5.0](https://github.com/swellweb/targetBridge/compare/v3.5.0...v3.5.1) From 4ec48072a4a211d1a5403fae7f496a0cba828ba3 Mon Sep 17 00:00:00 2001 From: Betafer Date: Sat, 12 Sep 2026 02:11:16 +0200 Subject: [PATCH 35/40] fix(sender): reserve video capacity before encoding reference frames --- .../TBDisplaySenderService.swift | 31 +++++++++----- .../scripts/test_video_queue_budget.rb | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 TargetBridge-Sender/scripts/test_video_queue_budget.rb diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 44793461..4752b983 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -11,6 +11,17 @@ import Network @preconcurrency import ScreenCaptureKit import VideoToolbox +/// Reserve network capacity before encoding. Once encoded, even a non-keyframe +/// may be a reference for subsequent frames and must not be discarded locally. +enum TBVideoQueueBudget { + static func canEncode(pending: Int, inFlight: Int, packetLimit: Int, encodeLimit: Int) -> Bool { + let limit = min(64, max(1, packetLimit)) + let encoding = min(64, max(1, encodeLimit)) + return pending >= 0 && inFlight >= 0 && pending < limit && + inFlight < encoding && inFlight < limit - pending + } +} + enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { case standard1440p case smooth1440p60 @@ -646,9 +657,10 @@ private final class TBVideoPipeline: @unchecked Sendable { guard running, let encoder = vtEncoder, let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - if preset.dropsBeforeEncodeWhenBacklogged, - (pendingVideoPackets >= preset.maxPendingVideoPackets || - inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { + if !TBVideoQueueBudget.canEncode(pending: pendingVideoPackets, + inFlight: inFlightEncodeFrames, + packetLimit: preset.maxPendingVideoPackets, + encodeLimit: preset.maxInFlightEncodeFrames) { droppedBeforeEncodeFrames += 1 return } @@ -672,9 +684,10 @@ private final class TBVideoPipeline: @unchecked Sendable { droppedByFrameRatePacer += 1 return } - if preset.dropsBeforeEncodeWhenBacklogged, - (pendingVideoPackets >= preset.maxPendingVideoPackets || - inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { + if !TBVideoQueueBudget.canEncode(pending: pendingVideoPackets, + inFlight: inFlightEncodeFrames, + packetLimit: preset.maxPendingVideoPackets, + encodeLimit: preset.maxInFlightEncodeFrames) { droppedBeforeEncodeFrames += 1 return } @@ -742,10 +755,8 @@ private final class TBVideoPipeline: @unchecked Sendable { let notSync = attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false let isKeyframe = !notSync - if !isKeyframe, pendingVideoPackets >= preset.maxPendingVideoPackets { - droppedAfterEncodeFrames += 1 - return - } + // Capacity was reserved before encoding. Preserve the complete encoded + // reference chain; dropping here corrupts HEVC/H.264 until the next IDR. if isKeyframe, let format = CMSampleBufferGetFormatDescription(sampleBuffer), diff --git a/TargetBridge-Sender/scripts/test_video_queue_budget.rb b/TargetBridge-Sender/scripts/test_video_queue_budget.rb new file mode 100644 index 00000000..2f029035 --- /dev/null +++ b/TargetBridge-Sender/scripts/test_video_queue_budget.rb @@ -0,0 +1,41 @@ +# Execute the production policy, plus source guards for both capture paths. +# Synthetic counters only; no display capture, network, credentials or input. +require 'tmpdir' +root = File.expand_path('..', __dir__) +source = File.read(File.join(root, 'TBDisplaySender/TBDisplaySenderService.swift')) +policy = source[/enum TBVideoQueueBudget \{.*?(?=\nenum TBDisplayCapturePreset)/m] +abort 'missing production budget policy' unless policy +abort 'both capture paths must reserve capacity' unless source.scan('if !TBVideoQueueBudget.canEncode(').size == 2 +encoded = source[/private func handleEncoded\(.*?(?= \/\/\/ Raw passthrough)/m] +abort 'encoded frames are still dropped' if encoded.include?('droppedAfterEncodeFrames +=') +tests = <<~'SWIFT' +import Foundation +func admit(_ pending: Int, _ flight: Int, _ limit: Int = 3, _ enc: Int = 2) -> Bool { + TBVideoQueueBudget.canEncode(pending: pending, inFlight: flight, packetLimit: limit, encodeLimit: enc) +} +precondition(admit(0, 0) && admit(1, 1) && !admit(2, 1) && !admit(0, 2)) +precondition(!admit(-1, 0) && !admit(0, -1) && !admit(Int.max, 0)) +precondition(admit(0, 0, 0, 0) && !admit(1, 0, 0, 0)) +precondition(!admit(64, 0, Int.max, Int.max)) +// Explore all legal transitions: capture, encoder success/failure, transport ACK. +// No post-encode discard is needed, including when ACKs are delayed indefinitely. +for limit in 1...12 { + for enc in 1...12 { + for pending in 0...limit { + for flight in 0...(limit - pending) { + if admit(pending, flight, limit, enc) { + precondition(pending + flight + 1 <= limit && flight + 1 <= enc) + } + if flight > 0 { precondition((pending + 1) + (flight - 1) <= limit) } + } + } + } +} +print("PASS production video budget: both paths, bounded reservations, delayed ACKs, encode failure, invalid limits; no post-encode discard") +SWIFT +Dir.mktmpdir('tb-video-budget.') do |dir| + file = File.join(dir, 'main.swift') + File.write(file, policy + tests) + abort 'compile failed' unless system('swiftc', '-module-cache-path', File.join(dir, 'cache'), file, '-o', File.join(dir, 'test')) + abort 'tests failed' unless system(File.join(dir, 'test')) +end From d7bf6e2a4fb3a4f6479e2ad497c94f7196a23ef7 Mon Sep 17 00:00:00 2001 From: Betafer Date: Sat, 12 Sep 2026 07:12:35 +0200 Subject: [PATCH 36/40] fix(sender): distinguish idle capture from a stalled pipeline --- .../TBDisplaySender/TBCaptureHealth.swift | 83 +++++++++ .../TBDisplaySenderService.swift | 95 +++++----- .../TBCaptureHealthTests.swift | 172 ++++++++++++++++++ .../TargetBridge.xcodeproj/project.pbxproj | 8 + .../scripts/test_capture_health.rb | 24 +++ 5 files changed, 341 insertions(+), 41 deletions(-) create mode 100644 TargetBridge-Sender/TBDisplaySender/TBCaptureHealth.swift create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBCaptureHealthTests.swift create mode 100644 TargetBridge-Sender/scripts/test_capture_health.rb diff --git a/TargetBridge-Sender/TBDisplaySender/TBCaptureHealth.swift b/TargetBridge-Sender/TBDisplaySender/TBCaptureHealth.swift new file mode 100644 index 00000000..f657c3d6 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBCaptureHealth.swift @@ -0,0 +1,83 @@ +import Foundation +import CoreGraphics +import CoreMedia +import ScreenCaptureKit + +/// Capture health is not FPS: event-driven capture may remain idle indefinitely. +/// Owned by the pipeline under its snapshot lock. Times use monotonic uptime. +struct TBCaptureHealth { + enum State: String { + case starting, active, idle, blank, suspended, stopped, unknown + + init(_ status: SCFrameStatus) { + switch status { + case .complete, .started: self = .active + case .idle: self = .idle + case .blank: self = .blank + case .suspended: self = .suspended + case .stopped: self = .stopped + @unknown default: self = .unknown + } + } + + init(_ status: CGDisplayStreamFrameStatus) { + switch status { + case .frameComplete: self = .active + case .frameIdle: self = .idle + case .frameBlank: self = .blank + case .stopped: self = .stopped + @unknown default: self = .unknown + } + } + + init(sampleBuffer: CMSampleBuffer) { + guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) + as? [[SCStreamFrameInfo: Any]], + let raw = attachments.first?[SCStreamFrameInfo.status] as? Int, + let status = SCFrameStatus(rawValue: raw) else { + self = .unknown + return + } + self.init(status) + } + + var canContainFrame: Bool { self == .active || self == .unknown } + var isQuiescent: Bool { self == .idle || self == .blank || self == .suspended } + } + + private(set) var state: State = .starting + private(set) var frameCount: UInt64 = 0 + private(set) var idleCount: UInt64 = 0 + private(set) var lastFrameAt: TimeInterval? + private var progressAt: TimeInterval + + init(now: TimeInterval) { progressAt = now } + + mutating func observe(_ next: State, now: TimeInterval) { + // A resumed stream gets a fresh grace period, but repeated callbacks + // without usable frames must not hide a genuine capture failure. + if state.isQuiescent && !next.isQuiescent && next != .stopped { + progressAt = now + } + state = next + if next == .idle { idleCount &+= 1 } + } + + mutating func recordFrame(now: TimeInterval) { + state = .active + lastFrameAt = now + progressAt = now + frameCount &+= 1 + } + + func shouldRestart(now: TimeInterval, timeout: TimeInterval = 8) -> Bool { + if state == .stopped { return true } + // Blank/suspended are deliberate states, not failures; don't wake a + // sleeping display in a restart loop. System-wake recovery is separate. + if state == .blank || state == .suspended { return false } + // An idle event is a state transition, NOT a periodic heartbeat promise. + // Require a real first frame so initial idle cannot mask a failed start. + if state == .idle && lastFrameAt != nil { return false } + return now - progressAt >= timeout + } +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 44793461..94bdd4e3 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -410,6 +410,7 @@ private final class TBDirectDisplayStreamCapture { // Delivered on `queue` — the pipeline's own serial queue — so encode // runs here, off the main thread, with no extra hop. guard let self else { return } + self.pipeline.observeCaptureStatus(TBCaptureHealth.State(status)) if status == .stopped { // The stream has fully drained; no further frames will arrive, so // it is now safe to release the stream and drop the self-retain. @@ -449,7 +450,7 @@ private final class TBDirectDisplayStreamCapture { /// dedicated serial queue, off the main thread. SwiftUI layout (or any other /// main-thread work) therefore cannot stall frame delivery. All mutable encode /// state is confined to `queue`; the two values the main thread polls -/// (`sentFrames`, `lastCaptureFrameAt`) are guarded by a small lock instead of +/// (`sentFrames`, capture health) are guarded by a small lock instead of /// a per-frame hop back to main. private final class TBVideoPipeline: @unchecked Sendable { let queue = DispatchQueue(label: "fd.tbmonitor.sender.pipeline", qos: .userInteractive) @@ -480,7 +481,7 @@ private final class TBVideoPipeline: @unchecked Sendable { private var droppedByFrameRatePacer = 0 private var droppedBeforeEncodeFrames = 0 private var droppedAfterEncodeFrames = 0 - private var _lastCaptureFrameAt = Date() + private var captureHealth = TBCaptureHealth(now: ProcessInfo.processInfo.systemUptime) init(preset: TBDisplayCapturePreset, codecType: CMVideoCodecType, @@ -537,9 +538,14 @@ private final class TBVideoPipeline: @unchecked Sendable { return _sentFrames } - var lastCaptureFrameAtSnapshot: Date { + var captureHealthSnapshot: TBCaptureHealth { lock.lock(); defer { lock.unlock() } - return _lastCaptureFrameAt + return captureHealth + } + + func observeCaptureStatus(_ state: TBCaptureHealth.State) { + lock.lock(); defer { lock.unlock() } + captureHealth.observe(state, now: ProcessInfo.processInfo.systemUptime) } func diagnosticsSnapshot() -> (pending: Int, inFlight: Int, ptsSeq: CMTimeValue, captured: Int, droppedPacing: Int, droppedPre: Int, droppedPost: Int) { @@ -558,7 +564,9 @@ private final class TBVideoPipeline: @unchecked Sendable { private func markCaptureFrame() { capturedFrames += 1 - lock.lock(); _lastCaptureFrameAt = Date(); lock.unlock() + lock.lock() + captureHealth.recordFrame(now: ProcessInfo.processInfo.systemUptime) + lock.unlock() } // MARK: - Encoder setup (on `queue`) @@ -633,6 +641,7 @@ private final class TBVideoPipeline: @unchecked Sendable { /// direct Thunderbolt Bridge link comfortably sustains. /// SCStream capture path. Must be dispatched onto `queue` by the caller. func encode(_ sampleBuffer: CMSampleBuffer) { + guard running, let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } markCaptureFrame() let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) guard frameRatePacer.shouldEmit(presentationTime: pts) else { @@ -643,9 +652,7 @@ private final class TBVideoPipeline: @unchecked Sendable { sendRawFrame(sampleBuffer) return } - guard running, let encoder = vtEncoder, - let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) - else { return } + guard let encoder = vtEncoder else { return } if preset.dropsBeforeEncodeWhenBacklogged, (pendingVideoPackets >= preset.maxPendingVideoPackets || inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { @@ -658,8 +665,8 @@ private final class TBVideoPipeline: @unchecked Sendable { /// CGDisplayStream capture path. Delivered directly on `queue` by /// `TBDirectDisplayStreamCapture`. func encodeDisplaySurface(_ surface: IOSurfaceRef, displayTime: UInt64) { - markCaptureFrame() guard running, let encoder = vtEncoder else { return } + markCaptureFrame() var pts = displayTime != 0 ? CMClockMakeHostTimeFromSystemUnits(displayTime) @@ -1274,6 +1281,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u var onRemoteDeactivateInputRequest: (() -> Void)? nonisolated(unsafe) private var wakeObservers: [NSObjectProtocol] = [] private var isRestartingCaptureAfterWake = false + private var captureRestartGeneration: UInt64 = 0 + private var lastLoggedCaptureHealthState: TBCaptureHealth.State? nonisolated(unsafe) private var displayReconfigurationCallbackRegistered = false private var verboseLoggingTimer: Timer? private var captureHealthWatchdog: Timer? @@ -1290,28 +1299,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private final class CaptureDelegate: NSObject, SCStreamOutput, SCStreamDelegate { var onFrame: ((CMSampleBuffer) -> Void)? + var onFrameStatus: ((TBCaptureHealth.State) -> Void)? var onAudio: ((CMSampleBuffer) -> Void)? var onError: ((Error) -> Void)? - private static func shouldProcessFrame(_ sampleBuffer: CMSampleBuffer) -> Bool { - guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) - as? [[SCStreamFrameInfo: Any]], - let rawStatus = attachments.first?[SCStreamFrameInfo.status] as? Int, - let status = SCFrameStatus(rawValue: rawStatus) - else { - return true - } - - switch status { - case .complete, .started: - return true - case .idle, .blank, .suspended, .stopped: - return false - @unknown default: - return true - } - } - nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { @@ -1320,7 +1311,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } guard type == .screen else { return } - guard Self.shouldProcessFrame(sampleBuffer) else { return } + let state = TBCaptureHealth.State(sampleBuffer: sampleBuffer) + onFrameStatus?(state) + guard state.canContainFrame else { return } onFrame?(sampleBuffer) } @@ -1693,6 +1686,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u teardownCompletion: (@MainActor @Sendable () -> Void)? = nil ) { let connectionToClose = connection + captureRestartGeneration &+= 1 + isRestartingCaptureAfterWake = false if persistArrangement { persistExtendedDisplayArrangementIfNeeded() } @@ -2615,6 +2610,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u ) let delegate = CaptureDelegate() + delegate.onFrameStatus = { state in + pipeline.observeCaptureStatus(state) + } delegate.onFrame = { sampleBuffer in // ScreenCaptureKit already invokes this closure on pipeline.queue. // Encode immediately so its IOSurface returns to WindowServer @@ -2626,7 +2624,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } delegate.onError = { [weak self] error in Task { @MainActor [weak self] in - guard let self else { return } + guard let self, self.pipeline === pipeline else { return } self.setStatus(.captureError(self.formattedCaptureErrorMessage(for: error))) self.stop(resetStatusTo: nil) } @@ -3285,6 +3283,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func startCaptureWatchdog() { captureHealthWatchdog?.invalidate() + lastLoggedCaptureHealthState = nil captureHealthWatchdog = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.checkCaptureHealth() @@ -3299,10 +3298,15 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func checkCaptureHealth() { guard isStreaming, activeProfile != nil, !isRestartingCaptureAfterWake, let pipeline else { return } - let elapsed = Date().timeIntervalSince(pipeline.lastCaptureFrameAtSnapshot) - guard elapsed >= 8.0 else { return } - NSLog("TargetBridge: capture watchdog tripped — %.1fs since last frame, soft restart", elapsed) - scheduleCaptureRestart(reason: "watchdog (\(Int(elapsed))s without frames)", delaySeconds: 0.5) + let health = pipeline.captureHealthSnapshot + let now = ProcessInfo.processInfo.systemUptime + if health.state != lastLoggedCaptureHealthState { + lastLoggedCaptureHealthState = health.state + NSLog("TargetBridge: capture health state=%@ frames=%llu idleEvents=%llu", health.state.rawValue, health.frameCount, health.idleCount) + } + guard health.shouldRestart(now: now) else { return } + NSLog("TargetBridge: capture watchdog tripped — state=%@ frames=%llu idleEvents=%llu", health.state.rawValue, health.frameCount, health.idleCount) + scheduleCaptureRestart(reason: "watchdog (\(health.state.rawValue))", delaySeconds: 0.5, onlyIfUnhealthy: true) } private func logStreamSnapshot() { @@ -3347,21 +3351,30 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u isStreaming && activeProfile != nil && !isRestartingCaptureAfterWake } - private func scheduleCaptureRestart(reason: String, delaySeconds: Double) { - guard isStreaming, !isRestartingCaptureAfterWake, let profile = activeProfile else { return } + private func scheduleCaptureRestart(reason: String, delaySeconds: Double, onlyIfUnhealthy: Bool = false) { + guard isStreaming, !isRestartingCaptureAfterWake, let profile = activeProfile, let scheduledPipeline = pipeline else { return } isRestartingCaptureAfterWake = true - NSLog("TargetBridge: \(reason) — soft restart of capture pipeline") + captureRestartGeneration &+= 1 + let generation = captureRestartGeneration Task { @MainActor [weak self] in if delaySeconds > 0 { try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) } - guard let self else { return } - guard self.isStreaming, self.activeProfile?.receiverName == profile.receiverName else { - self.isRestartingCaptureAfterWake = false + guard let self, self.captureRestartGeneration == generation else { return } + defer { + if self.captureRestartGeneration == generation { + self.isRestartingCaptureAfterWake = false + } + } + // A delayed recovery must never restart a replacement connection, + // or interrupt a stream that recovered during the grace period. + guard self.isStreaming, self.pipeline === scheduledPipeline else { return } + if onlyIfUnhealthy && !scheduledPipeline.captureHealthSnapshot.shouldRestart(now: ProcessInfo.processInfo.systemUptime) { + NSLog("TargetBridge: capture watchdog recovery cancelled — capture is healthy") return } + NSLog("TargetBridge: \(reason) — soft restart of capture pipeline") await self.softRestartCapture(for: profile) - self.isRestartingCaptureAfterWake = false } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBCaptureHealthTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBCaptureHealthTests.swift new file mode 100644 index 00000000..66f6eb18 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBCaptureHealthTests.swift @@ -0,0 +1,172 @@ +import XCTest +import CoreGraphics +import CoreMedia +import CoreVideo +import ScreenCaptureKit +@testable import TargetBridge + +final class TBCaptureHealthTests: XCTestCase { + func testStartupTimeoutWithoutCallbacks() { + let health = TBCaptureHealth(now: 100) + XCTAssertFalse(health.shouldRestart(now: 107.999)) + XCTAssertTrue(health.shouldRestart(now: 108)) + } + + func testActiveCaptureStillDetectsSilentStall() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 2) + XCTAssertFalse(health.shouldRestart(now: 9.9)) + XCTAssertTrue(health.shouldRestart(now: 10)) + } + + func testSingleIdleEventCanRemainHealthyForHours() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.idle, now: 2) + XCTAssertFalse(health.shouldRestart(now: 10)) + XCTAssertFalse(health.shouldRestart(now: 86_400)) + XCTAssertEqual(health.frameCount, 1) + XCTAssertEqual(health.idleCount, 1) + } + + func testRepeatedIdleDoesNotInflateFrameCount() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + for time in 2...600 { health.observe(.idle, now: Double(time)) } + XCTAssertFalse(health.shouldRestart(now: 900)) + XCTAssertEqual(health.frameCount, 1) + XCTAssertEqual(health.idleCount, 599) + } + + func testIdleCannotMaskMissingFirstFrame() { + var health = TBCaptureHealth(now: 0) + for time in 1...9 { health.observe(.idle, now: Double(time)) } + XCTAssertTrue(health.shouldRestart(now: 10)) + XCTAssertNil(health.lastFrameAt) + } + + func testNewFramesResumeAfterLongIdle() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.idle, now: 2) + health.observe(.active, now: 600) + health.recordFrame(now: 600) + XCTAssertEqual(health.state, .active) + XCTAssertEqual(health.frameCount, 2) + XCTAssertFalse(health.shouldRestart(now: 607.9)) + XCTAssertTrue(health.shouldRestart(now: 608)) + } + + func testResumedStatusGetsGraceButCannotMaskMissingFrames() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.idle, now: 2) + for time in 600...607 { health.observe(.active, now: Double(time)) } + XCTAssertFalse(health.shouldRestart(now: 607.9)) + XCTAssertTrue(health.shouldRestart(now: 608)) + } + + func testBlankDoesNotTriggerWakeLoop() { + var health = TBCaptureHealth(now: 0) + health.observe(.blank, now: 1) + XCTAssertFalse(health.shouldRestart(now: 3600)) + health.observe(.active, now: 3601) + XCTAssertFalse(health.shouldRestart(now: 3608)) + XCTAssertTrue(health.shouldRestart(now: 3609)) + } + + func testSuspendedDoesNotTriggerWakeLoop() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.suspended, now: 2) + XCTAssertFalse(health.shouldRestart(now: 3600)) + health.recordFrame(now: 3601) + XCTAssertFalse(health.shouldRestart(now: 3602)) + } + + func testStoppedAfterIdleRequiresRecovery() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.idle, now: 2) + health.observe(.stopped, now: 3) + XCTAssertTrue(health.shouldRestart(now: 3)) + } + + func testUnknownStatusDoesNotDisableWatchdog() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.unknown, now: 7) + XCTAssertTrue(health.shouldRestart(now: 9)) + } + + func testFreshPipelineDoesNotInheritOldIdleState() { + var old = TBCaptureHealth(now: 0) + old.recordFrame(now: 1) + old.observe(.idle, now: 2) + let fresh = TBCaptureHealth(now: 100) + XCTAssertFalse(old.shouldRestart(now: 200)) + XCTAssertTrue(fresh.shouldRestart(now: 108)) + XCTAssertEqual(fresh.frameCount, 0) + } + + func testRecoveryRecheckCancelsAfterAFrameArrives() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + XCTAssertTrue(health.shouldRestart(now: 10)) + health.recordFrame(now: 10.2) + XCTAssertFalse(health.shouldRestart(now: 10.5)) + } + + func testRecoveryRecheckCancelsAfterIdleNotification() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + XCTAssertTrue(health.shouldRestart(now: 10)) + health.observe(.idle, now: 10.2) + XCTAssertFalse(health.shouldRestart(now: 10.5)) + } + + func testScreenCaptureKitMappingsAndFiltering() { + for (status, expected) in [(SCFrameStatus.complete, TBCaptureHealth.State.active), + (.started, .active), (.idle, .idle), (.blank, .blank), + (.suspended, .suspended), (.stopped, .stopped)] { + XCTAssertEqual(TBCaptureHealth.State(status), expected) + XCTAssertEqual(expected.canContainFrame, expected == .active) + } + XCTAssertTrue(TBCaptureHealth.State.unknown.canContainFrame) + } + + func testDirectDisplayStreamMappingsAndFiltering() { + for (status, expected) in [(CGDisplayStreamFrameStatus.frameComplete, TBCaptureHealth.State.active), + (.frameIdle, .idle), (.frameBlank, .blank), (.stopped, .stopped)] { + XCTAssertEqual(TBCaptureHealth.State(status), expected) + XCTAssertEqual(expected.canContainFrame, expected == .active) + } + } + + func testRealSampleAttachmentParsing() throws { + var pixel: CVPixelBuffer? + XCTAssertEqual(CVPixelBufferCreate(kCFAllocatorDefault, 8, 8, + kCVPixelFormatType_32BGRA, nil, &pixel), kCVReturnSuccess) + var format: CMVideoFormatDescription? + XCTAssertEqual(CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, imageBuffer: try XCTUnwrap(pixel), + formatDescriptionOut: &format), noErr) + var timing = CMSampleTimingInfo(duration: .invalid, + presentationTimeStamp: .zero, decodeTimeStamp: .invalid) + var sample: CMSampleBuffer? + XCTAssertEqual(CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, imageBuffer: try XCTUnwrap(pixel), + formatDescription: try XCTUnwrap(format), sampleTiming: &timing, + sampleBufferOut: &sample), noErr) + let buffer = try XCTUnwrap(sample) + XCTAssertEqual(TBCaptureHealth.State(sampleBuffer: buffer), .unknown) + let array = try XCTUnwrap(CMSampleBufferGetSampleAttachmentsArray(buffer, createIfNecessary: true)) + let dict = unsafeBitCast(CFArrayGetValueAtIndex(array, 0), to: NSMutableDictionary.self) + for status in [SCFrameStatus.complete, .idle, .blank, .started, .suspended, .stopped] { + dict[SCStreamFrameInfo.status] = status.rawValue + XCTAssertEqual(TBCaptureHealth.State(sampleBuffer: buffer), TBCaptureHealth.State(status)) + } + dict[SCStreamFrameInfo.status] = 999 + XCTAssertEqual(TBCaptureHealth.State(sampleBuffer: buffer), .unknown) + } +} diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 93239001..de0cb1b6 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */; }; 4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */; }; 4C59B780CEA3CC5F563BC86F /* TBInjectedClickStateTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4134F78ABD695DF9DB5D485B /* TBInjectedClickStateTracker.swift */; }; + 50C6F9D35F964ABF03193C5B /* TBCaptureHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8E93993FCAE2FCBA71C45B /* TBCaptureHealth.swift */; }; 5109DFCF1BFD76222E7B4BDD /* TBDisplayProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D419C88EEEA66EE0829BA975 /* TBDisplayProfileTests.swift */; }; 55163F49604063EA7E86D079 /* TBConnectionDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6EB41D253E67E350F5E25D /* TBConnectionDiagnostics.swift */; }; 58980FE95A48D8F8EDB42055 /* TBVirtualDisplayModeMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */; }; @@ -33,6 +34,7 @@ 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */; }; 7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; }; 7FB34AA1E75B01E7D1EC3E21 /* it.json in Resources */ = {isa = PBXBuildFile; fileRef = 036E14097FA59989FFC456FD /* it.json */; }; + 83FB3853596BEDEFAC5E129A /* TBCaptureHealthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11B4EB862393E5CE95426D4 /* TBCaptureHealthTests.swift */; }; 8A729C68ECAC4642FA20A284 /* TBLocalizationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C932E960346E674A0659624 /* TBLocalizationStore.swift */; }; 8D544352D7E2E226D551E46E /* TBInputBindingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */; }; 8D9D2A40A1CD90BD4D4DFC2C /* TBDisplaySenderAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */; }; @@ -79,6 +81,7 @@ 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputBindingsView.swift; sourceTree = ""; }; 374B5A5AA247C6922BD9AF72 /* TBDisplaySenderAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderAboutView.swift; sourceTree = ""; }; 37515A84B71F4B19C8EB65B4 /* TBInputBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputBindingTests.swift; sourceTree = ""; }; + 3E8E93993FCAE2FCBA71C45B /* TBCaptureHealth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCaptureHealth.swift; sourceTree = ""; }; 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConnectionDiagnosticsTests.swift; sourceTree = ""; }; 4134F78ABD695DF9DB5D485B /* TBInjectedClickStateTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedClickStateTracker.swift; sourceTree = ""; }; 4644CFB05098C895378B648A /* TBDisplaySenderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderManager.swift; sourceTree = ""; }; @@ -99,6 +102,7 @@ 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 = ""; }; 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 = ""; }; C3658E86F5BE2168F4591491 /* de.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = de.json; sourceTree = ""; }; C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMonitorProtocolTests.swift; sourceTree = ""; }; @@ -131,6 +135,7 @@ isa = PBXGroup; children = ( C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */, + 3E8E93993FCAE2FCBA71C45B /* TBCaptureHealth.swift */, A3F0782C970AA23871AE73A6 /* TBConfigurationDiagnostics.swift */, 4E4C95A1820A99E5DAED17DE /* TBDisplayProfile.swift */, 374B5A5AA247C6922BD9AF72 /* TBDisplaySenderAboutView.swift */, @@ -159,6 +164,7 @@ 578C8E4277884577DC1F2820 /* TBDisplaySenderTests */ = { isa = PBXGroup; children = ( + C11B4EB862393E5CE95426D4 /* TBCaptureHealthTests.swift */, A7086193EE5710E2C37F86F1 /* TBConfigurationDiagnosticsTests.swift */, 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */, D419C88EEEA66EE0829BA975 /* TBDisplayProfileTests.swift */, @@ -361,6 +367,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 83FB3853596BEDEFAC5E129A /* TBCaptureHealthTests.swift in Sources */, 7528751850893B52660870E4 /* TBConfigurationDiagnosticsTests.swift in Sources */, 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */, 5109DFCF1BFD76222E7B4BDD /* TBDisplayProfileTests.swift in Sources */, @@ -379,6 +386,7 @@ 337BD561622AA19F43D3B95B /* ReceiverBackedVirtualDisplaySession.swift in Sources */, 4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */, E81B28F81AE82FF904E0871F /* TBAddonStore.swift in Sources */, + 50C6F9D35F964ABF03193C5B /* TBCaptureHealth.swift in Sources */, 2819CE06D15CA7EDD1069062 /* TBConfigurationDiagnostics.swift in Sources */, 55163F49604063EA7E86D079 /* TBConnectionDiagnostics.swift in Sources */, D81394631056BA19B9032116 /* TBDisplayProfile.swift in Sources */, diff --git a/TargetBridge-Sender/scripts/test_capture_health.rb b/TargetBridge-Sender/scripts/test_capture_health.rb new file mode 100644 index 00000000..5bb3d470 --- /dev/null +++ b/TargetBridge-Sender/scripts/test_capture_health.rb @@ -0,0 +1,24 @@ +#!/usr/bin/env ruby +# Run the actual model and XCTest file without launching the Sender or requesting +# screen/input permissions. The temporary package has no external dependencies. +require 'tmpdir' +require 'fileutils' +root = File.expand_path('..', __dir__) +Dir.mktmpdir('tb-capture-health.') do |dir| + FileUtils.mkdir_p(File.join(dir, 'Sources/TargetBridge')) + FileUtils.mkdir_p(File.join(dir, 'Tests/TargetBridgeTests')) + FileUtils.cp(File.join(root, 'TBDisplaySender/TBCaptureHealth.swift'), File.join(dir, 'Sources/TargetBridge')) + FileUtils.cp(File.join(root, 'TBDisplaySenderTests/TBCaptureHealthTests.swift'), File.join(dir, 'Tests/TargetBridgeTests')) + File.write(File.join(dir, 'Package.swift'), <<~'SWIFT') + // swift-tools-version: 5.9 + import PackageDescription + let package = Package(name: "CaptureHealthTests", platforms: [.macOS(.v14)], + targets: [.target(name: "TargetBridge"), + .testTarget(name: "TargetBridgeTests", dependencies: ["TargetBridge"])]) + SWIFT + ok = 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') + abort 'Capture health tests failed' unless ok +end From 126141aaa292d7ddf3ff70aae514675796444fca Mon Sep 17 00:00:00 2001 From: swellweb Date: Sun, 13 Sep 2026 11:55:16 +0200 Subject: [PATCH 37/40] test: guard combined capture fixes and permission probes --- .github/workflows/ci.yml | 9 ++++++ .../scripts/test_permission_status.rb | 31 +++++++++++++++++++ .../scripts/test_video_queue_budget.rb | 5 +++ docs/Automation.md | 25 +++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 TargetBridge-Sender/scripts/test_permission_status.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f74a389f..c14519ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,12 @@ jobs: cd TargetBridge-Sender xcodebuild test -project TargetBridge.xcodeproj -scheme TBDisplaySender -destination 'platform=macOS' -derivedDataPath .build/DerivedData + - name: Check Integrated Video Queue Guards + run: ruby TargetBridge-Sender/scripts/test_video_queue_budget.rb + + - name: Check Sender Permission Probe + run: ruby TargetBridge-Sender/scripts/test_permission_status.rb sender build/TargetBridge.app/Contents/MacOS/TargetBridge + build-receiver: name: Build Receiver (${{ matrix.os }} - ${{ matrix.arch }}) strategy: @@ -71,3 +77,6 @@ jobs: run: | cd TargetBridge-Receiver ./scripts/build_tbreceiver_c_app.sh + + - name: Check Receiver Permission Probe + run: ruby TargetBridge-Sender/scripts/test_permission_status.rb receiver 'build/TargetBridge Receiver.app/Contents/MacOS/TargetBridgeReceiver' diff --git a/TargetBridge-Sender/scripts/test_permission_status.rb b/TargetBridge-Sender/scripts/test_permission_status.rb new file mode 100644 index 00000000..b0e29189 --- /dev/null +++ b/TargetBridge-Sender/scripts/test_permission_status.rb @@ -0,0 +1,31 @@ +# Smoke-test either built binary without normal UI startup or permission requests. +require 'tempfile' +require 'timeout' + +role, executable = ARGV +abort 'Usage: ruby test_permission_status.rb sender|receiver ' unless + ARGV.length == 2 && %w[sender receiver].include?(role) && File.executable?(executable) + +2.times do + Tempfile.create('tb-permission-stdout') do |output| + Tempfile.create('tb-permission-stderr') do |errors| + pid = Process.spawn(executable, '--permission-status', out: output, err: errors) + status = nil + begin + Timeout.timeout(10) { _, status = Process.wait2(pid) } + rescue Timeout::Error + Process.kill('KILL', pid) rescue Errno::ESRCH + Process.wait(pid) rescue Errno::ECHILD + abort "#{role}: permission probe did not exit within 10 seconds" + end + output.rewind + errors.rewind + text = output.read + abort "#{role}: probe failed: #{errors.read}" unless status.success? + match = /\ATB_PERMISSION_STATUS:screen_recording=([01]):accessibility=([01]):input_monitoring=([01])\n\z/.match(text) + abort "#{role}: unexpected probe output: #{text.inspect}" unless match + abort 'receiver: screen recording must be zero (not applicable)' if role == 'receiver' && match[1] != '0' + end + end +end +puts "PASS #{role}: two bounded permission probes returned valid status and exited" diff --git a/TargetBridge-Sender/scripts/test_video_queue_budget.rb b/TargetBridge-Sender/scripts/test_video_queue_budget.rb index 2f029035..6895851a 100644 --- a/TargetBridge-Sender/scripts/test_video_queue_budget.rb +++ b/TargetBridge-Sender/scripts/test_video_queue_budget.rb @@ -6,6 +6,11 @@ policy = source[/enum TBVideoQueueBudget \{.*?(?=\nenum TBDisplayCapturePreset)/m] abort 'missing production budget policy' unless policy abort 'both capture paths must reserve capacity' unless source.scan('if !TBVideoQueueBudget.canEncode(').size == 2 +capture = source[/func encode\(_ sampleBuffer: CMSampleBuffer\).*?(?= \/\/\/ CGDisplayStream)/m] +abort 'missing usable-frame guard before health accounting' unless capture&.match?( + /guard running, let pixelBuffer = CMSampleBufferGetImageBuffer\(sampleBuffer\) else \{ return \}\s+markCaptureFrame\(\)/ +) +abort 'capture budget must follow the usable-frame guard' unless capture.index('markCaptureFrame()') < capture.index('TBVideoQueueBudget.canEncode(') encoded = source[/private func handleEncoded\(.*?(?= \/\/\/ Raw passthrough)/m] abort 'encoded frames are still dropped' if encoded.include?('droppedAfterEncodeFrames +=') tests = <<~'SWIFT' diff --git a/docs/Automation.md b/docs/Automation.md index 6f6bf22f..49c185c3 100644 --- a/docs/Automation.md +++ b/docs/Automation.md @@ -78,6 +78,31 @@ Quit from the menu bar cancels that loop and removes file as a `PathState` marker so crash recovery remains automatic while a user Stop remains stopped. Packaging such a LaunchAgent is intentionally outside this change. +## Read-only permission diagnostics + +Run the executable inside the app you want to check, rather than `open -a`: + +```bash +"/Applications/TargetBridge.app/Contents/MacOS/TargetBridge" --permission-status +"/Applications/TargetBridge Receiver.app/Contents/MacOS/TargetBridgeReceiver" --permission-status +``` + +Each command prints one line and exits without opening the normal UI, creating a +virtual display, starting capture, or requesting permissions: + +```text +TB_PERMISSION_STATUS:screen_recording=1:accessibility=1:input_monitoring=1 +``` + +The values above are an example: `1` means granted and `0` means not granted. +The Receiver always reports `screen_recording=0` because it does not capture the +screen; that value does not indicate a missing Receiver permission. + +Results apply to the exact binary and execution context being checked. A temporary +build or an SSH/terminal launch can differ from the installed app's normal launch. +This command neither grants permissions nor proves that a full session will work. +Check both Macs in their intended roles when troubleshooting remote input. + ## Recipes **Connect from another Mac over SSH** — the Sender must be at a logged-in desktop with From ffc43f3115c7e8afde7b3726fd77b16b99e24062 Mon Sep 17 00:00:00 2001 From: swellweb Date: Mon, 14 Sep 2026 12:38:16 +0200 Subject: [PATCH 38/40] fix(input): preserve receiver modifiers on relayed pointer events --- .../TBDisplaySenderService.swift | 19 ++--- .../TBInjectedPointerEvent.swift | 20 +++++ .../TBInjectedPointerEventTests.swift | 73 +++++++++++++++++++ .../TargetBridge.xcodeproj/project.pbxproj | 8 ++ 4 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBInjectedPointerEventTests.swift diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index d8202909..1628f821 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -2135,7 +2135,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if shouldWarp { CGWarpMouseCursorPosition(target) } - guard let event = CGEvent(mouseEventSource: localInputEventSource(), mouseType: type, mouseCursorPosition: target, mouseButton: button) else { return } + guard let event = TBInjectedPointerEvent.mouse(source: localInputEventSource(), type: type, + position: target, button: button, modifiers: currentInjectedModifierFlags()) else { return } event.setIntegerValueField(.mouseEventDeltaX, value: Int64(dx)) event.setIntegerValueField(.mouseEventDeltaY, value: Int64(dy)) event.post(tap: .cghidEventTap) @@ -2147,7 +2148,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let frame = screenFrame(containing: target), target.x <= frame.minX || target.x >= frame.maxX - 1 || target.y <= frame.minY || target.y >= frame.maxY - 1, - let edgeEvent = CGEvent(mouseEventSource: localInputEventSource(), mouseType: .mouseMoved, mouseCursorPosition: target, mouseButton: button) { + let edgeEvent = TBInjectedPointerEvent.mouse(source: localInputEventSource(), type: .mouseMoved, + position: target, button: button, modifiers: currentInjectedModifierFlags()) { edgeEvent.setIntegerValueField(.mouseEventDeltaX, value: Int64(dx)) edgeEvent.setIntegerValueField(.mouseEventDeltaY, value: Int64(dy)) edgeEvent.post(tap: .cghidEventTap) @@ -2157,7 +2159,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func postLocalMouseButton(type: CGEventType, button: CGMouseButton, clickCount: Int? = nil) { logLocalInputInjectionStateIfNeeded(context: "mouseButton") guard let current = injectedRemoteMouseLocation ?? currentLocalMouseLocation() else { return } - guard let event = CGEvent(mouseEventSource: localInputEventSource(), mouseType: type, mouseCursorPosition: current, mouseButton: button) else { return } + guard let event = TBInjectedPointerEvent.mouse(source: localInputEventSource(), type: type, + position: current, button: button, modifiers: currentInjectedModifierFlags()) else { return } if let clickCount { event.setIntegerValueField(.mouseEventClickState, value: Int64(min(max(clickCount, 1), 3))) } else if button == .left { @@ -2178,14 +2181,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func postLocalScroll(scrollX: Int, scrollY: Int) { logLocalInputInjectionStateIfNeeded(context: "scroll") - guard let event = CGEvent( - scrollWheelEvent2Source: localInputEventSource(), - units: .line, - wheelCount: 2, - wheel1: Int32(scrollY), - wheel2: Int32(scrollX), - wheel3: 0 - ) else { return } + guard let event = TBInjectedPointerEvent.scroll(source: localInputEventSource(), + x: Int32(scrollX), y: Int32(scrollY), modifiers: currentInjectedModifierFlags()) else { return } event.post(tap: .cghidEventTap) } diff --git a/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift b/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift new file mode 100644 index 00000000..63bc04af --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift @@ -0,0 +1,20 @@ +import CoreGraphics + +/// Pointer events must carry the relayed keyboard state, not the sender's HID flags. +enum TBInjectedPointerEvent { + static func mouse(source: CGEventSource?, type: CGEventType, position: CGPoint, + button: CGMouseButton, modifiers: CGEventFlags) -> CGEvent? { + guard let event = CGEvent(mouseEventSource: source, mouseType: type, + mouseCursorPosition: position, mouseButton: button) else { return nil } + event.flags = modifiers + return event + } + + static func scroll(source: CGEventSource?, x: Int32, y: Int32, + modifiers: CGEventFlags) -> CGEvent? { + guard let event = CGEvent(scrollWheelEvent2Source: source, units: .line, + wheelCount: 2, wheel1: y, wheel2: x, wheel3: 0) else { return nil } + event.flags = modifiers + return event + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBInjectedPointerEventTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBInjectedPointerEventTests.swift new file mode 100644 index 00000000..9ab9b32a --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBInjectedPointerEventTests.swift @@ -0,0 +1,73 @@ +import CoreGraphics +import XCTest +@testable import TargetBridge + +final class TBInjectedPointerEventTests: XCTestCase { + private func mouse(_ type: CGEventType, _ flags: CGEventFlags, + button: CGMouseButton = .left) throws -> CGEvent { + try XCTUnwrap(TBInjectedPointerEvent.mouse(source: CGEventSource(stateID: .hidSystemState), + type: type, position: CGPoint(x: 120, y: 240), button: button, modifiers: flags)) + } + + func testShiftAndCommandReachBothHalvesOfClick() throws { + for flags: CGEventFlags in [.maskShift, .maskCommand, [.maskShift, .maskCommand]] { + for type: CGEventType in [.leftMouseDown, .leftMouseUp] { + let event = try mouse(type, flags) + XCTAssertEqual(event.flags, flags) + XCTAssertEqual(event.type, type) + } + } + } + + func testRepeatedSelectionClicksAndModifierRelease() throws { + // The remote key state stays held across clicks, then becomes empty on key-up. + for flags: CGEventFlags in [.maskCommand, .maskCommand, .maskShift, []] { + XCTAssertEqual(try mouse(.leftMouseDown, flags).flags, flags) + XCTAssertEqual(try mouse(.leftMouseUp, flags).flags, flags) + } + } + + func testAllDragButtonsPreserveModifiers() throws { + let flags: CGEventFlags = [.maskCommand, .maskShift] + for (type, button) in [(CGEventType.leftMouseDragged, CGMouseButton.left), + (.rightMouseDragged, .right), (.otherMouseDragged, .center)] { + let event = try mouse(type, flags, button: button) + XCTAssertEqual(event.flags, flags) + XCTAssertEqual(event.type, type) + XCTAssertEqual(event.location, CGPoint(x: 120, y: 240)) + } + } + + func testPointerMotionIncludingEdgeMovesPreservesModifiers() throws { + let flags: CGEventFlags = [.maskShift, .maskAlternate] + let event = try mouse(.mouseMoved, flags) + event.setIntegerValueField(.mouseEventDeltaX, value: -3) + event.setIntegerValueField(.mouseEventDeltaY, value: 7) + XCTAssertEqual(event.flags, flags) + XCTAssertEqual(event.getIntegerValueField(.mouseEventDeltaX), -3) + XCTAssertEqual(event.getIntegerValueField(.mouseEventDeltaY), 7) + } + + func testRightAndMiddleClicksAndDoubleClickStateStayIntact() throws { + for (type, button) in [(CGEventType.rightMouseDown, CGMouseButton.right), + (.rightMouseUp, .right), (.otherMouseDown, .center), + (.otherMouseUp, .center), (.leftMouseDown, .left)] { + let event = try mouse(type, .maskControl, button: button) + event.setIntegerValueField(.mouseEventClickState, value: 2) + XCTAssertEqual(event.flags, .maskControl) + XCTAssertEqual(event.getIntegerValueField(.mouseEventClickState), 2) + XCTAssertEqual(event.getIntegerValueField(.mouseEventButtonNumber), Int64(button.rawValue)) + } + } + + func testScrollPreservesOnlyRemoteFlagsAndClearsThemAfterRelease() throws { + for flags: CGEventFlags in [.maskAlternate, .maskShift, [.maskShift, .maskCommand], []] { + let event = try XCTUnwrap(TBInjectedPointerEvent.scroll( + source: CGEventSource(stateID: .hidSystemState), x: 2, y: -3, modifiers: flags)) + XCTAssertEqual(event.flags, flags) + XCTAssertFalse(event.flags.contains(.maskSecondaryFn)) + XCTAssertEqual(event.getIntegerValueField(.scrollWheelEventDeltaAxis1), -3) + XCTAssertEqual(event.getIntegerValueField(.scrollWheelEventDeltaAxis2), 2) + } + } +} diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index de0cb1b6..b76e8d17 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */; }; 6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */; }; 6DB5F29F60382A0CA97568F3 /* TBMenuMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08C140ECFEDAE11AD68D12B /* TBMenuMetrics.swift */; }; + 6FBBB12A4345F23FAD561DC9 /* TBInjectedPointerEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FECDAFCED370C001F6BBB7 /* TBInjectedPointerEventTests.swift */; }; 7528751850893B52660870E4 /* TBConfigurationDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7086193EE5710E2C37F86F1 /* TBConfigurationDiagnosticsTests.swift */; }; 762884127C8093D3E9797790 /* TBMenuToggleRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */; }; 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */; }; @@ -43,6 +44,7 @@ A3FD74604472363535D63273 /* TBDisplaySenderSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.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 */; }; BBFD76A352FEDC54029B2158 /* TBDisplaySenderLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */; }; BF146F68BB365CFE5AA9A1BD /* check_localizations.sh in Resources */ = {isa = PBXBuildFile; fileRef = 10E53D6EF9DCC519EEBFA5A2 /* check_localizations.sh */; }; C1A77120267A12D9E355DCEA /* network-link.json in Resources */ = {isa = PBXBuildFile; fileRef = 6579B499CBC11196966318EA /* network-link.json */; }; @@ -74,6 +76,7 @@ 06D011FF8AF7BB6D26EDC25B /* TBAddonStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonStore.swift; sourceTree = ""; }; 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderService.swift; sourceTree = ""; }; 10E53D6EF9DCC519EEBFA5A2 /* check_localizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = check_localizations.sh; sourceTree = ""; }; + 10EC09EEBA6EA9B2EA6ACD1F /* TBInjectedPointerEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedPointerEvent.swift; sourceTree = ""; }; 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscoveryModelTests.swift; sourceTree = ""; }; 17FD38CAA4E2CF350CC210D2 /* TBDisplaySender.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TBDisplaySender.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1819A55F8D993D9347F0E407 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -95,6 +98,7 @@ 73EDC0FF3A4EA4DC1ED63528 /* fr.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fr.json; sourceTree = ""; }; 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputRelayController.swift; sourceTree = ""; }; 80543A7A5A3B583C1543ABC7 /* TBInputDebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputDebugLog.swift; sourceTree = ""; }; + 99FECDAFCED370C001F6BBB7 /* TBInjectedPointerEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedPointerEventTests.swift; sourceTree = ""; }; 9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderAutomation.swift; sourceTree = ""; }; 9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderLocalization.swift; sourceTree = ""; }; A3964153C9F34085F90C2C26 /* audio-relay.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "audio-relay.json"; sourceTree = ""; }; @@ -150,6 +154,7 @@ 6AC1AF99431D4889F947CE1B /* TBDisplaySenderStatusItemController.swift */, DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */, 4134F78ABD695DF9DB5D485B /* TBInjectedClickStateTracker.swift */, + 10EC09EEBA6EA9B2EA6ACD1F /* TBInjectedPointerEvent.swift */, 4A72C42968FA2A6B2A0515B9 /* TBInputBinding.swift */, 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */, 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */, @@ -169,6 +174,7 @@ 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */, D419C88EEEA66EE0829BA975 /* TBDisplayProfileTests.swift */, EF17070AC1C6F9E9F3B4DFDF /* TBInjectedClickStateTrackerTests.swift */, + 99FECDAFCED370C001F6BBB7 /* TBInjectedPointerEventTests.swift */, 37515A84B71F4B19C8EB65B4 /* TBInputBindingTests.swift */, C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */, 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */, @@ -372,6 +378,7 @@ 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */, 5109DFCF1BFD76222E7B4BDD /* TBDisplayProfileTests.swift in Sources */, 0E118FC7EFBBF44A70799B03 /* TBInjectedClickStateTrackerTests.swift in Sources */, + 6FBBB12A4345F23FAD561DC9 /* TBInjectedPointerEventTests.swift in Sources */, 15D142162EDD10261447EC43 /* TBInputBindingTests.swift in Sources */, 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */, 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */, @@ -402,6 +409,7 @@ E9F7A263085592067562F768 /* TBDisplaySenderStatusItemController.swift in Sources */, BB4D986A5A0F32BC1480BD4D /* TBDisplaySenderSurfaceViews.swift in Sources */, 4C59B780CEA3CC5F563BC86F /* TBInjectedClickStateTracker.swift in Sources */, + BB61FDD4D950F325E9D2ADAC /* TBInjectedPointerEvent.swift in Sources */, 5A8811B2F7DB1830CBA7B538 /* TBInputBinding.swift in Sources */, 8D544352D7E2E226D551E46E /* TBInputBindingsView.swift in Sources */, D5E6B5B7B49E740C92010C6F /* TBInputDebugLog.swift in Sources */, From 4f67b12926bdf4b43c344b5565f69d2f6730b53e Mon Sep 17 00:00:00 2001 From: swellweb Date: Mon, 14 Sep 2026 18:14:25 +0200 Subject: [PATCH 39/40] fix(input): apply remote modifiers to local pointer clicks --- .../TBDisplaySenderService.swift | 7 ++ .../TBInjectedPointerEvent.swift | 4 + .../TBLocalPointerModifierBridge.swift | 63 ++++++++++++++++ .../TBLocalPointerModifiers.swift | 22 ++++++ .../TBLocalPointerModifiersTests.swift | 73 +++++++++++++++++++ .../TargetBridge.xcodeproj/project.pbxproj | 12 +++ 6 files changed, 181 insertions(+) create mode 100644 TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifierBridge.swift create mode 100644 TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifiers.swift create mode 100644 TargetBridge-Sender/TBDisplaySenderTests/TBLocalPointerModifiersTests.swift diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 1628f821..53bf1712 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1275,6 +1275,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var lastCursorPacket: TBMonitorCursor? private var injectedRemoteMouseLocation: CGPoint? private var injectedLeftClickTracker = TBInjectedClickStateTracker() + private let localPointerModifierBridge = TBLocalPointerModifierBridge() private var injectedCommandDown = false private var injectedShiftDown = false private var injectedOptionDown = false @@ -2196,6 +2197,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u case 57: injectedCapsDown = isDown default: break } + if TBInputBindingEngine.modifierBit(for: keyCode) != nil { + let flags = currentInjectedModifierFlags() + localPointerModifierBridge.update(inputControlRole == .receiverMaster ? flags : []) + TBInputDebugLog.log("sender remote modifier key=\(keyCode) down=\(isDown) flags=\(flags.rawValue)") + } guard let event = CGEvent(keyboardEventSource: localInputEventSource(), virtualKey: CGKeyCode(keyCode), keyDown: isDown) else { return } event.flags = currentInjectedModifierFlags() event.post(tap: .cghidEventTap) @@ -2242,6 +2248,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u postLocalKey(keyCode: 57, isDown: false) injectedCapsDown = false } + localPointerModifierBridge.stop() } private func postLocalSpaceSwitch(direction: Int) { diff --git a/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift b/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift index 63bc04af..e30df305 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift @@ -2,11 +2,14 @@ import CoreGraphics /// Pointer events must carry the relayed keyboard state, not the sender's HID flags. enum TBInjectedPointerEvent { + static let marker: Int64 = 0x5442504F494E5445 + static func mouse(source: CGEventSource?, type: CGEventType, position: CGPoint, button: CGMouseButton, modifiers: CGEventFlags) -> CGEvent? { guard let event = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: position, mouseButton: button) else { return nil } event.flags = modifiers + event.setIntegerValueField(.eventSourceUserData, value: marker) return event } @@ -15,6 +18,7 @@ enum TBInjectedPointerEvent { guard let event = CGEvent(scrollWheelEvent2Source: source, units: .line, wheelCount: 2, wheel1: y, wheel2: x, wheel3: 0) else { return nil } event.flags = modifiers + event.setIntegerValueField(.eventSourceUserData, value: marker) return event } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifierBridge.swift b/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifierBridge.swift new file mode 100644 index 00000000..34abfde1 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifierBridge.swift @@ -0,0 +1,63 @@ +import CoreGraphics +import Foundation + +@MainActor +final class TBLocalPointerModifierBridge { + private var modifiers = TBLocalPointerModifiers() + // Mutated on the main actor; deinit only invalidates/removes the CF resources. + nonisolated(unsafe) private var tap: CFMachPort? + nonisolated(unsafe) private var source: CFRunLoopSource? + + func update(_ flags: CGEventFlags) { + modifiers.remoteFlags = flags.intersection(TBLocalPointerModifiers.supported) + guard !modifiers.remoteFlags.isEmpty else { stop(); return } + guard tap == nil else { return } + let mask = TBLocalPointerModifiers.eventTypes.reduce(CGEventMask(0)) { $0 | (1 << $1.rawValue) } + guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, + options: .defaultTap, eventsOfInterest: mask, callback: { _, type, event, context in + guard let context else { return Unmanaged.passUnretained(event) } + // The tap is serviced exclusively by the main run loop. + MainActor.assumeIsolated { + let bridge = Unmanaged.fromOpaque(context).takeUnretainedValue() + bridge.handle(type: type, event: event) + } + return Unmanaged.passUnretained(event) + }, userInfo: Unmanaged.passUnretained(self).toOpaque()) else { + TBInputDebugLog.log("sender local-pointer modifier bridge unavailable; check Accessibility") + return + } + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + CFMachPortInvalidate(tap) + return + } + self.tap = tap + self.source = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + TBInputDebugLog.log("sender local-pointer modifier bridge active") + } + + func stop() { + modifiers.remoteFlags = [] + if let tap { CFMachPortInvalidate(tap) } + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + tap = nil + source = nil + } + + private func handle(type: CGEventType, event: CGEvent) { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + } else if modifiers.apply(to: event), type == .leftMouseDown { + let flags = modifiers.remoteFlags.rawValue + Task { @MainActor in + TBInputDebugLog.log("sender local click with remote modifiers=\(flags)") + } + } + } + + deinit { + if let tap { CFMachPortInvalidate(tap) } + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + } +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifiers.swift b/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifiers.swift new file mode 100644 index 00000000..5ee29cb0 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifiers.swift @@ -0,0 +1,22 @@ +import CoreGraphics + +/// Combines a remote keyboard with a local mouse, without rewriting key events. +struct TBLocalPointerModifiers { + static let supported: CGEventFlags = [.maskShift, .maskCommand, .maskAlternate, .maskControl] + static let eventTypes: [CGEventType] = [ + .leftMouseDown, .leftMouseUp, .leftMouseDragged, + .rightMouseDown, .rightMouseUp, .rightMouseDragged, + .otherMouseDown, .otherMouseUp, .otherMouseDragged + ] + var remoteFlags: CGEventFlags = [] + + @discardableResult + func apply(to event: CGEvent) -> Bool { + guard Self.eventTypes.contains(event.type), + event.getIntegerValueField(.eventSourceUserData) != TBInjectedPointerEvent.marker else { return false } + let combined = event.flags.union(remoteFlags.intersection(Self.supported)) + guard combined != event.flags else { return false } + event.flags = combined + return true + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBLocalPointerModifiersTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBLocalPointerModifiersTests.swift new file mode 100644 index 00000000..a40239de --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBLocalPointerModifiersTests.swift @@ -0,0 +1,73 @@ +import CoreGraphics +import XCTest +@testable import TargetBridge + +final class TBLocalPointerModifiersTests: XCTestCase { + private func pointer(_ type: CGEventType = .leftMouseDown, + flags: CGEventFlags = []) throws -> CGEvent { + let event = try XCTUnwrap(CGEvent(mouseEventSource: nil, mouseType: type, + mouseCursorPosition: CGPoint(x: 120, y: 240), mouseButton: .left)) + event.flags = flags + return event + } + + func testRemoteShiftAndCommandReachLocalClicksAndDrags() throws { + for flags: CGEventFlags in [.maskShift, .maskCommand, [.maskShift, .maskCommand]] { + let state = TBLocalPointerModifiers(remoteFlags: flags) + for type in TBLocalPointerModifiers.eventTypes { + let event = try pointer(type) + XCTAssertTrue(state.apply(to: event)) + XCTAssertEqual(event.flags, flags) + XCTAssertEqual(event.type, type) + } + } + } + + func testHeldModifierPersistsAcrossClicksThenReleaseClearsIt() throws { + var state = TBLocalPointerModifiers(remoteFlags: [.maskShift, .maskCommand]) + for _ in 0..<3 { + XCTAssertTrue(state.apply(to: try pointer())) + } + state.remoteFlags = .maskShift + let stillShift = try pointer() + state.apply(to: stillShift) + XCTAssertEqual(stillShift.flags, .maskShift) + state.remoteFlags = [] + let released = try pointer() + XCTAssertFalse(state.apply(to: released)) + XCTAssertEqual(released.flags, []) + } + + func testPreservesPhysicalModifiersPositionAndClickCount() throws { + let event = try pointer(flags: [.maskAlternate, .maskAlphaShift]) + event.setIntegerValueField(.mouseEventClickState, value: 2) + TBLocalPointerModifiers(remoteFlags: .maskShift).apply(to: event) + XCTAssertEqual(event.flags, [.maskAlternate, .maskAlphaShift, .maskShift]) + XCTAssertEqual(event.location, CGPoint(x: 120, y: 240)) + XCTAssertEqual(event.getIntegerValueField(.mouseEventClickState), 2) + } + + func testRemoteFnAndUnsupportedFlagsNeverLeakIntoLocalClicks() throws { + let event = try pointer() + TBLocalPointerModifiers(remoteFlags: [.maskShift, .maskSecondaryFn, .maskAlphaShift, + .maskNumericPad]).apply(to: event) + XCTAssertEqual(event.flags, .maskShift) + } + + func testKeyboardMotionAndScrollAreNotRewritten() throws { + let state = TBLocalPointerModifiers(remoteFlags: [.maskShift, .maskCommand]) + for type: CGEventType in [.keyDown, .keyUp, .flagsChanged, .mouseMoved, .scrollWheel] { + let event = try pointer() + event.type = type + XCTAssertFalse(state.apply(to: event)) + XCTAssertEqual(event.flags, []) + } + } + + func testQueuedRelayedClickKeepsOriginalModifierSnapshot() throws { + let event = try XCTUnwrap(TBInjectedPointerEvent.mouse(source: nil, type: .leftMouseDown, + position: .zero, button: .left, modifiers: .maskShift)) + XCTAssertFalse(TBLocalPointerModifiers(remoteFlags: .maskCommand).apply(to: event)) + XCTAssertEqual(event.flags, .maskShift) + } +} diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index b76e8d17..113f7053 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -13,12 +13,14 @@ 15D142162EDD10261447EC43 /* TBInputBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37515A84B71F4B19C8EB65B4 /* TBInputBindingTests.swift */; }; 198725EB3B2C7662DBB0B8D0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1819A55F8D993D9347F0E407 /* Assets.xcassets */; }; 233BAEE5ABED3F316F9D3D12 /* input-dockstation.json in Resources */ = {isa = PBXBuildFile; fileRef = A51B69D318A85FF3061D6ED4 /* input-dockstation.json */; }; + 24481F31E6A64909C94CEB56 /* TBLocalPointerModifierBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 051ACEA420DA015B4F108362 /* TBLocalPointerModifierBridge.swift */; }; 2819CE06D15CA7EDD1069062 /* TBConfigurationDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F0782C970AA23871AE73A6 /* TBConfigurationDiagnostics.swift */; }; 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */; }; 337BD561622AA19F43D3B95B /* ReceiverBackedVirtualDisplaySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */; }; 4290B6572B8CA96E01C56425 /* TBDisplaySenderBuildInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */; }; 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */; }; 4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */; }; + 4C42F1437E28DD5FA01998CF /* TBLocalPointerModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFC1E2E64342FDE66C3AAC8 /* TBLocalPointerModifiers.swift */; }; 4C59B780CEA3CC5F563BC86F /* TBInjectedClickStateTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4134F78ABD695DF9DB5D485B /* TBInjectedClickStateTracker.swift */; }; 50C6F9D35F964ABF03193C5B /* TBCaptureHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8E93993FCAE2FCBA71C45B /* TBCaptureHealth.swift */; }; 5109DFCF1BFD76222E7B4BDD /* TBDisplayProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D419C88EEEA66EE0829BA975 /* TBDisplayProfileTests.swift */; }; @@ -39,6 +41,7 @@ 8A729C68ECAC4642FA20A284 /* TBLocalizationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C932E960346E674A0659624 /* TBLocalizationStore.swift */; }; 8D544352D7E2E226D551E46E /* TBInputBindingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */; }; 8D9D2A40A1CD90BD4D4DFC2C /* TBDisplaySenderAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */; }; + 969232A8C441AEFC4A09459C /* TBLocalPointerModifiersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AD26344132A9583BF34ED8 /* TBLocalPointerModifiersTests.swift */; }; 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 */; }; @@ -72,8 +75,10 @@ /* Begin PBXFileReference section */ 036E14097FA59989FFC456FD /* it.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = it.json; sourceTree = ""; }; 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuToggleRowView.swift; sourceTree = ""; }; + 051ACEA420DA015B4F108362 /* TBLocalPointerModifierBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalPointerModifierBridge.swift; sourceTree = ""; }; 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscovery.swift; sourceTree = ""; }; 06D011FF8AF7BB6D26EDC25B /* TBAddonStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonStore.swift; sourceTree = ""; }; + 0AFC1E2E64342FDE66C3AAC8 /* TBLocalPointerModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalPointerModifiers.swift; sourceTree = ""; }; 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderService.swift; sourceTree = ""; }; 10E53D6EF9DCC519EEBFA5A2 /* check_localizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = check_localizations.sh; sourceTree = ""; }; 10EC09EEBA6EA9B2EA6ACD1F /* TBInjectedPointerEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedPointerEvent.swift; sourceTree = ""; }; @@ -91,6 +96,7 @@ 4A72C42968FA2A6B2A0515B9 /* TBInputBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputBinding.swift; sourceTree = ""; }; 4C932E960346E674A0659624 /* TBLocalizationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalizationStore.swift; sourceTree = ""; }; 4E4C95A1820A99E5DAED17DE /* TBDisplayProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplayProfile.swift; sourceTree = ""; }; + 56AD26344132A9583BF34ED8 /* TBLocalPointerModifiersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalPointerModifiersTests.swift; sourceTree = ""; }; 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonManifest.swift; sourceTree = ""; }; 6579B499CBC11196966318EA /* network-link.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "network-link.json"; sourceTree = ""; }; 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBVirtualDisplayModeMemory.swift; sourceTree = ""; }; @@ -158,6 +164,8 @@ 4A72C42968FA2A6B2A0515B9 /* TBInputBinding.swift */, 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */, 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */, + 051ACEA420DA015B4F108362 /* TBLocalPointerModifierBridge.swift */, + 0AFC1E2E64342FDE66C3AAC8 /* TBLocalPointerModifiers.swift */, F08C140ECFEDAE11AD68D12B /* TBMenuMetrics.swift */, 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */, 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */, @@ -176,6 +184,7 @@ EF17070AC1C6F9E9F3B4DFDF /* TBInjectedClickStateTrackerTests.swift */, 99FECDAFCED370C001F6BBB7 /* TBInjectedPointerEventTests.swift */, 37515A84B71F4B19C8EB65B4 /* TBInputBindingTests.swift */, + 56AD26344132A9583BF34ED8 /* TBLocalPointerModifiersTests.swift */, C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */, 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */, ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */, @@ -380,6 +389,7 @@ 0E118FC7EFBBF44A70799B03 /* TBInjectedClickStateTrackerTests.swift in Sources */, 6FBBB12A4345F23FAD561DC9 /* TBInjectedPointerEventTests.swift in Sources */, 15D142162EDD10261447EC43 /* TBInputBindingTests.swift in Sources */, + 969232A8C441AEFC4A09459C /* TBLocalPointerModifiersTests.swift in Sources */, 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */, 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */, 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */, @@ -414,6 +424,8 @@ 8D544352D7E2E226D551E46E /* TBInputBindingsView.swift in Sources */, D5E6B5B7B49E740C92010C6F /* TBInputDebugLog.swift in Sources */, CBA7C2F0FA57CC475E2307AF /* TBInputRelayController.swift in Sources */, + 24481F31E6A64909C94CEB56 /* TBLocalPointerModifierBridge.swift in Sources */, + 4C42F1437E28DD5FA01998CF /* TBLocalPointerModifiers.swift in Sources */, 8A729C68ECAC4642FA20A284 /* TBLocalizationStore.swift in Sources */, 6DB5F29F60382A0CA97568F3 /* TBMenuMetrics.swift in Sources */, 762884127C8093D3E9797790 /* TBMenuToggleRowView.swift in Sources */, From b952c98181b85cf34caaf66aba01166153aeb348 Mon Sep 17 00:00:00 2001 From: swellweb Date: Mon, 14 Sep 2026 21:05:08 +0200 Subject: [PATCH 40/40] release: prepare TargetBridge 3.5.2 --- README.md | 17 ++++-- TargetBridge-Receiver/TBReceiverC/Makefile | 2 +- .../TBReceiverC/src/display.c | 2 +- .../scripts/build_tbreceiver_c_app.sh | 2 +- .../TBDisplaySenderBuildInfo.swift | 2 +- .../TargetBridge.xcodeproj/project.pbxproj | 6 +- TargetBridge-Sender/project.yml | 4 +- docs/releases/3.5.2.md | 55 +++++++++++++++++++ 8 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 docs/releases/3.5.2.md diff --git a/README.md b/README.md index 04ee09a6..63e083ad 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,18 @@ 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.1 +## TargetBridge 3.5.2 -TargetBridge 3.5.1 is a maintenance release focused on safer input control, -accurate Receiver display profiles, idle resource handling, and improved compatibility with older Macs: +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. + +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: - 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 @@ -46,7 +54,8 @@ accurate Receiver display profiles, idle resource handling, and improved compati - 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.1 release notes](docs/releases/3.5.1.md) and +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 [testing guide](docs/Testing.md), including the new monitor-shield lifecycle test. It also keeps the established multi-Mac workspace features: diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 9e852873..8660b5a5 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -16,7 +16,7 @@ CC ?= clang PKG ?= pkgconf -APP_VERSION ?= 3.5.1 +APP_VERSION ?= 3.5.2 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 80bdf1d8..e432ee95 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.2.0" +#define TB_RECEIVER_VERSION "3.5.2" #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 a14975ad..289e46cd 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.1}" +APP_VERSION="${APP_VERSION:-3.5.2}" 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 60401058..b3ff066d 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.5.1" + static let marketingVersion = "3.5.2" static let buildNumber = "20260810141154" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 113f7053..5f896ae7 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -373,7 +373,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" <