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 0a34d47..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 = "20260521211758" + 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 39d0702..489c16c 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -163,6 +163,12 @@ 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) + + Toggle(TBDisplaySenderL10n.verboseDisplayLogging(service.language), isOn: $service.verboseDisplayLogging) } } } @@ -336,6 +342,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..b2a3967 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -480,6 +480,38 @@ 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 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 929d4c2..5931c4f 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -31,6 +31,27 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } } + @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 = 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() @@ -70,7 +91,13 @@ final class TBDisplaySenderService: ObservableObject { } func addSession() { - let session = TBDisplaySenderSession(language: language, largeCursor: largeCursor) + let session = TBDisplaySenderSession( + language: language, + largeCursor: largeCursor, + preventDisplaySleep: preventDisplaySleep, + autoRestartOnWake: autoRestartOnWake, + verboseDisplayLogging: verboseDisplayLogging + ) 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..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) } } } @@ -394,18 +394,42 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let id = UUID() - init(language: TBDisplaySenderLanguage, largeCursor: Bool) { + init( + language: TBDisplaySenderLanguage, + largeCursor: Bool, + preventDisplaySleep: Bool = false, + autoRestartOnWake: Bool = false, + verboseDisplayLogging: Bool = false + ) { 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.verboseDisplayLogging = verboseDisplayLogging self.streamResolutionText = TBDisplaySenderL10n.streamSummary( preset: .standard1440p, source: .desktopMirror, language: language ) super.init() + registerWakeObservers() + registerDisplayReconfigurationCallback() + } + + deinit { + for token in wakeObservers { + NSWorkspace.shared.notificationCenter.removeObserver(token) + DistributedNotificationCenter.default().removeObserver(token) + } + if displayReconfigurationCallbackRegistered { + CGDisplayRemoveReconfigurationCallback( + Self.displayReconfigurationCallback, + Unmanaged.passUnretained(self).toOpaque() + ) + } } @Published var isConnected = false @@ -432,6 +456,17 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } @Published var largeCursor: Bool + @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 { @@ -477,6 +512,20 @@ 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 + 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)? @@ -957,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) @@ -1056,7 +1105,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 +1143,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 +1703,183 @@ 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 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) + } + + 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