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..f05c437 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 = "20260531002036" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index 39d0702..651f868 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) } } } @@ -229,7 +235,11 @@ private struct TBDisplaySenderSessionCard: View { } .onChange(of: session.selectedReceiverID) { _, newValue in guard let receiver = service.discoveredReceivers.first(where: { $0.id == newValue }) else { return } - service.applyDiscoveredReceiver(receiver, to: session) + // Defer the mutation past SwiftUI's current view-update phase to avoid + // "Publishing changes from within view updates is not allowed". + DispatchQueue.main.async { + service.applyDiscoveredReceiver(receiver, to: session) + } } .disabled(session.isConnected || session.isStreaming) } @@ -336,6 +346,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) } @@ -354,7 +370,9 @@ private struct TBDisplaySenderSessionCard: View { infoRow(TBDisplaySenderL10n.receiverLabel(service.language), session.receiverPanelText) infoRow(TBDisplaySenderL10n.virtualDisplayLabel(service.language), session.virtualDisplayText) infoRow(TBDisplaySenderL10n.streamLabel(service.language), session.streamResolutionText) - infoRow(TBDisplaySenderL10n.fpsLabel(service.language), "\(session.senderFPS)") + // Observes the dedicated metrics object so the ~1 Hz FPS tick + // re-renders only this row, not the whole session card / window. + SessionMonitorFPSRow(label: TBDisplaySenderL10n.fpsLabel(service.language), metrics: session.liveMetrics) infoRow("Capture", session.captureDisplayText) infoRow("State", session.displayStateText) } @@ -505,6 +523,26 @@ private struct TBDisplaySenderSessionCard: View { } } +/// FPS readout that observes only `TBSessionLiveMetrics`. Isolating it here means +/// the once-per-second FPS update invalidates just this small row instead of the +/// entire session card (and, via the manager bubble-up, the whole window). +private struct SessionMonitorFPSRow: View { + let label: String + @ObservedObject var metrics: TBSessionLiveMetrics + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 14) { + Text(label) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 138, alignment: .leading) + Text("\(metrics.senderFPS)") + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + private struct SurfaceCard: View { @ViewBuilder let content: Content 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..4c9ded6 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -20,7 +20,6 @@ final class TBDisplaySenderService: ObservableObject { didSet { language.persist() sessions.forEach { $0.language = language } - objectWillChange.send() } } @Published var showsMenuBarIcon = true @@ -28,7 +27,24 @@ final class TBDisplaySenderService: ObservableObject { didSet { UserDefaults.standard.set(largeCursor, forKey: "fd.tbdisplaysender.largeCursor") sessions.forEach { $0.largeCursor = largeCursor } - 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 } + } + } + @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 } + } + } + @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 } } } @@ -40,7 +56,6 @@ final class TBDisplaySenderService: ObservableObject { discoveryCancellable = receiverDiscovery.$receivers.sink { [weak self] receivers in guard let self else { return } discoveredReceivers = receivers - objectWillChange.send() } refreshBridgeInterfaces() addSession() @@ -70,7 +85,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 @@ -80,7 +101,6 @@ final class TBDisplaySenderService: ObservableObject { } attachSession(session) sessions.append(session) - objectWillChange.send() } func removeSession(_ session: TBDisplaySenderSession) { @@ -89,7 +109,6 @@ final class TBDisplaySenderService: ObservableObject { sessions.removeAll { $0.id == session.id } sessionCancellables.removeValue(forKey: session.id) normalizeSessionInterfaces() - objectWillChange.send() } func stopAll() { @@ -101,7 +120,6 @@ final class TBDisplaySenderService: ObservableObject { bridgeInterfaces = detectBridgeInterfaces() receiverDiscovery.refresh() normalizeSessionInterfaces() - objectWillChange.send() } func applyDiscoveredReceiver(_ receiver: TBDiscoveredReceiver, to session: TBDisplaySenderSession) { @@ -109,7 +127,6 @@ final class TBDisplaySenderService: ObservableObject { if session.localTBIP.isEmpty { session.localTBIP = suggestedInterfaceForNewSession()?.ip ?? bridgeInterfaces.first?.ip ?? "" } - objectWillChange.send() } func sessionTitle(for session: TBDisplaySenderSession) -> String { diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 5238797..150f661 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -238,23 +238,31 @@ 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) } } } private final class TBDirectDisplayStreamCapture { - private let serviceRef: UnsafeMutableRawPointer + // Strong reference so the pipeline (and its delivery queue) outlives every + // frame callback — a stray frame must never deref a freed pipeline. + private let pipeline: TBVideoPipeline private let queue: DispatchQueue private var stream: CGDisplayStream? - - init(service: TBDisplaySenderSession, queue: DispatchQueue) { - self.serviceRef = Unmanaged.passUnretained(service).toOpaque() + // CGDisplayStreamStop is asynchronous: frames already in flight keep arriving + // until the stream delivers a final `.stopped` frame, and releasing the + // CGDisplayStream before then crashes inside SkyLight's + // `_CGYDisplayStreamFrameAvailable`. This self-reference keeps the capture + // object (and the stream) alive from stop() until that `.stopped` frame. + private var pendingStopRetain: TBDirectDisplayStreamCapture? + + init(pipeline: TBVideoPipeline, queue: DispatchQueue) { + self.pipeline = pipeline self.queue = queue } @@ -265,7 +273,6 @@ private final class TBDirectDisplayStreamCapture { CGDisplayStream.minimumFrameTime: 1.0 / Double(preset.expectedFrameRate) ] - let serviceRefValue = UInt(bitPattern: serviceRef) let displayStream = CGDisplayStream( dispatchQueueDisplay: displayID, outputWidth: preset.width, @@ -273,20 +280,21 @@ private final class TBDirectDisplayStreamCapture { pixelFormat: Int32(kCVPixelFormatType_32BGRA), properties: properties, queue: queue - ) { status, displayTime, surface, _ in - guard status == .frameComplete, let surface else { return } - let surfaceRefValue = UInt(bitPattern: Unmanaged.passRetained(surface).toOpaque()) - DispatchQueue.main.async { - guard let serviceRef = UnsafeRawPointer(bitPattern: serviceRefValue), - let surfaceRef = UnsafeRawPointer(bitPattern: surfaceRefValue) else { - return - } - let service = Unmanaged.fromOpaque(serviceRef).takeUnretainedValue() - let surface = Unmanaged.fromOpaque(surfaceRef).takeRetainedValue() - MainActor.assumeIsolated { - service.encodeDisplaySurface(surface, displayTime: displayTime) - } + ) { [weak self] status, displayTime, surface, _ in + // 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 } + 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. + self.stream = nil + self.pendingStopRetain = nil + return } + guard status == .frameComplete, let surface else { return } + // After pipeline.stop(), encodeDisplaySurface() no-ops on its `running` + // guard, so a late in-flight frame here is harmless. + self.pipeline.encodeDisplaySurface(surface, displayTime: displayTime) } guard let displayStream, displayStream.start() == .success else { @@ -298,11 +306,378 @@ private final class TBDirectDisplayStreamCapture { } func stop() { + guard stream != nil else { return } + // Stay alive until the `.stopped` frame arrives (see pendingStopRetain); + // the stream is released in the handler, never here, so it is never freed + // with frame events still queued on `queue`. + pendingStopRetain = self stream?.stop() - stream = nil } } +/// Owns the capture→encode→send video pipeline and runs it entirely on a +/// 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 +/// a per-frame hop back to main. +private final class TBVideoPipeline: @unchecked Sendable { + let queue = DispatchQueue(label: "fd.tbmonitor.sender.pipeline", qos: .userInteractive) + + private let preset: TBDisplayCapturePreset + private let connection: NWConnection + private let displayName: String + private let displayID: CGDirectDisplayID + private let onFirstFrame: @Sendable () -> Void + + // Confined to `queue`. + private var vtEncoder: VTCompressionSession? + private var vtEncoderRef: Unmanaged? + private var pendingVideoPackets = 0 + private var inFlightEncodeFrames = 0 + private var displayStreamFrameSequence: CMTimeValue = 0 + private var lastEncodedDisplayPTS: CMTime? + private var ackSent: Bool + private var running = false + + // Read from the main thread (fps timer / watchdog); guarded by `lock`. + private let lock = NSLock() + private var _sentFrames = 0 + private var _lastCaptureFrameAt = Date() + + init(preset: TBDisplayCapturePreset, + connection: NWConnection, + displayName: String, + displayID: CGDirectDisplayID, + ackAlreadySent: Bool, + onFirstFrame: @escaping @Sendable () -> Void) { + self.preset = preset + self.connection = connection + self.displayName = displayName + self.displayID = displayID + self.ackSent = ackAlreadySent + self.onFirstFrame = onFirstFrame + } + + // MARK: - Lifecycle (called from the main actor) + + /// Sets up the encoder on `queue`. Returns false if the hardware encoder + /// could not be created. + func start() -> Bool { + queue.sync { + setupEncoder() + running = vtEncoder != nil + return running + } + } + + /// Tears the encoder down on `queue`. Because the queue is serial, any + /// in-flight `encode` completes before `VTCompressionSessionInvalidate`, + /// so a frame can never encode into an invalidated session. + func stop() { + queue.sync { + running = false + if let encoder = vtEncoder { VTCompressionSessionInvalidate(encoder) } + vtEncoder = nil + vtEncoderRef?.release() + vtEncoderRef = nil + } + } + + // MARK: - Snapshots for the main thread + + var sentFramesSnapshot: Int { + lock.lock(); defer { lock.unlock() } + return _sentFrames + } + + var lastCaptureFrameAtSnapshot: Date { + lock.lock(); defer { lock.unlock() } + return _lastCaptureFrameAt + } + + func diagnosticsSnapshot() -> (pending: Int, inFlight: Int, ptsSeq: CMTimeValue) { + queue.sync { (pending: pendingVideoPackets, inFlight: inFlightEncodeFrames, ptsSeq: displayStreamFrameSequence) } + } + + private func markCaptureFrame() { + lock.lock(); _lastCaptureFrameAt = Date(); lock.unlock() + } + + // MARK: - Encoder setup (on `queue`) + + private func setupEncoder() { + if let encoder = vtEncoder { VTCompressionSessionInvalidate(encoder) } + vtEncoder = nil + vtEncoderRef?.release() + vtEncoderRef = nil + + let spec: NSDictionary = [ + kVTVideoEncoderSpecification_EnableHardwareAcceleratedVideoEncoder: true, + kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder: true + ] + let retained = Unmanaged.passRetained(self) + vtEncoderRef = retained + + let callback: VTCompressionOutputCallback = { ref, _, status, _, sampleBuffer in + guard let ref else { return } + let pipeline = Unmanaged.fromOpaque(ref).takeUnretainedValue() + pipeline.queue.async { + pipeline.inFlightEncodeFrames = max(0, pipeline.inFlightEncodeFrames - 1) + guard status == noErr, let sampleBuffer else { return } + pipeline.handleEncoded(sampleBuffer) + } + } + + var session: VTCompressionSession? + guard VTCompressionSessionCreate( + allocator: nil, + width: Int32(preset.width), + height: Int32(preset.height), + codecType: preset.codecType, + encoderSpecification: spec, + imageBufferAttributes: nil, + compressedDataAllocator: nil, + outputCallback: callback, + refcon: retained.toOpaque(), + compressionSessionOut: &session + ) == noErr, let session else { + retained.release() + vtEncoderRef = nil + return + } + + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue) + if preset.codecType == kCMVideoCodecType_HEVC { + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_HEVC_Main_AutoLevel) + } else { + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_Main_AutoLevel) + } + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: preset.expectedFrameRate)) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: NSNumber(value: preset.maxKeyFrameInterval)) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, value: NSNumber(value: preset.maxKeyFrameIntervalDuration)) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxFrameDelayCount, value: NSNumber(value: preset.maxFrameDelayCount)) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: NSNumber(value: preset.averageBitRate)) + if preset.prioritizeSpeed { + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_PrioritizeEncodingSpeedOverQuality, value: kCFBooleanTrue) + } + VTCompressionSessionPrepareToEncodeFrames(session) + vtEncoder = session + } + + // MARK: - Encode paths (on `queue`) + + /// SCStream capture path. Must be dispatched onto `queue` by the caller. + func encode(_ sampleBuffer: CMSampleBuffer) { + markCaptureFrame() + guard running, let encoder = vtEncoder, + let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) + else { return } + if preset.dropsBeforeEncodeWhenBacklogged, + (pendingVideoPackets >= preset.maxPendingVideoPackets || + inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { + return + } + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + encode(pixelBuffer: pixelBuffer, presentationTimeStamp: pts, using: encoder) + } + + /// CGDisplayStream capture path. Delivered directly on `queue` by + /// `TBDirectDisplayStreamCapture`. + func encodeDisplaySurface(_ surface: IOSurfaceRef, displayTime: UInt64) { + markCaptureFrame() + guard running, let encoder = vtEncoder else { return } + if preset.dropsBeforeEncodeWhenBacklogged, + (pendingVideoPackets >= preset.maxPendingVideoPackets || + inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { + return + } + + let attrs: NSDictionary = [ + kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey: preset.width, + kCVPixelBufferHeightKey: preset.height, + kCVPixelBufferIOSurfacePropertiesKey: NSDictionary() + ] + var unmanagedPixelBuffer: Unmanaged? + guard CVPixelBufferCreateWithIOSurface( + kCFAllocatorDefault, + surface, + attrs, + &unmanagedPixelBuffer + ) == kCVReturnSuccess, let unmanagedPixelBuffer else { + return + } + let pixelBuffer = unmanagedPixelBuffer.takeRetainedValue() + + displayStreamFrameSequence += 1 + // Derive PTS from the frame's actual capture time. CGDisplayStream + // delivers frames irregularly (event-driven on screen changes), so a + // frame-counter PTS would drift away from real wall-clock time over a + // long session and pace the receiver progressively wrong. displayTime is + // in mach-absolute units, the same host clock the SCStream path uses. + var pts = displayTime != 0 + ? CMClockMakeHostTimeFromSystemUnits(displayTime) + : CMClockGetTime(CMClockGetHostTimeClock()) + if let last = lastEncodedDisplayPTS, CMTimeCompare(pts, last) <= 0 { + // VTCompressionSession requires strictly increasing PTS. + pts = CMTimeAdd(last, CMTime(value: 1, timescale: 600)) + } + lastEncodedDisplayPTS = pts + encode(pixelBuffer: pixelBuffer, presentationTimeStamp: pts, using: encoder) + } + + private func encode(pixelBuffer: CVPixelBuffer, presentationTimeStamp pts: CMTime, using encoder: VTCompressionSession) { + inFlightEncodeFrames += 1 + let status = VTCompressionSessionEncodeFrame( + encoder, + imageBuffer: pixelBuffer, + presentationTimeStamp: pts, + duration: .invalid, + frameProperties: nil, + sourceFrameRefcon: nil, + infoFlagsOut: nil + ) + if status != noErr { + inFlightEncodeFrames = max(0, inFlightEncodeFrames - 1) + } + } + + private func handleEncoded(_ sampleBuffer: CMSampleBuffer) { + guard running else { return } + + if !ackSent { + ackSent = true + let ack = TBMonitorCreateSessionAck( + accepted: true, + displayName: displayName, + displayID: displayID + ) + if let packet = TBMonitorProtocol.makeJSONPacket(type: .createSessionAck, value: ack) { + connection.send(content: packet, completion: .contentProcessed({ _ in })) + } + onFirstFrame() + } + + let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[CFString: Any]] + let notSync = attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false + let isKeyframe = !notSync + + if !isKeyframe, pendingVideoPackets >= preset.maxPendingVideoPackets { + return + } + + if isKeyframe, + let format = CMSampleBufferGetFormatDescription(sampleBuffer), + let packet = buildParamSetsPacket(from: format, codecType: preset.codecType) { + connection.send(content: packet, completion: .contentProcessed({ _ in })) + } + + if let packet = buildFramePacket(from: sampleBuffer) { + pendingVideoPackets += 1 + connection.send(content: packet, completion: .contentProcessed({ [weak self] _ in + guard let self else { return } + self.queue.async { + self.pendingVideoPackets = max(0, self.pendingVideoPackets - 1) + } + })) + lock.lock(); _sentFrames += 1; lock.unlock() + } + } + + private func buildParamSetsPacket(from format: CMVideoFormatDescription, codecType: CMVideoCodecType) -> Data? { + if codecType == kCMVideoCodecType_HEVC { + var count = 0 + CMVideoFormatDescriptionGetHEVCParameterSetAtIndex( + format, + parameterSetIndex: 0, + parameterSetPointerOut: nil, + parameterSetSizeOut: nil, + parameterSetCountOut: &count, + nalUnitHeaderLengthOut: nil + ) + guard count > 0 else { return nil } + + var payload = Data([2, UInt8(count)]) + for index in 0..? + var size = 0 + CMVideoFormatDescriptionGetHEVCParameterSetAtIndex( + format, + parameterSetIndex: index, + parameterSetPointerOut: &pointer, + parameterSetSizeOut: &size, + parameterSetCountOut: nil, + nalUnitHeaderLengthOut: nil + ) + guard let pointer else { continue } + TBMonitorProtocol.appendBE32(&payload, UInt32(size)) + payload.append(UnsafeBufferPointer(start: pointer, count: size)) + } + return TBMonitorProtocol.makePacket(type: .paramSets, payload: payload) + } else { + var count = 0 + CMVideoFormatDescriptionGetH264ParameterSetAtIndex( + format, + parameterSetIndex: 0, + parameterSetPointerOut: nil, + parameterSetSizeOut: nil, + parameterSetCountOut: &count, + nalUnitHeaderLengthOut: nil + ) + guard count > 0 else { return nil } + + var payload = Data([1, UInt8(count)]) + for index in 0..? + var size = 0 + CMVideoFormatDescriptionGetH264ParameterSetAtIndex( + format, + parameterSetIndex: index, + parameterSetPointerOut: &pointer, + parameterSetSizeOut: &size, + parameterSetCountOut: nil, + nalUnitHeaderLengthOut: nil + ) + guard let pointer else { continue } + TBMonitorProtocol.appendBE32(&payload, UInt32(size)) + payload.append(UnsafeBufferPointer(start: pointer, count: size)) + } + return TBMonitorProtocol.makePacket(type: .paramSets, payload: payload) + } + } + + private func buildFramePacket(from sampleBuffer: CMSampleBuffer) -> Data? { + guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return nil } + let totalLength = CMBlockBufferGetDataLength(blockBuffer) + guard totalLength > 0 else { return nil } + + var payload = Data(count: totalLength) + let status = payload.withUnsafeMutableBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { + return kCMBlockBufferBadCustomBlockSourceErr + } + return CMBlockBufferCopyDataBytes( + blockBuffer, + atOffset: 0, + dataLength: totalLength, + destination: baseAddress + ) + } + guard status == kCMBlockBufferNoErr else { return nil } + return TBMonitorProtocol.makePacket(type: .frame, payload: payload) + } +} + +/// Live, frequently-updating session readouts (currently just the FPS counter), +/// split out of `TBDisplaySenderSession` so their ~1 Hz changes only invalidate +/// the small subview that displays them rather than the whole session card. +@MainActor +final class TBSessionLiveMetrics: ObservableObject { + @Published var senderFPS = 0 +} + @MainActor final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @unchecked Sendable { private static let receiverIPDefaultsKey = "fd.tbdisplaysender.receiverIP" @@ -394,18 +769,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 @@ -421,7 +820,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u UserDefaults.standard.set(receiverIP, forKey: Self.receiverIPDefaultsKey) } } - @Published var senderFPS = 0 + // Live FPS readout. Kept on a dedicated observable so its once-per-second + // update only re-renders the small FPS subview — not the whole session card + // or (via the manager's objectWillChange bubble-up) the entire window. + let liveMetrics = TBSessionLiveMetrics() @Published var receiverPanelText: String @Published var virtualDisplayText: String @Published var captureDisplayText = "Capture display: n/a" @@ -432,6 +834,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 { @@ -458,10 +871,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var captureDelegate: CaptureDelegate? private var scStream: SCStream? private var directDisplayStream: TBDirectDisplayStreamCapture? - private var vtEncoder: VTCompressionSession? - private var vtEncoderRef: Unmanaged? + private var pipeline: TBVideoPipeline? - private var sentFrames = 0 private var sentSnapshot = 0 private var sessionAckSent = false private var fpsTimer: Timer? @@ -471,12 +882,24 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var heartbeatSequence: UInt64 = 0 private var statusState: TBDisplaySenderStatusState = .ready private var streamingActivity: NSObjectProtocol? - private var pendingVideoPackets = 0 - private var inFlightEncodeFrames = 0 - private var displayStreamFrameSequence: CMTimeValue = 0 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? + private var captureHealthWatchdog: 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)? @@ -744,6 +1167,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u cursorTimer = nil fpsTimer?.invalidate() fpsTimer = nil + stopCaptureWatchdog() if let directDisplayStream { directDisplayStream.stop() self.directDisplayStream = nil @@ -760,10 +1184,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u ProcessInfo.processInfo.endActivity(activity) streamingActivity = nil } - if let encoder = vtEncoder { VTCompressionSessionInvalidate(encoder) } - vtEncoder = nil - vtEncoderRef?.release() - vtEncoderRef = nil + pipeline?.stop() + pipeline = nil connection?.stateUpdateHandler = nil connection?.cancel() connection = nil @@ -780,13 +1202,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u setStatus(status) } refreshLocalizedText() - senderFPS = 0 - sentFrames = 0 + liveMetrics.senderFPS = 0 sentSnapshot = 0 sessionAckSent = false - pendingVideoPackets = 0 - inFlightEncodeFrames = 0 - displayStreamFrameSequence = 0 baselineDisplayIDs = [] cursorDisplayID = kCGNullDirectDisplay lastCursorPacket = nil @@ -957,7 +1375,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) @@ -999,6 +1417,24 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func startCapture(for profile: TBMonitorDisplayProfile) async -> Bool { do { let preset = capturePreset + guard let connection else { return false } + + // The encode/send pipeline runs entirely on its own serial queue, + // off the main thread, so SwiftUI layout can never stall frame + // delivery. Preset/dimensions/codec are immutable for a session + // (the pickers are disabled while streaming), so we capture them once. + let pipeline = TBVideoPipeline( + preset: preset, + connection: connection, + displayName: session.displayName, + displayID: session.displayID, + ackAlreadySent: sessionAckSent, + onFirstFrame: { [weak self] in + Task { @MainActor in self?.handleFirstEncodedFrame() } + } + ) + guard pipeline.start() else { return false } + self.pipeline = pipeline if captureSource == .extendedDesktop, session.displayID != kCGNullDirectDisplay { if startDirectDisplayStream(displayID: session.displayID, preset: preset) { @@ -1021,18 +1457,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u configuration.scalesToFit = true configuration.captureResolution = preset.captureResolution - setupEncoder( - width: preset.width, - height: preset.height, - preset: preset, - codecType: preset.codecType, - averageBitRate: preset.averageBitRate - ) streamResolutionText = TBDisplaySenderL10n.streamSummary(preset: preset, source: captureSource, language: language) let delegate = CaptureDelegate() - delegate.onFrame = { [weak self] sampleBuffer in - self?.encode(sampleBuffer) + delegate.onFrame = { sampleBuffer in + pipeline.queue.async { pipeline.encode(sampleBuffer) } } delegate.onError = { [weak self] error in Task { @MainActor [weak self] in @@ -1056,10 +1485,11 @@ 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() + startCaptureWatchdog() return true } catch { if error.localizedDescription.hasPrefix("no virtual SCDisplay available") { @@ -1072,19 +1502,13 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func startDirectDisplayStream(displayID: CGDirectDisplayID, preset: TBDisplayCapturePreset) -> Bool { - setupEncoder( - width: preset.width, - height: preset.height, - preset: preset, - codecType: preset.codecType, - averageBitRate: preset.averageBitRate - ) - guard vtEncoder != nil else { return false } + guard let pipeline else { return false } - displayStreamFrameSequence = 0 streamResolutionText = TBDisplaySenderL10n.streamSummary(preset: preset, source: captureSource, language: language) - let directCapture = TBDirectDisplayStreamCapture(service: self, queue: connectionQueue) + // Deliver frames straight onto the pipeline's own queue — the handler + // runs there, so encode happens off the main thread with no extra hop. + let directCapture = TBDirectDisplayStreamCapture(pipeline: pipeline, queue: pipeline.queue) guard directCapture.start(displayID: displayID, preset: preset, showCursor: !largeCursor) else { return false } @@ -1094,13 +1518,22 @@ 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() + startCaptureWatchdog() 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, @@ -1315,253 +1748,6 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return Array(displays.prefix(Int(count))) } - private func setupEncoder(width: Int, height: Int, preset: TBDisplayCapturePreset, codecType: CMVideoCodecType, averageBitRate: Int) { - if let encoder = vtEncoder { VTCompressionSessionInvalidate(encoder) } - vtEncoder = nil - vtEncoderRef?.release() - vtEncoderRef = nil - - let spec: NSDictionary = [ - kVTVideoEncoderSpecification_EnableHardwareAcceleratedVideoEncoder: true, - kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder: true - ] - let retained = Unmanaged.passRetained(self) - vtEncoderRef = retained - - let callback: VTCompressionOutputCallback = { ref, _, status, _, sampleBuffer in - guard let ref else { return } - let service = Unmanaged.fromOpaque(ref).takeUnretainedValue() - DispatchQueue.main.async { - service.inFlightEncodeFrames = max(0, service.inFlightEncodeFrames - 1) - guard status == noErr, let sampleBuffer else { return } - service.handleEncoded(sampleBuffer) - } - } - - var session: VTCompressionSession? - guard VTCompressionSessionCreate( - allocator: nil, - width: Int32(width), - height: Int32(height), - codecType: codecType, - encoderSpecification: spec, - imageBufferAttributes: nil, - compressedDataAllocator: nil, - outputCallback: callback, - refcon: retained.toOpaque(), - compressionSessionOut: &session - ) == noErr, let session else { - retained.release() - vtEncoderRef = nil - return - } - - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue) - if codecType == kCMVideoCodecType_HEVC { - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_HEVC_Main_AutoLevel) - } else { - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_Main_AutoLevel) - } - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse) - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: preset.expectedFrameRate)) - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: NSNumber(value: preset.maxKeyFrameInterval)) - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, value: NSNumber(value: preset.maxKeyFrameIntervalDuration)) - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxFrameDelayCount, value: NSNumber(value: preset.maxFrameDelayCount)) - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: NSNumber(value: averageBitRate)) - if preset.prioritizeSpeed { - VTSessionSetProperty(session, key: kVTCompressionPropertyKey_PrioritizeEncodingSpeedOverQuality, value: kCFBooleanTrue) - } - VTCompressionSessionPrepareToEncodeFrames(session) - vtEncoder = session - } - - private func encode(_ sampleBuffer: CMSampleBuffer) { - guard let encoder = vtEncoder, - let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) - else { return } - if capturePreset.dropsBeforeEncodeWhenBacklogged, - (pendingVideoPackets >= capturePreset.maxPendingVideoPackets || - inFlightEncodeFrames >= capturePreset.maxInFlightEncodeFrames) { - return - } - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - encode(pixelBuffer: pixelBuffer, presentationTimeStamp: pts, using: encoder) - } - - fileprivate func encodeDisplaySurface(_ surface: IOSurface, displayTime: UInt64) { - guard let encoder = vtEncoder else { return } - if capturePreset.dropsBeforeEncodeWhenBacklogged, - (pendingVideoPackets >= capturePreset.maxPendingVideoPackets || - inFlightEncodeFrames >= capturePreset.maxInFlightEncodeFrames) { - return - } - - let attrs: NSDictionary = [ - kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA, - kCVPixelBufferWidthKey: capturePreset.width, - kCVPixelBufferHeightKey: capturePreset.height, - kCVPixelBufferIOSurfacePropertiesKey: NSDictionary() - ] - var unmanagedPixelBuffer: Unmanaged? - guard CVPixelBufferCreateWithIOSurface( - kCFAllocatorDefault, - surface, - attrs, - &unmanagedPixelBuffer - ) == kCVReturnSuccess, let unmanagedPixelBuffer else { - return - } - let pixelBuffer = unmanagedPixelBuffer.takeRetainedValue() - - displayStreamFrameSequence += 1 - let pts = CMTime(value: displayStreamFrameSequence, timescale: Int32(capturePreset.expectedFrameRate)) - encode(pixelBuffer: pixelBuffer, presentationTimeStamp: pts, using: encoder) - } - - private func encode(pixelBuffer: CVPixelBuffer, presentationTimeStamp pts: CMTime, using encoder: VTCompressionSession) { - inFlightEncodeFrames += 1 - let status = VTCompressionSessionEncodeFrame( - encoder, - imageBuffer: pixelBuffer, - presentationTimeStamp: pts, - duration: .invalid, - frameProperties: nil, - sourceFrameRefcon: nil, - infoFlagsOut: nil - ) - if status != noErr { - inFlightEncodeFrames = max(0, inFlightEncodeFrames - 1) - } - } - - private func handleEncoded(_ sampleBuffer: CMSampleBuffer) { - guard let connection, isConnected else { return } - - if !sessionAckSent { - sessionAckSent = true - firstFrameTimer?.invalidate() - firstFrameTimer = nil - let ack = TBMonitorCreateSessionAck( - accepted: true, - displayName: session.displayName, - displayID: session.displayID - ) - if let packet = TBMonitorProtocol.makeJSONPacket(type: .createSessionAck, value: ack) { - send(packet) - } - setStatus(.captureActive(capturePreset.description, capturePreset.codecName, captureSource)) - } - - let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[CFString: Any]] - let notSync = attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false - let isKeyframe = !notSync - - if !isKeyframe, pendingVideoPackets >= capturePreset.maxPendingVideoPackets { - return - } - - if isKeyframe, - let format = CMSampleBufferGetFormatDescription(sampleBuffer), - let packet = buildParamSetsPacket(from: format, codecType: capturePreset.codecType) { - send(packet) - } - - if let packet = buildFramePacket(from: sampleBuffer) { - pendingVideoPackets += 1 - connection.send(content: packet, completion: .contentProcessed({ [weak self] _ in - guard let self else { return } - Task { @MainActor [weak self] in - guard let self else { return } - pendingVideoPackets = max(0, pendingVideoPackets - 1) - } - })) - sentFrames += 1 - } - } - - private func buildParamSetsPacket(from format: CMVideoFormatDescription, codecType: CMVideoCodecType) -> Data? { - if codecType == kCMVideoCodecType_HEVC { - var count = 0 - CMVideoFormatDescriptionGetHEVCParameterSetAtIndex( - format, - parameterSetIndex: 0, - parameterSetPointerOut: nil, - parameterSetSizeOut: nil, - parameterSetCountOut: &count, - nalUnitHeaderLengthOut: nil - ) - guard count > 0 else { return nil } - - var payload = Data([2, UInt8(count)]) - for index in 0..? - var size = 0 - CMVideoFormatDescriptionGetHEVCParameterSetAtIndex( - format, - parameterSetIndex: index, - parameterSetPointerOut: &pointer, - parameterSetSizeOut: &size, - parameterSetCountOut: nil, - nalUnitHeaderLengthOut: nil - ) - guard let pointer else { continue } - TBMonitorProtocol.appendBE32(&payload, UInt32(size)) - payload.append(UnsafeBufferPointer(start: pointer, count: size)) - } - return TBMonitorProtocol.makePacket(type: .paramSets, payload: payload) - } else { - var count = 0 - CMVideoFormatDescriptionGetH264ParameterSetAtIndex( - format, - parameterSetIndex: 0, - parameterSetPointerOut: nil, - parameterSetSizeOut: nil, - parameterSetCountOut: &count, - nalUnitHeaderLengthOut: nil - ) - guard count > 0 else { return nil } - - var payload = Data([1, UInt8(count)]) - for index in 0..? - var size = 0 - CMVideoFormatDescriptionGetH264ParameterSetAtIndex( - format, - parameterSetIndex: index, - parameterSetPointerOut: &pointer, - parameterSetSizeOut: &size, - parameterSetCountOut: nil, - nalUnitHeaderLengthOut: nil - ) - guard let pointer else { continue } - TBMonitorProtocol.appendBE32(&payload, UInt32(size)) - payload.append(UnsafeBufferPointer(start: pointer, count: size)) - } - return TBMonitorProtocol.makePacket(type: .paramSets, payload: payload) - } - } - - private func buildFramePacket(from sampleBuffer: CMSampleBuffer) -> Data? { - guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return nil } - let totalLength = CMBlockBufferGetDataLength(blockBuffer) - guard totalLength > 0 else { return nil } - - var payload = Data(count: totalLength) - let status = payload.withUnsafeMutableBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { - return kCMBlockBufferBadCustomBlockSourceErr - } - return CMBlockBufferCopyDataBytes( - blockBuffer, - atOffset: 0, - dataLength: totalLength, - destination: baseAddress - ) - } - guard status == kCMBlockBufferNoErr else { return nil } - return TBMonitorProtocol.makePacket(type: .frame, payload: payload) - } - private func startCursorUpdates(displayID: CGDirectDisplayID) { cursorTimer?.invalidate() cursorDisplayID = displayID @@ -1646,14 +1832,219 @@ 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 startCaptureWatchdog() { + captureHealthWatchdog?.invalidate() + captureHealthWatchdog = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.checkCaptureHealth() + } + } + } + + private func stopCaptureWatchdog() { + captureHealthWatchdog?.invalidate() + captureHealthWatchdog = nil + } + + 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) + } + + private func logStreamSnapshot() { + guard verboseDisplayLogging else { return } + let online = onlineDisplayIDs() + let virtualOnline = online.contains(session.displayID) + let diag = pipeline?.diagnosticsSnapshot() ?? (pending: 0, inFlight: 0, ptsSeq: 0) + NSLog( + "TargetBridge: stream snapshot streaming=%@ fps=%d virtualID=%u online=%@ pendingPackets=%d inFlightEncode=%d ptsSeq=%lld", + isStreaming ? "yes" : "no", + liveMetrics.senderFPS, + session.displayID, + virtualOnline ? "yes" : "no", + diag.pending, + diag.inFlight, + diag.ptsSeq + ) + } + + 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 + stopCaptureWatchdog() + 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 + } + pipeline?.stop() + pipeline = nil + isStreaming = false + liveMetrics.senderFPS = 0 + sentSnapshot = 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 handleFirstEncodedFrame() { + guard !sessionAckSent else { return } + sessionAckSent = true + firstFrameTimer?.invalidate() + firstFrameTimer = nil + setStatus(.captureActive(capturePreset.description, capturePreset.codecName, captureSource)) + } + private func startFPSTimer() { fpsTimer?.invalidate() - sentSnapshot = sentFrames + sentSnapshot = pipeline?.sentFramesSnapshot ?? 0 fpsTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self else { return } MainActor.assumeIsolated { - senderFPS = sentFrames - sentSnapshot - sentSnapshot = sentFrames + let total = pipeline?.sentFramesSnapshot ?? 0 + liveMetrics.senderFPS = total - sentSnapshot + sentSnapshot = total } } }