From b761ee6cb536d6c2e32826676b05125eaff0f8eb Mon Sep 17 00:00:00 2001 From: Betafer Date: Wed, 19 Aug 2026 14:58:48 +0200 Subject: [PATCH 1/7] 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 4ec48072a4a211d1a5403fae7f496a0cba828ba3 Mon Sep 17 00:00:00 2001 From: Betafer Date: Sat, 12 Sep 2026 02:11:16 +0200 Subject: [PATCH 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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" <