diff --git a/VoidDisplay/App/CaptureController.swift b/VoidDisplay/App/CaptureController.swift index 59d3b11..a770141 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? { @@ -35,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/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/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 fddcfc2..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) } @@ -18,6 +22,10 @@ protocol CaptureMonitoringServiceProtocol: AnyObject { final class CaptureMonitoringService: CaptureMonitoringServiceProtocol { private var sessions: [ScreenMonitoringSession] = [] + init(initialSessions: [ScreenMonitoringSession] = []) { + self.sessions = initialSessions + } + var currentSessions: [ScreenMonitoringSession] { sessions } @@ -38,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 de27d0e..6c35dba 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -13,17 +13,31 @@ 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() + @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 + @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) } @@ -54,7 +68,9 @@ struct CaptureDisplayView: View { width: nativeFrameSizeInPoints.width, height: nativeFrameSizeInPoints.height ) + .background(Color.black) } + .background(TransparentScrollViewConfigurator()) .frame(maxWidth: .infinity, maxHeight: .infinity) } } else { @@ -69,10 +85,12 @@ struct CaptureDisplayView: View { var body: some View { ZStack { - Color.black + Color(nsColor: .windowBackgroundColor) previewContent } .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("capture_preview_content") .toolbar { ToolbarItem(placement: .principal) { Picker("Scale Mode", selection: $scaleMode) { @@ -80,19 +98,56 @@ struct CaptureDisplayView: View { Text("1:1").tag(PreviewScaleMode.native) } .pickerStyle(.segmented) + .controlSize(.small) .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) + if let window { + if newValue == .fit { + windowCoordinator.snapWindowToAspect(window) + } + } + } .onChange(of: capture.screenCaptureSessions.map(\.id)) { _, ids in if !ids.contains(sessionId) { dismiss() } } + .onChange(of: session?.capturesCursor ?? false) { _, newValue in + if !isUpdatingCursorCapture { + capturesCursor = newValue + } + } .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,18 +155,28 @@ struct CaptureDisplayView: View { } .onDisappear { if let session { + if let recordingSink { + session.previewSubscription.detachPreviewSink(recordingSink) + } session.previewSubscription.detachPreviewSink(renderer) } + windowCoordinator.tearDown() renderer.flush() 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() } } @@ -123,6 +188,41 @@ 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 @@ -131,35 +231,58 @@ extension CaptureDisplayView { let aspect = preferredAspect() 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) + window.backgroundColor = .windowBackgroundColor 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 { + previewWidth = min(max(320, overriddenWidth), maxPreviewWidth) + previewHeight = previewWidth / ratio + } + + if previewWidth > maxPreviewWidth { + previewWidth = maxPreviewWidth + previewHeight = previewWidth / ratio + } + if previewHeight > maxPreviewHeight { + previewHeight = maxPreviewHeight + previewWidth = previewHeight * ratio } - if w > maxW { w = maxW; h = w / ratio } - if h > maxH { h = maxH; w = h * ratio } + let targetContentSize = NSSize( + width: previewWidth + layoutInsetWidth, + height: previewHeight + layoutInsetHeight + ) 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 @@ -194,6 +317,194 @@ extension CaptureDisplayView { } } +// MARK: - Window Coordination + +private final class CapturePreviewWindowCoordinator: NSObject { + private weak var window: NSWindow? + nonisolated(unsafe) private 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 + } + + func update(aspect: CGSize, shouldLockAspect: Bool) { + self.aspect = aspect + self.shouldLockAspect = shouldLockAspect + } + + 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 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 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 = window.contentRect( + forFrameRect: NSRect(origin: .zero, size: proposedFrameSize) + ) + 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 previewWidthPixels: Int + let previewHeightPixels: Int + + if proposedPreviewWidthPixels * aspectHeightPixels > proposedPreviewHeightPixels * aspectWidthPixels { + previewHeightPixels = proposedPreviewHeightPixels + previewWidthPixels = max( + 1, + Int((CGFloat(previewHeightPixels) * aspect.width / aspect.height).rounded(.down)) + ) + } else { + previewWidthPixels = proposedPreviewWidthPixels + previewHeightPixels = max( + 1, + Int((CGFloat(previewWidthPixels) * aspect.height / aspect.width).rounded(.down)) + ) + } + + let targetContentRect = NSRect( + origin: .zero, + size: NSSize( + width: CGFloat(previewWidthPixels + insetWidthPixels) / scale, + height: CGFloat(previewHeightPixels + insetHeightPixels) / scale + ) + ) + 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) + } +} + +// 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/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/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..ca6a57c --- /dev/null +++ b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift @@ -0,0 +1,303 @@ +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, + capturesCursor: false, + 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 setPreviewShowsCursor(_ showsCursor: Bool) async throws {} + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + + 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..c9bba36 100644 --- a/VoidDisplayTests/App/CaptureControllerTests.swift +++ b/VoidDisplayTests/App/CaptureControllerTests.swift @@ -16,12 +16,30 @@ 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 {} } @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) @@ -55,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) @@ -105,6 +135,7 @@ struct CaptureControllerTests { session: CaptureControllerDummySession(), cancelClosure: {} ), + capturesCursor: false, state: .starting ) } diff --git a/VoidDisplayTests/App/VirtualDisplayControllerTests.swift b/VoidDisplayTests/App/VirtualDisplayControllerTests.swift index f2bcc47..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() @@ -158,7 +204,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 +823,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/Services/CaptureMonitoringServiceTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift index 9457243..2cce2e5 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 {} } @@ -58,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) @@ -104,6 +128,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/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/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/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)") } 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/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 } 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/docs/capture_preview_black_bar_fix_notes.md b/docs/capture_preview_black_bar_fix_notes.md new file mode 100644 index 0000000..f6d1f66 --- /dev/null +++ b/docs/capture_preview_black_bar_fix_notes.md @@ -0,0 +1,417 @@ +# 屏幕监听预览左右黑边问题修复记录 + +## 背景 + +屏幕监听预览窗口长期存在一个顽固问题: + +- 预览内容左右会出现黑边 +- 调整后经常变成左右黑边消失,但上下内容被裁掉 +- 仅靠人工截图反馈,调试回路很慢 + +这次修复的目标有两个: + +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 + +## 这轮新增经验 + +这轮又补了三个和预览窗口观感直接相关的问题: + +- `适应` 和 `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 或手工裁切 + +## 当前结论 + +这次问题的本质不是渲染层不会铺满,也不是缺少几组分辨率预设。 +问题在于窗口真实可用内容区的宽高比计算错了。 + +这次修复后: + +- 左右黑边问题已通过自验证矩阵消除 +- 没有引入上下裁切 +- 没有引入非等比拉伸 +- 方法对非标比例也成立 + +## 维护建议 + +后续如果再调整以下内容,要优先回归这套自验证链路: + +- 预览窗口 toolbar 样式 +- 预览窗口 titlebar 布局 +- `CaptureDisplayView` 初始尺寸逻辑 +- 预览层宿主视图层级 +- 采集会话首帧尺寸来源 + +建议原则: + +- 先确认真实内容承载区比例 +- 再调整窗口尺寸 +- 最后再看视觉效果 + +顺序不要反过来。 diff --git a/scripts/test/capture_preview_analyze.swift b/scripts/test/capture_preview_analyze.swift new file mode 100644 index 0000000..4076665 --- /dev/null +++ b/scripts/test/capture_preview_analyze.swift @@ -0,0 +1,308 @@ +#!/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 +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 = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL.path + let bitmap = try loadBitmap(path: imagePath) + let width = bitmap.pixelsWide + let height = bitmap.pixelsHigh + + 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, rect) in edgeSearchRegions { + let expected = expectedColors[name]! + 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 { + failures.append("\(name) edge looks black, likely side letterboxing remains") + } + } + + 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 { + 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, 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 Int(searchRect.minX).. 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..= 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) +} + +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 { + 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