From 9d8c32666d9d13629e44bcd7d056e9bd5b9df030 Mon Sep 17 00:00:00 2001 From: Chen Date: Wed, 11 Mar 2026 13:33:34 +0800 Subject: [PATCH 01/11] =?UTF-8?q?test(capture):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B1=8F=E5=B9=95=E7=9B=91=E5=90=AC=E9=A2=84=E8=A7=88=E8=87=AA?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增参数化预览诊断会话与真实首帧录制回放支持 - 新增专用 UI test、截图导出和像素分析脚本 - 接入自动开窗与预览内容标识,便于本地独立判断黑边问题 --- VoidDisplay/App/CaptureController.swift | 1 + VoidDisplay/App/HomeView.swift | 18 ++ .../App/VirtualDisplayController.swift | 2 + VoidDisplay/App/VoidDisplayApp.swift | 11 + .../Services/CaptureMonitoringService.swift | 4 + .../Capture/Views/CaptureDisplayView.swift | 19 ++ .../CapturePreviewDiagnosticsRuntime.swift | 87 +++++ .../CapturePreviewDiagnosticsSession.swift | 296 ++++++++++++++++++ .../Testing/CapturePreviewRecordingSink.swift | 128 ++++++++ .../ScreenCapturePermissionProvider.swift | 2 + .../Shared/Testing/UITestRuntime.swift | 1 + .../App/CaptureControllerTests.swift | 10 + ...apturePreviewDiagnosticsRuntimeTests.swift | 31 ++ .../CapturePreviewDiagnosticsTests.swift | 62 ++++ scripts/test/capture_preview_analyze.swift | 228 ++++++++++++++ scripts/test/capture_preview_self_check.sh | 45 +++ 16 files changed, 945 insertions(+) create mode 100644 VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift create mode 100644 VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift create mode 100644 VoidDisplay/Shared/Testing/CapturePreviewRecordingSink.swift create mode 100644 VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift create mode 100644 VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift create mode 100644 scripts/test/capture_preview_analyze.swift create mode 100644 scripts/test/capture_preview_self_check.sh diff --git a/VoidDisplay/App/CaptureController.swift b/VoidDisplay/App/CaptureController.swift index 59d3b11..6638145 100644 --- a/VoidDisplay/App/CaptureController.swift +++ b/VoidDisplay/App/CaptureController.swift @@ -17,6 +17,7 @@ final class CaptureController { init(captureMonitoringService: any CaptureMonitoringServiceProtocol) { self.captureMonitoringService = captureMonitoringService + self.screenCaptureSessions = captureMonitoringService.currentSessions } func monitoringSession(for id: UUID) -> ScreenMonitoringSession? { diff --git a/VoidDisplay/App/HomeView.swift b/VoidDisplay/App/HomeView.swift index e8da524..856ba6c 100644 --- a/VoidDisplay/App/HomeView.swift +++ b/VoidDisplay/App/HomeView.swift @@ -10,6 +10,7 @@ struct HomeView: View { @Environment(CaptureController.self) private var capture @Environment(SharingController.self) private var sharing @Environment(VirtualDisplayController.self) private var virtualDisplay + @Environment(\.openWindow) private var openWindow private enum SidebarItem: Hashable { case screen @@ -19,6 +20,7 @@ struct HomeView: View { } @State private var selection: SidebarItem? = .screen + @State private var hasAutoOpenedCapturePreview = false var body: some View { NavigationSplitView { @@ -75,6 +77,22 @@ struct HomeView: View { } } } + .onAppear { + autoOpenCapturePreviewWindowIfNeeded() + } + } + + private func autoOpenCapturePreviewWindowIfNeeded() { + guard CapturePreviewDiagnosticsRuntime.shouldAutoOpenPreviewWindow, + !hasAutoOpenedCapturePreview, + let sessionID = capture.screenCaptureSessions.first?.id + else { + return + } + + selection = .monitorScreen + openWindow(value: sessionID) + hasAutoOpenedCapturePreview = true } } diff --git a/VoidDisplay/App/VirtualDisplayController.swift b/VoidDisplay/App/VirtualDisplayController.swift index bf79961..f379e20 100644 --- a/VoidDisplay/App/VirtualDisplayController.swift +++ b/VoidDisplay/App/VirtualDisplayController.swift @@ -64,6 +64,8 @@ final class VirtualDisplayController { switch scenario { case .baseline: break + case .capturePreviewDiagnostics: + break case .displayCatalogLoading: break case .permissionDenied: diff --git a/VoidDisplay/App/VoidDisplayApp.swift b/VoidDisplay/App/VoidDisplayApp.swift index c464588..d836e4e 100644 --- a/VoidDisplay/App/VoidDisplayApp.swift +++ b/VoidDisplay/App/VoidDisplayApp.swift @@ -78,8 +78,19 @@ enum AppBootstrap { } let scenario = UITestRuntime.scenario + let captureMonitoringService: (any CaptureMonitoringServiceProtocol)? = { + guard scenario == .capturePreviewDiagnostics, + let configuration = CapturePreviewDiagnosticsRuntime.configuration() + else { + return nil + } + return try? CapturePreviewDiagnosticsBootstrap.makeMonitoringService( + configuration: configuration + ) + }() return makeEnvironment( preview: false, + captureMonitoringService: captureMonitoringService, virtualDisplayFacade: UITestVirtualDisplayFacade(scenario: scenario), startupPlan: .init( shouldRestoreVirtualDisplays: true, diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift index fddcfc2..162c3cf 100644 --- a/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift @@ -18,6 +18,10 @@ protocol CaptureMonitoringServiceProtocol: AnyObject { final class CaptureMonitoringService: CaptureMonitoringServiceProtocol { private var sessions: [ScreenMonitoringSession] = [] + init(initialSessions: [ScreenMonitoringSession] = []) { + self.sessions = initialSessions + } + var currentSessions: [ScreenMonitoringSession] { sessions } diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index de27d0e..3b46fb6 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -16,6 +16,7 @@ struct CaptureDisplayView: View { @Environment(\.dismiss) private var dismiss @State private var renderer = ZeroCopyPreviewRenderer() + @State private var recordingSink: CapturePreviewRecordingSink? @State private var window: NSWindow? @State private var hasAppliedInitialSize = false @State private var scaleMode: PreviewScaleMode = .fit @@ -73,6 +74,8 @@ struct CaptureDisplayView: View { previewContent } .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("capture_preview_content") .toolbar { ToolbarItem(placement: .principal) { Picker("Scale Mode", selection: $scaleMode) { @@ -93,6 +96,14 @@ struct CaptureDisplayView: View { .onAppear { if let session { session.previewSubscription.attachPreviewSink(renderer) + if let destinationDirectory = CapturePreviewDiagnosticsRuntime.configuration()?.recordDirectoryURL { + let sink = CapturePreviewRecordingSink( + destinationDirectory: destinationDirectory, + session: session + ) + recordingSink = sink + session.previewSubscription.attachPreviewSink(sink) + } capture.markMonitoringSessionActive(id: sessionId) } else { dismiss() @@ -100,6 +111,9 @@ struct CaptureDisplayView: View { } .onDisappear { if let session { + if let recordingSink { + session.previewSubscription.detachPreviewSink(recordingSink) + } session.previewSubscription.detachPreviewSink(renderer) } renderer.flush() @@ -155,6 +169,11 @@ extension CaptureDisplayView { h = pixelSize.height / scale } + if let overriddenWidth = CapturePreviewDiagnosticsRuntime.configuration()?.targetContentWidth { + w = min(max(320, overriddenWidth), maxW) + h = w / ratio + } + if w > maxW { w = maxW; h = w / ratio } if h > maxH { h = maxH; w = h * ratio } diff --git a/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift new file mode 100644 index 0000000..d87163e --- /dev/null +++ b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift @@ -0,0 +1,87 @@ +import AppKit +import CoreGraphics +import Foundation + +struct CapturePreviewDiagnosticsConfiguration: Sendable { + let sourcePixelSize: CGSize + let targetContentWidth: CGFloat? + let replayImageURL: URL? + let recordDirectoryURL: URL? +} + +enum CapturePreviewDiagnosticsRuntime { + nonisolated static let sourceSizeEnvironmentKey = "VOIDDISPLAY_CAPTURE_PREVIEW_SOURCE_SIZE" + nonisolated static let targetContentWidthEnvironmentKey = "VOIDDISPLAY_CAPTURE_PREVIEW_TARGET_CONTENT_WIDTH" + nonisolated static let replayImagePathEnvironmentKey = "VOIDDISPLAY_CAPTURE_PREVIEW_REPLAY_IMAGE_PATH" + nonisolated static let recordDirectoryPathEnvironmentKey = "VOIDDISPLAY_CAPTURE_PREVIEW_RECORD_DIRECTORY" + + nonisolated static var isPreviewDiagnosticsScenario: Bool { + UITestRuntime.isEnabled && UITestRuntime.scenario == .capturePreviewDiagnostics + } + + nonisolated static var shouldAutoOpenPreviewWindow: Bool { + isPreviewDiagnosticsScenario + } + + nonisolated static func configuration( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> CapturePreviewDiagnosticsConfiguration? { + let replayImageURL: URL? + if let path = environment[replayImagePathEnvironmentKey], !path.isEmpty { + replayImageURL = URL(fileURLWithPath: path) + } else { + replayImageURL = nil + } + + let sourcePixelSize = parsedSize(from: environment[sourceSizeEnvironmentKey]) + ?? replayImageSize(from: replayImageURL) + ?? CGSize(width: 2560, height: 1600) + + let targetContentWidth: CGFloat? + if let rawWidth = environment[targetContentWidthEnvironmentKey], + let width = Double(rawWidth) { + targetContentWidth = CGFloat(width) + } else { + targetContentWidth = nil + } + + let recordDirectoryURL: URL? + if let path = environment[recordDirectoryPathEnvironmentKey], !path.isEmpty { + recordDirectoryURL = URL(fileURLWithPath: path, isDirectory: true) + } else { + recordDirectoryURL = nil + } + + return CapturePreviewDiagnosticsConfiguration( + sourcePixelSize: sourcePixelSize, + targetContentWidth: targetContentWidth, + replayImageURL: replayImageURL, + recordDirectoryURL: recordDirectoryURL + ) + } + + nonisolated static func parsedSize(from rawValue: String?) -> CGSize? { + guard let rawValue else { return nil } + let separators: [Character] = ["x", "X", "×", ","] + guard let separator = separators.first(where: rawValue.contains) else { return nil } + let parts = rawValue.split(separator: separator, maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard parts.count == 2, + let width = Double(parts[0]), width > 0, + let height = Double(parts[1]), height > 0 + else { + return nil + } + return CGSize(width: width, height: height) + } + + nonisolated private static func replayImageSize(from url: URL?) -> CGSize? { + guard let url, + let image = NSImage(contentsOf: url), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) + else { + return nil + } + return CGSize(width: cgImage.width, height: cgImage.height) + } +} diff --git a/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift new file mode 100644 index 0000000..9c1b88b --- /dev/null +++ b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift @@ -0,0 +1,296 @@ +import AppKit +import CoreGraphics +import CoreImage +import CoreMedia +import CoreVideo +import Foundation + +@MainActor +enum CapturePreviewDiagnosticsBootstrap { + static func makeMonitoringService( + configuration: CapturePreviewDiagnosticsConfiguration + ) throws -> CaptureMonitoringService { + let session = try UITestCapturePreviewSession(configuration: configuration) + let previewSubscription = DisplayPreviewSubscription( + displayID: session.displayID, + resolutionText: "\(Int(configuration.sourcePixelSize.width)) × \(Int(configuration.sourcePixelSize.height))", + session: session, + cancelClosure: {} + ) + let monitoringSession = ScreenMonitoringSession( + id: UUID(), + displayID: session.displayID, + displayName: "Preview Diagnostics", + resolutionText: previewSubscription.resolutionText, + isVirtualDisplay: false, + previewSubscription: previewSubscription, + state: .starting + ) + return CaptureMonitoringService(initialSessions: [monitoringSession]) + } +} + +final class UITestCapturePreviewSession: @unchecked Sendable, DisplayCaptureSessioning { + nonisolated let displayID: CGDirectDisplayID = 99_001 + nonisolated let sessionHub = WebRTCSessionHub() + + private nonisolated(unsafe) let sampleBuffer: CMSampleBuffer + private let fanout = PreviewSampleFanout() + + init(configuration: CapturePreviewDiagnosticsConfiguration) throws { + self.sampleBuffer = try Self.makeSampleBuffer(configuration: configuration) + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + fanout.attachPreviewSink(sink) + fanout.publishPreviewFrame(sampleBuffer) + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + fanout.detachPreviewSink(sink) + } + + nonisolated func stopSharing() {} + + nonisolated func stop() async {} +} + +private extension UITestCapturePreviewSession { + static func makeSampleBuffer( + configuration: CapturePreviewDiagnosticsConfiguration + ) throws -> CMSampleBuffer { + let sourceSize = configuration.sourcePixelSize + let width = max(1, Int(sourceSize.width.rounded())) + let height = max(1, Int(sourceSize.height.rounded())) + + var pixelBuffer: CVPixelBuffer? + let attributes: [CFString: Any] = [ + kCVPixelBufferCGImageCompatibilityKey: true, + kCVPixelBufferCGBitmapContextCompatibilityKey: true + ] + + let creationStatus = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + kCVPixelFormatType_32BGRA, + attributes as CFDictionary, + &pixelBuffer + ) + guard creationStatus == kCVReturnSuccess, let pixelBuffer else { + throw CapturePreviewDiagnosticsError.pixelBufferCreationFailed(creationStatus) + } + + CVPixelBufferLockBaseAddress(pixelBuffer, []) + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } + + guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { + throw CapturePreviewDiagnosticsError.pixelBufferBaseAddressUnavailable + } + + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo.byteOrder32Little.rawValue + | CGImageAlphaInfo.premultipliedFirst.rawValue + guard let context = CGContext( + data: baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + throw CapturePreviewDiagnosticsError.bitmapContextCreationFailed + } + + if let replayImageURL = configuration.replayImageURL { + try drawReplayImage(from: replayImageURL, in: context, size: CGSize(width: width, height: height)) + } else { + drawDiagnosticPattern(in: context, size: CGSize(width: width, height: height)) + } + + var formatDescription: CMVideoFormatDescription? + let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescriptionOut: &formatDescription + ) + guard formatStatus == noErr, let formatDescription else { + throw CapturePreviewDiagnosticsError.formatDescriptionCreationFailed(formatStatus) + } + + var timing = CMSampleTimingInfo( + duration: .invalid, + presentationTimeStamp: .zero, + decodeTimeStamp: .invalid + ) + var sampleBuffer: CMSampleBuffer? + let sampleStatus = CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescription: formatDescription, + sampleTiming: &timing, + sampleBufferOut: &sampleBuffer + ) + guard sampleStatus == noErr, let sampleBuffer else { + throw CapturePreviewDiagnosticsError.sampleBufferCreationFailed(sampleStatus) + } + + return sampleBuffer + } + + static func drawReplayImage( + from url: URL, + in context: CGContext, + size: CGSize + ) throws { + guard let image = NSImage(contentsOf: url), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) + else { + throw CapturePreviewDiagnosticsError.replayImageLoadFailed(url.path) + } + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(origin: .zero, size: size)) + } + + static func drawDiagnosticPattern(in context: CGContext, size: CGSize) { + let width = size.width + let height = size.height + let border = max(24, min(width, height) * 0.045) + let cornerSize = max(border * 0.9, min(width, height) * 0.08) + let circleDiameter = min(width, height) * 0.32 + + let background = CGColor(red: 0.93, green: 0.95, blue: 0.91, alpha: 1) + context.setFillColor(background) + context.fill(CGRect(origin: .zero, size: size)) + + context.setFillColor(CGColor(red: 0.92, green: 0.32, blue: 0.27, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: border, height: height)) + + context.setFillColor(CGColor(red: 0.20, green: 0.46, blue: 0.96, alpha: 1)) + context.fill(CGRect(x: width - border, y: 0, width: border, height: height)) + + context.setFillColor(CGColor(red: 0.14, green: 0.69, blue: 0.31, alpha: 1)) + context.fill(CGRect(x: 0, y: height - border, width: width, height: border)) + + context.setFillColor(CGColor(red: 0.94, green: 0.78, blue: 0.17, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: border)) + + drawCornerSquare( + in: context, + rect: CGRect(x: border * 1.2, y: height - border - cornerSize * 1.2, width: cornerSize, height: cornerSize), + color: CGColor(red: 0.85, green: 0.20, blue: 0.68, alpha: 1) + ) + drawCornerSquare( + in: context, + rect: CGRect(x: width - border - cornerSize * 1.2, y: height - border - cornerSize * 1.2, width: cornerSize, height: cornerSize), + color: CGColor(red: 0.06, green: 0.74, blue: 0.82, alpha: 1) + ) + drawCornerSquare( + in: context, + rect: CGRect(x: border * 1.2, y: border * 1.2, width: cornerSize, height: cornerSize), + color: CGColor(red: 0.95, green: 0.48, blue: 0.18, alpha: 1) + ) + drawCornerSquare( + in: context, + rect: CGRect(x: width - border - cornerSize * 1.2, y: border * 1.2, width: cornerSize, height: cornerSize), + color: CGColor(red: 0.46, green: 0.30, blue: 0.85, alpha: 1) + ) + + context.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.35)) + context.setLineWidth(max(2, border * 0.08)) + let step = max(60, min(width, height) * 0.08) + var x: CGFloat = border + while x < width - border { + context.move(to: CGPoint(x: x, y: border)) + context.addLine(to: CGPoint(x: x, y: height - border)) + x += step + } + var y: CGFloat = border + while y < height - border { + context.move(to: CGPoint(x: border, y: y)) + context.addLine(to: CGPoint(x: width - border, y: y)) + y += step + } + context.strokePath() + + let circleRect = CGRect( + x: (width - circleDiameter) / 2, + y: (height - circleDiameter) / 2, + width: circleDiameter, + height: circleDiameter + ) + context.setStrokeColor(CGColor(red: 0.82, green: 0.16, blue: 0.66, alpha: 1)) + context.setLineWidth(max(8, border * 0.18)) + context.strokeEllipse(in: circleRect) + + context.setStrokeColor(CGColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 0.85)) + context.setLineWidth(max(4, border * 0.09)) + context.move(to: CGPoint(x: width / 2, y: border)) + context.addLine(to: CGPoint(x: width / 2, y: height - border)) + context.move(to: CGPoint(x: border, y: height / 2)) + context.addLine(to: CGPoint(x: width - border, y: height / 2)) + context.strokePath() + } + + static func drawCornerSquare(in context: CGContext, rect: CGRect, color: CGColor) { + context.setFillColor(color) + context.fill(rect) + context.setStrokeColor(CGColor(red: 0, green: 0, blue: 0, alpha: 0.35)) + context.setLineWidth(max(3, rect.width * 0.05)) + context.stroke(rect) + } +} + +private final class PreviewSampleFanout: Sendable { + private let sinks = NSLock() + private nonisolated(unsafe) var attachedSinks: [ObjectIdentifier: any DisplayPreviewSink] = [:] + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + sinks.lock() + attachedSinks[ObjectIdentifier(sink as AnyObject)] = sink + sinks.unlock() + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + sinks.lock() + attachedSinks.removeValue(forKey: ObjectIdentifier(sink as AnyObject)) + sinks.unlock() + } + + nonisolated func publishPreviewFrame(_ sampleBuffer: CMSampleBuffer) { + sinks.lock() + let currentSinks = Array(attachedSinks.values) + sinks.unlock() + for sink in currentSinks { + sink.submitFrame(sampleBuffer) + } + } +} + +enum CapturePreviewDiagnosticsError: LocalizedError { + case pixelBufferCreationFailed(CVReturn) + case pixelBufferBaseAddressUnavailable + case bitmapContextCreationFailed + case replayImageLoadFailed(String) + case formatDescriptionCreationFailed(OSStatus) + case sampleBufferCreationFailed(OSStatus) + + var errorDescription: String? { + switch self { + case .pixelBufferCreationFailed(let status): + return "Failed to create preview diagnostics pixel buffer: \(status)" + case .pixelBufferBaseAddressUnavailable: + return "Failed to access preview diagnostics pixel buffer memory." + case .bitmapContextCreationFailed: + return "Failed to create preview diagnostics bitmap context." + case .replayImageLoadFailed(let path): + return "Failed to load replay image at path: \(path)" + case .formatDescriptionCreationFailed(let status): + return "Failed to create preview diagnostics format description: \(status)" + case .sampleBufferCreationFailed(let status): + return "Failed to create preview diagnostics sample buffer: \(status)" + } + } +} diff --git a/VoidDisplay/Shared/Testing/CapturePreviewRecordingSink.swift b/VoidDisplay/Shared/Testing/CapturePreviewRecordingSink.swift new file mode 100644 index 0000000..d8e6077 --- /dev/null +++ b/VoidDisplay/Shared/Testing/CapturePreviewRecordingSink.swift @@ -0,0 +1,128 @@ +import AppKit +import CoreImage +import CoreMedia +import CoreVideo +import Foundation +import OSLog + +final class CapturePreviewRecordingSink: @unchecked Sendable, DisplayPreviewSink { + private let destinationDirectory: URL + private let metadata: CapturePreviewRecordingMetadata + private let stateLock = NSLock() + private nonisolated(unsafe) var hasWrittenFrame = false + + init( + destinationDirectory: URL, + session: ScreenMonitoringSession + ) { + self.destinationDirectory = destinationDirectory + self.metadata = CapturePreviewRecordingMetadata( + sessionID: session.id.uuidString, + displayID: session.displayID, + displayName: session.displayName, + resolutionText: session.resolutionText + ) + } + + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { + let shouldWrite: Bool = { + stateLock.lock() + defer { stateLock.unlock() } + guard !hasWrittenFrame else { return false } + hasWrittenFrame = true + return true + }() + + guard shouldWrite else { return } + + let sampleBufferBox = SendableSampleBufferBox(sampleBuffer) + Task { @MainActor [destinationDirectory, metadata] in + do { + try FileManager.default.createDirectory( + at: destinationDirectory, + withIntermediateDirectories: true + ) + + guard let pixelBuffer = sampleBufferBox.sampleBuffer.imageBuffer else { + throw CapturePreviewRecordingError.missingPixelBuffer + } + + let image = CIImage(cvPixelBuffer: pixelBuffer) + let imageRect = image.extent.integral + let ciContext = CIContext(options: nil) + guard let cgImage = ciContext.createCGImage(image, from: imageRect) else { + throw CapturePreviewRecordingError.cgImageCreationFailed + } + + let pngURL = destinationDirectory.appendingPathComponent("frame.png") + try writePNG(cgImage: cgImage, to: pngURL) + + let frameMetadata = CapturePreviewRecordedFrameMetadata( + width: Int(imageRect.width), + height: Int(imageRect.height) + ) + let metadataURL = destinationDirectory.appendingPathComponent("metadata.json") + let payload = CapturePreviewRecordedPayload( + session: metadata, + frame: frameMetadata + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + try data.write(to: metadataURL, options: .atomic) + } catch { + AppLog.capture.error("Failed to record preview sample: \(error.localizedDescription, privacy: .public)") + } + } + } +} + +private struct CapturePreviewRecordingMetadata: Codable, Sendable { + let sessionID: String + let displayID: UInt32 + let displayName: String + let resolutionText: String +} + +private struct CapturePreviewRecordedFrameMetadata: Codable, Sendable { + let width: Int + let height: Int +} + +private struct CapturePreviewRecordedPayload: Codable, Sendable { + let session: CapturePreviewRecordingMetadata + let frame: CapturePreviewRecordedFrameMetadata +} + +private enum CapturePreviewRecordingError: LocalizedError { + case missingPixelBuffer + case cgImageCreationFailed + + var errorDescription: String? { + switch self { + case .missingPixelBuffer: + return "Preview sample buffer did not contain an image buffer." + case .cgImageCreationFailed: + return "Failed to create CGImage from preview sample buffer." + } + } +} + +private struct SendableSampleBufferBox: @unchecked Sendable { + nonisolated(unsafe) let sampleBuffer: CMSampleBuffer + + nonisolated init(_ sampleBuffer: CMSampleBuffer) { + self.sampleBuffer = sampleBuffer + } +} + +@MainActor +private func writePNG(cgImage: CGImage, to url: URL) throws { + let image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + guard let tiffData = image.tiffRepresentation, + let imageRep = NSBitmapImageRep(data: tiffData), + let pngData = imageRep.representation(using: .png, properties: [:]) else { + throw CapturePreviewRecordingError.cgImageCreationFailed + } + try pngData.write(to: url, options: .atomic) +} diff --git a/VoidDisplay/Shared/Testing/ScreenCapturePermissionProvider.swift b/VoidDisplay/Shared/Testing/ScreenCapturePermissionProvider.swift index b89ac4b..d65b71e 100644 --- a/VoidDisplay/Shared/Testing/ScreenCapturePermissionProvider.swift +++ b/VoidDisplay/Shared/Testing/ScreenCapturePermissionProvider.swift @@ -22,6 +22,8 @@ struct UITestScreenCapturePermissionProvider: ScreenCapturePermissionProvider { switch scenario { case .baseline: return true + case .capturePreviewDiagnostics: + return true case .displayCatalogLoading: return true case .virtualDisplayRebuilding: diff --git a/VoidDisplay/Shared/Testing/UITestRuntime.swift b/VoidDisplay/Shared/Testing/UITestRuntime.swift index fc8b580..79f5e3e 100644 --- a/VoidDisplay/Shared/Testing/UITestRuntime.swift +++ b/VoidDisplay/Shared/Testing/UITestRuntime.swift @@ -2,6 +2,7 @@ import Foundation enum UITestScenario: String { case baseline + case capturePreviewDiagnostics = "capture_preview_diagnostics" case displayCatalogLoading = "display_catalog_loading" case permissionDenied = "permission_denied" case virtualDisplayRebuilding = "virtual_display_rebuilding" diff --git a/VoidDisplayTests/App/CaptureControllerTests.swift b/VoidDisplayTests/App/CaptureControllerTests.swift index dccd974..b159e36 100644 --- a/VoidDisplayTests/App/CaptureControllerTests.swift +++ b/VoidDisplayTests/App/CaptureControllerTests.swift @@ -22,6 +22,16 @@ private final class CaptureControllerDummySession: DisplayCaptureSessioning, @un @Suite(.serialized) @MainActor struct CaptureControllerTests { + @Test func initSynchronizesExistingSessionsFromService() { + let service = MockCaptureMonitoringService() + let existingSession = makeSession(id: UUID(), displayID: 66) + service.currentSessions = [existingSession] + + let controller = CaptureController(captureMonitoringService: service) + + #expect(controller.screenCaptureSessions.map(\.id) == [existingSession.id]) + } + @Test func addAndRemoveSessionSyncsControllerState() { let service = MockCaptureMonitoringService() let controller = CaptureController(captureMonitoringService: service) diff --git a/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift b/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift new file mode 100644 index 0000000..fef3c07 --- /dev/null +++ b/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +@testable import VoidDisplay + +@Suite(.serialized) +struct CapturePreviewDiagnosticsRuntimeTests { + @Test @MainActor func configurationParsesSourceSizeAndWidthOverride() { + let configuration = CapturePreviewDiagnosticsRuntime.configuration( + environment: [ + CapturePreviewDiagnosticsRuntime.sourceSizeEnvironmentKey: "3008x1692", + CapturePreviewDiagnosticsRuntime.targetContentWidthEnvironmentKey: "1180" + ] + ) + + #expect(configuration?.sourcePixelSize == CGSize(width: 3008, height: 1692)) + #expect(configuration?.targetContentWidth == 1180) + #expect(configuration?.replayImageURL == nil) + } + + @Test @MainActor func parsedSizeAcceptsMultipleSeparators() { + #expect( + CapturePreviewDiagnosticsRuntime.parsedSize(from: "2560×1600") + == CGSize(width: 2560, height: 1600) + ) + #expect( + CapturePreviewDiagnosticsRuntime.parsedSize(from: "1080,1920") + == CGSize(width: 1080, height: 1920) + ) + #expect(CapturePreviewDiagnosticsRuntime.parsedSize(from: "bad-value") == nil) + } +} diff --git a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift new file mode 100644 index 0000000..7f35241 --- /dev/null +++ b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift @@ -0,0 +1,62 @@ +import XCTest + +final class CapturePreviewDiagnosticsTests: XCTestCase { + private struct DiagnosticCase { + let id: String + let sourceSize: String + let targetContentWidth: Int + } + + private let diagnosticCases: [DiagnosticCase] = [ + .init(id: "macbook-16x10-compact", sourceSize: "2560x1600", targetContentWidth: 860), + .init(id: "macbook-16x10-wide", sourceSize: "2560x1600", targetContentWidth: 1320), + .init(id: "desktop-16x9-medium", sourceSize: "3008x1692", targetContentWidth: 1180), + .init(id: "ultrawide-21x9-medium", sourceSize: "3440x1440", targetContentWidth: 1380), + .init(id: "portrait-tall", sourceSize: "1080x1920", targetContentWidth: 520) + ] + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testCapturePreviewLayoutMatrix() throws { + for testCase in diagnosticCases { + XCTContext.runActivity(named: testCase.id) { _ in + let app = launchCapturePreviewDiagnosticsApp( + sourceSize: testCase.sourceSize, + targetContentWidth: testCase.targetContentWidth + ) + defer { app.terminate() } + + let preview = smokeElement(app, identifier: "capture_preview_content") + let scalePicker = smokeElement(app, identifier: "capture_preview_scale_mode_picker") + XCTAssertTrue(scalePicker.waitForExistence(timeout: 4)) + XCTAssertTrue(preview.waitForExistence(timeout: 4)) + + let screenshot = preview.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = "capture-preview-\(testCase.id)" + attachment.lifetime = .keepAlways + add(attachment) + } + } + } +} + +private extension CapturePreviewDiagnosticsTests { + @MainActor + func launchCapturePreviewDiagnosticsApp( + sourceSize: String, + targetContentWidth: Int + ) -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["VOIDDISPLAY_UI_TEST_MODE"] = "1" + app.launchEnvironment["VOIDDISPLAY_TEST_ISOLATION_ID"] = UUID().uuidString + app.launchEnvironment["VOIDDISPLAY_UI_TEST_SCENARIO"] = "capture_preview_diagnostics" + app.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_SOURCE_SIZE"] = sourceSize + app.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_TARGET_CONTENT_WIDTH"] = String(targetContentWidth) + app.launch() + return app + } +} diff --git a/scripts/test/capture_preview_analyze.swift b/scripts/test/capture_preview_analyze.swift new file mode 100644 index 0000000..a8026b6 --- /dev/null +++ b/scripts/test/capture_preview_analyze.swift @@ -0,0 +1,228 @@ +#!/usr/bin/swift + +import AppKit +import Foundation + +struct RGBAColor { + let red: Double + let green: Double + let blue: Double + + func distance(to other: RGBAColor) -> Double { + let dr = red - other.red + let dg = green - other.green + let db = blue - other.blue + return (dr * dr + dg * dg + db * db).squareRoot() + } + + var luminance: Double { + 0.2126 * red + 0.7152 * green + 0.0722 * blue + } +} + +enum AnalyzerError: LocalizedError { + case missingArgument + case imageLoadFailed(String) + case bitmapUnavailable(String) + + var errorDescription: String? { + switch self { + case .missingArgument: + return "Usage: capture_preview_analyze.swift " + case .imageLoadFailed(let path): + return "Failed to load image at path: \(path)" + case .bitmapUnavailable(let path): + return "Failed to create bitmap from image at path: \(path)" + } + } +} + +let expectedColors: [String: RGBAColor] = [ + "left": .init(red: 0.92, green: 0.32, blue: 0.27), + "right": .init(red: 0.20, green: 0.46, blue: 0.96), + "top": .init(red: 0.14, green: 0.69, blue: 0.31), + "bottom": .init(red: 0.94, green: 0.78, blue: 0.17), + "topLeftCorner": .init(red: 0.85, green: 0.20, blue: 0.68), + "topRightCorner": .init(red: 0.06, green: 0.74, blue: 0.82), + "bottomLeftCorner": .init(red: 0.95, green: 0.48, blue: 0.18), + "bottomRightCorner": .init(red: 0.46, green: 0.30, blue: 0.85) +] + +let colorTolerance = 0.35 +let blackLuminanceThreshold = 0.08 + +func main() throws { + guard CommandLine.arguments.count >= 2 else { + throw AnalyzerError.missingArgument + } + + let imagePath = CommandLine.arguments[1] + let bitmap = try loadBitmap(path: imagePath) + let width = bitmap.pixelsWide + let height = bitmap.pixelsHigh + + let samples: [(String, Double, Double)] = [ + ("left", 0.02, 0.50), + ("right", 0.98, 0.50), + ("top", 0.50, 0.98), + ("bottom", 0.50, 0.02), + ("topLeftCorner", 0.10, 0.90), + ("topRightCorner", 0.90, 0.90), + ("bottomLeftCorner", 0.10, 0.10), + ("bottomRightCorner", 0.90, 0.10) + ] + + var failures: [String] = [] + for (name, x, y) in samples { + let actual = averageColor(bitmap: bitmap, normalizedX: x, normalizedY: y, radius: 3) + let expected = expectedColors[name]! + if actual.distance(to: expected) > colorTolerance { + failures.append("\(name) expected close to diagnostic color, actual=(\(format(actual.red)), \(format(actual.green)), \(format(actual.blue)))") + } + if (name == "left" || name == "right") && actual.luminance < blackLuminanceThreshold { + failures.append("\(name) edge looks black, likely side letterboxing remains") + } + } + + let circleBounds = detectMagentaCircleBounds(bitmap: bitmap) + if let circleBounds { + let ratio = Double(circleBounds.width) / Double(circleBounds.height) + if abs(ratio - 1) > 0.12 { + failures.append("center circle looks stretched, ratio=\(format(ratio))") + } + } else { + failures.append("failed to detect center circle") + } + + let leftBlackColumns = leadingBlackColumns(bitmap: bitmap, normalizedY: 0.5) + let rightBlackColumns = trailingBlackColumns(bitmap: bitmap, normalizedY: 0.5) + if leftBlackColumns > max(2, width / 200) { + failures.append("left black bar width=\(leftBlackColumns)px") + } + if rightBlackColumns > max(2, width / 200) { + failures.append("right black bar width=\(rightBlackColumns)px") + } + + if failures.isEmpty { + print("PASS \(imagePath) size=\(width)x\(height) leftBlack=\(leftBlackColumns) rightBlack=\(rightBlackColumns)") + return + } + + print("FAIL \(imagePath)") + for failure in failures { + print(" - \(failure)") + } + exit(1) +} + +func loadBitmap(path: String) throws -> NSBitmapImageRep { + guard let image = NSImage(contentsOfFile: path) else { + throw AnalyzerError.imageLoadFailed(path) + } + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) else { + throw AnalyzerError.bitmapUnavailable(path) + } + return bitmap +} + +func averageColor( + bitmap: NSBitmapImageRep, + normalizedX: Double, + normalizedY: Double, + radius: Int +) -> RGBAColor { + let centerX = Int((Double(bitmap.pixelsWide - 1) * normalizedX).rounded()) + let centerY = Int((Double(bitmap.pixelsHigh - 1) * normalizedY).rounded()) + + var red = 0.0 + var green = 0.0 + var blue = 0.0 + var samples = 0.0 + + for dx in -radius...radius { + for dy in -radius...radius { + let x = min(max(0, centerX + dx), bitmap.pixelsWide - 1) + let y = min(max(0, centerY + dy), bitmap.pixelsHigh - 1) + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { continue } + red += Double(color.redComponent) + green += Double(color.greenComponent) + blue += Double(color.blueComponent) + samples += 1 + } + } + + return .init( + red: red / max(1, samples), + green: green / max(1, samples), + blue: blue / max(1, samples) + ) +} + +func detectMagentaCircleBounds(bitmap: NSBitmapImageRep) -> CGRect? { + var minX = bitmap.pixelsWide + var maxX = 0 + var minY = bitmap.pixelsHigh + var maxY = 0 + var found = false + + for x in 0.. 0.6 && color.blueComponent > 0.45 && color.greenComponent < 0.45 { + found = true + minX = min(minX, x) + maxX = max(maxX, x) + minY = min(minY, y) + maxY = max(maxY, y) + } + } + } + + guard found else { return nil } + return CGRect( + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1 + ) +} + +func leadingBlackColumns(bitmap: NSBitmapImageRep, normalizedY: Double) -> Int { + let y = Int((Double(bitmap.pixelsHigh - 1) * normalizedY).rounded()) + for x in 0..= blackLuminanceThreshold { + return x + } + } + return bitmap.pixelsWide +} + +func trailingBlackColumns(bitmap: NSBitmapImageRep, normalizedY: Double) -> Int { + let y = Int((Double(bitmap.pixelsHigh - 1) * normalizedY).rounded()) + for x in stride(from: bitmap.pixelsWide - 1, through: 0, by: -1) { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { continue } + let luminance = 0.2126 * Double(color.redComponent) + + 0.7152 * Double(color.greenComponent) + + 0.0722 * Double(color.blueComponent) + if luminance >= blackLuminanceThreshold { + return bitmap.pixelsWide - 1 - x + } + } + return bitmap.pixelsWide +} + +func format(_ value: Double) -> String { + String(format: "%.3f", value) +} + +do { + try main() +} catch { + fputs("\(error.localizedDescription)\n", stderr) + exit(1) +} diff --git a/scripts/test/capture_preview_self_check.sh b/scripts/test/capture_preview_self_check.sh new file mode 100644 index 0000000..9307fb1 --- /dev/null +++ b/scripts/test/capture_preview_self_check.sh @@ -0,0 +1,45 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +TMP_DIR="$ROOT_DIR/.ai-tmp/capture-preview-check" +DERIVED_DATA_DIR="$ROOT_DIR/.ai-tmp/capture-preview-check/DerivedData" +BUILD_LOG="$ROOT_DIR/.ai-tmp/capture-preview-check/test.log" +ATTACHMENTS_DIR="$ROOT_DIR/.ai-tmp/capture-preview-check/attachments" + +mkdir -p "$TMP_DIR" +find "$TMP_DIR" -maxdepth 1 -name '*.png' -delete +rm -rf "$ATTACHMENTS_DIR" + +xcodebuild \ + -project "$ROOT_DIR/VoidDisplay.xcodeproj" \ + -scheme VoidDisplay \ + -configuration Debug \ + -derivedDataPath "$DERIVED_DATA_DIR" \ + -destination 'platform=macOS' \ + -only-testing:VoidDisplayUITests/CapturePreviewDiagnosticsTests/testCapturePreviewLayoutMatrix \ + test \ + > "$BUILD_LOG" 2>&1 + +RESULT_BUNDLE="$(find "$DERIVED_DATA_DIR/Logs/Test" -maxdepth 1 -name '*.xcresult' | sort | tail -n 1)" +if [[ -z "$RESULT_BUNDLE" ]]; then + echo "No xcresult bundle was generated. See $BUILD_LOG" >&2 + exit 1 +fi + +xcrun xcresulttool export attachments \ + --path "$RESULT_BUNDLE" \ + --output-path "$ATTACHMENTS_DIR" \ + > /dev/null 2>&1 + +typeset -a images +images=("$ATTACHMENTS_DIR"/*.png(N)) + +if (( ${#images[@]} == 0 )); then + echo "No capture preview diagnostic screenshots were generated. See $BUILD_LOG and $RESULT_BUNDLE" >&2 + exit 1 +fi + +for image in "${images[@]}"; do + swift "$ROOT_DIR/scripts/test/capture_preview_analyze.swift" "$image" +done From 3c64e797114c3794a8af10cce81cb24a05889761 Mon Sep 17 00:00:00 2001 From: Chen Date: Wed, 11 Mar 2026 14:34:38 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix(capture):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B1=8F=E5=B9=95=E7=9B=91=E5=90=AC=E9=A2=84=E8=A7=88=E5=B7=A6?= =?UTF-8?q?=E5=8F=B3=E9=BB=91=E8=BE=B9\n\n-=20=E4=BF=AE=E6=AD=A3=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E7=AA=97=E5=8F=A3=E5=88=9D=E5=A7=8B=E5=B0=BA=E5=AF=B8?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=EF=BC=8C=E6=8C=89=E7=9C=9F=E5=AE=9E=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=B8=83=E5=B1=80=E5=8C=BA=E5=8C=B9=E9=85=8D=E9=87=87?= =?UTF-8?q?=E9=9B=86=E6=BA=90=E5=AE=BD=E9=AB=98=E6=AF=94\n-=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E8=AF=8A=E6=96=AD=E5=88=86=E6=9E=90=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E5=8D=87=E8=B6=85=E5=AE=BD=E5=92=8C=E5=9C=86?= =?UTF-8?q?=E8=A7=92=E5=9C=BA=E6=99=AF=E4=B8=8B=E7=9A=84=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E7=A8=B3=E5=AE=9A=E6=80=A7\n-=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E9=BB=91=E8=BE=B9=E9=97=AE=E9=A2=98=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E3=80=81=E4=BF=AE=E5=A4=8D=E5=8E=9F=E7=90=86=E4=B8=8E=E8=87=AA?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=B5=81=E7=A8=8B=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/Views/CaptureDisplayView.swift | 57 ++-- docs/capture_preview_black_bar_fix_notes.md | 294 ++++++++++++++++++ scripts/test/capture_preview_analyze.swift | 124 ++++++-- 3 files changed, 434 insertions(+), 41 deletions(-) create mode 100644 docs/capture_preview_black_bar_fix_notes.md diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 3b46fb6..f41f866 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -146,39 +146,58 @@ extension CaptureDisplayView { guard let window, aspect.width > 0, aspect.height > 0, !hasAppliedInitialSize else { return } window.backgroundColor = .black - window.contentAspectRatio = NSSize(width: aspect.width, height: aspect.height) - - let contentRect = window.contentRect(forFrameRect: window.frame) let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame - let chromeWidth = window.frame.width - contentRect.width - let chromeHeight = window.frame.height - contentRect.height - - let maxW = max(320, (visibleFrame?.width ?? 1280) - chromeWidth - 16) - let maxH = max(180, (visibleFrame?.height ?? 800) - chromeHeight - 16) + let contentRect = window.contentRect(forFrameRect: window.frame) + let layoutRect = window.contentLayoutRect + let chromeWidth = max(0, window.frame.width - contentRect.width) + let chromeHeight = max(0, window.frame.height - contentRect.height) + let layoutInsetWidth = max(0, contentRect.width - layoutRect.width) + let layoutInsetHeight = max(0, contentRect.height - layoutRect.height) + + let maxPreviewWidth = max( + 320, + (visibleFrame?.width ?? 1280) - chromeWidth - layoutInsetWidth - 16 + ) + let maxPreviewHeight = max( + 180, + (visibleFrame?.height ?? 800) - chromeHeight - layoutInsetHeight - 16 + ) let ratio = aspect.width / aspect.height let scale = max(1, window.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) let pixelSize = renderer.framePixelSize - let defaultContentWidth = max(320, maxW * 0.85) - let defaultContentHeight = defaultContentWidth / ratio - var w = defaultContentWidth - var h = defaultContentHeight + let defaultPreviewWidth = max(320, maxPreviewWidth * 0.85) + let defaultPreviewHeight = defaultPreviewWidth / ratio + var previewWidth = defaultPreviewWidth + var previewHeight = defaultPreviewHeight if pixelSize.width > 0, pixelSize.height > 0 { - w = pixelSize.width / scale - h = pixelSize.height / scale + previewWidth = pixelSize.width / scale + previewHeight = pixelSize.height / scale } if let overriddenWidth = CapturePreviewDiagnosticsRuntime.configuration()?.targetContentWidth { - w = min(max(320, overriddenWidth), maxW) - h = w / ratio + previewWidth = min(max(320, overriddenWidth), maxPreviewWidth) + previewHeight = previewWidth / ratio } - if w > maxW { w = maxW; h = w / ratio } - if h > maxH { h = maxH; w = h * ratio } + if previewWidth > maxPreviewWidth { + previewWidth = maxPreviewWidth + previewHeight = previewWidth / ratio + } + if previewHeight > maxPreviewHeight { + previewHeight = maxPreviewHeight + previewWidth = previewHeight * ratio + } + + let targetContentSize = NSSize( + width: previewWidth + layoutInsetWidth, + height: previewHeight + layoutInsetHeight + ) + window.contentAspectRatio = targetContentSize let targetFrame = window.frameRect( - forContentRect: NSRect(origin: .zero, size: NSSize(width: w, height: h)) + forContentRect: NSRect(origin: .zero, size: targetContentSize) ) var newFrame = window.frame newFrame.origin.x += (newFrame.width - targetFrame.width) / 2 diff --git a/docs/capture_preview_black_bar_fix_notes.md b/docs/capture_preview_black_bar_fix_notes.md new file mode 100644 index 0000000..16afcfe --- /dev/null +++ b/docs/capture_preview_black_bar_fix_notes.md @@ -0,0 +1,294 @@ +# 屏幕监听预览左右黑边问题修复记录 + +## 背景 + +屏幕监听预览窗口长期存在一个顽固问题: + +- 预览内容左右会出现黑边 +- 调整后经常变成左右黑边消失,但上下内容被裁掉 +- 仅靠人工截图反馈,调试回路很慢 + +这次修复的目标有两个: + +1. 解决预览窗口左右黑边 +2. 建立一套可重复、自验证的本地检查方法,避免后续继续靠人工截图来回试错 + +## 现象与误区 + +### 现象 + +预览窗口里使用的是 `AVSampleBufferDisplayLayer`,视频内容按原始比例显示。窗口开启“适应”模式时,理想效果应当是: + +- 保持完整画面 +- 不拉伸 +- 不裁切 +- 不出现左右黑边 + +实际却出现了左右黑边。 + +### 常见误判 + +这个问题很容易被误判成以下几类: + +- 采集帧本身有黑边 +- `AVSampleBufferDisplayLayer.videoGravity` 选错 +- 只要切到“填充”或强行拉伸就能解决 +- 只要写几组常见分辨率预设就能解决 + +这些方向都不对,或者只能暂时掩盖问题。 + +## 根因 + +根因在预览窗口初始 sizing 逻辑,位置见 [CaptureDisplayView.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift#L144)。 + +旧逻辑的关键问题: + +- 用采集源宽高比计算窗口大小 +- 计算时只考虑 `window.contentRect(forFrameRect:)` +- 预览窗口又使用了系统 unified toolbar/titlebar +- 真正承载预览层的区域实际是 `window.contentLayoutRect` + +这两者并不相同。 + +`contentRect` 表示窗口内容区域。 +`contentLayoutRect` 才是系统 toolbar/titlebar 扣除后,真正适合承载内容布局的区域。 + +旧逻辑的问题可以表达成: + +```text +代码以为: +窗口内容区宽高比 == 采集源宽高比 + +实际发生: +真实预览承载区宽高比 != 采集源宽高比 +``` + +结果就是: + +- 对窗口尺寸来说,看起来像是按正确比例设置了 +- 对 `AVSampleBufferDisplayLayer` 来说,实际显示区域偏宽 +- 在 `resizeAspect` 下,左右自然会留黑边 + +## 这次修复的原理 + +修复点仍在 [CaptureDisplayView.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift#L144)。 + +核心思路: + +1. 先拿到采集源真实宽高比 +2. 同时拿到窗口 `contentRect` 与 `contentLayoutRect` +3. 计算两者之间的 `layout inset` +4. 用“真实预览尺寸 + layout inset”反推窗口应该有的内容尺寸 +5. 再用这组尺寸设置 `window.contentAspectRatio` 和最终 window frame + +关键代码思路如下: + +```text +layoutInsetWidth = contentRect.width - contentLayoutRect.width +layoutInsetHeight = contentRect.height - contentLayoutRect.height + +targetContentSize = + previewRenderableSize + layoutInsets + +window.contentAspectRatio = targetContentSize +window.frameRect(forContentRect: targetContentSize) +``` + +修复后的目标关系: + +```text +真实预览承载区宽高比 == 采集源宽高比 +``` + +这就是左右黑边消失的原因。 + +## 这次不是靠预设修复 + +这次修复不依赖固定分辨率表,也不依赖一批写死的宽高比预设。 + +生效条件只有两个: + +- 上游能提供正确的采集源宽高比 +- 当前窗口的 `contentLayoutRect` 能正确反映 toolbar/titlebar 对内容区的占用 + +因此它是公式化、自适应的做法,适用于: + +- 16:10 +- 16:9 +- 21:9 +- 竖屏 +- 其他非标比例 + +只要源宽高比是准确的,窗口都会按同一套规则计算,不需要为每种屏幕写一套特殊分支。 + +## 为什么以前容易修歪 + +### 误把“消黑边”做成“裁内容” + +如果直接朝“黑边消失”这个目标调,很容易滑向下面两种做法: + +- 改成类似 `aspectFill` +- 强行把窗口高度压小或宽度拉满 + +这样视觉上左右黑边确实会没掉,但代价是: + +- 上下内容被裁掉 +- 或者画面发生非等比拉伸 + +这类修法属于错方向。 + +### 只看整窗截图,不看真实内容区 + +另一个常见坑是看整窗截图判断。整窗会包含: + +- 标题栏 +- toolbar +- 圆角 +- 阴影 +- 黑色背景层 + +这些因素会干扰判断,导致很难确认问题是在: + +- 预览层本身 +- 窗口尺寸 +- 还是分析方法 + +正确做法是只看预览内容区。 + +## 这次新增的自验证链路 + +为避免后续继续靠人工截图反馈,这次加了一套专门的自验证工具链。 + +相关文件: + +- [CapturePreviewDiagnosticsRuntime.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift) +- [CapturePreviewDiagnosticsSession.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift) +- [CapturePreviewRecordingSink.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Shared/Testing/CapturePreviewRecordingSink.swift) +- [CapturePreviewDiagnosticsTests.swift](/Users/syc/Project/VoidDisplay/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift) +- [capture_preview_self_check.sh](/Users/syc/Project/VoidDisplay/scripts/test/capture_preview_self_check.sh) +- [capture_preview_analyze.swift](/Users/syc/Project/VoidDisplay/scripts/test/capture_preview_analyze.swift) + +### 自验证思路 + +1. UI test 场景下注入假的监听会话 +2. 用诊断图代替真实桌面 +3. 诊断图包含四边彩色边框、四角标记、中心圆与网格 +4. 自动打开预览窗口 +5. 只截取预览内容区 +6. 用脚本做像素判定 + +### 这套方法能回答什么问题 + +- 左右是否还存在黑边 +- 上下是否被裁掉 +- 四角标记是否完整可见 +- 中心圆是否被拉伸成椭圆 + +### 当前诊断矩阵 + +已覆盖以下场景: + +- `macbook-16x10-compact` +- `macbook-16x10-wide` +- `desktop-16x9-medium` +- `ultrawide-21x9-medium` +- `portrait-tall` + +这些场景足以覆盖绝大部分常见与非常见比例。 + +## 这次分析脚本踩过的坑 + +分析脚本最初也踩了几个坑,位置在 [capture_preview_analyze.swift](/Users/syc/Project/VoidDisplay/scripts/test/capture_preview_analyze.swift#L57)。 + +### 1. 边缘取样点过死 + +最早是用固定点取样,例如左边取 `x=0.02, y=0.5`。 +问题在于超宽图、圆角、边框厚度、抗锯齿都会让固定点落在非目标区域,导致误判。 + +后面改成: + +- 在边缘窄条区域内搜索最接近目标色的像素 + +这样对不同宽高比更稳。 + +### 2. 角标检测不能只看单点 + +四角角标本身面积不大,抗锯齿明显。 +如果只看某一个点,很容易碰到边缘半透明像素。 + +后面改成: + +- 在对应象限搜索最接近角标色的像素 + +### 3. 圆形检测范围不能过大 + +如果中心圆检测范围太大,网格线和其他颜色会干扰边界推断。 + +后面改成: + +- 只在中心区域搜索圆形颜色 + +### 4. 相对路径在脚本里不够稳 + +分析脚本直接接收路径时,若路径未标准化,批处理和不同调用目录下可能出现加载失败。 + +后面改成: + +- 先把输入路径转成标准化绝对路径再加载 + +## 推荐的后续排查顺序 + +以后如果再碰到预览显示异常,建议按这个顺序查: + +1. 先跑自验证脚本 + `zsh scripts/test/capture_preview_self_check.sh` + +2. 先看诊断图结果,再看真实桌面效果 + 这样可以先排除布局算法问题 + +3. 如果诊断图正常、真实桌面异常,优先查上游元数据 + 重点看: + - `renderer.framePixelSize` + - `session.resolutionText` + - 首帧 `CMVideoFormatDescriptionGetDimensions` + +4. 如果诊断图也异常,优先查窗口 sizing + 重点看: + - `contentRect` + - `contentLayoutRect` + - `contentAspectRatio` + - 预览层所在视图实际 bounds + +5. 不要先改 `videoGravity` + +6. 不要先改成 fill 或手工裁切 + +## 当前结论 + +这次问题的本质不是渲染层不会铺满,也不是缺少几组分辨率预设。 +问题在于窗口真实可用内容区的宽高比计算错了。 + +这次修复后: + +- 左右黑边问题已通过自验证矩阵消除 +- 没有引入上下裁切 +- 没有引入非等比拉伸 +- 方法对非标比例也成立 + +## 维护建议 + +后续如果再调整以下内容,要优先回归这套自验证链路: + +- 预览窗口 toolbar 样式 +- 预览窗口 titlebar 布局 +- `CaptureDisplayView` 初始尺寸逻辑 +- 预览层宿主视图层级 +- 采集会话首帧尺寸来源 + +建议原则: + +- 先确认真实内容承载区比例 +- 再调整窗口尺寸 +- 最后再看视觉效果 + +顺序不要反过来。 diff --git a/scripts/test/capture_preview_analyze.swift b/scripts/test/capture_preview_analyze.swift index a8026b6..4076665 100644 --- a/scripts/test/capture_preview_analyze.swift +++ b/scripts/test/capture_preview_analyze.swift @@ -50,33 +50,36 @@ let expectedColors: [String: RGBAColor] = [ let colorTolerance = 0.35 let blackLuminanceThreshold = 0.08 +let cornerTolerance = 0.28 +let circleColor = RGBAColor(red: 0.82, green: 0.16, blue: 0.66) +let circleTolerance = 0.34 func main() throws { guard CommandLine.arguments.count >= 2 else { throw AnalyzerError.missingArgument } - let imagePath = CommandLine.arguments[1] + let imagePath = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL.path let bitmap = try loadBitmap(path: imagePath) let width = bitmap.pixelsWide let height = bitmap.pixelsHigh - let samples: [(String, Double, Double)] = [ - ("left", 0.02, 0.50), - ("right", 0.98, 0.50), - ("top", 0.50, 0.98), - ("bottom", 0.50, 0.02), - ("topLeftCorner", 0.10, 0.90), - ("topRightCorner", 0.90, 0.90), - ("bottomLeftCorner", 0.10, 0.10), - ("bottomRightCorner", 0.90, 0.10) + let edgeSearchRegions: [(String, CGRect)] = [ + ("left", normalizedRect(0.00, 0.10, 0.04, 0.80, imageWidth: width, imageHeight: height)), + ("right", normalizedRect(0.96, 0.10, 0.04, 0.80, imageWidth: width, imageHeight: height)), + ("top", normalizedRect(0.10, 0.00, 0.80, 0.04, imageWidth: width, imageHeight: height)), + ("bottom", normalizedRect(0.10, 0.96, 0.80, 0.04, imageWidth: width, imageHeight: height)) ] var failures: [String] = [] - for (name, x, y) in samples { - let actual = averageColor(bitmap: bitmap, normalizedX: x, normalizedY: y, radius: 3) + for (name, rect) in edgeSearchRegions { let expected = expectedColors[name]! - if actual.distance(to: expected) > colorTolerance { + let (distance, actual) = nearestColorMatch( + bitmap: bitmap, + rect: rect, + expected: expected + ) + if distance > colorTolerance { failures.append("\(name) expected close to diagnostic color, actual=(\(format(actual.red)), \(format(actual.green)), \(format(actual.blue)))") } if (name == "left" || name == "right") && actual.luminance < blackLuminanceThreshold { @@ -84,7 +87,28 @@ func main() throws { } } - let circleBounds = detectMagentaCircleBounds(bitmap: bitmap) + let cornerSearchRegions: [(String, CGRect)] = [ + ("topLeftCorner", normalizedRect(0.02, 0.02, 0.22, 0.22, imageWidth: width, imageHeight: height)), + ("topRightCorner", normalizedRect(0.78, 0.02, 0.20, 0.22, imageWidth: width, imageHeight: height)), + ("bottomLeftCorner", normalizedRect(0.02, 0.78, 0.22, 0.20, imageWidth: width, imageHeight: height)), + ("bottomRightCorner", normalizedRect(0.78, 0.78, 0.20, 0.20, imageWidth: width, imageHeight: height)) + ] + + for (name, rect) in cornerSearchRegions { + let distance = nearestColorDistance( + bitmap: bitmap, + rect: rect, + expected: expectedColors[name]! + ) + if distance > cornerTolerance { + failures.append("\(name) marker not found in expected quadrant") + } + } + + let circleBounds = detectMagentaCircleBounds( + bitmap: bitmap, + searchRect: normalizedRect(0.25, 0.25, 0.50, 0.50, imageWidth: width, imageHeight: height) + ) if let circleBounds { let ratio = Double(circleBounds.width) / Double(circleBounds.height) if abs(ratio - 1) > 0.12 { @@ -159,17 +183,22 @@ func averageColor( ) } -func detectMagentaCircleBounds(bitmap: NSBitmapImageRep) -> CGRect? { - var minX = bitmap.pixelsWide - var maxX = 0 - var minY = bitmap.pixelsHigh - var maxY = 0 +func detectMagentaCircleBounds(bitmap: NSBitmapImageRep, searchRect: CGRect) -> CGRect? { + var minX = Int(searchRect.maxX) + var maxX = Int(searchRect.minX) + var minY = Int(searchRect.maxY) + var maxY = Int(searchRect.minY) var found = false - for x in 0.. 0.6 && color.blueComponent > 0.45 && color.greenComponent < 0.45 { + let actual = RGBAColor( + red: Double(color.redComponent), + green: Double(color.greenComponent), + blue: Double(color.blueComponent) + ) + if actual.distance(to: circleColor) <= circleTolerance { found = true minX = min(minX, x) maxX = max(maxX, x) @@ -188,6 +217,41 @@ func detectMagentaCircleBounds(bitmap: NSBitmapImageRep) -> CGRect? { ) } +func nearestColorDistance( + bitmap: NSBitmapImageRep, + rect: CGRect, + expected: RGBAColor +) -> Double { + nearestColorMatch(bitmap: bitmap, rect: rect, expected: expected).distance +} + +func nearestColorMatch( + bitmap: NSBitmapImageRep, + rect: CGRect, + expected: RGBAColor +) -> (distance: Double, color: RGBAColor) { + var bestDistance = Double.greatestFiniteMagnitude + var bestColor = RGBAColor(red: 0, green: 0, blue: 0) + + for x in Int(rect.minX).. Int { let y = Int((Double(bitmap.pixelsHigh - 1) * normalizedY).rounded()) for x in 0.. String { String(format: "%.3f", value) } +func normalizedRect( + _ x: Double, + _ y: Double, + _ width: Double, + _ height: Double, + imageWidth: Int, + imageHeight: Int +) -> CGRect { + CGRect( + x: Double(imageWidth) * x, + y: Double(imageHeight) * y, + width: Double(imageWidth) * width, + height: Double(imageHeight) * height + ) +} + do { try main() } catch { From 14dd178ee63a62728c4547e44ab880edf635305b Mon Sep 17 00:00:00 2001 From: Chen Date: Wed, 11 Mar 2026 17:42:01 +0800 Subject: [PATCH 03/11] =?UTF-8?q?test(localization):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E7=8E=AF=E5=A2=83=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E6=95=8F=E6=84=9F=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将写死英文的错误提示断言改为与产品代码同源的本地化文案 - 覆盖虚拟显示器、屏幕监听与 Web 服务相关的语言敏感测试场景 - 验证中英文环境下整套 VoidDisplayTests 均可稳定通过 --- .../App/VirtualDisplayControllerTests.swift | 11 +++++++++-- .../ViewModels/CaptureChooseViewModelTests.swift | 2 +- .../Sharing/Services/WebServiceControllerTests.swift | 3 ++- .../EditVirtualDisplayWorkflowTests.swift | 2 +- .../VirtualDisplayOrchestratorLightTests.swift | 7 ++++++- 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/VoidDisplayTests/App/VirtualDisplayControllerTests.swift b/VoidDisplayTests/App/VirtualDisplayControllerTests.swift index f2bcc47..4a0a7d1 100644 --- a/VoidDisplayTests/App/VirtualDisplayControllerTests.swift +++ b/VoidDisplayTests/App/VirtualDisplayControllerTests.swift @@ -158,7 +158,12 @@ struct AppBootstrapTests { env.virtualDisplay.loadPersistedConfigsAndRestoreDesiredVirtualDisplays() #expect(env.virtualDisplay.configStorePresentation.hasLoadFailure) - #expect(env.virtualDisplay.configStorePresentation.loadErrorMessage?.contains("Reset") == true) + #expect( + env.virtualDisplay.configStorePresentation.loadErrorMessage + == VirtualDisplayConfigStoreError + .unsupportedSchemaVersion(expected: 3, actual: 2) + .userFacingMessage + ) #expect(env.virtualDisplay.configStorePresentation.diagnosticsSummary?.contains("primary=/tmp/virtual-displays.json") == true) } @@ -772,7 +777,9 @@ struct AppBootstrapTests { #expect(sut.persistenceAlert != nil) #expect( sut.persistenceAlert?.message == - "Create failed and the config rollback could not be saved. Check config file permissions or reset the config file." + String( + localized: "Create failed and the config rollback could not be saved. Check config file permissions or reset the config file." + ) ) #expect(sut.displayConfigs.count == 1) } diff --git a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift index 8ed471f..43e1040 100644 --- a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift +++ b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift @@ -170,7 +170,7 @@ struct CaptureChooseViewModelTests { await sut.startMonitoring(display: display) { openedSessionIDs.append($0) } #expect(openedSessionIDs.isEmpty) - #expect(sut.userFacingAlert?.title == "Start Monitoring Failed") + #expect(sut.userFacingAlert?.title == String(localized: "Start Monitoring Failed")) #expect(sut.userFacingAlert?.message.isEmpty == false) #expect(sut.startingDisplayIDs.isEmpty) } diff --git a/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift b/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift index 49007de..3ab4f8b 100644 --- a/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift @@ -159,7 +159,8 @@ struct WebServiceControllerTests { #expect( states.contains(where: { state in if case .failed(.listenerFailed(let failedPort, let message)) = state { - return failedPort == port && message.contains("unexpectedly") + return failedPort == port + && message == String(localized: "Web listener stopped unexpectedly.") } return false }) diff --git a/VoidDisplayTests/Features/VirtualDisplay/EditVirtualDisplayWorkflowTests.swift b/VoidDisplayTests/Features/VirtualDisplay/EditVirtualDisplayWorkflowTests.swift index 867dda6..46e2a19 100644 --- a/VoidDisplayTests/Features/VirtualDisplay/EditVirtualDisplayWorkflowTests.swift +++ b/VoidDisplayTests/Features/VirtualDisplay/EditVirtualDisplayWorkflowTests.swift @@ -42,7 +42,7 @@ struct EditVirtualDisplayWorkflowTests { #expect(throws: Error.self) { try controller.updateConfig(config) } - #expect(controller.persistenceAlert?.title == "Save Failed") + #expect(controller.persistenceAlert?.title == String(localized: "Save Failed")) #expect(controller.persistenceAlert?.message.isEmpty == false) } diff --git a/VoidDisplayTests/Features/VirtualDisplay/VirtualDisplayOrchestratorLightTests.swift b/VoidDisplayTests/Features/VirtualDisplay/VirtualDisplayOrchestratorLightTests.swift index 99af951..acafb21 100644 --- a/VoidDisplayTests/Features/VirtualDisplay/VirtualDisplayOrchestratorLightTests.swift +++ b/VoidDisplayTests/Features/VirtualDisplay/VirtualDisplayOrchestratorLightTests.swift @@ -269,7 +269,12 @@ struct VirtualDisplayOrchestratorLightTests { Issue.record("Unexpected error: \(error)") return } - #expect(message.contains("rollback")) + #expect( + message == + String( + localized: "Create failed and the config rollback could not be saved. Check config file permissions or reset the config file." + ) + ) } catch { Issue.record("Unexpected error type: \(error)") } From 5ffb72968d095bba54adad492892eb41b6f54d07 Mon Sep 17 00:00:00 2001 From: Chen Date: Wed, 11 Mar 2026 19:32:10 +0800 Subject: [PATCH 04/11] =?UTF-8?q?refactor(capture):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=AA=97=E5=8F=A3=20toolbar=20=E4=B8=8E?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E8=83=8C=E6=99=AF=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预览窗口改用 unified toolbar,避免 compact 标题栏下 principal 分段控件明显偏位 - 将黑底限制在实际预览画面层,保留内容映射的悬浮感并避免 toolbar 被整块压黑 - 撤回失败的滚动隐藏尝试,保持交互代码和视图层级简洁 --- VoidDisplay/App/VoidDisplayApp.swift | 2 +- .../Features/Capture/Views/CaptureDisplayView.swift | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/VoidDisplay/App/VoidDisplayApp.swift b/VoidDisplay/App/VoidDisplayApp.swift index d836e4e..dbc4324 100644 --- a/VoidDisplay/App/VoidDisplayApp.swift +++ b/VoidDisplay/App/VoidDisplayApp.swift @@ -42,7 +42,7 @@ struct VoidDisplayApp: App { .environment(sharing) .environment(virtualDisplay) } - .windowToolbarStyle(.unifiedCompact(showsTitle: true)) + .windowToolbarStyle(.unified(showsTitle: true)) Settings { AppSettingsView() diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index f41f866..3d1b316 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -48,6 +48,7 @@ struct CaptureDisplayView: View { if scaleMode == .fit { ZeroCopyPreviewLayerView(renderer: renderer) .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) } else { ScrollView([.horizontal, .vertical]) { ZeroCopyPreviewLayerView(renderer: renderer) @@ -55,6 +56,7 @@ struct CaptureDisplayView: View { width: nativeFrameSizeInPoints.width, height: nativeFrameSizeInPoints.height ) + .background(Color.black) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -70,7 +72,7 @@ struct CaptureDisplayView: View { var body: some View { ZStack { - Color.black + Color(nsColor: .windowBackgroundColor) previewContent } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -137,7 +139,6 @@ struct CaptureDisplayView: View { // MARK: - Window Sizing extension CaptureDisplayView { - /// Sets the window's initial size and aspect ratio to match the /// captured display. Called once when both the window reference /// and the first frame's pixel dimensions become available. @@ -145,7 +146,7 @@ extension CaptureDisplayView { let aspect = preferredAspect() guard let window, aspect.width > 0, aspect.height > 0, !hasAppliedInitialSize else { return } - window.backgroundColor = .black + window.backgroundColor = .windowBackgroundColor let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame let contentRect = window.contentRect(forFrameRect: window.frame) let layoutRect = window.contentLayoutRect From 1c4ba8dc025bfad8e6d8d6cdf5d0f5a57bcf0ec8 Mon Sep 17 00:00:00 2001 From: Chen Date: Wed, 11 Mar 2026 22:12:48 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix(capture):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=AA=97=E5=8F=A3=E5=85=A8=E5=B1=8F=E4=B8=8E?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一预览窗口在适应与 1:1 模式下的 toolbar 材质承接 - 全屏时自动隐藏 toolbar 并修复 1:1 滚动宿主背景影响 - 为适应模式补充拖拽 resize 比例锁定并更新排障文档 --- .../Capture/Views/CaptureDisplayView.swift | 108 ++++++++++++++- docs/capture_preview_black_bar_fix_notes.md | 123 ++++++++++++++++++ 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 3d1b316..d95eeaf 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -18,6 +18,7 @@ struct CaptureDisplayView: View { @State private var renderer = ZeroCopyPreviewRenderer() @State private var recordingSink: CapturePreviewRecordingSink? @State private var window: NSWindow? + @State private var windowCoordinator = CapturePreviewWindowCoordinator() @State private var hasAppliedInitialSize = false @State private var scaleMode: PreviewScaleMode = .fit @@ -48,7 +49,6 @@ struct CaptureDisplayView: View { if scaleMode == .fit { ZeroCopyPreviewLayerView(renderer: renderer) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black) } else { ScrollView([.horizontal, .vertical]) { ZeroCopyPreviewLayerView(renderer: renderer) @@ -58,6 +58,7 @@ struct CaptureDisplayView: View { ) .background(Color.black) } + .background(TransparentScrollViewConfigurator()) .frame(maxWidth: .infinity, maxHeight: .infinity) } } else { @@ -90,6 +91,12 @@ struct CaptureDisplayView: View { } } .toolbarTitleDisplayMode(.inline) + .onAppear { + windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: scaleMode == .fit) + } + .onChange(of: scaleMode) { _, newValue in + windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: newValue == .fit) + } .onChange(of: capture.screenCaptureSessions.map(\.id)) { _, ids in if !ids.contains(sessionId) { dismiss() @@ -122,12 +129,18 @@ struct CaptureDisplayView: View { capture.removeMonitoringSession(id: sessionId) } .onChange(of: renderer.framePixelSize) { _, _ in + windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: scaleMode == .fit) applyInitialWindowSize() } .overlay { WindowAccessor { resolvedWindow in if window !== resolvedWindow { window = resolvedWindow + windowCoordinator.attach(to: resolvedWindow) + windowCoordinator.update( + aspect: preferredAspect(), + shouldLockAspect: scaleMode == .fit + ) applyInitialWindowSize() } } @@ -233,6 +246,99 @@ extension CaptureDisplayView { } } +// MARK: - Window Coordination + +@MainActor +private final class CapturePreviewWindowCoordinator: NSObject, NSWindowDelegate { + private weak var window: NSWindow? + private var aspect = CGSize.zero + private var shouldLockAspect = true + + func attach(to window: NSWindow) { + guard self.window !== window else { return } + self.window = window + window.delegate = self + } + + func update(aspect: CGSize, shouldLockAspect: Bool) { + self.aspect = aspect + self.shouldLockAspect = shouldLockAspect + } + + func window( + _ window: NSWindow, + willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions + ) -> NSApplication.PresentationOptions { + proposedOptions.union(.autoHideToolbar) + } + + func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { + guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { + return frameSize + } + + let currentContentRect = sender.contentRect(forFrameRect: sender.frame) + let currentLayoutRect = sender.contentLayoutRect + let layoutInsetWidth = max(0, currentContentRect.width - currentLayoutRect.width) + let layoutInsetHeight = max(0, currentContentRect.height - currentLayoutRect.height) + let proposedContentRect = sender.contentRect( + forFrameRect: NSRect(origin: .zero, size: frameSize) + ) + let proposedPreviewWidth = max(1, proposedContentRect.width - layoutInsetWidth) + let proposedPreviewHeight = max(1, proposedContentRect.height - layoutInsetHeight) + let ratio = aspect.width / aspect.height + + let previewWidth: CGFloat + let previewHeight: CGFloat + + if proposedPreviewWidth / proposedPreviewHeight > ratio { + previewHeight = proposedPreviewHeight + previewWidth = previewHeight * ratio + } else { + previewWidth = proposedPreviewWidth + previewHeight = previewWidth / ratio + } + + let targetContentRect = NSRect( + origin: .zero, + size: NSSize( + width: previewWidth + layoutInsetWidth, + height: previewHeight + layoutInsetHeight + ) + ) + return sender.frameRect(forContentRect: targetContentRect).size + } +} + +// MARK: - Scroll View Configuration + +private struct TransparentScrollViewConfigurator: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + Task { @MainActor in + configure(from: view) + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + Task { @MainActor in + configure(from: nsView) + } + } + + @MainActor + private func configure(from view: NSView) { + guard let scrollView = sequence(first: view.superview, next: { $0?.superview }) + .first(where: { $0 is NSScrollView }) as? NSScrollView + else { return } + + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.contentView.drawsBackground = false + } +} + // MARK: - Zero-Copy Preview Renderer /// Renders captured frames via `AVSampleBufferDisplayLayer` with zero diff --git a/docs/capture_preview_black_bar_fix_notes.md b/docs/capture_preview_black_bar_fix_notes.md index 16afcfe..f6d1f66 100644 --- a/docs/capture_preview_black_bar_fix_notes.md +++ b/docs/capture_preview_black_bar_fix_notes.md @@ -259,6 +259,129 @@ window.frameRect(forContentRect: targetContentSize) - `contentAspectRatio` - 预览层所在视图实际 bounds +## 这轮新增经验 + +这轮又补了三个和预览窗口观感直接相关的问题: + +- `适应` 和 `1:1` 时 toolbar 颜色不一致 +- 进入全屏后 toolbar 仍然显示,顶部是一整条白色区域 +- 拖动窗口边缘改变尺寸时,`适应` 模式重新出现左右白边 + +### 1. toolbar 颜色不一致的真正影响项 + +现象是: + +- `适应` 模式下 toolbar 更偏灰 +- `1:1` 模式下 toolbar 更接近灰白 + +这次确认后,影响项主要有两个: + +- `适应` 和 `1:1` 的宿主结构不同 +- `1:1` 的底层 `NSScrollView` 自带背景参与了系统 toolbar 的材质取样 + +曾经试过的几个方向都不理想: + +- 直接给 `.windowToolbar` 强制固定 `.regularMaterial` +- 让 `适应` 也套一层伪 `ScrollView` 宿主 +- 直接把内容顶到 toolbar 后面 + +这些方案会带来新的副作用,例如: + +- toolbar 变成偏灰的固定材质,看起来和常见 macOS 应用不一致 +- 全屏时黑屏或白边 +- 内容跑到标题栏后方 + +这次最终保留的做法: + +- `适应` 模式恢复成普通预览层 +- `1:1` 模式继续使用真实 `ScrollView` +- 给 `1:1` 的底层滚动宿主做透明化处理 + +相关代码位置: + +- [CaptureDisplayView.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift#L45) +- [CaptureDisplayView.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift#L55) +- [CaptureDisplayView.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift#L272) + +透明化处理的核心是: + +- `NSScrollView.drawsBackground = false` +- `NSClipView.drawsBackground = false` +- 去掉滚动视图边框 + +这样系统 toolbar 仍然使用自己的灰白材质,`1:1` 又不会多出一层背景去污染取样。 + +### 2. 全屏时 toolbar 不隐藏的处理方式 + +预览窗口进入全屏后,如果 toolbar 继续常驻,效果会非常差: + +- 顶部会出现一整条白色区域 +- 预览内容观感被破坏 +- 和系统常见的媒体、预览类窗口表现不一致 + +这次采用的是系统级做法,在窗口 delegate 里返回全屏展示选项: + +```text +proposedOptions.union(.autoHideToolbar) +``` + +对应代码位置: + +- [CaptureDisplayView.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift#L241) + +这样进入全屏后,toolbar 会自动隐藏。 +这个方案优先级高于在 SwiftUI 视图层硬做显隐控制,因为它直接走 `NSWindow` 的系统行为。 + +### 3. `适应` 模式拖拽 resize 后白边回来的根因 + +这次确认了一个之前没有补上的问题: + +- 初始窗口创建时已经按真实内容区宽高比设置了尺寸 +- 用户后续手动拖动窗口边缘时,这个比例约束没有继续生效 +- 窗口一旦被拖成偏宽或偏高,`AVSampleBufferDisplayLayer` 在 `resizeAspect` 下就会重新留白边 + +所以“初始尺寸算对”还不够,拖拽过程也要继续维持内容区比例。 + +最终做法: + +- 在窗口 delegate 里实现 `windowWillResize` +- 只在 `适应` 模式下启用 +- 使用当前窗口真实的 `contentRect` 与 `contentLayoutRect` 差值,推导可用预览承载区 +- 按采集源宽高比修正用户即将拖出的目标尺寸 + +对应代码位置: + +- [CaptureDisplayView.swift](/Users/syc/Project/VoidDisplay/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift#L258) + +这样有几个好处: + +- `适应` 模式下拖拽窗口不会再重新出现左右白边 +- `1:1` 模式仍然保留自由窗口尺寸,不会被强行锁比例 +- 逻辑和初始 sizing 使用同一套内容区修正思路,行为更一致 + +### 4. 这轮明确排除掉的错误方向 + +这轮调试里已经证明以下方向不适合作为最终方案: + +- 给 toolbar 强制固定 `.regularMaterial` +- 让 `适应` 模式借一个禁用滚动的伪 `ScrollView` 来模拟 `1:1` +- 用 `ignoresSafeArea(.container, edges: .top)` 把内容直接推进标题栏 + +这些尝试虽然能短暂改变 toolbar 颜色,但会带来更坏的问题: + +- 全屏黑屏 +- 顶部白边 +- 内容跑进标题栏 +- resize 行为变差 + +后续如果再遇到 toolbar 材质和内容区相互影响的问题,优先顺序应该是: + +1. 先检查不同模式下的宿主结构是否一致 +2. 再检查 `NSScrollView` / `NSClipView` 是否自带背景 +3. 最后才考虑是否需要改 toolbar 材质 + +不要先用强制材质去压问题。 + 5. 不要先改 `videoGravity` 6. 不要先改成 fill 或手工裁切 From bc799076a665005173fdfef68a0e70b9982b8d3a Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 12 Mar 2026 00:30:29 +0800 Subject: [PATCH 06/11] =?UTF-8?q?refactor(capture):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=AA=97=E5=8F=A3=E4=B8=BA=E5=8D=95=E4=B8=80?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E4=BB=A3=E7=90=86=E5=8D=8F=E8=B0=83=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以 NSWindowDelegate 主导预览窗口的缩放、拖拽与全屏行为 - 删除通知驱动与 contentAspectRatio 补丁,保留 1:1 自由拖拽 - 为双击标题栏缩放与全屏隐藏 toolbar 保留原 delegate 转发能力 --- .../Capture/Views/CaptureDisplayView.swift | 116 +++++++++++++++--- 1 file changed, 100 insertions(+), 16 deletions(-) diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index d95eeaf..9df6b4f 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -96,6 +96,11 @@ struct CaptureDisplayView: View { } .onChange(of: scaleMode) { _, newValue in windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: newValue == .fit) + if let window { + if newValue == .fit { + windowCoordinator.snapWindowToAspect(window) + } + } } .onChange(of: capture.screenCaptureSessions.map(\.id)) { _, ids in if !ids.contains(sessionId) { @@ -125,6 +130,7 @@ struct CaptureDisplayView: View { } session.previewSubscription.detachPreviewSink(renderer) } + windowCoordinator.tearDown() renderer.flush() capture.removeMonitoringSession(id: sessionId) } @@ -208,7 +214,6 @@ extension CaptureDisplayView { width: previewWidth + layoutInsetWidth, height: previewHeight + layoutInsetHeight ) - window.contentAspectRatio = targetContentSize let targetFrame = window.frameRect( forContentRect: NSRect(origin: .zero, size: targetContentSize) @@ -248,15 +253,21 @@ extension CaptureDisplayView { // MARK: - Window Coordination -@MainActor -private final class CapturePreviewWindowCoordinator: NSObject, NSWindowDelegate { +private final class CapturePreviewWindowCoordinator: NSObject { private weak var window: NSWindow? + nonisolated(unsafe) private weak var forwardedDelegate: (any NSWindowDelegate)? private var aspect = CGSize.zero private var shouldLockAspect = true func attach(to window: NSWindow) { guard self.window !== window else { return } + restoreWindowDelegate() self.window = window + if let delegate = window.delegate, delegate !== self { + forwardedDelegate = delegate + } else { + forwardedDelegate = nil + } window.delegate = self } @@ -265,24 +276,45 @@ private final class CapturePreviewWindowCoordinator: NSObject, NSWindowDelegate self.shouldLockAspect = shouldLockAspect } - func window( - _ window: NSWindow, - willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions - ) -> NSApplication.PresentationOptions { - proposedOptions.union(.autoHideToolbar) + func snapWindowToAspect(_ window: NSWindow) { + guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { return } + let targetSize = aspectLockedFrameSize(for: window, proposedFrameSize: window.frame.size) + guard abs(targetSize.width - window.frame.width) > 0.5 + || abs(targetSize.height - window.frame.height) > 0.5 else { return } + + var newFrame = window.frame + newFrame.origin.x += (newFrame.width - targetSize.width) / 2 + newFrame.origin.y += (newFrame.height - targetSize.height) / 2 + newFrame.size = targetSize + window.setFrame(newFrame, display: true, animate: false) } - func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { - guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { - return frameSize + func tearDown() { + restoreWindowDelegate() + window = nil + forwardedDelegate = nil + } + + private func aspectLockedFrameSize(for window: NSWindow, proposedFrameSize: NSSize) -> NSSize { + guard let targetContentSize = aspectLockedContentSize( + for: window, + proposedFrameSize: proposedFrameSize + ) else { + return proposedFrameSize } - let currentContentRect = sender.contentRect(forFrameRect: sender.frame) - let currentLayoutRect = sender.contentLayoutRect + let targetContentRect = NSRect(origin: .zero, size: targetContentSize) + return window.frameRect(forContentRect: targetContentRect).size + } + + private func aspectLockedContentSize(for window: NSWindow, proposedFrameSize: NSSize) -> NSSize? { + guard aspect.width > 0, aspect.height > 0 else { return nil } + let currentContentRect = window.contentRect(forFrameRect: window.frame) + let currentLayoutRect = window.contentLayoutRect let layoutInsetWidth = max(0, currentContentRect.width - currentLayoutRect.width) let layoutInsetHeight = max(0, currentContentRect.height - currentLayoutRect.height) - let proposedContentRect = sender.contentRect( - forFrameRect: NSRect(origin: .zero, size: frameSize) + let proposedContentRect = window.contentRect( + forFrameRect: NSRect(origin: .zero, size: proposedFrameSize) ) let proposedPreviewWidth = max(1, proposedContentRect.width - layoutInsetWidth) let proposedPreviewHeight = max(1, proposedContentRect.height - layoutInsetHeight) @@ -306,7 +338,59 @@ private final class CapturePreviewWindowCoordinator: NSObject, NSWindowDelegate height: previewHeight + layoutInsetHeight ) ) - return sender.frameRect(forContentRect: targetContentRect).size + return targetContentRect.size + } + + private func restoreWindowDelegate() { + guard let window, window.delegate === self else { return } + window.delegate = forwardedDelegate + } +} + +extension CapturePreviewWindowCoordinator: NSWindowDelegate { + nonisolated override func responds(to aSelector: Selector!) -> Bool { + super.responds(to: aSelector) || (forwardedDelegate?.responds(to: aSelector) ?? false) + } + + nonisolated override func forwardingTarget(for aSelector: Selector!) -> Any? { + if forwardedDelegate?.responds(to: aSelector) == true { + return forwardedDelegate + } + return super.forwardingTarget(for: aSelector) + } + + func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { + let proposedFrameSize = forwardedDelegate?.windowWillResize?(sender, to: frameSize) ?? frameSize + guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { + return proposedFrameSize + } + return aspectLockedFrameSize(for: sender, proposedFrameSize: proposedFrameSize) + } + + func windowWillUseStandardFrame(_ window: NSWindow, defaultFrame newFrame: NSRect) -> NSRect { + let proposedFrame = forwardedDelegate?.windowWillUseStandardFrame?(window, defaultFrame: newFrame) + ?? newFrame + guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { + return proposedFrame + } + + let targetSize = aspectLockedFrameSize(for: window, proposedFrameSize: proposedFrame.size) + var adjustedFrame = proposedFrame + adjustedFrame.origin.x += (proposedFrame.width - targetSize.width) / 2 + adjustedFrame.origin.y += (proposedFrame.height - targetSize.height) / 2 + adjustedFrame.size = targetSize + return adjustedFrame + } + + func window( + _ window: NSWindow, + willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions + ) -> NSApplication.PresentationOptions { + let forwardedOptions = forwardedDelegate?.window?( + window, + willUseFullScreenPresentationOptions: proposedOptions + ) ?? proposedOptions + return forwardedOptions.union(.autoHideToolbar) } } From 3066f49d4aadb04f3d509afc94d9b682bcbadc04 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 12 Mar 2026 00:50:11 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(capture):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E9=80=82=E5=BA=94=E6=A8=A1=E5=BC=8F=E5=83=8F=E7=B4=A0=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E4=B8=8E=E4=BB=A3=E7=90=86=E8=BD=AC=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为适应模式的缩放与拖拽结果增加物理像素对齐,消除静态细白边 - 将原窗口代理改为强持有,确保窗口行为转发在接管期间稳定有效 - 保持 1:1 自由拖拽与现有预览窗口视觉方案不变 --- .../Capture/Views/CaptureDisplayView.swift | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 9df6b4f..8a4a3e1 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -255,7 +255,7 @@ extension CaptureDisplayView { private final class CapturePreviewWindowCoordinator: NSObject { private weak var window: NSWindow? - nonisolated(unsafe) private weak var forwardedDelegate: (any NSWindowDelegate)? + nonisolated(unsafe) private var forwardedDelegate: (any NSWindowDelegate)? private var aspect = CGSize.zero private var shouldLockAspect = true @@ -316,26 +316,42 @@ private final class CapturePreviewWindowCoordinator: NSObject { let proposedContentRect = window.contentRect( forFrameRect: NSRect(origin: .zero, size: proposedFrameSize) ) - let proposedPreviewWidth = max(1, proposedContentRect.width - layoutInsetWidth) - let proposedPreviewHeight = max(1, proposedContentRect.height - layoutInsetHeight) - let ratio = aspect.width / aspect.height + let scale = max(1, window.backingScaleFactor) + let insetWidthPixels = max(0, Int((layoutInsetWidth * scale).rounded())) + let insetHeightPixels = max(0, Int((layoutInsetHeight * scale).rounded())) + let proposedPreviewWidthPixels = max( + 1, + Int(((proposedContentRect.width - layoutInsetWidth) * scale).rounded(.down)) + ) + let proposedPreviewHeightPixels = max( + 1, + Int(((proposedContentRect.height - layoutInsetHeight) * scale).rounded(.down)) + ) + let aspectWidthPixels = max(1, Int(aspect.width.rounded())) + let aspectHeightPixels = max(1, Int(aspect.height.rounded())) - let previewWidth: CGFloat - let previewHeight: CGFloat + let previewWidthPixels: Int + let previewHeightPixels: Int - if proposedPreviewWidth / proposedPreviewHeight > ratio { - previewHeight = proposedPreviewHeight - previewWidth = previewHeight * ratio + if proposedPreviewWidthPixels * aspectHeightPixels > proposedPreviewHeightPixels * aspectWidthPixels { + previewHeightPixels = proposedPreviewHeightPixels + previewWidthPixels = max( + 1, + Int((CGFloat(previewHeightPixels) * aspect.width / aspect.height).rounded(.down)) + ) } else { - previewWidth = proposedPreviewWidth - previewHeight = previewWidth / ratio + previewWidthPixels = proposedPreviewWidthPixels + previewHeightPixels = max( + 1, + Int((CGFloat(previewWidthPixels) * aspect.height / aspect.width).rounded(.down)) + ) } let targetContentRect = NSRect( origin: .zero, size: NSSize( - width: previewWidth + layoutInsetWidth, - height: previewHeight + layoutInsetHeight + width: CGFloat(previewWidthPixels + insetWidthPixels) / scale, + height: CGFloat(previewHeightPixels + insetHeightPixels) / scale ) ) return targetContentRect.size From f3c2cfb9bb6be423f6a3c93f1295c4b4e5e0ff13 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 12 Mar 2026 01:54:26 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(capture):=20=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=AA=97=E5=8F=A3=E6=A0=87=E9=A2=98=E6=A0=8F?= =?UTF-8?q?=E9=AB=98=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预览窗口改用 unifiedCompact 工具栏样式,降低标题栏整体高度 - 为比例切换分段控件使用 small control size,保持紧凑且几何居中 - 保持现有预览窗口缩放、全屏与背景承接行为不变 --- VoidDisplay/App/VoidDisplayApp.swift | 2 +- VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/VoidDisplay/App/VoidDisplayApp.swift b/VoidDisplay/App/VoidDisplayApp.swift index dbc4324..d836e4e 100644 --- a/VoidDisplay/App/VoidDisplayApp.swift +++ b/VoidDisplay/App/VoidDisplayApp.swift @@ -42,7 +42,7 @@ struct VoidDisplayApp: App { .environment(sharing) .environment(virtualDisplay) } - .windowToolbarStyle(.unified(showsTitle: true)) + .windowToolbarStyle(.unifiedCompact(showsTitle: true)) Settings { AppSettingsView() diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 8a4a3e1..48b6f57 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -86,6 +86,7 @@ struct CaptureDisplayView: View { Text("1:1").tag(PreviewScaleMode.native) } .pickerStyle(.segmented) + .controlSize(.small) .frame(width: 150) .accessibilityIdentifier("capture_preview_scale_mode_picker") } From 20ed788674d23b0189357fd2a961a95597d9df35 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 12 Mar 2026 02:40:40 +0800 Subject: [PATCH 09/11] =?UTF-8?q?feat(capture):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=AA=97=E5=8F=A3=E5=85=89=E6=A0=87=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E4=B8=8E=E5=85=B1=E4=BA=AB=E7=8A=B6=E6=80=81=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为屏幕监听预览增加光标开关并补齐多语言文案 - 维持单条 SCStream 复用并支持共享时强制显示光标 - 共享进行中将预览开关同步为开启态并禁用交互 --- VoidDisplay/App/CaptureController.swift | 9 ++ .../Models/ScreenMonitoringSession.swift | 1 + .../Services/CaptureMonitoringService.swift | 12 ++ .../Services/ScreenCaptureFunction.swift | 115 ++++++++++++++++-- .../ViewModels/CaptureChooseViewModel.swift | 1 + .../Capture/Views/CaptureDisplayView.swift | 65 ++++++++++ .../Services/DisplaySharingCoordinator.swift | 1 + VoidDisplay/Resources/Localizable.xcstrings | 12 +- .../CapturePreviewDiagnosticsSession.swift | 7 ++ 9 files changed, 209 insertions(+), 14 deletions(-) diff --git a/VoidDisplay/App/CaptureController.swift b/VoidDisplay/App/CaptureController.swift index 6638145..a770141 100644 --- a/VoidDisplay/App/CaptureController.swift +++ b/VoidDisplay/App/CaptureController.swift @@ -36,6 +36,15 @@ final class CaptureController { } } + func setMonitoringSessionCapturesCursor(id: UUID, capturesCursor: Bool) { + mutateAndSync { + captureMonitoringService.updateMonitoringSessionCapturesCursor( + id: id, + capturesCursor: capturesCursor + ) + } + } + func removeMonitoringSession(id: UUID) { mutateAndSync { captureMonitoringService.removeMonitoringSession(id: id) diff --git a/VoidDisplay/Features/Capture/Models/ScreenMonitoringSession.swift b/VoidDisplay/Features/Capture/Models/ScreenMonitoringSession.swift index dd3e383..1646c14 100644 --- a/VoidDisplay/Features/Capture/Models/ScreenMonitoringSession.swift +++ b/VoidDisplay/Features/Capture/Models/ScreenMonitoringSession.swift @@ -16,5 +16,6 @@ struct ScreenMonitoringSession: Identifiable { let resolutionText: String let isVirtualDisplay: Bool let previewSubscription: DisplayPreviewSubscription + var capturesCursor: Bool var state: State } diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift index 162c3cf..12ec71c 100644 --- a/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift @@ -10,6 +10,10 @@ protocol CaptureMonitoringServiceProtocol: AnyObject { id: UUID, state: ScreenMonitoringSession.State ) + func updateMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) func removeMonitoringSession(id: UUID) func removeMonitoringSessions(displayID: CGDirectDisplayID) } @@ -42,6 +46,14 @@ final class CaptureMonitoringService: CaptureMonitoringServiceProtocol { sessions[index].state = state } + func updateMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + sessions[index].capturesCursor = capturesCursor + } + func removeMonitoringSession(id: UUID) { if let session = sessions.first(where: { $0.id == id }) { session.previewSubscription.cancel() diff --git a/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift b/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift index da48ec5..de52863 100644 --- a/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift +++ b/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift @@ -31,6 +31,9 @@ protocol DisplayCaptureSessioning: AnyObject, Sendable { nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) nonisolated func stopSharing() + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws + nonisolated func retainShareCursorOverride() async throws + nonisolated func releaseShareCursorOverride() async throws nonisolated func stop() async } @@ -97,6 +100,10 @@ final class DisplayPreviewSubscription: Sendable { closure() } + nonisolated func setShowsCursor(_ showsCursor: Bool) async throws { + try await session.setPreviewShowsCursor(showsCursor) + } + deinit { cancel() } } @@ -106,25 +113,36 @@ final class DisplayShareSubscription: Sendable { let displayID: CGDirectDisplayID let sessionHub: WebRTCSessionHub + private let session: any DisplayCaptureSessioning private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) nonisolated init( displayID: CGDirectDisplayID, sessionHub: WebRTCSessionHub, + session: any DisplayCaptureSessioning, cancelClosure: @escaping @Sendable () -> Void ) { self.displayID = displayID self.sessionHub = sessionHub + self.session = session cancelState.withLock { $0 = cancelClosure } } + nonisolated func prepareForSharing() async throws { + try await session.retainShareCursorOverride() + } + nonisolated func cancel() { let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in let current = state state = nil return current } - closure?() + guard let closure else { return } + Task { + try? await session.releaseShareCursorOverride() + closure() + } } deinit { cancel() } @@ -217,6 +235,7 @@ actor DisplayCaptureRegistry { return DisplayShareSubscription( displayID: token.displayID, sessionHub: record.session.sessionHub, + session: record.session, cancelClosure: { [weak self] in guard let self else { return } Task { await self.release(token) } @@ -457,6 +476,17 @@ private final class DisplaySampleFanout: Sendable { // MARK: - Display Capture Session final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning { + private struct StreamConfigurationState: Sendable { + let width: Int + let height: Int + let minimumFrameInterval: CMTime + let queueDepth: Int + let capturesAudio: Bool + let pixelFormat: OSType + var previewShowsCursor: Bool + var shareCursorOverrideCount: Int + } + nonisolated let displayID: CGDirectDisplayID nonisolated let sessionHub: WebRTCSessionHub @@ -465,6 +495,7 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning nonisolated private let captureQueue: DispatchQueue nonisolated private let fanout = DisplaySampleFanout() nonisolated private let metrics = Mutex(DisplayCaptureMetrics()) + nonisolated private let configurationState: Mutex // MARK: Lifecycle @@ -475,10 +506,12 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning qos: .userInitiated ) - let config = try await Self.makeStreamConfiguration(display: display) + let state = try await Self.makeStreamConfigurationState(display: display, showsCursor: false) + let config = Self.makeStreamConfiguration(from: state) let filter = try await Self.makeContentFilter(display: display) self.stream = SCStream(filter: filter, configuration: config, delegate: output) self.sessionHub = WebRTCSessionHub() + self.configurationState = Mutex(state) output.session = self @@ -502,6 +535,45 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning sessionHub.stopSharing() } + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + let updatedState = configurationState.withLock { state -> StreamConfigurationState in + if state.previewShowsCursor == showsCursor { + return state + } + var copy = state + copy.previewShowsCursor = showsCursor + return copy + } + guard updatedState.previewShowsCursor == showsCursor else { return } + try await applyStreamConfiguration(updatedState) + } + + nonisolated func retainShareCursorOverride() async throws { + let updatedState = configurationState.withLock { state -> StreamConfigurationState in + var copy = state + copy.shareCursorOverrideCount += 1 + return copy + } + try await applyStreamConfiguration(updatedState) + } + + nonisolated func releaseShareCursorOverride() async throws { + let updatedState = configurationState.withLock { state -> StreamConfigurationState in + var copy = state + copy.shareCursorOverrideCount = max(0, copy.shareCursorOverrideCount - 1) + return copy + } + try await applyStreamConfiguration(updatedState) + } + + nonisolated private func applyStreamConfiguration(_ updatedState: StreamConfigurationState) async throws { + try await stream.updateConfiguration(Self.makeStreamConfiguration(from: updatedState)) + configurationState.withLock { state in + state.previewShowsCursor = updatedState.previewShowsCursor + state.shareCursorOverrideCount = updatedState.shareCursorOverrideCount + } + } + nonisolated func stop() async { stopSharing() try? await stream.stopCapture() @@ -531,26 +603,43 @@ extension DisplayCaptureSession { return scaled.value > 0 ? UInt64(scaled.value) : 0 } - nonisolated private static func makeStreamConfiguration( - display: SCDisplay - ) async throws -> SCStreamConfiguration { - let config = SCStreamConfiguration() + nonisolated private static func makeStreamConfigurationState( + display: SCDisplay, + showsCursor: Bool + ) async throws -> StreamConfigurationState { let displayMode = CGDisplayCopyDisplayMode(display.displayID) let captureSize = preferredCaptureSize(display: display, displayMode: displayMode) let refreshRate = max(60.0, min(displayMode?.refreshRate ?? 60.0, 120.0)) let timescale = CMTimeScale(max(1, Int32(refreshRate.rounded()))) - config.width = captureSize.width - config.height = captureSize.height - config.minimumFrameInterval = CMTime(value: 1, timescale: timescale) - config.queueDepth = 2 - config.showsCursor = true - config.capturesAudio = false - config.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + let state = StreamConfigurationState( + width: captureSize.width, + height: captureSize.height, + minimumFrameInterval: CMTime(value: 1, timescale: timescale), + queueDepth: 2, + capturesAudio: false, + pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + previewShowsCursor: showsCursor, + shareCursorOverrideCount: 0 + ) AppLog.capture.notice( "Capture config display=\(display.displayID, privacy: .public) size=\(captureSize.width)x\(captureSize.height, privacy: .public)" ) + return state + } + + nonisolated private static func makeStreamConfiguration( + from state: StreamConfigurationState + ) -> SCStreamConfiguration { + let config = SCStreamConfiguration() + config.width = state.width + config.height = state.height + config.minimumFrameInterval = state.minimumFrameInterval + config.queueDepth = state.queueDepth + config.showsCursor = state.shareCursorOverrideCount > 0 || state.previewShowsCursor + config.capturesAudio = state.capturesAudio + config.pixelFormat = state.pixelFormat return config } diff --git a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift index 69b088d..46172af 100644 --- a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift +++ b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift @@ -133,6 +133,7 @@ final class CaptureChooseViewModel { resolutionText: resolutionText(for: display), isVirtualDisplay: isVirtualDisplay(display), previewSubscription: previewSubscription, + capturesCursor: false, state: .starting ) dependencies.captureActions.addMonitoringSession(session) diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 48b6f57..6c35dba 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -13,6 +13,7 @@ struct CaptureDisplayView: View { let sessionId: UUID @Environment(CaptureController.self) private var capture + @Environment(SharingController.self) private var sharing @Environment(\.dismiss) private var dismiss @State private var renderer = ZeroCopyPreviewRenderer() @@ -21,11 +22,22 @@ struct CaptureDisplayView: View { @State private var windowCoordinator = CapturePreviewWindowCoordinator() @State private var hasAppliedInitialSize = false @State private var scaleMode: PreviewScaleMode = .fit + @State private var capturesCursor = false + @State private var isUpdatingCursorCapture = false private var session: ScreenMonitoringSession? { capture.monitoringSession(for: sessionId) } + private var isSharingDisplay: Bool { + guard let displayID = session?.displayID else { return false } + return sharing.isDisplaySharing(displayID: displayID) + } + + private var effectiveCapturesCursor: Bool { + capturesCursor || isSharingDisplay + } + private var currentScaleFactor: CGFloat { max(1, window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) } @@ -90,10 +102,22 @@ struct CaptureDisplayView: View { .frame(width: 150) .accessibilityIdentifier("capture_preview_scale_mode_picker") } + ToolbarItem(placement: .automatic) { + HStack(spacing: 6) { + Text(String(localized: "Cursor")) + Toggle("", isOn: cursorCaptureBinding) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .disabled(isUpdatingCursorCapture || isSharingDisplay) + .accessibilityIdentifier("capture_preview_cursor_toggle") + } + } } .toolbarTitleDisplayMode(.inline) .onAppear { windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: scaleMode == .fit) + capturesCursor = session?.capturesCursor ?? false } .onChange(of: scaleMode) { _, newValue in windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: newValue == .fit) @@ -108,6 +132,11 @@ struct CaptureDisplayView: View { dismiss() } } + .onChange(of: session?.capturesCursor ?? false) { _, newValue in + if !isUpdatingCursorCapture { + capturesCursor = newValue + } + } .onAppear { if let session { session.previewSubscription.attachPreviewSink(renderer) @@ -159,6 +188,42 @@ struct CaptureDisplayView: View { // MARK: - Window Sizing extension CaptureDisplayView { + private var cursorCaptureBinding: Binding { + Binding( + get: { effectiveCapturesCursor }, + set: { newValue in + guard !isSharingDisplay else { return } + let previousValue = capturesCursor + capturesCursor = newValue + + guard let session else { return } + isUpdatingCursorCapture = true + Task { + do { + try await session.previewSubscription.setShowsCursor(newValue) + await MainActor.run { + capture.setMonitoringSessionCapturesCursor( + id: sessionId, + capturesCursor: newValue + ) + isUpdatingCursorCapture = false + } + } catch { + AppErrorMapper.logFailure( + "Update cursor capture", + error: error, + logger: AppLog.capture + ) + await MainActor.run { + capturesCursor = previousValue + isUpdatingCursorCapture = false + } + } + } + } + ) + } + /// Sets the window's initial size and aspect ratio to match the /// captured display. Called once when both the window reference /// and the first frame's pixel dimensions become available. diff --git a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift index be2200e..388cbd8 100644 --- a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift +++ b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift @@ -119,6 +119,7 @@ final class DisplaySharingCoordinator { func startSharing(display: SCDisplay) async throws { stopSharing(displayID: display.displayID) let subscription = try await captureRegistry.acquireShare(display: SendableDisplay(display)) + try await subscription.prepareForSharing() sessionsByDisplayID[display.displayID] = SharingSession(display: display, subscription: subscription) if CGDisplayIsMain(display.displayID) != 0 { mainDisplayID = display.displayID diff --git a/VoidDisplay/Resources/Localizable.xcstrings b/VoidDisplay/Resources/Localizable.xcstrings index 34d58f4..c34a66a 100644 --- a/VoidDisplay/Resources/Localizable.xcstrings +++ b/VoidDisplay/Resources/Localizable.xcstrings @@ -384,6 +384,16 @@ } } }, + "Cursor" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "光标" + } + } + } + }, "Custom" : { "localizations" : { "zh-Hans" : { @@ -1984,4 +1994,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift index 9c1b88b..ca6a57c 100644 --- a/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift +++ b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift @@ -24,6 +24,7 @@ enum CapturePreviewDiagnosticsBootstrap { resolutionText: previewSubscription.resolutionText, isVirtualDisplay: false, previewSubscription: previewSubscription, + capturesCursor: false, state: .starting ) return CaptureMonitoringService(initialSessions: [monitoringSession]) @@ -52,6 +53,12 @@ final class UITestCapturePreviewSession: @unchecked Sendable, DisplayCaptureSess nonisolated func stopSharing() {} + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws {} + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func stop() async {} } From 4a983c9387823dd5f7e5ea8d2ae32f4963e7b070 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 12 Mar 2026 11:47:00 +0800 Subject: [PATCH 10/11] =?UTF-8?q?test(capture):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E6=89=A9=E5=B1=95=E5=90=8E=E7=9A=84=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=A1=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为采集相关 mock 和 fake 会话补齐新的光标控制协议方法 - 同步更新测试用监控会话的 capturesCursor 初始字段 - 确保本地单测与 HomeSmokeTests 可继续编译并通过 --- .../App/CaptureControllerTests.swift | 9 +++++++++ .../Services/CaptureMonitoringServiceTests.swift | 9 +++++++++ .../Services/DisplayCaptureRegistryTests.swift | 16 ++++++++++++++++ .../DisplayPreviewSubscriptionTests.swift | 9 ++++++++- .../SharingEndToEndIntegrationTests.swift | 8 ++++++++ .../TestSupport/TestServiceMocks.swift | 10 ++++++++++ 6 files changed, 60 insertions(+), 1 deletion(-) diff --git a/VoidDisplayTests/App/CaptureControllerTests.swift b/VoidDisplayTests/App/CaptureControllerTests.swift index b159e36..51747d7 100644 --- a/VoidDisplayTests/App/CaptureControllerTests.swift +++ b/VoidDisplayTests/App/CaptureControllerTests.swift @@ -16,6 +16,14 @@ private final class CaptureControllerDummySession: DisplayCaptureSessioning, @un nonisolated func stopSharing() {} + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func stop() async {} } @@ -115,6 +123,7 @@ struct CaptureControllerTests { session: CaptureControllerDummySession(), cancelClosure: {} ), + capturesCursor: false, state: .starting ) } diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift index 9457243..72bd537 100644 --- a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift @@ -16,6 +16,14 @@ private final class CaptureMonitoringDummySession: DisplayCaptureSessioning, @un nonisolated func stopSharing() {} + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func stop() async {} } @@ -104,6 +112,7 @@ struct CaptureMonitoringServiceTests { resolutionText: "1920 x 1080", isVirtualDisplay: false, previewSubscription: subscription, + capturesCursor: false, state: .starting ) return (session, cancelCount) diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift index 2fb2276..764231c 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift @@ -25,6 +25,14 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen counters.withLock { $0.stopSharingCalls += 1 } } + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func stop() async { counters.withLock { $0.stopCalls += 1 } } @@ -81,6 +89,14 @@ private final class ControlledStopCaptureSession: DisplayCaptureSessioning, @unc counters.withLock { $0.stopSharing += 1 } } + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func stop() async { counters.withLock { $0.stop += 1 } await stopGate.waitUntilOpen() diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift index 7ee70ba..263f7c9 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift @@ -42,6 +42,14 @@ private final class MockDisplayCaptureSession: @unchecked Sendable, DisplayCaptu state.withLock { $0.stopSharingCallCount += 1 } } + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func stop() async { state.withLock { $0.stopCallCount += 1 } } @@ -93,4 +101,3 @@ struct DisplayPreviewSubscriptionTests { #expect(cancelCalls.withLock { $0 } == 1) } } - diff --git a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift index 0629c09..9ff3813 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift @@ -17,6 +17,14 @@ private final class EndToEndFakeCaptureSession: DisplayCaptureSessioning, @unche nonisolated func stopSharing() {} + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func stop() async {} } diff --git a/VoidDisplayTests/TestSupport/TestServiceMocks.swift b/VoidDisplayTests/TestSupport/TestServiceMocks.swift index 78e696a..ec45862 100644 --- a/VoidDisplayTests/TestSupport/TestServiceMocks.swift +++ b/VoidDisplayTests/TestSupport/TestServiceMocks.swift @@ -11,6 +11,7 @@ final class MockCaptureMonitoringService: CaptureMonitoringServiceProtocol { var removeByDisplayCallCount = 0 var removedDisplayIDs: [CGDirectDisplayID] = [] var updateStateCallCount = 0 + var updateCapturesCursorCallCount = 0 func monitoringSession(for id: UUID) -> ScreenMonitoringSession? { currentSessions.first(where: { $0.id == id }) @@ -30,6 +31,15 @@ final class MockCaptureMonitoringService: CaptureMonitoringServiceProtocol { currentSessions[index].state = state } + func updateMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) { + updateCapturesCursorCallCount += 1 + guard let index = currentSessions.firstIndex(where: { $0.id == id }) else { return } + currentSessions[index].capturesCursor = capturesCursor + } + func removeMonitoringSession(id: UUID) { removeCallCount += 1 currentSessions.removeAll { $0.id == id } From fe6093ba744d80876cb580df6a3d4951197ec716 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 12 Mar 2026 11:58:47 +0800 Subject: [PATCH 11/11] =?UTF-8?q?test(capture):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=85=89=E6=A0=87=E5=90=8C=E6=AD=A5=E4=B8=8E=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E5=88=86=E6=94=AF=E8=A6=86=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补充 CaptureController 光标状态同步用例 - 补充 CaptureMonitoringService 光标状态更新用例 - 覆盖 AppBootstrap 诊断场景与默认监控服务分支 --- .../App/CaptureControllerTests.swift | 12 +++++ .../App/VirtualDisplayControllerTests.swift | 46 +++++++++++++++++++ .../CaptureMonitoringServiceTests.swift | 16 +++++++ 3 files changed, 74 insertions(+) diff --git a/VoidDisplayTests/App/CaptureControllerTests.swift b/VoidDisplayTests/App/CaptureControllerTests.swift index 51747d7..c9bba36 100644 --- a/VoidDisplayTests/App/CaptureControllerTests.swift +++ b/VoidDisplayTests/App/CaptureControllerTests.swift @@ -73,6 +73,18 @@ struct CaptureControllerTests { #expect(service.updateStateCallCount == 1) } + @Test func setMonitoringSessionCapturesCursorRefreshesSnapshot() { + let service = MockCaptureMonitoringService() + let session = makeSession(id: UUID(), displayID: 89) + service.currentSessions = [session] + let controller = CaptureController(captureMonitoringService: service) + + controller.setMonitoringSessionCapturesCursor(id: session.id, capturesCursor: true) + + #expect(controller.screenCaptureSessions.first?.capturesCursor == true) + #expect(service.updateCapturesCursorCallCount == 1) + } + @Test func removeMonitoringSessionsFiltersByDisplayID() { let service = MockCaptureMonitoringService() let first = makeSession(id: UUID(), displayID: 91) diff --git a/VoidDisplayTests/App/VirtualDisplayControllerTests.swift b/VoidDisplayTests/App/VirtualDisplayControllerTests.swift index 4a0a7d1..e390647 100644 --- a/VoidDisplayTests/App/VirtualDisplayControllerTests.swift +++ b/VoidDisplayTests/App/VirtualDisplayControllerTests.swift @@ -6,6 +6,52 @@ import CoreGraphics @MainActor @Suite(.serialized) struct AppBootstrapTests { + @Test func initUsesDefaultCaptureMonitoringServiceWhenInjectionIsOmitted() async { + let sharing = MockSharingService() + let virtualDisplay = MockVirtualDisplayFacade() + + let env = AppBootstrap.makeEnvironment( + preview: true, + sharingService: sharing, + virtualDisplayFacade: virtualDisplay, + isRunningUnderXCTestOverride: true + ) + + #expect(env.capture.screenCaptureSessions.isEmpty) + #expect(sharing.startWebServiceCallCount == 0) + #expect(virtualDisplay.loadPersistedConfigsCallCount == 0) + } + + @Test func initCapturePreviewDiagnosticsScenarioBuildsMonitoringSessionFromRuntimeConfiguration() async throws { + let overrides = [ + (UITestRuntime.modeEnvironmentKey, "1"), + (UITestRuntime.scenarioEnvironmentKey, UITestScenario.capturePreviewDiagnostics.rawValue), + (CapturePreviewDiagnosticsRuntime.sourceSizeEnvironmentKey, "3008x1692") + ] + let previousValues = overrides.map { ($0.0, ProcessInfo.processInfo.environment[$0.0]) } + for (key, value) in overrides { + setenv(key, value, 1) + } + defer { + for (key, previousValue) in previousValues { + if let previousValue { + setenv(key, previousValue, 1) + } else { + unsetenv(key) + } + } + } + + let env = AppBootstrap.makeEnvironment() + + let session = try #require(env.capture.screenCaptureSessions.first) + #expect(env.capture.screenCaptureSessions.count == 1) + #expect(session.displayName == "Preview Diagnostics") + #expect(session.resolutionText == "3008 × 1692") + #expect(session.capturesCursor == false) + #expect(env.virtualDisplay.displayConfigs.count == 2) + } + @Test func previewEnvironmentDoesNotPersistPreferredPortToStandardDefaults() async { let requestedPort = TestPortAllocator.randomUnprivilegedPort() let sharing = MockSharingService() diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift index 72bd537..2cce2e5 100644 --- a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift @@ -66,6 +66,22 @@ struct CaptureMonitoringServiceTests { } } + @Test func updateMonitoringSessionCapturesCursorMutatesOnlyMatchingSession() { + let service = CaptureMonitoringService() + let first = makeSession(id: UUID(), displayID: 12).session + let second = makeSession(id: UUID(), displayID: 13).session + service.addMonitoringSession(first) + service.addMonitoringSession(second) + + service.updateMonitoringSessionCapturesCursor(id: second.id, capturesCursor: true) + + let cursorStates = service.currentSessions.reduce(into: [UUID: Bool]()) { + $0[$1.id] = $1.capturesCursor + } + #expect(cursorStates[first.id] == false) + #expect(cursorStates[second.id] == true) + } + @Test func removeMonitoringSessionCancelsSubscription() { let service = CaptureMonitoringService() let (session, cancelCount) = makeSession(id: UUID(), displayID: 22)