Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions VoidDisplay/App/CaptureController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class CaptureController {

init(captureMonitoringService: any CaptureMonitoringServiceProtocol) {
self.captureMonitoringService = captureMonitoringService
self.screenCaptureSessions = captureMonitoringService.currentSessions
}

func monitoringSession(for id: UUID) -> ScreenMonitoringSession? {
Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions VoidDisplay/App/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +20,7 @@ struct HomeView: View {
}

@State private var selection: SidebarItem? = .screen
@State private var hasAutoOpenedCapturePreview = false

var body: some View {
NavigationSplitView {
Expand Down Expand Up @@ -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
}
}

Expand Down
2 changes: 2 additions & 0 deletions VoidDisplay/App/VirtualDisplayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ final class VirtualDisplayController {
switch scenario {
case .baseline:
break
case .capturePreviewDiagnostics:
break
case .displayCatalogLoading:
break
case .permissionDenied:
Expand Down
11 changes: 11 additions & 0 deletions VoidDisplay/App/VoidDisplayApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ struct ScreenMonitoringSession: Identifiable {
let resolutionText: String
let isVirtualDisplay: Bool
let previewSubscription: DisplayPreviewSubscription
var capturesCursor: Bool
var state: State
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
}
Expand All @@ -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()
Expand Down
115 changes: 102 additions & 13 deletions VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -97,6 +100,10 @@ final class DisplayPreviewSubscription: Sendable {
closure()
}

nonisolated func setShowsCursor(_ showsCursor: Bool) async throws {
try await session.setPreviewShowsCursor(showsCursor)
}

deinit { cancel() }
}

Expand All @@ -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() }
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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

Expand All @@ -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<StreamConfigurationState>

// MARK: Lifecycle

Expand All @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ final class CaptureChooseViewModel {
resolutionText: resolutionText(for: display),
isVirtualDisplay: isVirtualDisplay(display),
previewSubscription: previewSubscription,
capturesCursor: false,
state: .starting
)
dependencies.captureActions.addMonitoringSession(session)
Expand Down
Loading