From 4f6fd8f778965056a5fc53775079481195aca5a1 Mon Sep 17 00:00:00 2001 From: David Liebovitz Date: Mon, 25 May 2026 21:58:25 -0500 Subject: [PATCH 1/2] Sender: prevent post-screensaver/display-sleep stutter Adds three user-controllable options in the sender GUI to avoid the "stutter that needs an app restart" symptom after the screensaver or display sleep activates while streaming. - Settings toggle "Prevent screensaver / display sleep while streaming" (default on, persisted) gates passing `.idleDisplaySleepDisabled` to `ProcessInfo.beginActivity`, so the screensaver/display-sleep does not engage during an active session. - Settings toggle "Auto-restart capture after wake / unlock" (default on, persisted) enables observers on `screensDidWake`, `com.apple.screenIsUnlocked`, and `com.apple.screensaver.didstop`. When fired, the capture pipeline (SCStream/CGDisplayStream + VTCompressionSession + capture timers + activity) is torn down and rebuilt while keeping the network connection and the virtual display alive. The PTS sequence counter is reset so the receiver's pacing does not desync after the gap. - Per-session "Restart capture" button (enabled while streaming) runs the same soft-restart path on demand for any other stutter cause. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TBDisplaySenderBuildInfo.swift | 2 +- .../TBDisplaySenderContentView.swift | 10 ++ .../TBDisplaySenderLocalization.swift | 24 +++ .../TBDisplaySenderManager.swift | 31 +++- .../TBDisplaySenderService.swift | 139 +++++++++++++++++- 5 files changed, 201 insertions(+), 5 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift index 0a34d47..905de6d 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { static let marketingVersion = "2.0" - static let buildNumber = "20260521211758" + static let buildNumber = "20260525204941" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index 39d0702..b4af181 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -163,6 +163,10 @@ struct TBDisplaySenderContentView: View { Toggle(TBDisplaySenderL10n.largeCursor(service.language), isOn: $service.largeCursor) .disabled(service.anyConnected) + + Toggle(TBDisplaySenderL10n.preventDisplaySleep(service.language), isOn: $service.preventDisplaySleep) + + Toggle(TBDisplaySenderL10n.autoRestartOnWake(service.language), isOn: $service.autoRestartOnWake) } } } @@ -336,6 +340,12 @@ private struct TBDisplaySenderSessionCard: View { .buttonStyle(.bordered) .disabled(session.isConnected || session.isStreaming || session.isCableTesting || trimmedReceiverIP.isEmpty || session.localTBIP.isEmpty) + Button(TBDisplaySenderL10n.restartCaptureButton(service.language)) { + session.restartCaptureNow() + } + .buttonStyle(.bordered) + .disabled(!session.canRestartCapture) + Button(TBDisplaySenderL10n.removeSessionButton(service.language)) { service.removeSession(session) } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index 18fa916..ae82c23 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -480,6 +480,30 @@ enum TBDisplaySenderL10n { } } + static func preventDisplaySleep(_ language: TBDisplaySenderLanguage) -> String { + switch language { + case .italian: return "Impedisci screensaver e sospensione display durante lo streaming" + case .english: return "Prevent screensaver / display sleep while streaming" + case .german: return "Bildschirmschoner und Display-Ruhezustand beim Streamen verhindern" + } + } + + static func autoRestartOnWake(_ language: TBDisplaySenderLanguage) -> String { + switch language { + case .italian: return "Riavvia automaticamente la cattura al risveglio" + case .english: return "Auto-restart capture after wake / unlock" + case .german: return "Aufnahme nach Aufwachen/Entsperren automatisch neu starten" + } + } + + static func restartCaptureButton(_ language: TBDisplaySenderLanguage) -> String { + switch language { + case .italian: return "Riavvia cattura" + case .english: return "Restart capture" + case .german: return "Aufnahme neu starten" + } + } + static func showMainWindow(_ language: TBDisplaySenderLanguage) -> String { switch language { case .italian: return "Mostra finestra principale" diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index 929d4c2..c27f081 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -31,6 +31,30 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } } + @Published var preventDisplaySleep: Bool = { + if UserDefaults.standard.object(forKey: "fd.tbdisplaysender.preventDisplaySleep") == nil { + return true + } + return UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.preventDisplaySleep") + }() { + didSet { + UserDefaults.standard.set(preventDisplaySleep, forKey: "fd.tbdisplaysender.preventDisplaySleep") + sessions.forEach { $0.preventDisplaySleep = preventDisplaySleep } + objectWillChange.send() + } + } + @Published var autoRestartOnWake: Bool = { + if UserDefaults.standard.object(forKey: "fd.tbdisplaysender.autoRestartOnWake") == nil { + return true + } + return UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.autoRestartOnWake") + }() { + didSet { + UserDefaults.standard.set(autoRestartOnWake, forKey: "fd.tbdisplaysender.autoRestartOnWake") + sessions.forEach { $0.autoRestartOnWake = autoRestartOnWake } + objectWillChange.send() + } + } private var sessionCancellables: [UUID: AnyCancellable] = [:] private let receiverDiscovery = TBReceiverDiscovery() @@ -70,7 +94,12 @@ final class TBDisplaySenderService: ObservableObject { } func addSession() { - let session = TBDisplaySenderSession(language: language, largeCursor: largeCursor) + let session = TBDisplaySenderSession( + language: language, + largeCursor: largeCursor, + preventDisplaySleep: preventDisplaySleep, + autoRestartOnWake: autoRestartOnWake + ) if let previous = sessions.last { session.capturePreset = previous.capturePreset session.captureSource = previous.captureSource diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 5238797..aebdd6c 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -394,18 +394,33 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let id = UUID() - init(language: TBDisplaySenderLanguage, largeCursor: Bool) { + init( + language: TBDisplaySenderLanguage, + largeCursor: Bool, + preventDisplaySleep: Bool = true, + autoRestartOnWake: Bool = true + ) { self.statusText = TBDisplaySenderStatusState.ready.text(language) self.receiverPanelText = TBDisplaySenderL10n.waitingReceiverProfile(language) self.virtualDisplayText = TBDisplaySenderL10n.virtualDisplayNotCreated(language) self.language = language self.largeCursor = largeCursor + self.preventDisplaySleep = preventDisplaySleep + self.autoRestartOnWake = autoRestartOnWake self.streamResolutionText = TBDisplaySenderL10n.streamSummary( preset: .standard1440p, source: .desktopMirror, language: language ) super.init() + registerWakeObservers() + } + + deinit { + for token in wakeObservers { + NSWorkspace.shared.notificationCenter.removeObserver(token) + DistributedNotificationCenter.default().removeObserver(token) + } } @Published var isConnected = false @@ -432,6 +447,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } @Published var largeCursor: Bool + @Published var preventDisplaySleep: Bool = true + @Published var autoRestartOnWake: Bool = true @Published var capturePreset: TBDisplayCapturePreset = .standard1440p { didSet { if !isStreaming { @@ -477,6 +494,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var baselineDisplayIDs = Set() private var cursorDisplayID: CGDirectDisplayID = kCGNullDirectDisplay private var lastCursorPacket: TBMonitorCursor? + nonisolated(unsafe) private var wakeObservers: [NSObjectProtocol] = [] + private var isRestartingCaptureAfterWake = false private final class CaptureDelegate: NSObject, SCStreamOutput, SCStreamDelegate { var onFrame: ((CMSampleBuffer) -> Void)? @@ -1056,7 +1075,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u isStreaming = true if largeCursor { startCursorUpdates(displayID: display.displayID) } streamingActivity = ProcessInfo.processInfo.beginActivity( - options: [.userInitiated, .idleSystemSleepDisabled], + options: activityOptions(), reason: "TargetBridge streaming active" ) startFPSTimer() @@ -1094,13 +1113,21 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u isStreaming = true if largeCursor { startCursorUpdates(displayID: displayID) } streamingActivity = ProcessInfo.processInfo.beginActivity( - options: [.userInitiated, .idleSystemSleepDisabled], + options: activityOptions(), reason: "TargetBridge streaming active" ) startFPSTimer() return true } + private func activityOptions() -> ProcessInfo.ActivityOptions { + var options: ProcessInfo.ActivityOptions = [.userInitiated, .idleSystemSleepDisabled] + if preventDisplaySleep { + options.insert(.idleDisplaySleepDisabled) + } + return options + } + private func waitForCaptureDisplay() async throws -> SCDisplay { try await waitForVirtualDisplay( matching: session.displayID, @@ -1646,6 +1673,112 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } + private func registerWakeObservers() { + let handler: @Sendable (Notification) -> Void = { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleSystemWake() + } + } + + wakeObservers.append( + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: nil, + using: handler + ) + ) + wakeObservers.append( + DistributedNotificationCenter.default().addObserver( + forName: Notification.Name("com.apple.screenIsUnlocked"), + object: nil, + queue: nil, + using: handler + ) + ) + wakeObservers.append( + DistributedNotificationCenter.default().addObserver( + forName: Notification.Name("com.apple.screensaver.didstop"), + object: nil, + queue: nil, + using: handler + ) + ) + } + + private func handleSystemWake() { + guard autoRestartOnWake else { return } + scheduleCaptureRestart(reason: "system wake", delaySeconds: 1.0) + } + + func restartCaptureNow() { + scheduleCaptureRestart(reason: "manual restart", delaySeconds: 0.0) + } + + var canRestartCapture: Bool { + isStreaming && activeProfile != nil && !isRestartingCaptureAfterWake + } + + private func scheduleCaptureRestart(reason: String, delaySeconds: Double) { + guard isStreaming, !isRestartingCaptureAfterWake, let profile = activeProfile else { return } + isRestartingCaptureAfterWake = true + NSLog("TargetBridge: \(reason) — soft restart of capture pipeline") + 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 + return + } + await self.softRestartCapture(for: profile) + self.isRestartingCaptureAfterWake = false + } + } + + private func softRestartCapture(for profile: TBMonitorDisplayProfile) async { + // Tear down only the capture pipeline — keep the network connection and virtual display. + cursorTimer?.invalidate() + cursorTimer = nil + fpsTimer?.invalidate() + fpsTimer = nil + if let directDisplayStream { + directDisplayStream.stop() + self.directDisplayStream = nil + } + if let stream = scStream { + if let delegate = captureDelegate { + try? stream.removeStreamOutput(delegate, type: .screen) + } + stream.stopCapture(completionHandler: nil) + scStream = nil + } + captureDelegate = nil + if let activity = streamingActivity { + ProcessInfo.processInfo.endActivity(activity) + streamingActivity = nil + } + if let encoder = vtEncoder { VTCompressionSessionInvalidate(encoder) } + vtEncoder = nil + vtEncoderRef?.release() + vtEncoderRef = nil + isStreaming = false + displayStreamFrameSequence = 0 + senderFPS = 0 + sentSnapshot = sentFrames + pendingVideoPackets = 0 + inFlightEncodeFrames = 0 + cursorDisplayID = kCGNullDirectDisplay + lastCursorPacket = nil + + let started = await startCapture(for: profile) + if !started { + NSLog("TargetBridge: soft restart after wake failed — falling back to full stop") + stop(resetStatusTo: .captureError("capture restart after wake failed")) + } + } + private func startFPSTimer() { fpsTimer?.invalidate() sentSnapshot = sentFrames From 46b2c79e15b74463a5c00dd9af3f2e97e3a9814b Mon Sep 17 00:00:00 2001 From: David Liebovitz Date: Tue, 26 May 2026 20:52:51 -0500 Subject: [PATCH 2/2] Sender: stable extended-display identity + diagnostic logging; default toggles off After field testing, the prior toggles did not address the long-running stutter (which is duration-driven, not screensaver-driven). Updates: - Default both "Prevent screensaver / display sleep" and "Auto-restart capture after wake" to OFF. Both remain available for users who find them useful; existing UserDefaults values are preserved. - Derive the extended-desktop virtual display productID/serialNumber deterministically from the receiver identity (name + panel dimensions) instead of randomizing each session. macOS keys window-position memory on these fields, so the random identity caused windows on the extended display to be forgotten on every reconnect. Same receiver now produces the same virtual display identity across reconnects, which lets the system restore prior window placement and matches the identity convention already used for the saved arrangement key. - Add an opt-in "Log virtual display events to Console (verbose)" toggle (default off). When enabled, registers a CGDisplayRegisterReconfigurationCallback that logs every add / remove / mirror / mode-change event affecting any display, and emits a periodic (60 s) stream snapshot to NSLog with streaming state, FPS, virtual display online status, pending packet count, in-flight encode count, and PTS sequence. Intended for diagnosing the long-duration stutter from Console.app without leaving heavy logging on for ordinary users. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ReceiverBackedVirtualDisplaySession.swift | 21 +++- .../TBDisplaySenderBuildInfo.swift | 2 +- .../TBDisplaySenderContentView.swift | 2 + .../TBDisplaySenderLocalization.swift | 8 ++ .../TBDisplaySenderManager.swift | 24 ++-- .../TBDisplaySenderService.swift | 115 ++++++++++++++++-- 6 files changed, 147 insertions(+), 25 deletions(-) diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index fa99ada..59f488a 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -18,15 +18,28 @@ struct TBVirtualDisplayIdentity { usesDedicatedArrangementIdentity: false ) - static func extendedDesktop() -> TBVirtualDisplayIdentity { - let random = UInt32.random(in: 0x0100...0xFFFE) + static func extendedDesktop(for profile: TBMonitorDisplayProfile) -> TBVirtualDisplayIdentity { + // Deterministic identity per receiver so macOS retains window placement + // and the saved extended-desktop arrangement across reconnects. + let key = "\(profile.receiverName)|\(profile.panelWidth)x\(profile.panelHeight)" + let hash = djb2(key) + let productLow = (hash & 0x00FF) | 0x01 + let serialLow = (hash & 0xFFFE) | 0x0100 return TBVirtualDisplayIdentity( - productID: 0x6000 | (random & 0x00FF), - serialNumber: 0x2027_0000 | random, + productID: 0x6000 | productLow, + serialNumber: 0x2027_0000 | UInt32(serialLow), displayNamePrefix: "TB Extend", usesDedicatedArrangementIdentity: true ) } + + private static func djb2(_ input: String) -> UInt32 { + var hash: UInt32 = 5381 + for byte in input.utf8 { + hash = hash &* 33 &+ UInt32(byte) + } + return hash + } } @MainActor diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift index 905de6d..7e4bfe6 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { static let marketingVersion = "2.0" - static let buildNumber = "20260525204941" + static let buildNumber = "20260526205217" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index b4af181..489c16c 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -167,6 +167,8 @@ struct TBDisplaySenderContentView: View { Toggle(TBDisplaySenderL10n.preventDisplaySleep(service.language), isOn: $service.preventDisplaySleep) Toggle(TBDisplaySenderL10n.autoRestartOnWake(service.language), isOn: $service.autoRestartOnWake) + + Toggle(TBDisplaySenderL10n.verboseDisplayLogging(service.language), isOn: $service.verboseDisplayLogging) } } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index ae82c23..b2a3967 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -504,6 +504,14 @@ enum TBDisplaySenderL10n { } } + static func verboseDisplayLogging(_ language: TBDisplaySenderLanguage) -> String { + switch language { + case .italian: return "Diagnostica display in Console (verboso)" + case .english: return "Log virtual display events to Console (verbose)" + case .german: return "Virtuelle Display-Ereignisse in Console protokollieren (ausführlich)" + } + } + static func showMainWindow(_ language: TBDisplaySenderLanguage) -> String { switch language { case .italian: return "Mostra finestra principale" diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index c27f081..5931c4f 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -31,30 +31,27 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } } - @Published var preventDisplaySleep: Bool = { - if UserDefaults.standard.object(forKey: "fd.tbdisplaysender.preventDisplaySleep") == nil { - return true - } - return UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.preventDisplaySleep") - }() { + @Published var preventDisplaySleep: Bool = UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.preventDisplaySleep") { didSet { UserDefaults.standard.set(preventDisplaySleep, forKey: "fd.tbdisplaysender.preventDisplaySleep") sessions.forEach { $0.preventDisplaySleep = preventDisplaySleep } objectWillChange.send() } } - @Published var autoRestartOnWake: Bool = { - if UserDefaults.standard.object(forKey: "fd.tbdisplaysender.autoRestartOnWake") == nil { - return true - } - return UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.autoRestartOnWake") - }() { + @Published var autoRestartOnWake: Bool = UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.autoRestartOnWake") { didSet { UserDefaults.standard.set(autoRestartOnWake, forKey: "fd.tbdisplaysender.autoRestartOnWake") sessions.forEach { $0.autoRestartOnWake = autoRestartOnWake } objectWillChange.send() } } + @Published var verboseDisplayLogging: Bool = UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.verboseDisplayLogging") { + didSet { + UserDefaults.standard.set(verboseDisplayLogging, forKey: "fd.tbdisplaysender.verboseDisplayLogging") + sessions.forEach { $0.verboseDisplayLogging = verboseDisplayLogging } + objectWillChange.send() + } + } private var sessionCancellables: [UUID: AnyCancellable] = [:] private let receiverDiscovery = TBReceiverDiscovery() @@ -98,7 +95,8 @@ final class TBDisplaySenderService: ObservableObject { language: language, largeCursor: largeCursor, preventDisplaySleep: preventDisplaySleep, - autoRestartOnWake: autoRestartOnWake + autoRestartOnWake: autoRestartOnWake, + verboseDisplayLogging: verboseDisplayLogging ) if let previous = sessions.last { session.capturePreset = previous.capturePreset diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index aebdd6c..a7b1218 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -238,12 +238,12 @@ enum TBDisplayCaptureSource: String, CaseIterable, Identifiable { } } - var virtualDisplayIdentity: TBVirtualDisplayIdentity { + func virtualDisplayIdentity(for profile: TBMonitorDisplayProfile) -> TBVirtualDisplayIdentity { switch self { case .desktopMirror: return .desktopMirror case .extendedDesktop: - return .extendedDesktop() + return .extendedDesktop(for: profile) } } } @@ -397,8 +397,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u init( language: TBDisplaySenderLanguage, largeCursor: Bool, - preventDisplaySleep: Bool = true, - autoRestartOnWake: Bool = true + preventDisplaySleep: Bool = false, + autoRestartOnWake: Bool = false, + verboseDisplayLogging: Bool = false ) { self.statusText = TBDisplaySenderStatusState.ready.text(language) self.receiverPanelText = TBDisplaySenderL10n.waitingReceiverProfile(language) @@ -407,6 +408,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u self.largeCursor = largeCursor self.preventDisplaySleep = preventDisplaySleep self.autoRestartOnWake = autoRestartOnWake + self.verboseDisplayLogging = verboseDisplayLogging self.streamResolutionText = TBDisplaySenderL10n.streamSummary( preset: .standard1440p, source: .desktopMirror, @@ -414,6 +416,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u ) super.init() registerWakeObservers() + registerDisplayReconfigurationCallback() } deinit { @@ -421,6 +424,12 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u NSWorkspace.shared.notificationCenter.removeObserver(token) DistributedNotificationCenter.default().removeObserver(token) } + if displayReconfigurationCallbackRegistered { + CGDisplayRemoveReconfigurationCallback( + Self.displayReconfigurationCallback, + Unmanaged.passUnretained(self).toOpaque() + ) + } } @Published var isConnected = false @@ -447,8 +456,17 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } @Published var largeCursor: Bool - @Published var preventDisplaySleep: Bool = true - @Published var autoRestartOnWake: Bool = true + @Published var preventDisplaySleep: Bool = false + @Published var autoRestartOnWake: Bool = false + @Published var verboseDisplayLogging: Bool = false { + didSet { + if verboseDisplayLogging { + startVerboseLoggingTimer() + } else { + stopVerboseLoggingTimer() + } + } + } @Published var capturePreset: TBDisplayCapturePreset = .standard1440p { didSet { if !isStreaming { @@ -496,6 +514,18 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var lastCursorPacket: TBMonitorCursor? nonisolated(unsafe) private var wakeObservers: [NSObjectProtocol] = [] private var isRestartingCaptureAfterWake = false + nonisolated(unsafe) private var displayReconfigurationCallbackRegistered = false + private var verboseLoggingTimer: Timer? + + nonisolated(unsafe) private static let displayReconfigurationCallback: CGDisplayReconfigurationCallBack = { displayID, flags, userInfo in + guard let userInfo else { return } + let service = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + DispatchQueue.main.async { + MainActor.assumeIsolated { + service.handleDisplayReconfiguration(displayID: displayID, flags: flags) + } + } + } private final class CaptureDelegate: NSObject, SCStreamOutput, SCStreamDelegate { var onFrame: ((CMSampleBuffer) -> Void)? @@ -976,7 +1006,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u guard self.session.create( from: profile, refreshRate: self.capturePreset.virtualDisplayRefreshRate, - identity: self.captureSource.virtualDisplayIdentity + identity: self.captureSource.virtualDisplayIdentity(for: profile) ) else { self.setStatus(.virtualDisplayCreationFailed) self.stop(resetStatusTo: nil) @@ -1706,6 +1736,77 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u ) } + private func registerDisplayReconfigurationCallback() { + guard !displayReconfigurationCallbackRegistered else { return } + let context = Unmanaged.passUnretained(self).toOpaque() + let result = CGDisplayRegisterReconfigurationCallback(Self.displayReconfigurationCallback, context) + displayReconfigurationCallbackRegistered = (result == .success) + if verboseDisplayLogging { + startVerboseLoggingTimer() + } + } + + private func handleDisplayReconfiguration(displayID: CGDirectDisplayID, flags: CGDisplayChangeSummaryFlags) { + let isOurs = session.displayID != kCGNullDirectDisplay && displayID == session.displayID + guard verboseDisplayLogging || isOurs else { return } + var parts: [String] = [] + if flags.contains(.addFlag) { parts.append("add") } + if flags.contains(.removeFlag) { parts.append("remove") } + if flags.contains(.enabledFlag) { parts.append("enabled") } + if flags.contains(.disabledFlag) { parts.append("disabled") } + if flags.contains(.mirrorFlag) { parts.append("mirror") } + if flags.contains(.unMirrorFlag) { parts.append("unMirror") } + if flags.contains(.movedFlag) { parts.append("moved") } + if flags.contains(.setMainFlag) { parts.append("setMain") } + if flags.contains(.setModeFlag) { parts.append("setMode") } + if flags.contains(.beginConfigurationFlag) { parts.append("beginConfiguration") } + if flags.contains(.desktopShapeChangedFlag) { parts.append("desktopShapeChanged") } + let flagText = parts.isEmpty ? "none" : parts.joined(separator: "|") + NSLog( + "TargetBridge: display reconfiguration displayID=%u ours=%@ flags=%@ online=[%@]", + displayID, + isOurs ? "yes" : "no", + flagText, + onlineDisplayIDs().map(String.init).joined(separator: ",") + ) + if isOurs, session.displayID != kCGNullDirectDisplay { + displayStateText = describeDisplayState(for: session.displayID) + } + } + + private func startVerboseLoggingTimer() { + stopVerboseLoggingTimer() + guard verboseDisplayLogging else { return } + let timer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.logStreamSnapshot() + } + } + verboseLoggingTimer = timer + logStreamSnapshot() + } + + private func stopVerboseLoggingTimer() { + verboseLoggingTimer?.invalidate() + verboseLoggingTimer = nil + } + + private func logStreamSnapshot() { + guard verboseDisplayLogging else { return } + let online = onlineDisplayIDs() + let virtualOnline = online.contains(session.displayID) + NSLog( + "TargetBridge: stream snapshot streaming=%@ fps=%d virtualID=%u online=%@ pendingPackets=%d inFlightEncode=%d ptsSeq=%lld", + isStreaming ? "yes" : "no", + senderFPS, + session.displayID, + virtualOnline ? "yes" : "no", + pendingVideoPackets, + inFlightEncodeFrames, + displayStreamFrameSequence + ) + } + private func handleSystemWake() { guard autoRestartOnWake else { return } scheduleCaptureRestart(reason: "system wake", delaySeconds: 1.0)