diff --git a/.github/workflows/_reusable-ui-smoke-tests.yml b/.github/workflows/_reusable-ui-smoke-tests.yml index 2819ff5..7c17079 100644 --- a/.github/workflows/_reusable-ui-smoke-tests.yml +++ b/.github/workflows/_reusable-ui-smoke-tests.yml @@ -160,6 +160,13 @@ jobs: set -e if [ "$status" -eq 0 ]; then + if ! bash scripts/test/xcresult_test_count_guard.sh \ + --xcresult UISmokeTests.xcresult \ + --label "UI smoke attempt ${attempt}/${max_attempts}"; then + write_summary "failed" "selector_mismatch" "$attempt" "$log_file" + echo "UI smoke selector mismatch detected (0 tests executed)." + exit 1 + fi write_summary "passed" "none" "$attempt" "$log_file" exit 0 fi diff --git a/AGENTS.md b/AGENTS.md index 707021a..9dadb07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ ## Test Execution Policy - After every code change, explicitly check whether related tests need to be updated or added, and complete required test updates before handoff. - Default: run targeted tests related to changed module/feature. +- If related verification has already completed after the latest code change, and no repo-tracked file has changed since that verification, a later commit-only instruction must reuse the existing fresh verification result instead of rerunning the same tests. - For small, explicit, low-risk changes with tightly bounded impact, do not run the full `HomeSmokeTests` suite by default. Prefer build-only verification or a narrower targeted test that covers the changed control or flow. - Run full suite when changes are broad/high-risk or impact cannot be bounded: - shared/common code changes @@ -40,6 +41,13 @@ - high-risk runtime behavior (concurrency/persistence/network/security) - user explicitly requests full suite +## Test Permission Prompt Isolation +- Automated tests must not trigger app-driven macOS privacy prompts such as screen recording, microphone, or camera authorization dialogs. +- Test runs must remain non-interactive and must not depend on a human waiting for app permission prompts during execution. +- Any code path that may request app privacy permissions must switch to a test-specific provider, mock, stub, or equivalent isolation layer under test environments. +- macOS authorization required by the test harness itself, such as Automation, Accessibility, Input Monitoring, or related administrator approval for UI automation, is environment setup and should be handled separately from app permission flows. +- Reject any test change that can block local or CI execution by introducing new app-driven privacy authorization prompts. + ## UI Test Port Injection - Preferred port key is `SharingPortPreferenceKeys.preferredPort` (`sharing.preferredPort`). - For UI tests, inject with launch arguments: `-sharing.preferredPort `. @@ -58,6 +66,17 @@ - If the user goal or instruction is ambiguous, do not guess. Ask for clarification promptly before continuing. - Clarification questions must include all reasonable current interpretations from the agent, so the user can confirm or correct them directly. +## Execution Mode Recommendation +- Provide an execution mode recommendation only before starting work on an actionable request and only when there is a meaningful choice between immediate execution and plan-first handling. +- Do not provide this recommendation in completion handoff, commit summaries, verification summaries, or meta discussions about process, prompts, or repository policy. +- Do not provide this recommendation for analysis-only or question-only requests, unless the current turn is a code review that produced actionable findings requiring follow-up implementation. +- For code review requests with actionable findings, append exactly one execution mode recommendation after the findings summary. +- Do not provide this recommendation when the user has already explicitly chosen the mode for the current turn. +- Once execution has started in the current turn, stop emitting execution mode recommendations. +- Use `建议:直接执行` only when implementation has not started, scope is clear, affected area is bounded, validation path is clear, and there is no material decision gate. +- Use `建议:开启计划模式` only when implementation has not started and the task is ambiguous, cross-module, high-risk, multi-stage, blocked by unknowns, or depends on user choice between materially different options. +- Keep the recommendation to one sentence and state the concrete reason. + ## Code Review Output Policy - When review finds an issue, identify the root cause and provide a root-cause fix plan by default. - Always include a structural refactor assessment: whether it is needed, expected benefits, risks, and validation impact. @@ -66,6 +85,9 @@ ## Complexity and Size Guardrail - Default goal: solve problems without increasing code complexity and code size. - If that is not feasible, lower complexity first. +- Reject temporary fixes, glue code, and patch-style handling. Solve the root problem with a clean structural change. +- Do not add transitional adapters, one-off shims, or workaround layers unless the user explicitly requires them for a defined migration window. +- Do not preserve backward compatibility by default. Only keep it when explicitly required, and document the caller, removal condition, and validation impact in the handoff. - Prefer deleting duplicate branches and duplicate checks. - Keep equivalent validation at one convergence layer. Avoid multi-layer duplicate defense. - When adding defensive branches, prioritize deleting equivalent legacy branches in the same module. diff --git a/Readme.md b/Readme.md index 9e286f0..361afdc 100644 --- a/Readme.md +++ b/Readme.md @@ -139,7 +139,7 @@ Key files for debugging: | Area | Files | |------|-------| | Virtual Display | `VirtualDisplayService.swift`, `CreateVirtualDisplayObjectView.swift`, `EditVirtualDisplayConfigView.swift` | -| Screen Capture | `CaptureChooseViewModel.swift`, `ScreenCaptureFunction.swift` | +| Screen Capture | `CaptureChooseViewModel.swift`, `DisplayCaptureRegistry.swift`, `DisplayCaptureSession.swift`, `DisplayStartCoordinator.swift` | | LAN Sharing | `ShareViewModel.swift`, `SharingService.swift`, `Features/Sharing/Web/WebServer.swift` | Unified logs (`Logger`, subsystem `com.developerchen.voiddisplay`): diff --git a/VoidDisplay/App/AppSettingsView.swift b/VoidDisplay/App/AppSettingsView.swift index e1f0b63..b248e4d 100644 --- a/VoidDisplay/App/AppSettingsView.swift +++ b/VoidDisplay/App/AppSettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI struct AppSettingsView: View { @Environment(VirtualDisplayController.self) private var virtualDisplay + @Environment(CapturePerformancePreferences.self) private var capturePerformancePreferences @State private var showResetConfirmation = false @State private var resetCompleted = false @@ -14,6 +15,20 @@ struct AppSettingsView: View { @Bindable var bindableVirtualDisplay = virtualDisplay VStack(alignment: .leading, spacing: 12) { + Text("Capture Performance") + .font(.headline) + + Text("Choose how screen monitoring and sharing balance smoothness and resource usage.") + .font(.subheadline) + .foregroundStyle(.secondary) + + Picker("Capture Performance", selection: performanceModeBinding) { + Text("Automatic").tag(CapturePerformanceMode.automatic) + Text("Smooth").tag(CapturePerformanceMode.smooth) + Text("Power Efficient").tag(CapturePerformanceMode.powerEfficient) + } + .pickerStyle(.segmented) + Text("Virtual Displays") .font(.headline) @@ -35,7 +50,7 @@ struct AppSettingsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(16) - .frame(width: 420, height: 170, alignment: .topLeading) + .frame(width: 420, height: 270, alignment: .topLeading) .confirmationDialog( "Reset Virtual Display Configurations?", isPresented: $showResetConfirmation @@ -60,4 +75,11 @@ struct AppSettingsView: View { ) } } + + private var performanceModeBinding: Binding { + Binding( + get: { capturePerformancePreferences.mode }, + set: { capturePerformancePreferences.saveMode($0) } + ) + } } diff --git a/VoidDisplay/App/CaptureController.swift b/VoidDisplay/App/CaptureController.swift index a770141..b8b4784 100644 --- a/VoidDisplay/App/CaptureController.swift +++ b/VoidDisplay/App/CaptureController.swift @@ -5,55 +5,99 @@ import Foundation import CoreGraphics +import ScreenCaptureKit import Observation @MainActor @Observable final class CaptureController { var screenCaptureSessions: [ScreenMonitoringSession] = [] - @ObservationIgnored let displayCatalogState = ScreenCaptureDisplayCatalogState() + private(set) var startingDisplayIDs: Set = [] + @ObservationIgnored let catalogService: ScreenCaptureCatalogService @ObservationIgnored private let captureMonitoringService: any CaptureMonitoringServiceProtocol + @ObservationIgnored private let captureMonitoringLifecycleService: any CaptureMonitoringLifecycleServiceProtocol + @ObservationIgnored private let startTracker = DisplayStartTracker() + @ObservationIgnored private lazy var mutationRunner = SnapshotMutationRunner { [weak self] in + self?.syncCaptureMonitoringState() + } - init(captureMonitoringService: any CaptureMonitoringServiceProtocol) { + init( + captureMonitoringService: any CaptureMonitoringServiceProtocol, + captureMonitoringLifecycleService: (any CaptureMonitoringLifecycleServiceProtocol)? = nil, + catalogService: ScreenCaptureCatalogService? = nil + ) { self.captureMonitoringService = captureMonitoringService + self.captureMonitoringLifecycleService = captureMonitoringLifecycleService + ?? CaptureMonitoringLifecycleService(captureMonitoringService: captureMonitoringService) + self.catalogService = catalogService ?? ScreenCaptureCatalogService() self.screenCaptureSessions = captureMonitoringService.currentSessions } + var displayCatalogState: ScreenCaptureDisplayCatalogState { + catalogService.store + } + func monitoringSession(for id: UUID) -> ScreenMonitoringSession? { captureMonitoringService.monitoringSession(for: id) } - func addMonitoringSession(_ session: ScreenMonitoringSession) { - mutateAndSync { - captureMonitoringService.addMonitoringSession(session) + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startTracker.contains(displayID: displayID) + } + + func startMonitoring( + display: SCDisplay, + metadata: CaptureMonitoringDisplayMetadata + ) async throws -> DisplayStartOutcome { + let displayID = display.displayID + let startToken = startTracker.begin(displayID: displayID) + syncCaptureMonitoringState() + defer { + startTracker.end(displayID: displayID, token: startToken) + syncCaptureMonitoringState() + } + + return try await captureMonitoringLifecycleService.startMonitoring( + display: display, + metadata: metadata + ) + } + + func activateMonitoringSession(id: UUID) { + mutationRunner.run { + captureMonitoringLifecycleService.activateMonitoringSession(id: id) } } - func markMonitoringSessionActive(id: UUID) { - mutateAndSync { - captureMonitoringService.updateMonitoringSessionState(id: id, state: .active) + func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) { + mutationRunner.run { + captureMonitoringLifecycleService.attachPreviewSink(sink, to: id) } } - func setMonitoringSessionCapturesCursor(id: UUID, capturesCursor: Bool) { - mutateAndSync { - captureMonitoringService.updateMonitoringSessionCapturesCursor( + func setMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) async throws { + try await mutationRunner.run { + try await captureMonitoringLifecycleService.setMonitoringSessionCapturesCursor( id: id, capturesCursor: capturesCursor ) } } - func removeMonitoringSession(id: UUID) { - mutateAndSync { - captureMonitoringService.removeMonitoringSession(id: id) + func closeMonitoringSession(id: UUID) { + mutationRunner.run { + captureMonitoringLifecycleService.closeMonitoringSession(id: id) } } func removeMonitoringSessions(displayID: CGDirectDisplayID) { - mutateAndSync { - captureMonitoringService.removeMonitoringSessions(displayID: displayID) + startTracker.clear(displayID: displayID) + mutationRunner.run { + captureMonitoringLifecycleService.removeMonitoringSessions(displayID: displayID) } } @@ -69,10 +113,16 @@ final class CaptureController { private func syncCaptureMonitoringState() { screenCaptureSessions = captureMonitoringService.currentSessions + startingDisplayIDs = startTracker.activeDisplayIDs } - private func mutateAndSync(_ mutation: () -> Void) { - mutation() +#if DEBUG + func installStartingDisplayIDsForTesting(_ displayIDs: Set) { + startTracker.clearAll() + for displayID in displayIDs { + _ = startTracker.begin(displayID: displayID) + } syncCaptureMonitoringState() } +#endif } diff --git a/VoidDisplay/App/CaptureDisplayWindowRoot.swift b/VoidDisplay/App/CaptureDisplayWindowRoot.swift index 8e36399..948d095 100644 --- a/VoidDisplay/App/CaptureDisplayWindowRoot.swift +++ b/VoidDisplay/App/CaptureDisplayWindowRoot.swift @@ -8,14 +8,33 @@ import SwiftUI struct CaptureDisplayWindowRoot: View { @Environment(\.dismiss) private var dismiss let sessionId: UUID? + @State private var hasSeenSessionID = false var body: some View { - if let sessionId { - CaptureDisplayView(sessionId: sessionId) - .navigationTitle("Screen Monitoring") - } else { - Color.clear - .onAppear { dismiss() } + Group { + if let sessionId { + CaptureDisplayView(sessionId: sessionId) + .navigationTitle("Screen Monitoring") + } else { + Color.clear + } + } + .task(id: sessionId) { + if sessionId != nil { + hasSeenSessionID = true + return + } + + // Value-based windows can briefly render before their payload is + // attached. Give SwiftUI one turn to supply the session ID. + if !hasSeenSessionID { + try? await Task.sleep(for: .milliseconds(150)) + guard sessionId == nil, !hasSeenSessionID else { return } + dismiss() + return + } + + dismiss() } } } diff --git a/VoidDisplay/App/ControllerSupport/DisplayStartTracker.swift b/VoidDisplay/App/ControllerSupport/DisplayStartTracker.swift new file mode 100644 index 0000000..88bc39c --- /dev/null +++ b/VoidDisplay/App/ControllerSupport/DisplayStartTracker.swift @@ -0,0 +1,42 @@ +import CoreGraphics +import Foundation + +@MainActor +final class DisplayStartTracker { + private var tokensByDisplayID: [CGDirectDisplayID: Set] = [:] + + var activeDisplayIDs: Set { + Set(tokensByDisplayID.keys) + } + + func contains(displayID: CGDirectDisplayID) -> Bool { + tokensByDisplayID[displayID]?.isEmpty == false + } + + @discardableResult + func begin(displayID: CGDirectDisplayID) -> UUID { + let token = UUID() + var tokens = tokensByDisplayID[displayID] ?? [] + tokens.insert(token) + tokensByDisplayID[displayID] = tokens + return token + } + + func end(displayID: CGDirectDisplayID, token: UUID) { + guard var tokens = tokensByDisplayID[displayID] else { return } + tokens.remove(token) + if tokens.isEmpty { + tokensByDisplayID.removeValue(forKey: displayID) + } else { + tokensByDisplayID[displayID] = tokens + } + } + + func clear(displayID: CGDirectDisplayID) { + tokensByDisplayID.removeValue(forKey: displayID) + } + + func clearAll() { + tokensByDisplayID.removeAll() + } +} diff --git a/VoidDisplay/App/ControllerSupport/SnapshotMutationRunner.swift b/VoidDisplay/App/ControllerSupport/SnapshotMutationRunner.swift new file mode 100644 index 0000000..da63b1c --- /dev/null +++ b/VoidDisplay/App/ControllerSupport/SnapshotMutationRunner.swift @@ -0,0 +1,25 @@ +import Foundation + +@MainActor +final class SnapshotMutationRunner { + private let sync: @MainActor () -> Void + + init(sync: @escaping @MainActor () -> Void) { + self.sync = sync + } + + func run(_ mutation: () -> Void) { + mutation() + sync() + } + + func run(_ mutation: () async -> T) async -> T { + defer { sync() } + return await mutation() + } + + func run(_ mutation: () async throws -> T) async rethrows -> T { + defer { sync() } + return try await mutation() + } +} diff --git a/VoidDisplay/App/HomeView.swift b/VoidDisplay/App/HomeView.swift index 856ba6c..27573ea 100644 --- a/VoidDisplay/App/HomeView.swift +++ b/VoidDisplay/App/HomeView.swift @@ -11,6 +11,7 @@ struct HomeView: View { @Environment(SharingController.self) private var sharing @Environment(VirtualDisplayController.self) private var virtualDisplay @Environment(\.openWindow) private var openWindow + private let screenCatalogOrchestrator: ScreenCatalogOrchestrator private enum SidebarItem: Hashable { case screen @@ -22,24 +23,32 @@ struct HomeView: View { @State private var selection: SidebarItem? = .screen @State private var hasAutoOpenedCapturePreview = false + init(screenCatalogOrchestrator: ScreenCatalogOrchestrator) { + self.screenCatalogOrchestrator = screenCatalogOrchestrator + } + var body: some View { NavigationSplitView { List(selection: $selection) { Section("Display") { - Label("Displays", systemImage: "display") - .tag(SidebarItem.screen) + NavigationLink(value: SidebarItem.screen) { + Label("Displays", systemImage: "display") + } .accessibilityIdentifier("sidebar_screen") - Label("Virtual Displays", systemImage: "display.2") - .tag(SidebarItem.virtualDisplay) + NavigationLink(value: SidebarItem.virtualDisplay) { + Label("Virtual Displays", systemImage: "display.2") + } .accessibilityIdentifier("sidebar_virtual_display") - Label("Screen Monitoring", systemImage: "dot.scope.display") - .tag(SidebarItem.monitorScreen) + NavigationLink(value: SidebarItem.monitorScreen) { + Label("Screen Monitoring", systemImage: "dot.scope.display") + } .accessibilityIdentifier("sidebar_monitor_screen") } Section("Sharing") { - Label("Screen Sharing", systemImage: "display") - .tag(SidebarItem.screenSharing) + NavigationLink(value: SidebarItem.screenSharing) { + Label("Screen Sharing", systemImage: "display") + } .accessibilityIdentifier("sidebar_screen_sharing") } } @@ -66,15 +75,24 @@ struct HomeView: View { .accessibilityIdentifier("detail_virtual_display") } case .monitorScreen: - IsCapturing(capture: capture, virtualDisplay: virtualDisplay) + IsCapturing( + capture: capture, + virtualDisplay: virtualDisplay, + screenCatalogOrchestrator: screenCatalogOrchestrator + ) .navigationTitle("Screen Monitoring") .accessibilityIdentifier("detail_monitor_screen") case .screenSharing: - ShareView(sharing: sharing, virtualDisplay: virtualDisplay) + ShareView( + sharing: sharing, + virtualDisplay: virtualDisplay, + screenCatalogOrchestrator: screenCatalogOrchestrator + ) .navigationTitle("Screen Sharing") .accessibilityIdentifier("detail_screen_sharing") } } + .id(selection ?? .screen) } } .onAppear { @@ -98,7 +116,7 @@ struct HomeView: View { #Preview { let env = AppBootstrap.makeEnvironment(preview: true, isRunningUnderXCTestOverride: false) - HomeView() + HomeView(screenCatalogOrchestrator: env.screenCatalog) .environment(env.capture) .environment(env.sharing) .environment(env.virtualDisplay) diff --git a/VoidDisplay/App/ScreenCatalogOrchestrator.swift b/VoidDisplay/App/ScreenCatalogOrchestrator.swift new file mode 100644 index 0000000..f83bcec --- /dev/null +++ b/VoidDisplay/App/ScreenCatalogOrchestrator.swift @@ -0,0 +1,221 @@ +import CoreGraphics +import Foundation +import ScreenCaptureKit + +@MainActor +enum ScreenCatalogSource: Sendable, Equatable { + case capturePage + case sharingPage +} + +@MainActor +final class ScreenCatalogOrchestrator { + private let catalogService: ScreenCaptureCatalogService + private let capture: CaptureController + private let sharing: SharingController + private let virtualDisplay: VirtualDisplayController + private let captureRefreshOwner = ScreenCaptureCatalogService.RefreshOwner() + private let sharingRefreshOwner = ScreenCaptureCatalogService.RefreshOwner() + + private var topologyRefreshTask: Task? + private var hasPendingTopologyChange = false + + init( + catalogService: ScreenCaptureCatalogService, + capture: CaptureController, + sharing: SharingController, + virtualDisplay: VirtualDisplayController + ) { + self.catalogService = catalogService + self.capture = capture + self.sharing = sharing + self.virtualDisplay = virtualDisplay + } + + func handleAppear(source: ScreenCatalogSource) async { + await refreshPermissionIfNeeded(source: source) + } + + func handleDisappear(source: ScreenCatalogSource) async { + await cancelRefresh(for: source) + } + + func requestPermission(source: ScreenCatalogSource) async { + await requestPermissionIfNeeded(source: source) + } + + func refreshPermission(source: ScreenCatalogSource) async { + await refreshPermissionIfNeeded(source: source) + } + + func forceRefresh(source: ScreenCatalogSource) async { + await forceRefreshIfNeeded(source: source) + } + + func handleTopologyChanged() async { + hasPendingTopologyChange = true + if let topologyRefreshTask { + await topologyRefreshTask.value + return + } + let topologyRefreshTask = Task { @MainActor in + defer { self.topologyRefreshTask = nil } + await self.drainTopologyRefreshQueue() + } + self.topologyRefreshTask = topologyRefreshTask + await topologyRefreshTask.value + } + + func handleSharingServiceStateChanged(isRunning: Bool) async { + if isRunning { + await refreshSharingCatalogForRunningService() + } else { + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + } + } + + func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { + catalogService.openScreenCapturePrivacySettings(openURL: openURL) + } + + private func requestPermissionIfNeeded(source: ScreenCatalogSource) async { + let granted = catalogService.requestPermission() + guard granted else { + let loadErrorMessage = source == .sharingPage + ? String(localized: "Failed to load displays. Check permission and try again.") + : nil + await clearSnapshotForDeniedPermission(loadErrorMessage: loadErrorMessage) + return + } + await refreshAfterPermissionGranted(source: source) + } + + private func refreshPermissionIfNeeded(source: ScreenCatalogSource) async { + let granted = catalogService.refreshPermission() + guard granted else { + await clearSnapshotForDeniedPermission() + return + } + await refreshAfterPermissionGranted(source: source) + } + + private func forceRefreshIfNeeded(source: ScreenCatalogSource) async { + let granted = catalogService.refreshPermission() + guard granted else { + await clearSnapshotForDeniedPermission() + return + } + + switch source { + case .capturePage: + await refreshAndConverge( + intent: .userForcedRefresh, + owner: captureRefreshOwner + ) + case .sharingPage: + guard sharing.isWebServiceRunning else { + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + return + } + await refreshAndConverge( + intent: .userForcedRefresh, + owner: sharingRefreshOwner + ) + } + } + + private func refreshAfterPermissionGranted(source: ScreenCatalogSource) async { + switch source { + case .capturePage: + await refreshAndConverge( + intent: .permissionChanged, + owner: captureRefreshOwner + ) + case .sharingPage: + if sharing.isWebServiceRunning { + await refreshSharingCatalogForRunningService() + } else { + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + } + } + } + + private func refreshSharingCatalogForRunningService() async { + guard catalogService.refreshPermission() else { + await clearSnapshotForDeniedPermission() + return + } + guard sharing.isWebServiceRunning else { + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + return + } + await refreshAndConverge( + intent: .serviceBecameRunning, + owner: sharingRefreshOwner + ) + } + + private func cancelRefresh(for source: ScreenCatalogSource) async { + switch source { + case .capturePage: + await catalogService.cancelRefresh(owner: captureRefreshOwner) + case .sharingPage: + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + } + } + + private func clearSnapshotForDeniedPermission(loadErrorMessage: String? = nil) async { + await catalogService.clearSnapshotForDeniedPermission(loadErrorMessage: loadErrorMessage) + convergeToVisibleDisplays([]) + } + + private func drainTopologyRefreshQueue() async { + while hasPendingTopologyChange { + hasPendingTopologyChange = false + await runTopologyRefreshSequence() + } + } + + private func runTopologyRefreshSequence() async { + guard catalogService.refreshPermission() else { + await clearSnapshotForDeniedPermission() + return + } + + let result = await catalogService.submitRefresh(intent: .topologyChanged) + guard result != .failed else { return } + convergeToVisibleDisplaysFromCurrentSnapshot() + } + + private func refreshAndConverge( + intent: ScreenCaptureCatalogRefreshIntent, + owner: ScreenCaptureCatalogService.RefreshOwner? = nil + ) async { + let result = await catalogService.submitRefresh(intent: intent, owner: owner) + guard result != .failed else { return } + convergeToVisibleDisplaysFromCurrentSnapshot() + } + + private func convergeToVisibleDisplaysFromCurrentSnapshot() { + let visibleDisplays = catalogService.visibleDisplays(from: catalogService.store.displays ?? []) + convergeToVisibleDisplays(visibleDisplays) + } + + private func convergeToVisibleDisplays(_ visibleDisplays: [SCDisplay]) { + if sharing.isWebServiceRunning { + sharing.registerShareableDisplays(visibleDisplays) { [weak virtualDisplay] displayID in + virtualDisplay?.virtualSerialForManagedDisplay(displayID) + } + } + + let visibleDisplayIDs = Set(visibleDisplays.map(\.displayID)) + for displayID in sharing.activeSharingDisplayIDs where !visibleDisplayIDs.contains(displayID) { + sharing.stopSharing(displayID: displayID) + } + + let monitoredDisplayIDs = Set(capture.screenCaptureSessions.map(\.displayID)) + for displayID in monitoredDisplayIDs where !visibleDisplayIDs.contains(displayID) { + capture.removeMonitoringSessions(displayID: displayID) + } + } +} diff --git a/VoidDisplay/App/SharingController.swift b/VoidDisplay/App/SharingController.swift index 0709d47..96f6b10 100644 --- a/VoidDisplay/App/SharingController.swift +++ b/VoidDisplay/App/SharingController.swift @@ -18,31 +18,47 @@ final class SharingController { } var activeSharingDisplayIDs: Set = [] + private(set) var startingDisplayIDs: Set = [] var sharingClientCount = 0 var sharingClientCounts: [CGDirectDisplayID: Int] = [:] var isSharing = false var isWebServiceRunning = false var webServiceLifecycleState: WebServiceLifecycleState = .stopped - @ObservationIgnored let displayCatalogState = ScreenCaptureDisplayCatalogState() + @ObservationIgnored let catalogService: ScreenCaptureCatalogService @ObservationIgnored private(set) var webServer: WebServer? = nil @ObservationIgnored private let sharingService: any SharingServiceProtocol @ObservationIgnored private let portPreferences: any SharingPortPreferencesProtocol + @ObservationIgnored private let startTracker = DisplayStartTracker() + @ObservationIgnored private lazy var mutationRunner = SnapshotMutationRunner { [weak self] in + self?.syncSharingState() + } + @ObservationIgnored private var sharingStateSubscription: SharingStateSubscription? init( sharingService: any SharingServiceProtocol, - portPreferences: any SharingPortPreferencesProtocol + portPreferences: any SharingPortPreferencesProtocol, + catalogService: ScreenCaptureCatalogService? = nil ) { self.sharingService = sharingService self.portPreferences = portPreferences + self.catalogService = catalogService ?? ScreenCaptureCatalogService() self.sharingService.onWebServiceLifecycleStateChanged = { [weak self] _ in self?.syncSharingState() } + self.sharingStateSubscription = self.sharingService.subscribeSharingState { [weak self] _ in + self?.refreshSharingCountsFromSnapshot() + } + syncSharingState() + } + + var displayCatalogState: ScreenCaptureDisplayCatalogState { + catalogService.store } @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult { - await mutateAndSync { + await mutationRunner.run { let result = await sharingService.startWebService(requestedPort: requestedPort) if let binding = result.binding { portPreferences.savePreferredPort(binding.requestedPort) @@ -52,7 +68,8 @@ final class SharingController { } func stopWebService() { - mutateAndSync { + startTracker.clearAll() + mutationRunner.run { sharingService.stopWebService() } } @@ -61,25 +78,33 @@ final class SharingController { _ displays: [SCDisplay], virtualSerialResolver: @escaping (CGDirectDisplayID) -> UInt32? ) { - mutateAndSync { + mutationRunner.run { sharingService.registerShareableDisplays(displays, virtualSerialResolver: virtualSerialResolver) } } - func beginSharing(display: SCDisplay) async throws { - try await mutateAndSync { - try await sharingService.startSharing(display: display) + func beginSharing(display: SCDisplay) async throws -> DisplayStartOutcome { + let displayID = display.displayID + let startToken = startTracker.begin(displayID: displayID) + syncSharingState() + defer { + startTracker.end(displayID: displayID, token: startToken) + syncSharingState() } + + return try await sharingService.startSharing(display: display) } func stopSharing(displayID: CGDirectDisplayID) { - mutateAndSync { + startTracker.clear(displayID: displayID) + mutationRunner.run { sharingService.stopSharing(displayID: displayID) } } func stopAllSharing() { - mutateAndSync { + startTracker.clearAll() + mutationRunner.run { sharingService.stopAllSharing() } } @@ -92,11 +117,6 @@ final class SharingController { portPreferences.preferredPort } - func refreshSharingClientCount() { - sharingClientCount = sharingService.activeStreamClientCount - refreshSharingClientCounts() - } - func isDisplaySharing(displayID: CGDirectDisplayID) -> Bool { activeSharingDisplayIDs.contains(displayID) } @@ -105,6 +125,10 @@ final class SharingController { sharingService.isSharing(displayID: displayID) } + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startTracker.contains(displayID: displayID) + } + func sharePagePath(for displayID: CGDirectDisplayID) -> String? { guard let shareID = sharingService.shareID(for: displayID) else { return nil } return ShareTarget.id(shareID).displayPath @@ -138,15 +162,17 @@ final class SharingController { private func syncSharingState() { webServer = sharingService.currentWebServer - sharingClientCount = sharingService.activeStreamClientCount activeSharingDisplayIDs = sharingService.activeSharingDisplayIDs isSharing = sharingService.hasAnyActiveSharing isWebServiceRunning = sharingService.isWebServiceRunning webServiceLifecycleState = sharingService.webServiceLifecycleState - refreshSharingClientCounts() + startingDisplayIDs = startTracker.activeDisplayIDs + refreshSharingCountsFromSnapshot() } - private func refreshSharingClientCounts() { + private func refreshSharingCountsFromSnapshot() { + let snapshot = sharingService.sharingStateSnapshot + sharingClientCount = snapshot.streamingPeers guard isWebServiceRunning else { sharingClientCounts = [:] return @@ -154,24 +180,19 @@ final class SharingController { var counts: [CGDirectDisplayID: Int] = [:] for displayID in sharingService.activeSharingDisplayIDs { if let target = sharingService.shareTarget(for: displayID) { - counts[displayID] = sharingService.streamClientCount(for: target) + counts[displayID] = snapshot.streamingPeersByTarget[target] ?? 0 } } sharingClientCounts = counts } - private func mutateAndSync(_ mutation: () -> Void) { - mutation() +#if DEBUG + func installStartingDisplayIDsForTesting(_ displayIDs: Set) { + startTracker.clearAll() + for displayID in displayIDs { + _ = startTracker.begin(displayID: displayID) + } syncSharingState() } - - private func mutateAndSync(_ mutation: () async -> T) async -> T { - defer { syncSharingState() } - return await mutation() - } - - private func mutateAndSync(_ mutation: () async throws -> T) async rethrows -> T { - defer { syncSharingState() } - return try await mutation() - } +#endif } diff --git a/VoidDisplay/App/VoidDisplayApp.swift b/VoidDisplay/App/VoidDisplayApp.swift index d836e4e..fec1861 100644 --- a/VoidDisplay/App/VoidDisplayApp.swift +++ b/VoidDisplay/App/VoidDisplayApp.swift @@ -12,6 +12,8 @@ struct AppEnvironment { let capture: CaptureController let sharing: SharingController let virtualDisplay: VirtualDisplayController + let screenCatalog: ScreenCatalogOrchestrator + let capturePerformancePreferences: CapturePerformancePreferences } @main @@ -19,20 +21,32 @@ struct VoidDisplayApp: App { @State private var capture: CaptureController @State private var sharing: SharingController @State private var virtualDisplay: VirtualDisplayController + @State private var screenCatalog: ScreenCatalogOrchestrator + @State private var capturePerformancePreferences: CapturePerformancePreferences init() { let env = AppBootstrap.makeEnvironment() _capture = State(initialValue: env.capture) _sharing = State(initialValue: env.sharing) _virtualDisplay = State(initialValue: env.virtualDisplay) + _screenCatalog = State(initialValue: env.screenCatalog) + _capturePerformancePreferences = State(initialValue: env.capturePerformancePreferences) } var body: some Scene { WindowGroup { - HomeView() - .environment(capture) - .environment(sharing) - .environment(virtualDisplay) + Group { + if CapturePreviewDiagnosticsRuntime.shouldAutoOpenPreviewWindow, + let sessionID = capture.screenCaptureSessions.first?.id { + CaptureDisplayView(sessionId: sessionID) + } else { + HomeView(screenCatalogOrchestrator: screenCatalog) + } + } + .environment(capture) + .environment(sharing) + .environment(virtualDisplay) + .environment(capturePerformancePreferences) } .windowToolbarStyle(.unified(showsTitle: true)) @@ -49,6 +63,7 @@ struct VoidDisplayApp: App { .environment(capture) .environment(sharing) .environment(virtualDisplay) + .environment(capturePerformancePreferences) } } } @@ -114,19 +129,34 @@ enum AppBootstrap { ?? (ProcessInfo.processInfo.environment[xCTestConfigurationEnvironmentKey] != nil) let resolvedStartupPlan = startupPlan ?? (isRunningUnderXCTest ? .skipAll : .standard) let resolvedCaptureMonitoringService = captureMonitoringService ?? CaptureMonitoringService() + let catalogService = ScreenCaptureCatalogService() var persistenceEnvironment = ProcessInfo.processInfo.environment if preview { persistenceEnvironment[PersistenceContext.uiTestModeEnvironmentKey] = "1" } let persistenceContext = PersistenceContext.resolve(environment: persistenceEnvironment) + let capturePerformancePreferences = CapturePerformancePreferences( + defaults: persistenceContext.userDefaults + ) + let captureRegistry = DisplayCaptureRegistry( + performanceMode: capturePerformancePreferences.mode + ) + capturePerformancePreferences.onModeChanged = { mode in + Task { + await captureRegistry.updatePerformanceMode(mode) + } + } let resolvedSharingService: any SharingServiceProtocol if let sharingService { resolvedSharingService = sharingService } else { let idStore = DisplayShareIDStore(storeURL: persistenceContext.displayShareIDMappingsURL) - let sharingCoordinator = DisplaySharingCoordinator(idStore: idStore) + let sharingCoordinator = DisplaySharingCoordinator( + idStore: idStore, + captureRegistry: captureRegistry + ) resolvedSharingService = SharingService(sharingCoordinator: sharingCoordinator) } @@ -142,10 +172,18 @@ enum AppBootstrap { resolvedVirtualDisplayFacade = VirtualDisplayOrchestrator(configRepository: configRepository) } - let capture = CaptureController(captureMonitoringService: resolvedCaptureMonitoringService) + let capture = CaptureController( + captureMonitoringService: resolvedCaptureMonitoringService, + captureMonitoringLifecycleService: CaptureMonitoringLifecycleService( + captureMonitoringService: resolvedCaptureMonitoringService, + captureRegistry: captureRegistry + ), + catalogService: catalogService + ) let sharing = SharingController( sharingService: resolvedSharingService, - portPreferences: SharingPortPreferences(defaults: persistenceContext.userDefaults) + portPreferences: SharingPortPreferences(defaults: persistenceContext.userDefaults), + catalogService: catalogService ) let virtualDisplay = VirtualDisplayController( virtualDisplayFacade: resolvedVirtualDisplayFacade, @@ -161,7 +199,14 @@ enum AppBootstrap { let env = AppEnvironment( capture: capture, sharing: sharing, - virtualDisplay: virtualDisplay + virtualDisplay: virtualDisplay, + screenCatalog: ScreenCatalogOrchestrator( + catalogService: catalogService, + capture: capture, + sharing: sharing, + virtualDisplay: virtualDisplay + ), + capturePerformancePreferences: capturePerformancePreferences ) guard !preview else { return env } diff --git a/VoidDisplay/Features/Capture/Models/CaptureMonitoringDisplayMetadata.swift b/VoidDisplay/Features/Capture/Models/CaptureMonitoringDisplayMetadata.swift new file mode 100644 index 0000000..d03ee38 --- /dev/null +++ b/VoidDisplay/Features/Capture/Models/CaptureMonitoringDisplayMetadata.swift @@ -0,0 +1,7 @@ +import Foundation + +struct CaptureMonitoringDisplayMetadata: Equatable, Sendable { + let displayName: String + let resolutionText: String + let isVirtualDisplay: Bool +} diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift new file mode 100644 index 0000000..4e109ff --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift @@ -0,0 +1,125 @@ +import Foundation +import ScreenCaptureKit +import CoreGraphics + +@MainActor +final class CaptureMonitoringLifecycleService: CaptureMonitoringLifecycleServiceProtocol { + typealias AcquirePreview = @MainActor ( + SCDisplay, + DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome + + private let captureMonitoringService: any CaptureMonitoringServiceProtocol + private let startCoordinator: DisplayStreamStartCoordinator + private let acquirePreview: AcquirePreview + + init( + captureMonitoringService: any CaptureMonitoringServiceProtocol, + startCoordinator: DisplayStreamStartCoordinator = DisplayStreamStartCoordinator(), + captureRegistry: DisplayCaptureRegistry = .shared, + acquirePreview: AcquirePreview? = nil + ) { + self.captureMonitoringService = captureMonitoringService + self.startCoordinator = startCoordinator + self.acquirePreview = acquirePreview ?? { display, invalidationContext in + try await captureRegistry.acquirePreview( + display: SendableDisplay(display), + invalidationContext: invalidationContext + ) + } + } + + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startCoordinator.isStarting(kind: .monitoring, displayID: displayID) + } + + func startMonitoring( + display: SCDisplay, + metadata: CaptureMonitoringDisplayMetadata + ) async throws -> DisplayStartOutcome { + let displayID = display.displayID + if let existingSession = existingSession(for: displayID) { + return .started(existingSession.id) + } + + return try await startCoordinator.start( + kind: .monitoring, + displayID: displayID + ) { [captureMonitoringService, acquirePreview] invalidationContext in + if let existingSession = captureMonitoringService.currentSessions.first( + where: { $0.displayID == displayID } + ) { + return .started(existingSession.id) + } + + switch try await acquirePreview(display, invalidationContext) { + case .invalidated: + return .invalidated + case .started(let previewSubscription): + if invalidationContext.isInvalidated() { + previewSubscription.cancel() + return .invalidated + } + if let existingSession = captureMonitoringService.currentSessions.first( + where: { $0.displayID == displayID } + ) { + previewSubscription.cancel() + return .started(existingSession.id) + } + + let session = ScreenMonitoringSession( + id: UUID(), + displayID: displayID, + displayName: metadata.displayName, + resolutionText: metadata.resolutionText, + isVirtualDisplay: metadata.isVirtualDisplay, + previewSubscription: previewSubscription, + capturesCursor: false, + state: .starting + ) + if invalidationContext.isInvalidated() { + previewSubscription.cancel() + return .invalidated + } + captureMonitoringService.addMonitoringSession(session) + return .started(session.id) + } + } + } + + func activateMonitoringSession(id: UUID) { + guard captureMonitoringService.monitoringSession(for: id) != nil else { return } + captureMonitoringService.updateMonitoringSessionState(id: id, state: .active) + } + + func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) { + guard let session = captureMonitoringService.monitoringSession(for: id) else { return } + session.previewSubscription.attachPreviewSink(sink) + } + + func setMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) async throws { + guard let session = captureMonitoringService.monitoringSession(for: id) else { return } + try await session.previewSubscription.setShowsCursor(capturesCursor) + captureMonitoringService.updateMonitoringSessionCapturesCursor( + id: id, + capturesCursor: capturesCursor + ) + } + + func closeMonitoringSession(id: UUID) { + guard captureMonitoringService.monitoringSession(for: id) != nil else { return } + captureMonitoringService.removeMonitoringSession(id: id) + } + + func removeMonitoringSessions(displayID: CGDirectDisplayID) { + startCoordinator.invalidate(kind: .monitoring, displayID: displayID) + captureMonitoringService.removeMonitoringSessions(displayID: displayID) + } + + private func existingSession(for displayID: CGDirectDisplayID) -> ScreenMonitoringSession? { + captureMonitoringService.currentSessions.first(where: { $0.displayID == displayID }) + } +} diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift new file mode 100644 index 0000000..8ce1ebc --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift @@ -0,0 +1,19 @@ +import Foundation +import ScreenCaptureKit + +@MainActor +protocol CaptureMonitoringLifecycleServiceProtocol: AnyObject { + func isStarting(displayID: CGDirectDisplayID) -> Bool + func startMonitoring( + display: SCDisplay, + metadata: CaptureMonitoringDisplayMetadata + ) async throws -> DisplayStartOutcome + func activateMonitoringSession(id: UUID) + func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) + func setMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) async throws + func closeMonitoringSession(id: UUID) + func removeMonitoringSessions(displayID: CGDirectDisplayID) +} diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift index 12ec71c..a589834 100644 --- a/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift @@ -20,53 +20,43 @@ protocol CaptureMonitoringServiceProtocol: AnyObject { @MainActor final class CaptureMonitoringService: CaptureMonitoringServiceProtocol { - private var sessions: [ScreenMonitoringSession] = [] + private let sessionStore: CaptureMonitoringSessionStore init(initialSessions: [ScreenMonitoringSession] = []) { - self.sessions = initialSessions + self.sessionStore = CaptureMonitoringSessionStore(initialSessions: initialSessions) } var currentSessions: [ScreenMonitoringSession] { - sessions + sessionStore.currentSessions } func monitoringSession(for id: UUID) -> ScreenMonitoringSession? { - sessions.first { $0.id == id } + sessionStore.session(for: id) } func addMonitoringSession(_ session: ScreenMonitoringSession) { - sessions.append(session) + sessionStore.add(session) } func updateMonitoringSessionState( id: UUID, state: ScreenMonitoringSession.State ) { - guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } - sessions[index].state = state + sessionStore.updateState(id: id, state: state) } func updateMonitoringSessionCapturesCursor( id: UUID, capturesCursor: Bool ) { - guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } - sessions[index].capturesCursor = capturesCursor + sessionStore.updateCapturesCursor(id: id, capturesCursor: capturesCursor) } func removeMonitoringSession(id: UUID) { - if let session = sessions.first(where: { $0.id == id }) { - session.previewSubscription.cancel() - } - sessions.removeAll { $0.id == id } + sessionStore.remove(id: id) } func removeMonitoringSessions(displayID: CGDirectDisplayID) { - let targetSessionIDs = sessions - .filter { $0.displayID == displayID } - .map(\.id) - for sessionID in targetSessionIDs { - removeMonitoringSession(id: sessionID) - } + sessionStore.remove(displayID: displayID) } } diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringSessionStore.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringSessionStore.swift new file mode 100644 index 0000000..4a9d4ef --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringSessionStore.swift @@ -0,0 +1,70 @@ +import CoreGraphics +import Foundation + +@MainActor +final class CaptureMonitoringSessionStore { + private var sessions: [ScreenMonitoringSession] + + init(initialSessions: [ScreenMonitoringSession] = []) { + self.sessions = initialSessions + } + + var currentSessions: [ScreenMonitoringSession] { + sessions + } + + func session(for id: UUID) -> ScreenMonitoringSession? { + sessions.first { $0.id == id } + } + + func add(_ session: ScreenMonitoringSession) { + sessions.append(session) + } + + func updateState( + id: UUID, + state: ScreenMonitoringSession.State + ) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + let currentState = sessions[index].state + guard shouldApplyStateTransition(from: currentState, to: state) else { return } + sessions[index].state = state + } + + func updateCapturesCursor( + id: UUID, + capturesCursor: Bool + ) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + guard sessions[index].capturesCursor != capturesCursor else { return } + sessions[index].capturesCursor = capturesCursor + } + + func remove(id: UUID) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + sessions[index].previewSubscription.cancel() + sessions.remove(at: index) + } + + func remove(displayID: CGDirectDisplayID) { + let removalIndexes = sessions.indices.filter { sessions[$0].displayID == displayID } + guard !removalIndexes.isEmpty else { return } + + for index in removalIndexes { + sessions[index].previewSubscription.cancel() + } + sessions.removeAll { $0.displayID == displayID } + } + + private func shouldApplyStateTransition( + from currentState: ScreenMonitoringSession.State, + to nextState: ScreenMonitoringSession.State + ) -> Bool { + switch (currentState, nextState) { + case (.starting, .active): + true + case (.starting, .starting), (.active, .active), (.active, .starting): + false + } + } +} diff --git a/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift b/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift new file mode 100644 index 0000000..ddce436 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift @@ -0,0 +1,40 @@ +import Foundation +import Observation + +public enum CapturePerformancePreferenceKeys { + public static let mode = "capture.performanceMode" +} + +enum CapturePerformanceMode: String, CaseIterable, Sendable { + case automatic + case smooth + case powerEfficient +} + +@MainActor +protocol CapturePerformancePreferencesProtocol: AnyObject { + var mode: CapturePerformanceMode { get } + var onModeChanged: (@MainActor @Sendable (CapturePerformanceMode) -> Void)? { get set } + func saveMode(_ mode: CapturePerformanceMode) +} + +@MainActor +@Observable +final class CapturePerformancePreferences: CapturePerformancePreferencesProtocol { + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored var onModeChanged: (@MainActor @Sendable (CapturePerformanceMode) -> Void)? + var mode: CapturePerformanceMode + + init(defaults: UserDefaults) { + self.defaults = defaults + let rawValue = defaults.string(forKey: CapturePerformancePreferenceKeys.mode) + self.mode = rawValue.flatMap(CapturePerformanceMode.init(rawValue:)) ?? .automatic + } + + func saveMode(_ mode: CapturePerformanceMode) { + guard self.mode != mode else { return } + self.mode = mode + defaults.set(mode.rawValue, forKey: CapturePerformancePreferenceKeys.mode) + onModeChanged?(mode) + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureDemandDriver.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureDemandDriver.swift new file mode 100644 index 0000000..e5614bc --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureDemandDriver.swift @@ -0,0 +1,218 @@ +import Foundation +import Synchronization + +final class DisplayCaptureDemandDriver: @unchecked Sendable { + typealias ImmediateDemandApplier = @Sendable (DisplayCaptureDemandSnapshot) async throws -> Bool + typealias ConfigurationApplier = @Sendable (DisplayCaptureConfiguration) async throws -> Bool + typealias ConfigurationAppliedHandler = @Sendable (DisplayCaptureConfiguration) -> Void + typealias ConfigurationFailureHandler = @Sendable (any Error) -> Void + + private struct State { + var configurationCoordinator: DisplayCaptureConfigurationCoordinatorState + var taskLifetime = DisplayCaptureTaskLifetimeState() + var pendingTaskNonce: UInt64 = 0 + var pendingConfigurationTask: Task? + var activeApplyTask: Task? + } + + private let minimumDwellNanoseconds: UInt64 + private let currentTimeNanoseconds: @Sendable () -> UInt64 + private let applyImmediateDemandClosure: ImmediateDemandApplier + private let applyConfigurationClosure: ConfigurationApplier + private let onConfigurationApplied: ConfigurationAppliedHandler + private let onConfigurationFailure: ConfigurationFailureHandler? + private let state: Mutex + + nonisolated init( + initialConfiguration: DisplayCaptureConfiguration, + initialDemand: DisplayCaptureDemandSnapshot, + minimumDwellNanoseconds: UInt64, + currentTimeNanoseconds: @escaping @Sendable () -> UInt64 = { DispatchTime.now().uptimeNanoseconds }, + applyImmediateDemand: @escaping ImmediateDemandApplier, + applyConfiguration: @escaping ConfigurationApplier, + onConfigurationApplied: @escaping ConfigurationAppliedHandler = { _ in }, + onConfigurationFailure: ConfigurationFailureHandler? = nil + ) { + self.minimumDwellNanoseconds = minimumDwellNanoseconds + self.currentTimeNanoseconds = currentTimeNanoseconds + self.applyImmediateDemandClosure = applyImmediateDemand + self.applyConfigurationClosure = applyConfiguration + self.onConfigurationApplied = onConfigurationApplied + self.onConfigurationFailure = onConfigurationFailure + self.state = Mutex( + State( + configurationCoordinator: DisplayCaptureConfigurationCoordinatorState( + committedConfiguration: initialConfiguration, + demand: initialDemand + ) + ) + ) + } + + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = try await applyImmediateDemandClosure(demand) + scheduleConfigurationDecision { state, nowNs, minimumDwellNanoseconds in + state.configurationCoordinator.updateDemand( + demand, + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + } + + nonisolated func recordPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + scheduleConfigurationDecision { state, nowNs, minimumDwellNanoseconds in + state.configurationCoordinator.recordPreviewPerformanceSample( + sample, + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + } + + nonisolated func cancelAll() { + state.withLock { state in + _ = state.taskLifetime.invalidateAllTasks() + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil + state.activeApplyTask?.cancel() + state.activeApplyTask = nil + } + } + + nonisolated private func scheduleConfigurationDecision( + _ decisionProvider: ( + inout State, + UInt64, + UInt64 + ) -> DisplayCaptureConfigurationDecision + ) { + let decision = state.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64) in + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil + state.pendingTaskNonce &+= 1 + let decision = decisionProvider( + &state, + currentTimeNanoseconds(), + minimumDwellNanoseconds + ) + return (decision, state.pendingTaskNonce) + } + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func handleConfigurationDecision( + _ decision: DisplayCaptureConfigurationDecision, + schedulingNonce: UInt64 + ) { + switch decision { + case .noChange: + return + case .applyNow(let configuration): + let executionGeneration = state.withLock { $0.taskLifetime.currentGeneration } + let task = Task { [weak self] in + guard let self else { return } + await self.applyConfiguration( + configuration: configuration, + executionGeneration: executionGeneration + ) + } + state.withLock { state in + if state.taskLifetime.allowsExecution(for: executionGeneration) { + state.activeApplyTask = task + } else { + task.cancel() + } + } + case .applyAfter(_, let delayNanoseconds): + let task = Task { [weak self] in + try? await Task.sleep(nanoseconds: delayNanoseconds) + self?.resumeDemandDrivenConfigurationEvaluation(schedulingNonce: schedulingNonce) + } + state.withLock { state in + if state.pendingTaskNonce == schedulingNonce { + state.pendingConfigurationTask = task + } else { + task.cancel() + } + } + } + } + + nonisolated private func resumeDemandDrivenConfigurationEvaluation(schedulingNonce: UInt64) { + let decision = state.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64)? in + guard state.pendingTaskNonce == schedulingNonce else { + return nil + } + state.pendingConfigurationTask = nil + state.pendingTaskNonce &+= 1 + let decision = state.configurationCoordinator.resumeScheduledTransition( + nowNs: currentTimeNanoseconds(), + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + return (decision, state.pendingTaskNonce) + } + guard let decision else { return } + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func applyConfiguration( + configuration: DisplayCaptureConfiguration, + executionGeneration: UInt64 + ) async { + guard isExecutionAllowed(for: executionGeneration) else { return } + + let changed: Bool + do { + try Task.checkCancellation() + guard isExecutionAllowed(for: executionGeneration) else { return } + changed = try await applyConfigurationClosure(configuration) + } catch is CancellationError { + finishDiscardedConfigurationApply(executionGeneration: executionGeneration) + return + } catch { + finishDiscardedConfigurationApply(executionGeneration: executionGeneration) + onConfigurationFailure?(error) + return + } + + guard changed else { + finishDiscardedConfigurationApply(executionGeneration: executionGeneration) + return + } + + guard isExecutionAllowed(for: executionGeneration) else { return } + onConfigurationApplied(configuration) + + let decision = state.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64)? in + guard state.taskLifetime.allowsExecution(for: executionGeneration) else { + return nil + } + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil + state.activeApplyTask = nil + state.pendingTaskNonce &+= 1 + let decision = state.configurationCoordinator.finishAppliedTransition( + at: currentTimeNanoseconds(), + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + return (decision, state.pendingTaskNonce) + } + guard let decision else { return } + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func isExecutionAllowed(for generation: UInt64) -> Bool { + state.withLock { state in + state.taskLifetime.allowsExecution(for: generation) + } + } + + nonisolated private func finishDiscardedConfigurationApply(executionGeneration: UInt64) { + state.withLock { state in + guard state.taskLifetime.allowsExecution(for: executionGeneration) else { return } + state.activeApplyTask = nil + state.configurationCoordinator.failAppliedTransition() + } + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift new file mode 100644 index 0000000..eb51d60 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift @@ -0,0 +1,890 @@ +import CoreGraphics +import Foundation +import Synchronization + +final class DisplayPreviewSubscription: Sendable { + let displayID: CGDirectDisplayID + let resolutionText: String + + private let session: any DisplayCaptureSessioning + private let onAttachedPreviewSinkCountChanged: @Sendable (Int) -> Void + private let setShowsCursorClosure: @Sendable (Bool) async throws -> Void + private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) + private let attachedSinks = Mutex<[ObjectIdentifier: WeakSink]>([:]) + + private final class WeakSink: @unchecked Sendable { + nonisolated(unsafe) weak var value: (any DisplayPreviewSink)? + + nonisolated init(_ value: any DisplayPreviewSink) { + self.value = value + } + } + + nonisolated init( + displayID: CGDirectDisplayID, + resolutionText: String, + session: any DisplayCaptureSessioning, + cancelClosure: @escaping @Sendable () -> Void, + onAttachedPreviewSinkCountChanged: @escaping @Sendable (Int) -> Void = { _ in }, + setShowsCursorClosure: @escaping @Sendable (Bool) async throws -> Void = { _ in } + ) { + self.displayID = displayID + self.resolutionText = resolutionText + self.session = session + self.onAttachedPreviewSinkCountChanged = onAttachedPreviewSinkCountChanged + self.setShowsCursorClosure = setShowsCursorClosure + cancelState.withLock { $0 = cancelClosure } + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + let shouldAttach = attachedSinks.withLock { attachedSinks -> Bool in + let key = ObjectIdentifier(sink as AnyObject) + if let existing = attachedSinks[key], existing.value != nil { + return false + } + attachedSinks[key] = WeakSink(sink) + return true + } + guard shouldAttach else { return } + session.attachPreviewSink(sink) + onAttachedPreviewSinkCountChanged(1) + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + let shouldDetach = attachedSinks.withLock { attachedSinks -> Bool in + let key = ObjectIdentifier(sink as AnyObject) + guard let removed = attachedSinks.removeValue(forKey: key) else { + return false + } + return removed.value != nil + } + guard shouldDetach else { return } + session.detachPreviewSink(sink) + onAttachedPreviewSinkCountChanged(-1) + } + + nonisolated func cancel() { + let session = self.session + let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in + let current = state + state = nil + return current + } + guard let closure else { return } + + let sinksToDetach: [any DisplayPreviewSink] = attachedSinks.withLock { dict in + let snapshot = dict.values.compactMap(\.value) + dict.removeAll(keepingCapacity: true) + return snapshot + } + for sink in sinksToDetach { + session.detachPreviewSink(sink) + onAttachedPreviewSinkCountChanged(-1) + } + + closure() + } + + nonisolated func reportPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + session.reportPreviewPerformanceSample(sample) + } + + nonisolated func setShowsCursor(_ showsCursor: Bool) async throws { + try await setShowsCursorClosure(showsCursor) + } + + deinit { cancel() } +} + +final class DisplayShareSubscription: Sendable { + let displayID: CGDirectDisplayID + let sessionHub: WebRTCSessionHub + + private let prepareForSharingClosure: @Sendable () async throws -> Void + private let releasePreparedShareClosure: @Sendable () async -> Void + private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) + private let prepareRetainTask = Mutex?>(nil) + private let hasRetainedShareCursorOverride = Mutex(false) + + nonisolated init( + displayID: CGDirectDisplayID, + sessionHub: WebRTCSessionHub, + cancelClosure: @escaping @Sendable () -> Void, + prepareForSharingClosure: @escaping @Sendable () async throws -> Void = {}, + releasePreparedShareClosure: @escaping @Sendable () async -> Void = {} + ) { + self.displayID = displayID + self.sessionHub = sessionHub + self.prepareForSharingClosure = prepareForSharingClosure + self.releasePreparedShareClosure = releasePreparedShareClosure + cancelState.withLock { $0 = cancelClosure } + } + + nonisolated func prepareForSharing() async throws { + try await prepareForSharingClosure() + hasRetainedShareCursorOverride.withLock { $0 = true } + } + + nonisolated func prepareForSharing( + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + let retainTask = Task { + try await prepareForSharingClosure() + return true + } + prepareRetainTask.withLock { state in + state = retainTask + } + do { + let outcome = try await invalidationContext.race { + _ = try await retainTask.value + } + switch outcome { + case .started: + prepareRetainTask.withLock { state in + state = nil + } + hasRetainedShareCursorOverride.withLock { $0 = true } + case .invalidated: + cancel() + } + return outcome + } catch { + cancel() + throw error + } + } + + nonisolated func cancel() { + let pendingRetainTask = prepareRetainTask.withLock { state -> Task? in + let current = state + state = nil + return current + } + let hasRetained = hasRetainedShareCursorOverride.withLock { state -> Bool in + let current = state + state = false + return current + } + let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in + let current = state + state = nil + return current + } + guard let closure else { return } + if let pendingRetainTask { + Task.detached { [self] in + var needsRelease = hasRetained + do { + let didRetain = try await pendingRetainTask.value + needsRelease = needsRelease || didRetain + } catch { + } + if needsRelease { + await releasePreparedShareClosure() + } + closure() + } + return + } + Task { + if hasRetained { + await releasePreparedShareClosure() + } + closure() + } + } + + deinit { cancel() } +} + +nonisolated final class DisplayCaptureSessionStore: @unchecked Sendable { + nonisolated struct Record { + let session: any DisplayCaptureSessioning + let resolutionText: String + var state: DisplayCaptureRegistry.SessionResourceState + } + + private var recordsByDisplayID: [CGDirectDisplayID: Record] = [:] + private var sessionCreationTasks: [CGDirectDisplayID: Task] = [:] + private var sessionDrainTasksByDisplayID: [CGDirectDisplayID: Task] = [:] + private var initializingDisplayIDs: Set = [] + + nonisolated var activeDisplayIDs: [CGDirectDisplayID] { + recordsByDisplayID.compactMap { displayID, record in + record.state == .draining ? nil : displayID + } + } + + nonisolated func record(for displayID: CGDirectDisplayID) -> Record? { + recordsByDisplayID[displayID] + } + + nonisolated func sessionState( + for displayID: CGDirectDisplayID + ) -> DisplayCaptureRegistry.SessionResourceState { + if initializingDisplayIDs.contains(displayID) { + return .initializing + } + return recordsByDisplayID[displayID]?.state ?? .stopped + } + + nonisolated func installSessionForTesting( + displayID: CGDirectDisplayID, + resolutionText: String, + session: any DisplayCaptureSessioning + ) { + sessionDrainTasksByDisplayID[displayID]?.cancel() + sessionDrainTasksByDisplayID[displayID] = nil + initializingDisplayIDs.remove(displayID) + recordsByDisplayID[displayID] = Record( + session: session, + resolutionText: resolutionText, + state: .active + ) + } + + nonisolated func markActive(displayID: CGDirectDisplayID) { + guard var record = recordsByDisplayID[displayID] else { return } + record.state = .active + recordsByDisplayID[displayID] = record + } + + nonisolated func ensureSessionExists( + for display: SendableDisplay, + initialProfileProvider: @escaping @Sendable (CGDirectDisplayID) async -> DisplayCaptureProfile, + performanceMode: CapturePerformanceMode, + captureSessionFactory: @escaping DisplayCaptureRegistry.CaptureSessionFactory + ) async throws { + let displayID = display.displayID + if let existing = recordsByDisplayID[displayID] { + if existing.state != .draining { + return + } + await waitForDrainCompletion(for: displayID) + if let afterDrain = recordsByDisplayID[displayID], afterDrain.state != .draining { + return + } + } + + if let existingTask = sessionCreationTasks[displayID] { + let record = try await existingTask.value + storeInitializedSessionIfAbsent(record, for: displayID) + return + } + + let task = Task { [captureSessionFactory] in + await Task.yield() + let initialProfile = await initialProfileProvider(displayID) + let session = try await captureSessionFactory(display, initialProfile, performanceMode) + return Record( + session: session, + resolutionText: "\(display.width) × \(display.height)", + state: .active + ) + } + initializingDisplayIDs.insert(displayID) + sessionCreationTasks[displayID] = task + defer { sessionCreationTasks[displayID] = nil } + + do { + let record = try await task.value + storeInitializedSessionIfAbsent(record, for: displayID) + } catch { + initializingDisplayIDs.remove(displayID) + throw error + } + } + + nonisolated func beginDraining( + displayID: CGDirectDisplayID, + onStopCompleted: @escaping @Sendable (CGDirectDisplayID) async -> Void + ) { + guard var record = recordsByDisplayID[displayID] else { return } + record.state = .draining + recordsByDisplayID[displayID] = record + + let session = record.session + sessionDrainTasksByDisplayID[displayID]?.cancel() + sessionDrainTasksByDisplayID[displayID] = Task { [displayID] in + await session.stop() + await onStopCompleted(displayID) + } + } + + nonisolated func finishDraining(displayID: CGDirectDisplayID, hasActiveTokens: Bool) { + sessionDrainTasksByDisplayID[displayID] = nil + guard let record = recordsByDisplayID[displayID] else { return } + guard record.state == .draining else { return } + + if hasActiveTokens { + var resumedRecord = record + resumedRecord.state = .active + recordsByDisplayID[displayID] = resumedRecord + return + } + + recordsByDisplayID.removeValue(forKey: displayID) + } + + private nonisolated func waitForDrainCompletion(for displayID: CGDirectDisplayID) async { + guard let drainTask = sessionDrainTasksByDisplayID[displayID] else { return } + await drainTask.value + } + + private nonisolated func storeInitializedSessionIfAbsent( + _ record: Record, + for displayID: CGDirectDisplayID + ) { + initializingDisplayIDs.remove(displayID) + guard recordsByDisplayID[displayID] == nil else { return } + recordsByDisplayID[displayID] = record + } +} + +nonisolated final class DisplayCaptureLeaseBook: @unchecked Sendable { + nonisolated enum TokenKind: Sendable { + case preview + case share + } + + nonisolated struct PreviewLeaseState: Sendable, Equatable { + var attachedSinkCount = 0 + var showsCursor = false + } + + nonisolated struct ReleaseResult: Sendable, Equatable { + let displayID: CGDirectDisplayID + let shouldStopSharing: Bool + let shouldApplyDemand: Bool + let shouldDrainSession: Bool + } + + nonisolated struct PreviewCursorMutation: Sendable, Equatable { + let displayID: CGDirectDisplayID + let previousValue: Bool + } + + private nonisolated struct TokenRecord: Sendable { + let kind: TokenKind + let displayID: CGDirectDisplayID + } + + private nonisolated struct PendingCreationDemand: Sendable { + var previewCount = 0 + var shareCount = 0 + + mutating func record(_ kind: TokenKind, delta: Int) { + switch kind { + case .preview: + previewCount = max(0, previewCount + delta) + case .share: + shareCount = max(0, shareCount + delta) + } + } + + var initialProfile: DisplayCaptureProfile? { + DisplayCaptureDemandSnapshot( + attachedPreviewSinkCount: previewCount, + shareTokenCount: shareCount, + performanceMode: .automatic + ).desiredProfile + } + + var isEmpty: Bool { + previewCount == 0 && shareCount == 0 + } + } + + private nonisolated struct DisplayState { + var previewTokens: [UUID: PreviewLeaseState] = [:] + var shareTokens: Set = [] + var shareCursorOverrideTokens: Set = [] + + var hasActiveTokens: Bool { + previewTokens.isEmpty == false || shareTokens.isEmpty == false + } + } + + private var statesByDisplayID: [CGDirectDisplayID: DisplayState] = [:] + private var tokenOwnership: [UUID: TokenRecord] = [:] + private var pendingCreationDemandByDisplayID: [CGDirectDisplayID: PendingCreationDemand] = [:] + + nonisolated func recordPendingCreationDemand( + for displayID: CGDirectDisplayID, + kind: TokenKind, + delta: Int + ) { + var demand = pendingCreationDemandByDisplayID[displayID] ?? PendingCreationDemand() + demand.record(kind, delta: delta) + if demand.isEmpty { + pendingCreationDemandByDisplayID.removeValue(forKey: displayID) + } else { + pendingCreationDemandByDisplayID[displayID] = demand + } + } + + nonisolated func initialProfile( + for displayID: CGDirectDisplayID, + fallbackKind: TokenKind + ) -> DisplayCaptureProfile { + if let profile = pendingCreationDemandByDisplayID[displayID]?.initialProfile { + return profile + } + + switch fallbackKind { + case .preview: + return .previewOnly + case .share: + return .shareOnly + } + } + + nonisolated func registerToken(displayID: CGDirectDisplayID, kind: TokenKind) -> UUID { + let tokenID = UUID() + var state = statesByDisplayID[displayID] ?? DisplayState() + switch kind { + case .preview: + state.previewTokens[tokenID] = PreviewLeaseState() + case .share: + state.shareTokens.insert(tokenID) + } + statesByDisplayID[displayID] = state + tokenOwnership[tokenID] = TokenRecord(kind: kind, displayID: displayID) + return tokenID + } + + nonisolated func releaseToken(_ tokenID: UUID, expectedKind: TokenKind) -> ReleaseResult? { + guard let ownership = tokenOwnership.removeValue(forKey: tokenID), + ownership.kind == expectedKind else { + return nil + } + + var state = statesByDisplayID[ownership.displayID] ?? DisplayState() + switch ownership.kind { + case .preview: + state.previewTokens.removeValue(forKey: tokenID) + case .share: + state.shareTokens.remove(tokenID) + } + state.shareCursorOverrideTokens.remove(tokenID) + + let shouldDrainSession = state.hasActiveTokens == false + let shouldStopSharing = ownership.kind == .share && state.shareTokens.isEmpty + let shouldApplyDemand = shouldDrainSession == false + + if state.previewTokens.isEmpty && state.shareTokens.isEmpty && state.shareCursorOverrideTokens.isEmpty { + statesByDisplayID.removeValue(forKey: ownership.displayID) + } else { + statesByDisplayID[ownership.displayID] = state + } + + return ReleaseResult( + displayID: ownership.displayID, + shouldStopSharing: shouldStopSharing, + shouldApplyDemand: shouldApplyDemand, + shouldDrainSession: shouldDrainSession + ) + } + + nonisolated func recordAttachedPreviewSinkDelta(_ delta: Int, for tokenID: UUID) -> CGDirectDisplayID? { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .preview, + var state = statesByDisplayID[ownership.displayID], + var lease = state.previewTokens[tokenID] else { + return nil + } + lease.attachedSinkCount = max(0, lease.attachedSinkCount + delta) + state.previewTokens[tokenID] = lease + statesByDisplayID[ownership.displayID] = state + return ownership.displayID + } + + nonisolated func setPreviewShowsCursor( + _ showsCursor: Bool, + for tokenID: UUID + ) -> PreviewCursorMutation? { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .preview, + var state = statesByDisplayID[ownership.displayID], + var lease = state.previewTokens[tokenID] else { + return nil + } + let previousValue = lease.showsCursor + guard previousValue != showsCursor else { return nil } + + lease.showsCursor = showsCursor + state.previewTokens[tokenID] = lease + statesByDisplayID[ownership.displayID] = state + return PreviewCursorMutation(displayID: ownership.displayID, previousValue: previousValue) + } + + nonisolated func revertPreviewShowsCursor(for tokenID: UUID, previousValue: Bool) { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .preview, + var state = statesByDisplayID[ownership.displayID], + var lease = state.previewTokens[tokenID] else { + return + } + lease.showsCursor = previousValue + state.previewTokens[tokenID] = lease + statesByDisplayID[ownership.displayID] = state + } + + nonisolated func prepareShareForSharing(_ tokenID: UUID) -> CGDirectDisplayID? { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .share, + var state = statesByDisplayID[ownership.displayID] else { + return nil + } + guard state.shareCursorOverrideTokens.contains(tokenID) == false else { return nil } + + state.shareCursorOverrideTokens.insert(tokenID) + statesByDisplayID[ownership.displayID] = state + return ownership.displayID + } + + nonisolated func revertPreparedShare(_ tokenID: UUID) { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .share, + var state = statesByDisplayID[ownership.displayID] else { + return + } + guard state.shareCursorOverrideTokens.remove(tokenID) != nil else { return } + statesByDisplayID[ownership.displayID] = state + } + + nonisolated func releasePreparedShare(_ tokenID: UUID) -> CGDirectDisplayID? { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .share, + var state = statesByDisplayID[ownership.displayID] else { + return nil + } + guard state.shareCursorOverrideTokens.remove(tokenID) != nil else { return nil } + statesByDisplayID[ownership.displayID] = state + return ownership.displayID + } + + nonisolated func demandSnapshot( + for displayID: CGDirectDisplayID, + performanceMode: CapturePerformanceMode + ) -> DisplayCaptureDemandSnapshot { + let state = statesByDisplayID[displayID] ?? DisplayState() + return DisplayCaptureDemandSnapshot( + attachedPreviewSinkCount: state.previewTokens.values.reduce(0) { partialResult, lease in + partialResult + lease.attachedSinkCount + }, + shareTokenCount: state.shareTokens.count, + previewShowsCursor: state.previewTokens.values.contains { $0.showsCursor }, + shareCursorOverrideCount: state.shareCursorOverrideTokens.count, + performanceMode: performanceMode + ) + } + + nonisolated func hasActiveTokens(for displayID: CGDirectDisplayID) -> Bool { + statesByDisplayID[displayID]?.hasActiveTokens == true + } +} + +actor DisplayCaptureRegistry { + enum SessionResourceState: Equatable { + case initializing + case active + case draining + case stopped + } + + struct PreviewToken: Hashable, Sendable { + fileprivate let rawValue: UUID + let displayID: CGDirectDisplayID + } + + struct ShareToken: Hashable, Sendable { + fileprivate let rawValue: UUID + let displayID: CGDirectDisplayID + } + + private enum RegistryError: Error { + case sessionUnavailable + } + + typealias CaptureSessionFactory = @Sendable ( + SendableDisplay, + DisplayCaptureProfile, + CapturePerformanceMode + ) async throws -> any DisplayCaptureSessioning + + static let shared = DisplayCaptureRegistry() + + private let captureSessionFactory: CaptureSessionFactory + private var performanceMode: CapturePerformanceMode + private let sessionStore = DisplayCaptureSessionStore() + private let leaseBook = DisplayCaptureLeaseBook() + + init( + performanceMode: CapturePerformanceMode = .automatic, + captureSessionFactory: @escaping CaptureSessionFactory = { display, initialProfile, initialPerformanceMode in + try await DisplayCaptureSession( + display: display.value, + initialProfile: initialProfile, + initialPerformanceMode: initialPerformanceMode + ) + } + ) { + self.performanceMode = performanceMode + self.captureSessionFactory = captureSessionFactory + } + + func updatePerformanceMode(_ mode: CapturePerformanceMode) async { + performanceMode = mode + let displayIDs = sessionStore.activeDisplayIDs + for displayID in displayIDs { + try? await applyDemand(for: displayID) + } + } + + func acquirePreview(display: SendableDisplay) async throws -> DisplayPreviewSubscription { + let token = try await acquirePreviewToken(display: display) + guard let record = sessionStore.record(for: token.displayID) else { + throw RegistryError.sessionUnavailable + } + return DisplayPreviewSubscription( + displayID: token.displayID, + resolutionText: record.resolutionText, + session: record.session, + cancelClosure: { Task { await self.release(token) } }, + onAttachedPreviewSinkCountChanged: { [self] delta in + Task { await self.recordAttachedPreviewSinkDelta(delta, for: token.rawValue) } + }, + setShowsCursorClosure: { [self] showsCursor in + try await self.setPreviewShowsCursor(showsCursor, for: token.rawValue) + } + ) + } + + func acquirePreview( + display: SendableDisplay, + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + try await invalidationContext.race { + try await self.acquirePreview(display: display) + } + } + + func acquireShare(display: SendableDisplay) async throws -> DisplayShareSubscription { + let token = try await acquireShareToken(display: display) + guard let record = sessionStore.record(for: token.displayID) else { + throw RegistryError.sessionUnavailable + } + return DisplayShareSubscription( + displayID: token.displayID, + sessionHub: record.session.sessionHub, + cancelClosure: { Task { await self.release(token) } }, + prepareForSharingClosure: { [self] in + try await self.prepareShareForSharing(token.rawValue) + }, + releasePreparedShareClosure: { [self] in + await self.releasePreparedShare(token.rawValue) + } + ) + } + + func acquireShare( + display: SendableDisplay, + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + try await invalidationContext.race { + try await self.acquireShare(display: display) + } + } + + func acquirePreviewToken(display: SendableDisplay) async throws -> PreviewToken { + let tokenID = try await acquireToken(display: display, kind: .preview) + return PreviewToken(rawValue: tokenID, displayID: display.displayID) + } + + func acquireShareToken(display: SendableDisplay) async throws -> ShareToken { + let tokenID = try await acquireToken(display: display, kind: .share) + return ShareToken(rawValue: tokenID, displayID: display.displayID) + } + + func release(_ token: PreviewToken) async { + await releaseToken(token.rawValue, expectedKind: .preview) + } + + func release(_ token: ShareToken) async { + await releaseToken(token.rawValue, expectedKind: .share) + } + + func sessionState(for displayID: CGDirectDisplayID) -> SessionResourceState { + sessionStore.sessionState(for: displayID) + } + + private func acquireToken( + display: SendableDisplay, + kind: DisplayCaptureLeaseBook.TokenKind + ) async throws -> UUID { + leaseBook.recordPendingCreationDemand(for: display.displayID, kind: kind, delta: 1) + do { + try await ensureSessionExists(for: display, fallbackKind: kind) + leaseBook.recordPendingCreationDemand(for: display.displayID, kind: kind, delta: -1) + return try await registerToken(displayID: display.displayID, kind: kind) + } catch { + leaseBook.recordPendingCreationDemand(for: display.displayID, kind: kind, delta: -1) + throw error + } + } + +#if DEBUG + func installSessionForTesting( + displayID: CGDirectDisplayID, + resolutionText: String, + session: any DisplayCaptureSessioning + ) { + sessionStore.installSessionForTesting( + displayID: displayID, + resolutionText: resolutionText, + session: session + ) + } + + func acquirePreviewTokenForTesting(displayID: CGDirectDisplayID) throws -> PreviewToken { + let tokenID = try registerTokenForTesting(displayID: displayID, kind: .preview) + return PreviewToken(rawValue: tokenID, displayID: displayID) + } + + func acquireShareTokenForTesting(displayID: CGDirectDisplayID) throws -> ShareToken { + let tokenID = try registerTokenForTesting(displayID: displayID, kind: .share) + return ShareToken(rawValue: tokenID, displayID: displayID) + } +#endif + + private func registerToken( + displayID: CGDirectDisplayID, + kind: DisplayCaptureLeaseBook.TokenKind + ) async throws -> UUID { + guard let record = sessionStore.record(for: displayID) else { + throw RegistryError.sessionUnavailable + } + guard record.state != .draining else { + throw RegistryError.sessionUnavailable + } + sessionStore.markActive(displayID: displayID) + let tokenID = leaseBook.registerToken(displayID: displayID, kind: kind) + try? await applyDemand(for: displayID) + return tokenID + } + + private func registerTokenForTesting( + displayID: CGDirectDisplayID, + kind: DisplayCaptureLeaseBook.TokenKind + ) throws -> UUID { + guard let record = sessionStore.record(for: displayID) else { + throw RegistryError.sessionUnavailable + } + guard record.state != .draining else { + throw RegistryError.sessionUnavailable + } + sessionStore.markActive(displayID: displayID) + return leaseBook.registerToken(displayID: displayID, kind: kind) + } + + private func ensureSessionExists( + for display: SendableDisplay, + fallbackKind: DisplayCaptureLeaseBook.TokenKind + ) async throws { + try await sessionStore.ensureSessionExists( + for: display, + initialProfileProvider: { [weak self] displayID in + guard let self else { return fallbackKind == .preview ? .previewOnly : .shareOnly } + return await self.initialProfile(for: displayID, fallbackKind: fallbackKind) + }, + performanceMode: performanceMode, + captureSessionFactory: captureSessionFactory + ) + } + + private func releaseToken( + _ tokenID: UUID, + expectedKind: DisplayCaptureLeaseBook.TokenKind + ) async { + guard let result = leaseBook.releaseToken(tokenID, expectedKind: expectedKind) else { + return + } + + guard let record = sessionStore.record(for: result.displayID) else { return } + + if result.shouldStopSharing { + record.session.stopSharing() + } + if result.shouldDrainSession { + sessionStore.beginDraining(displayID: result.displayID) { [weak self] displayID in + await self?.finishDrainingSession(displayID: displayID) + } + } + if result.shouldApplyDemand { + try? await applyDemand(for: result.displayID) + } + } + + private func recordAttachedPreviewSinkDelta(_ delta: Int, for tokenID: UUID) async { + guard let displayID = leaseBook.recordAttachedPreviewSinkDelta(delta, for: tokenID) else { + return + } + try? await applyDemand(for: displayID) + } + + private func setPreviewShowsCursor(_ showsCursor: Bool, for tokenID: UUID) async throws { + guard let mutation = leaseBook.setPreviewShowsCursor(showsCursor, for: tokenID) else { + return + } + + do { + try await applyDemand(for: mutation.displayID) + } catch { + leaseBook.revertPreviewShowsCursor(for: tokenID, previousValue: mutation.previousValue) + try? await applyDemand(for: mutation.displayID) + throw error + } + } + + private func prepareShareForSharing(_ tokenID: UUID) async throws { + guard let displayID = leaseBook.prepareShareForSharing(tokenID) else { return } + + do { + try await applyDemand(for: displayID) + } catch { + leaseBook.revertPreparedShare(tokenID) + try? await applyDemand(for: displayID) + throw error + } + } + + private func releasePreparedShare(_ tokenID: UUID) async { + guard let displayID = leaseBook.releasePreparedShare(tokenID) else { return } + try? await applyDemand(for: displayID) + } + + private func applyDemand(for displayID: CGDirectDisplayID) async throws { + guard let record = sessionStore.record(for: displayID), record.state != .draining else { + return + } + try await record.session.setDemand( + leaseBook.demandSnapshot(for: displayID, performanceMode: performanceMode) + ) + } + + private func finishDrainingSession(displayID: CGDirectDisplayID) { + sessionStore.finishDraining( + displayID: displayID, + hasActiveTokens: leaseBook.hasActiveTokens(for: displayID) + ) + } + + private func initialProfile( + for displayID: CGDirectDisplayID, + fallbackKind: DisplayCaptureLeaseBook.TokenKind + ) async -> DisplayCaptureProfile { + leaseBook.initialProfile(for: displayID, fallbackKind: fallbackKind) + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift new file mode 100644 index 0000000..bb05dc5 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift @@ -0,0 +1,532 @@ +import AppKit +import CoreGraphics +import CoreMedia +import Foundation +import OSLog +import ScreenCaptureKit +import Synchronization + +private final class DisplayStreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { + nonisolated(unsafe) weak var session: DisplayCaptureSession? + + nonisolated override init() { + super.init() + } + + nonisolated func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { + session?.handle(sampleBuffer: sampleBuffer, type: type) + } + + nonisolated func stream(_ stream: SCStream, didStopWithError error: Error) { + Task { @MainActor in + AppErrorMapper.logFailure("Screen capture stream stopped", error: error, logger: AppLog.capture) + } + } +} + +private struct DisplayCaptureMetrics: Sendable { + var currentProfile: DisplayCaptureProfile? + var currentFrameRateTier: DisplayCaptureFrameRateTier? + var receivedFrameCount: UInt64 = 0 + var profileReconfigurationCount: UInt64 = 0 + var cursorOverrideReconfigurationCount: UInt64 = 0 + + nonisolated func snapshot() -> DisplayCaptureMetricsSnapshot { + .init( + currentProfile: currentProfile, + currentFrameRateTier: currentFrameRateTier, + receivedFrameCount: receivedFrameCount, + profileReconfigurationCount: profileReconfigurationCount, + cursorOverrideReconfigurationCount: cursorOverrideReconfigurationCount + ) + } +} + +private final class DisplayCaptureMetricsStore: Sendable { + let value = Mutex(DisplayCaptureMetrics()) +} + +nonisolated struct DisplayCaptureStreamConfigurationState: Sendable, Equatable { + let width: Int + let height: Int + let maximumPreviewFramesPerSecond: Int + let queueDepth: Int + let capturesAudio: Bool + let pixelFormat: OSType + var profile: DisplayCaptureProfile + var frameRateTier: DisplayCaptureFrameRateTier + var previewShowsCursor: Bool + var shareCursorOverrideCount: Int + + nonisolated var minimumFrameInterval: CMTime { + let framesPerSecond = DisplayCaptureSession.captureFramesPerSecond( + for: profile, + frameRateTier: frameRateTier, + maximumPreviewFramesPerSecond: maximumPreviewFramesPerSecond + ) + return CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(framesPerSecond)))) + } +} + +private nonisolated func makeDisplayCaptureStreamConfiguration( + from state: DisplayCaptureStreamConfigurationState +) -> 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 +} + +actor DisplayCaptureStreamConfigurationCoordinator { + typealias TestApplier = @Sendable (DisplayCaptureStreamConfigurationState) async throws -> Void + + private struct Waiter { + let revision: UInt64 + let continuation: CheckedContinuation + } + + private let stream: SCStream? + private let testApplier: TestApplier? + private var committedState: DisplayCaptureStreamConfigurationState + private var desiredState: DisplayCaptureStreamConfigurationState + private var committedRevision: UInt64 = 0 + private var nextRevision: UInt64 = 0 + private var pendingRevision: UInt64? + private var failedThroughRevision: UInt64? + private var lastFailure: (any Error)? + private var flushTask: Task? + private var waiters: [UUID: Waiter] = [:] + + init( + stream: SCStream, + initialState: DisplayCaptureStreamConfigurationState + ) { + self.stream = stream + self.testApplier = nil + self.committedState = initialState + self.desiredState = initialState + } + + init( + initialState: DisplayCaptureStreamConfigurationState, + applier: @escaping TestApplier + ) { + self.stream = nil + self.testApplier = applier + self.committedState = initialState + self.desiredState = initialState + } + + func applyImmediateDemand(_ demand: DisplayCaptureDemandSnapshot) async throws -> Bool { + try await applyMutation { state in + state.previewShowsCursor = demand.previewShowsCursor + state.shareCursorOverrideCount = demand.shareCursorOverrideCount + } + } + + func applyDemandDrivenConfiguration(_ configuration: DisplayCaptureConfiguration) async throws -> Bool { + try await applyMutation { state in + state.profile = configuration.profile + state.frameRateTier = configuration.frameRateTier + } + } + + func committedStateSnapshot() -> DisplayCaptureStreamConfigurationState { + committedState + } + + func cancelPending(error: any Error = CancellationError()) { + flushTask?.cancel() + flushTask = nil + + guard let pendingRevision else { return } + desiredState = committedState + self.pendingRevision = nil + failedThroughRevision = pendingRevision + lastFailure = error + failWaiters( + upTo: pendingRevision, + error: error + ) + } + + private func applyMutation( + _ mutation: (inout DisplayCaptureStreamConfigurationState) -> Void + ) async throws -> Bool { + var nextState = desiredState + mutation(&nextState) + + guard nextState != desiredState else { + if let pendingRevision { + try await waitForResolution(of: pendingRevision) + } + return false + } + + desiredState = nextState + nextRevision &+= 1 + let targetRevision = nextRevision + pendingRevision = targetRevision + if flushTask == nil { + flushTask = Task { + await self.flushLoop() + } + } + try await waitForResolution(of: targetRevision) + return true + } + + private func waitForResolution(of revision: UInt64) async throws { + if committedRevision >= revision { + return + } + if let failedThroughRevision, + failedThroughRevision >= revision, + let lastFailure { + throw lastFailure + } + + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if committedRevision >= revision { + continuation.resume(returning: ()) + return + } + if let failedThroughRevision, + failedThroughRevision >= revision, + let lastFailure { + continuation.resume(throwing: lastFailure) + return + } + waiters[waiterID] = Waiter( + revision: revision, + continuation: continuation + ) + } + } onCancel: { + Task { + await self.cancelWaiter(id: waiterID) + } + } + } + + private func flushLoop() async { + while let pendingRevision { + let stateToApply = desiredState + let revisionToApply = pendingRevision + + do { + try Task.checkCancellation() + try await applyState(stateToApply) + } catch { + let failedThrough = self.pendingRevision ?? revisionToApply + desiredState = committedState + self.pendingRevision = nil + failedThroughRevision = failedThrough + lastFailure = error + flushTask = nil + failWaiters( + upTo: failedThrough, + error: error + ) + return + } + + committedState = stateToApply + committedRevision = revisionToApply + resumeWaiters(upTo: revisionToApply) + + if self.pendingRevision == revisionToApply { + desiredState = committedState + self.pendingRevision = nil + } + } + + flushTask = nil + } + + private func applyState(_ state: DisplayCaptureStreamConfigurationState) async throws { + if let stream { + try await stream.updateConfiguration(makeDisplayCaptureStreamConfiguration(from: state)) + return + } + if let testApplier { + try await testApplier(state) + return + } + preconditionFailure("DisplayCaptureStreamConfigurationCoordinator requires an applier") + } + + private func cancelWaiter(id: UUID) { + guard let waiter = waiters.removeValue(forKey: id) else { return } + waiter.continuation.resume(throwing: CancellationError()) + } + + private func resumeWaiters(upTo revision: UInt64) { + let matchingIDs = waiters.compactMap { id, waiter in + waiter.revision <= revision ? id : nil + } + for id in matchingIDs { + guard let waiter = waiters.removeValue(forKey: id) else { continue } + waiter.continuation.resume(returning: ()) + } + } + + private func failWaiters( + upTo revision: UInt64, + error: any Error + ) { + let matchingIDs = waiters.compactMap { id, waiter in + waiter.revision <= revision ? id : nil + } + for id in matchingIDs { + guard let waiter = waiters.removeValue(forKey: id) else { continue } + waiter.continuation.resume(throwing: error) + } + } +} + +final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning { + nonisolated private static let minimumConfigurationDwellNanoseconds: UInt64 = 5_000_000_000 + + nonisolated let displayID: CGDirectDisplayID + nonisolated let sessionHub: WebRTCSessionHub + + nonisolated(unsafe) private let stream: SCStream + private let output = DisplayStreamOutput() + nonisolated private let captureQueue: DispatchQueue + nonisolated private let fanout = DisplaySampleFanout() + nonisolated private let metrics: DisplayCaptureMetricsStore + nonisolated private let streamConfigurationCoordinator: DisplayCaptureStreamConfigurationCoordinator + nonisolated private let demandDriver: DisplayCaptureDemandDriver + + nonisolated init( + display: SCDisplay, + initialProfile: DisplayCaptureProfile = .previewOnly, + initialPerformanceMode: CapturePerformanceMode = .automatic + ) async throws { + self.displayID = display.displayID + self.captureQueue = DispatchQueue( + label: "com.developerchen.voiddisplay.capture.\(display.displayID)", + qos: .userInitiated + ) + + let state = try await Self.makeStreamConfigurationState( + display: display, + showsCursor: false, + initialProfile: initialProfile, + initialPerformanceMode: initialPerformanceMode + ) + let config = makeDisplayCaptureStreamConfiguration(from: state) + let filter = try await Self.makeContentFilter(display: display) + self.stream = SCStream(filter: filter, configuration: config, delegate: output) + self.sessionHub = WebRTCSessionHub() + let metrics = DisplayCaptureMetricsStore() + self.metrics = metrics + let streamConfigurationCoordinator = DisplayCaptureStreamConfigurationCoordinator( + stream: self.stream, + initialState: state + ) + self.streamConfigurationCoordinator = streamConfigurationCoordinator + self.demandDriver = DisplayCaptureDemandDriver( + initialConfiguration: .init( + profile: state.profile, + frameRateTier: state.frameRateTier + ), + initialDemand: DisplayCaptureDemandSnapshot( + performanceMode: initialPerformanceMode + ), + minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds, + applyImmediateDemand: { demand in + let changed = try await streamConfigurationCoordinator.applyImmediateDemand(demand) + guard changed else { return false } + metrics.value.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } + return true + }, + applyConfiguration: { configuration in + try await streamConfigurationCoordinator.applyDemandDrivenConfiguration(configuration) + }, + onConfigurationApplied: { configuration in + metrics.value.withLock { metrics in + metrics.currentProfile = configuration.profile + metrics.currentFrameRateTier = configuration.frameRateTier + metrics.profileReconfigurationCount &+= 1 + } + }, + onConfigurationFailure: { error in + AppErrorMapper.logFailure( + "Update capture configuration", + error: error, + logger: AppLog.capture + ) + } + ) + metrics.value.withLock { + $0.currentProfile = state.profile + $0.currentFrameRateTier = state.frameRateTier + } + output.session = self + + try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: captureQueue) + try await stream.startCapture() + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + fanout.attachPreviewSink(sink) + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + fanout.detachPreviewSink(sink) + } + + nonisolated func stopSharing() { + sessionHub.stopSharing() + } + + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + try await demandDriver.setDemand(demand) + } + + nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + demandDriver.recordPreviewPerformanceSample(sample) + } + + nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot { + metrics.value.withLock { $0.snapshot() } + } + + nonisolated func stop() async { + demandDriver.cancelAll() + await streamConfigurationCoordinator.cancelPending() + stopSharing() + try? await stream.stopCapture() + } + + nonisolated func handle(sampleBuffer: CMSampleBuffer, type: SCStreamOutputType) { + guard type == .screen, let pixelBuffer = sampleBuffer.imageBuffer else { return } + metrics.value.withLock { $0.receivedFrameCount &+= 1 } + + fanout.publishPreviewFrame(sampleBuffer) + + guard sessionHub.hasDemand else { return } + let ptsUs = Self.microseconds(from: CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) + sessionHub.submitFrame(pixelBuffer: pixelBuffer, ptsUs: ptsUs) + } +} + +extension DisplayCaptureSession { + nonisolated static func captureFramesPerSecond( + for profile: DisplayCaptureProfile, + frameRateTier: DisplayCaptureFrameRateTier, + maximumPreviewFramesPerSecond: Int + ) -> Int { + switch profile { + case .previewOnly: + return min(maximumPreviewFramesPerSecond, frameRateTier.framesPerSecond) + case .shareOnly: + return frameRateTier.framesPerSecond + case .mixed: + return frameRateTier.framesPerSecond + } + } + + nonisolated static func microseconds(from time: CMTime) -> UInt64 { + guard time.isValid, !time.isIndefinite, time.seconds.isFinite else { return 0 } + let scaled = CMTimeConvertScale(time, timescale: 1_000_000, method: .default) + return scaled.value > 0 ? UInt64(scaled.value) : 0 + } + + nonisolated static func clampedPreviewFramesPerSecond(for refreshRate: Double) -> Int { + let normalizedRefreshRate = refreshRate > 0 ? refreshRate : 60.0 + return max(1, Int(min(normalizedRefreshRate, 60.0).rounded())) + } + + nonisolated private static func makeStreamConfigurationState( + display: SCDisplay, + showsCursor: Bool, + initialProfile: DisplayCaptureProfile, + initialPerformanceMode: CapturePerformanceMode + ) async throws -> DisplayCaptureStreamConfigurationState { + let displayMode = CGDisplayCopyDisplayMode(display.displayID) + + let captureSize = preferredCaptureSize(display: display, displayMode: displayMode) + let previewFramesPerSecond = clampedPreviewFramesPerSecond(for: displayMode?.refreshRate ?? 60.0) + let initialFrameRateTier = DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: initialProfile, + performanceMode: initialPerformanceMode + ) + + let state = DisplayCaptureStreamConfigurationState( + width: captureSize.width, + height: captureSize.height, + maximumPreviewFramesPerSecond: previewFramesPerSecond, + queueDepth: 2, + capturesAudio: false, + pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + profile: initialProfile, + frameRateTier: initialFrameRateTier, + 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 preferredCaptureSize( + display: SCDisplay, + displayMode: CGDisplayMode? + ) -> (width: Int, height: Int) { + let modePixelWidth = displayMode.map { Int($0.pixelWidth) } ?? display.width + let modePixelHeight = displayMode.map { Int($0.pixelHeight) } ?? display.height + let modeLogicalWidth = displayMode.map { $0.width } ?? modePixelWidth + let modeLogicalHeight = displayMode.map { $0.height } ?? modePixelHeight + let backingScale = screenBackingScaleFactor(for: display.displayID) + + let scaledLogicalWidth = max(1, Int((CGFloat(modeLogicalWidth) * backingScale).rounded())) + let scaledLogicalHeight = max(1, Int((CGFloat(modeLogicalHeight) * backingScale).rounded())) + + return ( + width: max(modePixelWidth, scaledLogicalWidth), + height: max(modePixelHeight, scaledLogicalHeight) + ) + } + + nonisolated private static func screenBackingScaleFactor(for displayID: CGDirectDisplayID) -> CGFloat { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let screen = NSScreen.screens.first(where: { + guard let number = $0.deviceDescription[key] as? NSNumber else { return false } + return number.uint32Value == displayID + }) else { + return 1.0 + } + return max(1.0, screen.backingScaleFactor) + } + + nonisolated private static func makeContentFilter( + display: SCDisplay + ) async throws -> SCContentFilter { + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: false + ) + let excludedApps = content.applications.filter { app in + Bundle.main.bundleIdentifier == app.bundleIdentifier + } + return SCContentFilter( + display: display, + excludingApplications: excludedApps, + exceptingWindows: [] + ) + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift new file mode 100644 index 0000000..37e3141 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift @@ -0,0 +1,564 @@ +import CoreGraphics +import CoreMedia +import Foundation +import ScreenCaptureKit + +enum DisplayStartOutcome: Sendable { + case started(Value) + case invalidated +} + +enum DisplayStartKind: Hashable, Sendable { + case monitoring + case sharing +} + +nonisolated enum DisplayCaptureFrameRateTier: Int, CaseIterable, Sendable, Equatable { + case fps30 = 30 + case fps45 = 45 + case fps60 = 60 + + var framesPerSecond: Int { + rawValue + } + + var nextLowerTier: DisplayCaptureFrameRateTier? { + switch self { + case .fps60: + .fps45 + case .fps45: + .fps30 + case .fps30: + nil + } + } + + var nextHigherTier: DisplayCaptureFrameRateTier? { + switch self { + case .fps30: + .fps45 + case .fps45: + .fps60 + case .fps60: + nil + } + } +} + +nonisolated enum DisplayCaptureProfile: String, Sendable, Equatable { + case previewOnly + case shareOnly + case mixed +} + +nonisolated enum DisplayCaptureProfileDecision: Sendable, Equatable { + case noChange + case applyNow(DisplayCaptureProfile) + case applyAfter(DisplayCaptureProfile, delayNanoseconds: UInt64) +} + +nonisolated struct DisplayCaptureDemandSnapshot: Sendable, Equatable { + var attachedPreviewSinkCount: Int + var shareTokenCount: Int + var previewShowsCursor: Bool + var shareCursorOverrideCount: Int + var performanceMode: CapturePerformanceMode + + init( + attachedPreviewSinkCount: Int = 0, + shareTokenCount: Int = 0, + previewShowsCursor: Bool = false, + shareCursorOverrideCount: Int = 0, + performanceMode: CapturePerformanceMode + ) { + self.attachedPreviewSinkCount = max(0, attachedPreviewSinkCount) + self.shareTokenCount = max(0, shareTokenCount) + self.previewShowsCursor = previewShowsCursor + self.shareCursorOverrideCount = max(0, shareCursorOverrideCount) + self.performanceMode = performanceMode + } + + nonisolated var desiredProfile: DisplayCaptureProfile? { + DisplayCaptureProfileStateMachine.desiredProfile(for: self) + } + + nonisolated var showsCursor: Bool { + previewShowsCursor || shareCursorOverrideCount > 0 + } + + nonisolated var isEmpty: Bool { + attachedPreviewSinkCount == 0 && shareTokenCount == 0 && !showsCursor + } +} + +nonisolated enum DisplayCaptureProfileStateMachine { + nonisolated static func desiredProfile(for demand: DisplayCaptureDemandSnapshot) -> DisplayCaptureProfile? { + switch (demand.attachedPreviewSinkCount > 0, demand.shareTokenCount > 0) { + case (true, false): + .previewOnly + case (false, true): + .shareOnly + case (true, true): + .mixed + case (false, false): + nil + } + } + + nonisolated static func decideTransition( + demand: DisplayCaptureDemandSnapshot, + currentProfile: DisplayCaptureProfile, + lastProfileSwitchTimeNs: UInt64?, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + guard let desiredProfile = desiredProfile(for: demand) else { + return .noChange + } + guard desiredProfile != currentProfile else { + return .noChange + } + guard let lastProfileSwitchTimeNs else { + return .applyNow(desiredProfile) + } + + let elapsed = nowNs &- lastProfileSwitchTimeNs + if elapsed >= minimumDwellNanoseconds { + return .applyNow(desiredProfile) + } + return .applyAfter( + desiredProfile, + delayNanoseconds: minimumDwellNanoseconds - elapsed + ) + } +} + +nonisolated struct DisplayCaptureProfileCoordinatorState: Sendable { + var demand: DisplayCaptureDemandSnapshot + var committedProfile: DisplayCaptureProfile + var inFlightProfile: DisplayCaptureProfile? + var lastProfileSwitchTimeNs: UInt64? + + nonisolated init( + committedProfile: DisplayCaptureProfile, + demand: DisplayCaptureDemandSnapshot, + lastProfileSwitchTimeNs: UInt64? = nil + ) { + self.demand = demand + self.committedProfile = committedProfile + self.lastProfileSwitchTimeNs = lastProfileSwitchTimeNs + } + + nonisolated mutating func updateDemand( + _ demand: DisplayCaptureDemandSnapshot, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + self.demand = demand + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + nonisolated mutating func resumeScheduledTransition( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + nonisolated mutating func finishAppliedTransition( + at nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + guard let inFlightProfile else { return .noChange } + committedProfile = inFlightProfile + self.inFlightProfile = nil + lastProfileSwitchTimeNs = nowNs + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + nonisolated mutating func failAppliedTransition() { + inFlightProfile = nil + } + + nonisolated private mutating func evaluateTransition( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + guard inFlightProfile == nil else { + return .noChange + } + + let decision = DisplayCaptureProfileStateMachine.decideTransition( + demand: demand, + currentProfile: committedProfile, + lastProfileSwitchTimeNs: lastProfileSwitchTimeNs, + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + if case .applyNow(let profile) = decision { + inFlightProfile = profile + } + return decision + } +} + +nonisolated struct DisplayCaptureTaskLifetimeState: Sendable { + private(set) var currentGeneration: UInt64 = 0 + + nonisolated mutating func invalidateAllTasks() -> UInt64 { + currentGeneration &+= 1 + return currentGeneration + } + + nonisolated func allowsExecution(for generation: UInt64) -> Bool { + currentGeneration == generation + } +} + +nonisolated struct DisplayPreviewPerformanceSample: Sendable, Equatable { + let renderedFrameCount: UInt64 + let droppedFrameCount: UInt64 + let latestRenderLatencyMilliseconds: Double + let pendingSlotOccupied: Bool + let capturedAt: UInt64 + + nonisolated var totalFrameCount: UInt64 { + renderedFrameCount &+ droppedFrameCount + } + + nonisolated var droppedFrameRatio: Double { + let total = max(1, totalFrameCount) + return Double(droppedFrameCount) / Double(total) + } +} + +nonisolated struct DisplayCaptureConfiguration: Sendable, Equatable { + let profile: DisplayCaptureProfile + let frameRateTier: DisplayCaptureFrameRateTier +} + +nonisolated enum DisplayCaptureConfigurationDecision: Sendable, Equatable { + case noChange + case applyNow(DisplayCaptureConfiguration) + case applyAfter(DisplayCaptureConfiguration, delayNanoseconds: UInt64) +} + +nonisolated struct DisplayCaptureAdaptivePolicyState: Sendable, Equatable { + private(set) var currentAutomaticMixedTier: DisplayCaptureFrameRateTier = .fps45 + private(set) var stableWindowCount = 0 + private(set) var pressureWindowCount = 0 + + mutating func resetToDefaultAutomaticMixedTier() { + currentAutomaticMixedTier = .fps45 + stableWindowCount = 0 + pressureWindowCount = 0 + } + + mutating func rebase( + desiredProfile: DisplayCaptureProfile?, + performanceMode: CapturePerformanceMode + ) { + guard desiredProfile == .mixed, performanceMode == .automatic else { + resetToDefaultAutomaticMixedTier() + return + } + } + + mutating func recordAutomaticMixedSample( + _ sample: DisplayPreviewPerformanceSample + ) -> DisplayCaptureFrameRateTier? { + guard sample.totalFrameCount > 0 else { + stableWindowCount = 0 + pressureWindowCount = 0 + return nil + } + let isPressureWindow = sample.droppedFrameRatio >= 0.08 + || sample.latestRenderLatencyMilliseconds >= 35 + || sample.pendingSlotOccupied + let isStableWindow = sample.droppedFrameRatio < 0.02 + && sample.latestRenderLatencyMilliseconds < 20 + && !sample.pendingSlotOccupied + + if isPressureWindow { + pressureWindowCount += 1 + stableWindowCount = 0 + if pressureWindowCount >= 2, let nextLowerTier = currentAutomaticMixedTier.nextLowerTier { + currentAutomaticMixedTier = nextLowerTier + stableWindowCount = 0 + pressureWindowCount = 0 + return nextLowerTier + } + return nil + } + + if isStableWindow { + stableWindowCount += 1 + pressureWindowCount = 0 + if stableWindowCount >= 4, let nextHigherTier = currentAutomaticMixedTier.nextHigherTier { + currentAutomaticMixedTier = nextHigherTier + stableWindowCount = 0 + pressureWindowCount = 0 + return nextHigherTier + } + return nil + } + + stableWindowCount = 0 + pressureWindowCount = 0 + return nil + } +} + +nonisolated enum DisplayCaptureConfigurationStateMachine { + nonisolated static func defaultFrameRateTier( + for profile: DisplayCaptureProfile, + performanceMode: CapturePerformanceMode, + adaptivePolicy: DisplayCaptureAdaptivePolicyState = .init() + ) -> DisplayCaptureFrameRateTier { + switch performanceMode { + case .automatic: + switch profile { + case .previewOnly: + .fps60 + case .shareOnly: + .fps60 + case .mixed: + adaptivePolicy.currentAutomaticMixedTier + } + case .smooth: + .fps60 + case .powerEfficient: + switch profile { + case .previewOnly: + .fps45 + case .shareOnly, .mixed: + .fps30 + } + } + } + + nonisolated static func desiredConfiguration( + for demand: DisplayCaptureDemandSnapshot, + adaptivePolicy: DisplayCaptureAdaptivePolicyState + ) -> DisplayCaptureConfiguration? { + guard let profile = demand.desiredProfile else { + return nil + } + return DisplayCaptureConfiguration( + profile: profile, + frameRateTier: defaultFrameRateTier( + for: profile, + performanceMode: demand.performanceMode, + adaptivePolicy: adaptivePolicy + ) + ) + } + + nonisolated static func decideTransition( + desiredConfiguration: DisplayCaptureConfiguration?, + currentConfiguration: DisplayCaptureConfiguration, + lastConfigurationSwitchTimeNs: UInt64?, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + guard let desiredConfiguration else { return .noChange } + guard desiredConfiguration != currentConfiguration else { return .noChange } + guard let lastConfigurationSwitchTimeNs else { + return .applyNow(desiredConfiguration) + } + + let elapsed = nowNs &- lastConfigurationSwitchTimeNs + if elapsed >= minimumDwellNanoseconds { + return .applyNow(desiredConfiguration) + } + return .applyAfter( + desiredConfiguration, + delayNanoseconds: minimumDwellNanoseconds - elapsed + ) + } +} + +nonisolated struct DisplayCaptureConfigurationCoordinatorState: Sendable, Equatable { + var demand: DisplayCaptureDemandSnapshot + var adaptivePolicy = DisplayCaptureAdaptivePolicyState() + var committedConfiguration: DisplayCaptureConfiguration + var inFlightConfiguration: DisplayCaptureConfiguration? + var lastConfigurationSwitchTimeNs: UInt64? + + init( + committedConfiguration: DisplayCaptureConfiguration, + demand: DisplayCaptureDemandSnapshot, + lastConfigurationSwitchTimeNs: UInt64? = nil + ) { + self.demand = demand + self.committedConfiguration = committedConfiguration + self.lastConfigurationSwitchTimeNs = lastConfigurationSwitchTimeNs + adaptivePolicy.rebase( + desiredProfile: committedConfiguration.profile, + performanceMode: demand.performanceMode + ) + } + + mutating func updateDemand( + _ demand: DisplayCaptureDemandSnapshot, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + self.demand = demand + adaptivePolicy.rebase( + desiredProfile: currentDesiredProfile, + performanceMode: demand.performanceMode + ) + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func recordPreviewPerformanceSample( + _ sample: DisplayPreviewPerformanceSample, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + let desiredProfile = currentDesiredProfile + adaptivePolicy.rebase(desiredProfile: desiredProfile, performanceMode: demand.performanceMode) + guard desiredProfile == .mixed, demand.performanceMode == .automatic else { + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + _ = adaptivePolicy.recordAutomaticMixedSample(sample) + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func resumeScheduledTransition( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func finishAppliedTransition( + at nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + guard let inFlightConfiguration else { return .noChange } + committedConfiguration = inFlightConfiguration + self.inFlightConfiguration = nil + lastConfigurationSwitchTimeNs = nowNs + adaptivePolicy.rebase( + desiredProfile: committedConfiguration.profile, + performanceMode: demand.performanceMode + ) + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func failAppliedTransition() { + inFlightConfiguration = nil + } + + private var currentDesiredProfile: DisplayCaptureProfile? { + demand.desiredProfile + } + + private mutating func evaluateTransition( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + guard inFlightConfiguration == nil else { + return .noChange + } + let decision = DisplayCaptureConfigurationStateMachine.decideTransition( + desiredConfiguration: DisplayCaptureConfigurationStateMachine.desiredConfiguration( + for: demand, + adaptivePolicy: adaptivePolicy + ), + currentConfiguration: committedConfiguration, + lastConfigurationSwitchTimeNs: lastConfigurationSwitchTimeNs, + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + if case .applyNow(let configuration) = decision { + inFlightConfiguration = configuration + } + return decision + } +} + +protocol DisplayPreviewSink: AnyObject, Sendable { + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) +} + +nonisolated struct SendableDisplay: @unchecked Sendable { + nonisolated(unsafe) let value: SCDisplay + nonisolated let displayID: CGDirectDisplayID + nonisolated let width: Int + nonisolated let height: Int + + nonisolated init(_ value: SCDisplay) { + self.value = value + self.displayID = value.displayID + self.width = value.width + self.height = value.height + } +} + +struct DisplayCaptureMetricsSnapshot: Sendable { + var currentProfile: DisplayCaptureProfile? + var currentFrameRateTier: DisplayCaptureFrameRateTier? + var receivedFrameCount: UInt64 + var profileReconfigurationCount: UInt64 + var cursorOverrideReconfigurationCount: UInt64 +} + +protocol DisplayCaptureSessioning: AnyObject, Sendable { + nonisolated var sessionHub: WebRTCSessionHub { get } + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) + nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) + nonisolated func stopSharing() + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws + nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot + nonisolated func stop() async +} + +extension DisplayCaptureSessioning { + nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + _ = sample + } + + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand + } + + nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot { + .init( + currentProfile: nil, + currentFrameRateTier: nil, + receivedFrameCount: 0, + profileReconfigurationCount: 0, + cursorOverrideReconfigurationCount: 0 + ) + } +} + +struct StartCoordinatorTypeMismatchError: Error {} diff --git a/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift b/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift new file mode 100644 index 0000000..b143198 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift @@ -0,0 +1,107 @@ +import CoreMedia +import Foundation +import Synchronization + +private struct SendableSampleBuffer: @unchecked Sendable { + nonisolated(unsafe) let value: CMSampleBuffer +} + +private final class PreviewSinkMailbox: @unchecked Sendable { + private struct State { + var latestFrame: SendableSampleBuffer? + var isDraining = false + var isActive = true + } + + private let sink: any DisplayPreviewSink + private let state = Mutex(State()) +#if DEBUG + nonisolated(unsafe) var willStartDrainForTesting: (@Sendable () -> Void)? +#endif + + nonisolated init(sink: any DisplayPreviewSink) { + self.sink = sink + } + + nonisolated func submit(_ sampleBuffer: CMSampleBuffer) { + let sample = SendableSampleBuffer(value: sampleBuffer) + let shouldStartDraining = state.withLock { state -> Bool in + guard state.isActive else { return false } + state.latestFrame = sample + guard !state.isDraining else { return false } + state.isDraining = true + return true + } + guard shouldStartDraining else { return } + +#if DEBUG + willStartDrainForTesting?() +#endif + Task.detached { [weak self] in + self?.drain() + } + } + + nonisolated func deactivate() { + state.withLock { state in + state.isActive = false + state.latestFrame = nil + if !state.isDraining { + state.isDraining = false + } + } + } + + nonisolated private func drain() { + while true { + let nextFrame = state.withLock { state -> SendableSampleBuffer? in + guard state.isActive else { + state.latestFrame = nil + state.isDraining = false + return nil + } + guard let latestFrame = state.latestFrame else { + state.isDraining = false + return nil + } + state.latestFrame = nil + return latestFrame + } + guard let nextFrame else { return } + sink.submitFrame(nextFrame.value) + } + } +} + +final class DisplaySampleFanout: Sendable { + private let mailboxes = Mutex<[ObjectIdentifier: PreviewSinkMailbox]>([:]) +#if DEBUG + nonisolated(unsafe) var willStartDrainForTesting: (@Sendable () -> Void)? +#endif + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + let key = ObjectIdentifier(sink as AnyObject) + mailboxes.withLock { mailboxes in + guard mailboxes[key] == nil else { return } + let mailbox = PreviewSinkMailbox(sink: sink) +#if DEBUG + mailbox.willStartDrainForTesting = willStartDrainForTesting +#endif + mailboxes[key] = mailbox + } + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + let mailbox = mailboxes.withLock { + $0.removeValue(forKey: ObjectIdentifier(sink as AnyObject)) + } + mailbox?.deactivate() + } + + nonisolated func publishPreviewFrame(_ sampleBuffer: CMSampleBuffer) { + let snapshot = mailboxes.withLock { Array($0.values) } + for mailbox in snapshot { + mailbox.submit(sampleBuffer) + } + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayStartCoordinator.swift b/VoidDisplay/Features/Capture/Services/DisplayStartCoordinator.swift new file mode 100644 index 0000000..b7997d4 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayStartCoordinator.swift @@ -0,0 +1,235 @@ +import CoreGraphics +import Foundation +import Synchronization + +final class DisplayStartInvalidationContext: Sendable { + private struct State { + var isInvalidated = false + var waiters: [UUID: CheckedContinuation] = [:] + } + + private let state = Mutex(State()) + + nonisolated func invalidate() { + let pendingWaiters = state.withLock { state -> [CheckedContinuation] in + guard !state.isInvalidated else { return [] } + state.isInvalidated = true + let waiters = Array(state.waiters.values) + state.waiters.removeAll() + return waiters + } + for waiter in pendingWaiters { + waiter.resume() + } + } + + nonisolated func isInvalidated() -> Bool { + state.withLock { $0.isInvalidated } + } + + nonisolated func waitForInvalidation() async { + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = state.withLock { state -> Bool in + if state.isInvalidated || Task.isCancelled { + return true + } + state.waiters[waiterID] = continuation + return false + } + if shouldResumeImmediately { + continuation.resume() + return + } + + if Task.isCancelled { + let cancelledWaiter = state.withLock { state -> CheckedContinuation? in + state.waiters.removeValue(forKey: waiterID) + } + cancelledWaiter?.resume() + } + } + } onCancel: { + let cancelledWaiter = self.state.withLock { state -> CheckedContinuation? in + state.waiters.removeValue(forKey: waiterID) + } + cancelledWaiter?.resume() + } + } + + nonisolated func race( + _ operation: @escaping @Sendable () async throws -> T + ) async throws -> DisplayStartOutcome { + if isInvalidated() { + return .invalidated + } + + return try await withThrowingTaskGroup(of: DisplayStartOutcome.self) { group in + group.addTask { + .started(try await operation()) + } + group.addTask { + await self.waitForInvalidation() + try Task.checkCancellation() + return .invalidated + } + + do { + guard let firstResult = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + while (try? await group.next()) != nil {} + return firstResult + } catch { + group.cancelAll() + while (try? await group.next()) != nil {} + throw error + } + } + } + + nonisolated var waiterCountForTesting: Int { + state.withLock { $0.waiters.count } + } +} + +@MainActor +final class DisplayStreamStartCoordinator { + private struct OperationKey: Hashable { + let kind: DisplayStartKind + let displayID: CGDirectDisplayID + } + + private enum OperationCompletion { + case finished(Any) + case invalidated + case failed(any Error) + } + + private final class OperationRecord { + let token = UUID() + let invalidationContext = DisplayStartInvalidationContext() + var waiters: [UUID: (OperationCompletion) -> Void] = [:] + var task: Task? + } + + private var operations: [OperationKey: OperationRecord] = [:] + + func isStarting( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) -> Bool { + operations[OperationKey(kind: kind, displayID: displayID)] != nil + } + + func start( + kind: DisplayStartKind, + displayID: CGDirectDisplayID, + operation: @escaping @MainActor (DisplayStartInvalidationContext) async throws -> DisplayStartOutcome + ) async throws -> DisplayStartOutcome { + let key = OperationKey(kind: kind, displayID: displayID) + if let existing = operations[key] { + return try await awaitResult(from: existing) + } + + let record = OperationRecord() + operations[key] = record + let operationToken = record.token + record.task = Task { @MainActor [weak self] in + let completion: OperationCompletion + do { + let outcome = try await operation(record.invalidationContext) + switch outcome { + case .started(let value): + completion = .finished(value) + case .invalidated: + completion = .invalidated + } + } catch { + completion = .failed(error) + } + self?.complete( + key: key, + operationToken: operationToken, + completion: completion + ) + } + + return try await awaitResult(from: record) + } + + func invalidate( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) { + invalidate(key: OperationKey(kind: kind, displayID: displayID)) + } + + func invalidateAll(displayID: CGDirectDisplayID) { + invalidate(kind: .monitoring, displayID: displayID) + invalidate(kind: .sharing, displayID: displayID) + } + + func invalidateAll(kind: DisplayStartKind) { + let keysToInvalidate = operations.keys.filter { $0.kind == kind } + for key in keysToInvalidate { + invalidate(key: key) + } + } + + func waiterCountForTesting( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) -> Int { + operations[OperationKey(kind: kind, displayID: displayID)]?.waiters.count ?? 0 + } + + private func awaitResult( + from record: OperationRecord + ) async throws -> DisplayStartOutcome { + try await withCheckedThrowingContinuation { continuation in + let waiterID = UUID() + record.waiters[waiterID] = { completion in + switch completion { + case .finished(let value): + guard let typedValue = value as? Value else { + continuation.resume(throwing: StartCoordinatorTypeMismatchError()) + return + } + continuation.resume(returning: .started(typedValue)) + case .invalidated: + continuation.resume(returning: .invalidated) + case .failed(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func complete( + key: OperationKey, + operationToken: UUID, + completion: OperationCompletion + ) { + guard let record = operations[key], record.token == operationToken else { return } + operations.removeValue(forKey: key) + let waiters = Array(record.waiters.values) + record.waiters.removeAll() + for waiter in waiters { + waiter(completion) + } + } + + private func invalidate(key: OperationKey) { + guard let record = operations.removeValue(forKey: key) else { return } + record.invalidationContext.invalidate() + record.task?.cancel() + let waiters = Array(record.waiters.values) + record.waiters.removeAll() + for waiter in waiters { + waiter(.invalidated) + } + } +} diff --git a/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift b/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift deleted file mode 100644 index de52863..0000000 --- a/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift +++ /dev/null @@ -1,697 +0,0 @@ -import AppKit -import CoreGraphics -import CoreMedia -import Foundation -import OSLog -import ScreenCaptureKit -import Synchronization - -// MARK: - Public Protocols & Value Types - -protocol DisplayPreviewSink: AnyObject, Sendable { - nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) -} - -nonisolated struct SendableDisplay: @unchecked Sendable { - nonisolated(unsafe) let value: SCDisplay - nonisolated let displayID: CGDirectDisplayID - nonisolated let width: Int - nonisolated let height: Int - - nonisolated init(_ value: SCDisplay) { - self.value = value - self.displayID = value.displayID - self.width = value.width - self.height = value.height - } -} - -protocol DisplayCaptureSessioning: AnyObject, Sendable { - nonisolated var sessionHub: WebRTCSessionHub { get } - 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 -} - -// MARK: - Preview Subscription - -final class DisplayPreviewSubscription: Sendable { - let displayID: CGDirectDisplayID - let resolutionText: String - - private let session: any DisplayCaptureSessioning - private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) - private let attachedSinks = Mutex<[ObjectIdentifier: WeakSink]>([:]) - - private final class WeakSink: @unchecked Sendable { - nonisolated(unsafe) weak var value: (any DisplayPreviewSink)? - - nonisolated init(_ value: any DisplayPreviewSink) { - self.value = value - } - } - - nonisolated init( - displayID: CGDirectDisplayID, - resolutionText: String, - session: any DisplayCaptureSessioning, - cancelClosure: @escaping @Sendable () -> Void - ) { - self.displayID = displayID - self.resolutionText = resolutionText - self.session = session - cancelState.withLock { $0 = cancelClosure } - } - - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { - attachedSinks.withLock { $0[ObjectIdentifier(sink as AnyObject)] = WeakSink(sink) } - session.attachPreviewSink(sink) - } - - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { - attachedSinks.withLock { _ = $0.removeValue(forKey: ObjectIdentifier(sink as AnyObject)) } - session.detachPreviewSink(sink) - } - - nonisolated func cancel() { - let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in - let current = state - state = nil - return current - } - guard let closure else { return } - - // Detach all sinks previously attached via this subscription to avoid - // leaving fanout strongly holding onto closed-window renderers when the - // monitoring session is removed externally. - let sinksToDetach: [any DisplayPreviewSink] = attachedSinks.withLock { dict in - let snapshot = dict.values.compactMap(\.value) - dict.removeAll(keepingCapacity: true) - return snapshot - } - for sink in sinksToDetach { - session.detachPreviewSink(sink) - } - - closure() - } - - nonisolated func setShowsCursor(_ showsCursor: Bool) async throws { - try await session.setPreviewShowsCursor(showsCursor) - } - - deinit { cancel() } -} - -// MARK: - Share Subscription - -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 - } - guard let closure else { return } - Task { - try? await session.releaseShareCursorOverride() - closure() - } - } - - deinit { cancel() } -} - -// MARK: - Capture Registry - -actor DisplayCaptureRegistry { - enum SessionResourceState: Equatable { - case initializing - case active - case draining - case stopped - } - - struct PreviewToken: Hashable, Sendable { - fileprivate let rawValue: UUID - let displayID: CGDirectDisplayID - } - - struct ShareToken: Hashable, Sendable { - fileprivate let rawValue: UUID - let displayID: CGDirectDisplayID - } - - private enum TokenKind: Sendable { - case preview - case share - } - - private struct TokenRecord: Sendable { - let kind: TokenKind - let displayID: CGDirectDisplayID - } - - struct SessionRecord { - let session: any DisplayCaptureSessioning - let resolutionText: String - var state: SessionResourceState - var previewTokens: Set - var shareTokens: Set - } - - private enum RegistryError: Error { - case sessionUnavailable - } - - typealias CaptureSessionFactory = @Sendable (SendableDisplay) async throws -> any DisplayCaptureSessioning - - static let shared = DisplayCaptureRegistry() - - private let captureSessionFactory: CaptureSessionFactory - private var sessionsByDisplayID: [CGDirectDisplayID: SessionRecord] = [:] - private var tokenOwnership: [UUID: TokenRecord] = [:] - private var sessionCreationTasks: [CGDirectDisplayID: Task] = [:] - private var sessionDrainTasksByDisplayID: [CGDirectDisplayID: Task] = [:] - private var initializingDisplayIDs: Set = [] - - init( - captureSessionFactory: @escaping CaptureSessionFactory = { display in - try await DisplayCaptureSession(display: display.value) - } - ) { - self.captureSessionFactory = captureSessionFactory - } - - // MARK: Acquire / Release - - func acquirePreview(display: SendableDisplay) async throws -> DisplayPreviewSubscription { - let token = try await acquirePreviewToken(display: display) - guard let record = sessionsByDisplayID[token.displayID] else { - throw RegistryError.sessionUnavailable - } - return DisplayPreviewSubscription( - displayID: token.displayID, - resolutionText: record.resolutionText, - session: record.session, - cancelClosure: { [weak self] in - guard let self else { return } - Task { await self.release(token) } - } - ) - } - - func acquireShare(display: SendableDisplay) async throws -> DisplayShareSubscription { - let token = try await acquireShareToken(display: display) - guard let record = sessionsByDisplayID[token.displayID] else { - throw RegistryError.sessionUnavailable - } - 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) } - } - ) - } - - func acquirePreviewToken(display: SendableDisplay) async throws -> PreviewToken { - let tokenID = try await acquireToken(display: display, kind: .preview) - return PreviewToken(rawValue: tokenID, displayID: display.displayID) - } - - func acquireShareToken(display: SendableDisplay) async throws -> ShareToken { - let tokenID = try await acquireToken(display: display, kind: .share) - return ShareToken(rawValue: tokenID, displayID: display.displayID) - } - - func release(_ token: PreviewToken) async { - await releaseToken(token.rawValue, expectedKind: .preview) - } - - func release(_ token: ShareToken) async { - await releaseToken(token.rawValue, expectedKind: .share) - } - - func sessionState(for displayID: CGDirectDisplayID) -> SessionResourceState { - if initializingDisplayIDs.contains(displayID) { - return .initializing - } - return sessionsByDisplayID[displayID]?.state ?? .stopped - } - - // MARK: Internal - - private func acquireToken( - display: SendableDisplay, - kind: TokenKind - ) async throws -> UUID { - try await ensureSessionExists(for: display) - return try registerToken(displayID: display.displayID, kind: kind) - } - -#if DEBUG - func installSessionForTesting( - displayID: CGDirectDisplayID, - resolutionText: String, - session: any DisplayCaptureSessioning - ) { - sessionDrainTasksByDisplayID[displayID]?.cancel() - sessionDrainTasksByDisplayID[displayID] = nil - initializingDisplayIDs.remove(displayID) - sessionsByDisplayID[displayID] = SessionRecord( - session: session, - resolutionText: resolutionText, - state: .active, - previewTokens: [], - shareTokens: [] - ) - } - - func acquirePreviewTokenForTesting(displayID: CGDirectDisplayID) throws -> PreviewToken { - let tokenID = try registerToken(displayID: displayID, kind: .preview) - return PreviewToken(rawValue: tokenID, displayID: displayID) - } - - func acquireShareTokenForTesting(displayID: CGDirectDisplayID) throws -> ShareToken { - let tokenID = try registerToken(displayID: displayID, kind: .share) - return ShareToken(rawValue: tokenID, displayID: displayID) - } -#endif - - private func registerToken(displayID: CGDirectDisplayID, kind: TokenKind) throws -> UUID { - let tokenID = UUID() - guard var record = sessionsByDisplayID[displayID] else { - throw RegistryError.sessionUnavailable - } - guard record.state != .draining else { - throw RegistryError.sessionUnavailable - } - record.state = .active - switch kind { - case .preview: - record.previewTokens.insert(tokenID) - case .share: - record.shareTokens.insert(tokenID) - } - sessionsByDisplayID[displayID] = record - tokenOwnership[tokenID] = TokenRecord(kind: kind, displayID: displayID) - return tokenID - } - - private func ensureSessionExists(for display: SendableDisplay) async throws { - let displayID = display.displayID - if let existing = sessionsByDisplayID[displayID] { - if existing.state != .draining { - return - } - await waitForDrainCompletion(for: displayID) - if let afterDrain = sessionsByDisplayID[displayID], afterDrain.state != .draining { - return - } - } - - if let existingTask = sessionCreationTasks[displayID] { - let record = try await existingTask.value - storeInitializedSessionIfAbsent(record, for: displayID) - return - } - - let task = Task { [captureSessionFactory] in - let session = try await captureSessionFactory(display) - return SessionRecord( - session: session, - resolutionText: "\(display.width) × \(display.height)", - state: .active, - previewTokens: [], - shareTokens: [] - ) - } - initializingDisplayIDs.insert(displayID) - sessionCreationTasks[displayID] = task - defer { sessionCreationTasks[displayID] = nil } - - do { - let record = try await task.value - storeInitializedSessionIfAbsent(record, for: displayID) - } catch { - initializingDisplayIDs.remove(displayID) - throw error - } - } - - private func storeInitializedSessionIfAbsent( - _ record: SessionRecord, - for displayID: CGDirectDisplayID - ) { - initializingDisplayIDs.remove(displayID) - guard sessionsByDisplayID[displayID] == nil else { return } - sessionsByDisplayID[displayID] = record - } - - private func releaseToken(_ tokenID: UUID, expectedKind: TokenKind) async { - guard let ownership = tokenOwnership.removeValue(forKey: tokenID), - ownership.kind == expectedKind else { - return - } - guard var record = sessionsByDisplayID[ownership.displayID] else { return } - - switch ownership.kind { - case .preview: - record.previewTokens.remove(tokenID) - case .share: - record.shareTokens.remove(tokenID) - } - - if ownership.kind == .share, record.shareTokens.isEmpty { - record.session.stopSharing() - } - - if record.previewTokens.isEmpty, record.shareTokens.isEmpty { - record.state = .draining - sessionsByDisplayID[ownership.displayID] = record - let session = record.session - sessionDrainTasksByDisplayID[ownership.displayID]?.cancel() - sessionDrainTasksByDisplayID[ownership.displayID] = Task { [session] in - await session.stop() - self.finishDrainingSession(displayID: ownership.displayID) - } - return - } - - record.state = .active - sessionsByDisplayID[ownership.displayID] = record - } - - private func waitForDrainCompletion(for displayID: CGDirectDisplayID) async { - guard let drainTask = sessionDrainTasksByDisplayID[displayID] else { return } - await drainTask.value - } - - private func finishDrainingSession(displayID: CGDirectDisplayID) { - sessionDrainTasksByDisplayID[displayID] = nil - guard let record = sessionsByDisplayID[displayID] else { return } - guard record.state == .draining else { return } - guard record.previewTokens.isEmpty, record.shareTokens.isEmpty else { - var resumed = record - resumed.state = .active - sessionsByDisplayID[displayID] = resumed - return - } - sessionsByDisplayID.removeValue(forKey: displayID) - } -} - -// MARK: - Stream Output Delegate - -private final class DisplayStreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { - nonisolated(unsafe) weak var session: DisplayCaptureSession? - - nonisolated override init() { - super.init() - } - - nonisolated func stream( - _ stream: SCStream, - didOutputSampleBuffer sampleBuffer: CMSampleBuffer, - of type: SCStreamOutputType - ) { - session?.handle(sampleBuffer: sampleBuffer, type: type) - } - - nonisolated func stream(_ stream: SCStream, didStopWithError error: Error) { - Task { @MainActor in - AppErrorMapper.logFailure("Screen capture stream stopped", error: error, logger: AppLog.capture) - } - } -} - -// MARK: - Sample Fanout - -private final class DisplaySampleFanout: Sendable { - private let sinks = Mutex<[ObjectIdentifier: any DisplayPreviewSink]>([:]) - - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { - sinks.withLock { $0[ObjectIdentifier(sink as AnyObject)] = sink } - } - - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { - sinks.withLock { _ = $0.removeValue(forKey: ObjectIdentifier(sink as AnyObject)) } - } - - nonisolated func publishPreviewFrame(_ sampleBuffer: CMSampleBuffer) { - let snapshot = sinks.withLock { Array($0.values) } - for sink in snapshot { sink.submitFrame(sampleBuffer) } - } -} - -// 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 - - nonisolated(unsafe) private let stream: SCStream - private let output = DisplayStreamOutput() - nonisolated private let captureQueue: DispatchQueue - nonisolated private let fanout = DisplaySampleFanout() - nonisolated private let metrics = Mutex(DisplayCaptureMetrics()) - nonisolated private let configurationState: Mutex - - // MARK: Lifecycle - - nonisolated init(display: SCDisplay) async throws { - self.displayID = display.displayID - self.captureQueue = DispatchQueue( - label: "com.developerchen.voiddisplay.capture.\(display.displayID)", - qos: .userInitiated - ) - - 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 - - try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: captureQueue) - try await stream.startCapture() - } - - // MARK: Preview Sinks - - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { - fanout.attachPreviewSink(sink) - } - - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { - fanout.detachPreviewSink(sink) - } - - // MARK: Sharing Control - - nonisolated func stopSharing() { - 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() - } - - // MARK: Frame Handling - - nonisolated func handle(sampleBuffer: CMSampleBuffer, type: SCStreamOutputType) { - guard type == .screen, let pixelBuffer = sampleBuffer.imageBuffer else { return } - metrics.withLock { $0.receivedFrameCount &+= 1 } - - fanout.publishPreviewFrame(sampleBuffer) - - guard sessionHub.hasDemand else { return } - let ptsUs = Self.microseconds(from: CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) - sessionHub.submitFrame(pixelBuffer: pixelBuffer, ptsUs: ptsUs) - } -} - -// MARK: - DisplayCaptureSession Helpers - -extension DisplayCaptureSession { - - nonisolated static func microseconds(from time: CMTime) -> UInt64 { - guard time.isValid, !time.isIndefinite, time.seconds.isFinite else { return 0 } - let scaled = CMTimeConvertScale(time, timescale: 1_000_000, method: .default) - return scaled.value > 0 ? UInt64(scaled.value) : 0 - } - - 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()))) - - 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 - } - - nonisolated private static func preferredCaptureSize( - display: SCDisplay, - displayMode: CGDisplayMode? - ) -> (width: Int, height: Int) { - let modePixelWidth = displayMode.map { Int($0.pixelWidth) } ?? display.width - let modePixelHeight = displayMode.map { Int($0.pixelHeight) } ?? display.height - let modeLogicalWidth = displayMode.map { $0.width } ?? modePixelWidth - let modeLogicalHeight = displayMode.map { $0.height } ?? modePixelHeight - let backingScale = screenBackingScaleFactor(for: display.displayID) - - let scaledLogicalWidth = max(1, Int((CGFloat(modeLogicalWidth) * backingScale).rounded())) - let scaledLogicalHeight = max(1, Int((CGFloat(modeLogicalHeight) * backingScale).rounded())) - - return ( - width: max(modePixelWidth, scaledLogicalWidth), - height: max(modePixelHeight, scaledLogicalHeight) - ) - } - - nonisolated private static func screenBackingScaleFactor(for displayID: CGDirectDisplayID) -> CGFloat { - let key = NSDeviceDescriptionKey("NSScreenNumber") - guard let screen = NSScreen.screens.first(where: { - guard let number = $0.deviceDescription[key] as? NSNumber else { return false } - return number.uint32Value == displayID - }) else { - return 1.0 - } - return max(1.0, screen.backingScaleFactor) - } - - nonisolated private static func makeContentFilter( - display: SCDisplay - ) async throws -> SCContentFilter { - let content = try await SCShareableContent.excludingDesktopWindows( - false, onScreenWindowsOnly: false - ) - let excludedApps = content.applications.filter { app in - Bundle.main.bundleIdentifier == app.bundleIdentifier - } - return SCContentFilter( - display: display, - excludingApplications: excludedApps, - exceptingWindows: [] - ) - } -} - -// MARK: - Internal Metrics - -struct DisplayCaptureMetrics: Sendable { - var receivedFrameCount: UInt64 = 0 -} diff --git a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift index 46172af..5f4875e 100644 --- a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift +++ b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift @@ -8,11 +8,13 @@ import OSLog @MainActor @Observable final class CaptureChooseViewModel { - typealias LoadErrorInfo = ScreenCaptureDisplayCatalogLoadErrorInfo - struct CaptureActions { var monitoringSessionForDisplayID: @MainActor (CGDirectDisplayID) -> ScreenMonitoringSession? - var addMonitoringSession: @MainActor (ScreenMonitoringSession) -> Void + var isStartingDisplayID: @MainActor (CGDirectDisplayID) -> Bool + var startMonitoring: @MainActor ( + SCDisplay, + CaptureMonitoringDisplayMetadata + ) async throws -> DisplayStartOutcome } struct VirtualDisplayQueries { @@ -32,8 +34,11 @@ final class CaptureChooseViewModel { monitoringSessionForDisplayID: { displayID in capture.screenCaptureSessions.first(where: { $0.displayID == displayID }) }, - addMonitoringSession: { session in - capture.addMonitoringSession(session) + isStartingDisplayID: { displayID in + capture.isStarting(displayID: displayID) + }, + startMonitoring: { display, metadata in + try await capture.startMonitoring(display: display, metadata: metadata) } ), virtualDisplayQueries: .init( @@ -47,41 +52,21 @@ final class CaptureChooseViewModel { } let catalog: ScreenCaptureDisplayCatalogState - var startingDisplayIDs: Set = [] var userFacingAlert: UserFacingAlertState? - private let makePreviewSubscription: @MainActor (SCDisplay) async throws -> DisplayPreviewSubscription - private let topologyCoordinator: ScreenCaptureCatalogTopologyCoordinator + @ObservationIgnored private let activeDisplayIDsProvider: @MainActor () -> Set @ObservationIgnored private let dependencies: Dependencies - @ObservationIgnored private let catalogLoader: ScreenCaptureDisplayCatalogLoader init( catalogState: ScreenCaptureDisplayCatalogState? = nil, - permissionProvider: (any ScreenCapturePermissionProvider)? = nil, - loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, - makePreviewSubscription: (@MainActor (SCDisplay) async throws -> DisplayPreviewSubscription)? = nil, activeDisplayIDsProvider: @escaping @MainActor () -> Set = { Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) }, dependencies: Dependencies ) { - let catalog = catalogState ?? ScreenCaptureDisplayCatalogState() - self.catalog = catalog - self.makePreviewSubscription = makePreviewSubscription ?? { display in - try await DisplayCaptureRegistry.shared.acquirePreview(display: SendableDisplay(display)) - } - self.topologyCoordinator = ScreenCaptureCatalogTopologyCoordinator( - state: catalog, - activeDisplayIDsProvider: activeDisplayIDsProvider - ) + self.catalog = catalogState ?? ScreenCaptureDisplayCatalogState() + self.activeDisplayIDsProvider = activeDisplayIDsProvider self.dependencies = dependencies - self.catalogLoader = ScreenCaptureDisplayCatalogLoader( - state: catalog, - permissionProvider: permissionProvider, - loadShareableDisplays: loadShareableDisplays, - logOperation: "Load shareable displays", - logger: AppLog.capture - ) } func isVirtualDisplay(_ display: SCDisplay) -> Bool { @@ -99,121 +84,51 @@ final class CaptureChooseViewModel { } func visibleDisplays(from displays: [SCDisplay]) -> [SCDisplay] { - topologyCoordinator.visibleDisplays(from: displays) + let activeDisplayIDs = activeDisplayIDsProvider() + return displays.filter { activeDisplayIDs.contains($0.displayID) } } - @discardableResult - func withDisplayStartLock( - displayID: CGDirectDisplayID, - operation: () async -> Void - ) async -> Bool { - guard !startingDisplayIDs.contains(displayID) else { return false } - startingDisplayIDs.insert(displayID) - defer { startingDisplayIDs.remove(displayID) } - await operation() - return true + func isStarting(displayID: CGDirectDisplayID) -> Bool { + dependencies.captureActions.isStartingDisplayID(displayID) } func startMonitoring( display: SCDisplay, openWindow: @escaping (UUID) -> Void ) async { - _ = await withDisplayStartLock(displayID: display.displayID) { - if let existingSession = dependencies.captureActions.monitoringSessionForDisplayID(display.displayID) { - openWindow(existingSession.id) - return - } + if let existingSession = dependencies.captureActions.monitoringSessionForDisplayID(display.displayID) { + openWindow(existingSession.id) + return + } + guard !isStarting(displayID: display.displayID) else { return } - do { - let previewSubscription = try await makePreviewSubscription(display) - let session = ScreenMonitoringSession( - id: UUID(), - displayID: display.displayID, - displayName: displayName(for: display), - resolutionText: resolutionText(for: display), - isVirtualDisplay: isVirtualDisplay(display), - previewSubscription: previewSubscription, - capturesCursor: false, - state: .starting - ) - dependencies.captureActions.addMonitoringSession(session) - openWindow(session.id) - } catch { - AppErrorMapper.logFailure("Start monitoring", error: error, logger: AppLog.capture) - userFacingAlert = UserFacingAlertState( - title: String(localized: "Start Monitoring Failed"), - message: AppErrorMapper.userMessage( - for: error, - fallback: String(localized: "Failed to start monitoring.") - ) - ) + do { + let metadata = CaptureMonitoringDisplayMetadata( + displayName: displayName(for: display), + resolutionText: resolutionText(for: display), + isVirtualDisplay: isVirtualDisplay(display) + ) + let outcome = try await dependencies.captureActions.startMonitoring(display, metadata) + switch outcome { + case .started(let sessionID): + openWindow(sessionID) + case .invalidated: + break } + } catch is CancellationError { + } catch { + AppErrorMapper.logFailure("Start monitoring", error: error, logger: AppLog.capture) + userFacingAlert = UserFacingAlertState( + title: String(localized: "Start Monitoring Failed"), + message: AppErrorMapper.userMessage( + for: error, + fallback: String(localized: "Failed to start monitoring.") + ) + ) } } func dismissAlert() { userFacingAlert = nil } - - func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { - catalogLoader.openScreenCapturePrivacySettings(openURL: openURL) - } - - func requestScreenCapturePermission() { - let granted = catalogLoader.requestPermission() - if !granted { - catalogLoader.clearDisplaysAndCancel() - AppLog.capture.notice("Screen capture permission request denied.") - return - } - loadDisplays() - } - - func refreshPermissionAndMaybeLoad() { - let granted = catalogLoader.refreshPermission() - if !granted { - catalogLoader.cancelInFlightDisplayLoad() - AppLog.capture.notice("Screen capture permission preflight denied.") - return - } - guard topologyCoordinator.needsRefresh() else { return } - if catalog.displays == nil { - loadDisplaysIfNeeded() - return - } - loadDisplaysPreservingExisting() - } - - func loadDisplays() { - catalogLoader.loadDisplays { [weak self] _ in - self?.topologyCoordinator.commitLoadedTopologySignature() - } - } - - func refreshDisplaysBackgroundSafe() { - guard catalog.hasScreenCapturePermission == true else { return } - guard !catalog.isLoadingDisplays else { return } - guard topologyCoordinator.needsRefresh() else { return } - if catalog.displays == nil { - loadDisplaysIfNeeded() - return - } - loadDisplaysPreservingExisting() - } - - func cancelInFlightDisplayLoad() { - catalogLoader.cancelInFlightDisplayLoad() - } - - private func loadDisplaysIfNeeded() { - catalogLoader.loadDisplaysIfNeeded { [weak self] _ in - self?.topologyCoordinator.commitLoadedTopologySignature() - } - } - - private func loadDisplaysPreservingExisting() { - catalogLoader.loadDisplays(preserveExistingDisplays: true) { [weak self] _ in - self?.topologyCoordinator.commitLoadedTopologySignature() - } - } } diff --git a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift index 6deb41e..d6b138e 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift @@ -11,13 +11,17 @@ import AppKit struct IsCapturing: View { @Bindable private var capture: CaptureController @State private var viewModel: CaptureChooseViewModel + @State private var lifecycle: DisplayTopologyRefreshLifecycleController @Environment(SharingController.self) private var sharing @Environment(\.openWindow) var openWindow @Environment(\.openURL) private var openURL + private let screenCatalogOrchestrator: ScreenCatalogOrchestrator init( capture: CaptureController, - virtualDisplay: VirtualDisplayController + virtualDisplay: VirtualDisplayController, + screenCatalogOrchestrator: ScreenCatalogOrchestrator, + lifecycle: DisplayTopologyRefreshLifecycleController = DisplayTopologyRefreshLifecycleController() ) { _capture = Bindable(capture) _viewModel = State( @@ -26,6 +30,8 @@ struct IsCapturing: View { dependencies: .live(capture: capture, virtualDisplay: virtualDisplay) ) ) + _lifecycle = State(initialValue: lifecycle) + self.screenCatalogOrchestrator = screenCatalogOrchestrator } private var shouldShowActiveSessionFallback: Bool { @@ -53,13 +59,17 @@ struct IsCapturing: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.handleAppear(source: .capturePage) } + guard !UITestRuntime.isEnabled else { return } + lifecycle.handleAppear { + guard viewModel.catalog.hasScreenCapturePermission == true else { return } + Task { await screenCatalogOrchestrator.handleTopologyChanged() } + } } .onDisappear { - viewModel.cancelInFlightDisplayLoad() - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) { _ in - viewModel.refreshDisplaysBackgroundSafe() + Task { await screenCatalogOrchestrator.handleDisappear(source: .capturePage) } + guard !UITestRuntime.isEnabled else { return } + lifecycle.handleDisappear() } .accessibilityElement(children: .contain) .accessibilityIdentifier("capture_choose_root") @@ -129,7 +139,7 @@ struct IsCapturing: View { .textSelection(.enabled) } Button("Retry") { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.refreshPermission(source: .capturePage) } } } } @@ -176,7 +186,7 @@ struct IsCapturing: View { session: session, isSharing: sharing.isDisplaySharing(displayID: session.displayID) ) { - capture.removeMonitoringSession(id: session.id) + capture.closeMonitoringSession(id: session.id) } } } @@ -190,7 +200,7 @@ struct IsCapturing: View { let isPrimaryDisplay = CGDisplayIsMain(display.displayID) != 0 let monitoringSession = capture.screenCaptureSessions.first(where: { $0.displayID == display.displayID }) let isMonitoring = monitoringSession?.state == .active - let isStarting = viewModel.startingDisplayIDs.contains(display.displayID) || monitoringSession?.state == .starting + let isStarting = capture.isStarting(displayID: display.displayID) || monitoringSession?.state == .starting return CaptureDisplayRow( display: display, @@ -203,7 +213,7 @@ struct IsCapturing: View { isSharing: sharing.isDisplaySharing(displayID: display.displayID) ) { if isMonitoring, let session = monitoringSession { - capture.removeMonitoringSession(id: session.id) + capture.closeMonitoringSession(id: session.id) } else { Task { await viewModel.startMonitoring(display: display) { sessionId in @@ -223,18 +233,18 @@ struct IsCapturing: View { ScreenCapturePermissionGuideView( loadErrorMessage: viewModel.catalog.loadErrorMessage, onOpenSettings: { - viewModel.openScreenCapturePrivacySettings { url in + screenCatalogOrchestrator.openScreenCapturePrivacySettings { url in openURL(url) } }, onRequestPermission: { - viewModel.requestScreenCapturePermission() + Task { await screenCatalogOrchestrator.requestPermission(source: .capturePage) } }, onRefresh: { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.refreshPermission(source: .capturePage) } }, onRetry: (viewModel.catalog.loadErrorMessage != nil || viewModel.catalog.lastLoadError != nil) ? { - viewModel.loadDisplays() + Task { await screenCatalogOrchestrator.forceRefresh(source: .capturePage) } } : nil, isDebugInfoExpanded: $bindableCatalog.showDebugInfo, debugItems: capturePermissionDebugItems, @@ -293,7 +303,11 @@ struct IsCapturing: View { #Preview { let env = AppBootstrap.makeEnvironment(preview: true, isRunningUnderXCTestOverride: false) - IsCapturing(capture: env.capture, virtualDisplay: env.virtualDisplay) + IsCapturing( + capture: env.capture, + virtualDisplay: env.virtualDisplay, + screenCatalogOrchestrator: env.screenCatalog + ) .environment(env.capture) .environment(env.sharing) .environment(env.virtualDisplay) diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 6c35dba..9dca79e 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -1,13 +1,21 @@ import AppKit -import AVFoundation import SwiftUI // MARK: - Capture Display View struct CaptureDisplayView: View { - private enum PreviewScaleMode: Hashable { + private enum PreviewScaleMode: Hashable, CaseIterable { case fit case native + + var title: LocalizedStringResource { + switch self { + case .fit: + "Fit" + case .native: + "1:1" + } + } } let sessionId: UUID @@ -24,6 +32,7 @@ struct CaptureDisplayView: View { @State private var scaleMode: PreviewScaleMode = .fit @State private var capturesCursor = false @State private var isUpdatingCursorCapture = false + @State private var lastReportedRendererMetrics: ZeroCopyPreviewRenderer.MetricsSnapshot? private var session: ScreenMonitoringSession? { capture.monitoringSession(for: sessionId) @@ -43,14 +52,10 @@ struct CaptureDisplayView: View { } private var nativeFrameSizeInPoints: CGSize { - let pixelSize = renderer.framePixelSize - guard pixelSize.width > 0, pixelSize.height > 0 else { - let fallback = preferredAspect() - return CGSize(width: max(1, fallback.width), height: max(1, fallback.height)) - } - return CGSize( - width: max(1, pixelSize.width / currentScaleFactor), - height: max(1, pixelSize.height / currentScaleFactor) + CapturePreviewGeometry.nativeFrameSizeInPoints( + framePixelSize: renderer.framePixelSize, + scaleFactor: currentScaleFactor, + fallbackAspect: preferredAspect() ) } @@ -94,28 +99,39 @@ struct CaptureDisplayView: View { .toolbar { ToolbarItem(placement: .principal) { Picker("Scale Mode", selection: $scaleMode) { - Text("Fit").tag(PreviewScaleMode.fit) - Text("1:1").tag(PreviewScaleMode.native) + ForEach(PreviewScaleMode.allCases, id: \.self) { mode in + Text(mode.title) + .tag(mode) + } } .pickerStyle(.segmented) .controlSize(.small) .frame(width: 150) .accessibilityIdentifier("capture_preview_scale_mode_picker") + .accessibilityValue(Text(scaleMode.title)) } ToolbarItem(placement: .automatic) { - HStack(spacing: 6) { + HStack(spacing: AppUI.Spacing.small + 2) { Text(String(localized: "Cursor")) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .fixedSize() Toggle("", isOn: cursorCaptureBinding) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) - .disabled(isUpdatingCursorCapture || isSharingDisplay) - .accessibilityIdentifier("capture_preview_cursor_toggle") + .accessibilityLabel(String(localized: "Cursor")) } + .padding(.horizontal, AppUI.Spacing.xSmall) + .disabled(isUpdatingCursorCapture || isSharingDisplay) + .accessibilityIdentifier("capture_preview_cursor_toggle") } } .toolbarTitleDisplayMode(.inline) .onAppear { + if let diagnosticsScaleMode = initialPreviewScaleModeOverride { + scaleMode = diagnosticsScaleMode + } windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: scaleMode == .fit) capturesCursor = session?.capturesCursor ?? false } @@ -139,30 +155,28 @@ struct CaptureDisplayView: View { } .onAppear { if let session { - session.previewSubscription.attachPreviewSink(renderer) + capture.attachPreviewSink(renderer, to: sessionId) if let destinationDirectory = CapturePreviewDiagnosticsRuntime.configuration()?.recordDirectoryURL { let sink = CapturePreviewRecordingSink( destinationDirectory: destinationDirectory, session: session ) recordingSink = sink - session.previewSubscription.attachPreviewSink(sink) + capture.attachPreviewSink(sink, to: sessionId) } - capture.markMonitoringSessionActive(id: sessionId) + capture.activateMonitoringSession(id: sessionId) } else { dismiss() } } .onDisappear { - if let session { - if let recordingSink { - session.previewSubscription.detachPreviewSink(recordingSink) - } - session.previewSubscription.detachPreviewSink(renderer) - } + capture.closeMonitoringSession(id: sessionId) windowCoordinator.tearDown() renderer.flush() - capture.removeMonitoringSession(id: sessionId) + lastReportedRendererMetrics = nil + } + .task(id: sessionId) { + await reportPreviewPerformanceLoop() } .onChange(of: renderer.framePixelSize) { _, _ in windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: scaleMode == .fit) @@ -188,6 +202,33 @@ struct CaptureDisplayView: View { // MARK: - Window Sizing extension CaptureDisplayView { + @MainActor + private func reportPreviewPerformanceLoop() async { + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(5)) + } catch { + return + } + + guard let session else { continue } + let currentMetrics = renderer.metricsSnapshot() + let previousMetrics = lastReportedRendererMetrics + lastReportedRendererMetrics = currentMetrics + + let renderedDelta = currentMetrics.renderedFrameCount &- (previousMetrics?.renderedFrameCount ?? 0) + let droppedDelta = currentMetrics.droppedFrameCount &- (previousMetrics?.droppedFrameCount ?? 0) + let sample = DisplayPreviewPerformanceSample( + renderedFrameCount: renderedDelta, + droppedFrameCount: droppedDelta, + latestRenderLatencyMilliseconds: currentMetrics.latestRenderLatencyMilliseconds ?? 0, + pendingSlotOccupied: currentMetrics.pendingSlotOccupied, + capturedAt: DispatchTime.now().uptimeNanoseconds + ) + session.previewSubscription.reportPerformanceSample(sample) + } + } + private var cursorCaptureBinding: Binding { Binding( get: { effectiveCapturesCursor }, @@ -196,16 +237,15 @@ extension CaptureDisplayView { let previousValue = capturesCursor capturesCursor = newValue - guard let session else { return } + guard session != nil else { return } isUpdatingCursorCapture = true Task { do { - try await session.previewSubscription.setShowsCursor(newValue) + try await capture.setMonitoringSessionCapturesCursor( + id: sessionId, + capturesCursor: newValue + ) await MainActor.run { - capture.setMonitoringSessionCapturesCursor( - id: sessionId, - capturesCursor: newValue - ) isUpdatingCursorCapture = false } } catch { @@ -232,57 +272,33 @@ extension CaptureDisplayView { guard let window, aspect.width > 0, aspect.height > 0, !hasAppliedInitialSize else { return } window.backgroundColor = .windowBackgroundColor - let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame 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 defaultPreviewWidth = max(320, maxPreviewWidth * 0.85) - let defaultPreviewHeight = defaultPreviewWidth / ratio - var previewWidth = defaultPreviewWidth - var previewHeight = defaultPreviewHeight - - if pixelSize.width > 0, pixelSize.height > 0 { - 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 - } - - let targetContentSize = NSSize( - width: previewWidth + layoutInsetWidth, - height: previewHeight + layoutInsetHeight + let targetContentSize = CapturePreviewGeometry.initialContentSize( + input: .init( + aspect: aspect, + framePixelSize: renderer.framePixelSize, + targetContentWidth: CapturePreviewDiagnosticsRuntime.configuration()?.targetContentWidth, + visibleFrameSize: (window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame)?.size + ?? CGSize(width: 1280, height: 800), + chromeSize: CGSize( + width: max(0, window.frame.width - contentRect.width), + height: max(0, window.frame.height - contentRect.height) + ), + layoutInsetSize: CGSize( + width: max(0, contentRect.width - layoutRect.width), + height: max(0, contentRect.height - layoutRect.height) + ), + scaleFactor: max(1, window.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) + ) ) + guard let targetContentSize else { return } let targetFrame = window.frameRect( - forContentRect: NSRect(origin: .zero, size: targetContentSize) + forContentRect: NSRect(origin: .zero, size: NSSize( + width: targetContentSize.width, + height: targetContentSize.height + )) ) var newFrame = window.frame newFrame.origin.x += (newFrame.width - targetFrame.width) / 2 @@ -297,182 +313,22 @@ extension CaptureDisplayView { /// resolution text (e.g. "2560 × 1440"), falling back to the /// pixel size reported by the renderer's first frame. private func preferredAspect() -> CGSize { - if let text = session?.resolutionText, - let size = Self.parseResolution(text) { - return size - } - return renderer.framePixelSize - } - - private static func parseResolution(_ text: String) -> CGSize? { - let separators: [Character] = ["×", "x", "X", "*"] - guard let sep = separators.first(where: { text.contains($0) }) else { return nil } - let parts = text.split(separator: sep, maxSplits: 1) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard parts.count == 2, - let w = Double(parts[0]), w > 0, - let h = Double(parts[1]), h > 0 - else { return nil } - return CGSize(width: w, height: h) - } -} - -// 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) + CapturePreviewGeometry.preferredAspect( + resolutionText: session?.resolutionText, + framePixelSize: renderer.framePixelSize ) - 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 + private var initialPreviewScaleModeOverride: PreviewScaleMode? { + guard let override = CapturePreviewDiagnosticsRuntime.configuration()?.initialScaleMode else { + return nil } - 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 + switch override { + case .fit: + return .fit + case .native: + return .native } - - 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) } } @@ -505,110 +361,6 @@ private struct TransparentScrollViewConfigurator: NSViewRepresentable { } } -// MARK: - Zero-Copy Preview Renderer - -/// Renders captured frames via `AVSampleBufferDisplayLayer` with zero -/// pixel-data copies. The layer natively accepts `CMSampleBuffer` -/// backed by `IOSurface`, handling YUV→RGB conversion and colour -/// management entirely on the GPU. -@Observable -final class ZeroCopyPreviewRenderer: @unchecked Sendable, DisplayPreviewSink { - var framePixelSize: CGSize = .zero - var hasReceivedFrame = false - - let displayLayer: AVSampleBufferDisplayLayer = { - let layer = AVSampleBufferDisplayLayer() - layer.videoGravity = .resizeAspect - layer.preventsDisplaySleepDuringVideoPlayback = false - return layer - }() - - nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { - let box = UncheckedSendableBuffer(sampleBuffer) - Task { @MainActor [weak self] in - guard let self else { return } - let renderer = self.displayLayer.sampleBufferRenderer - - if renderer.status == .failed { renderer.flush() } - renderer.enqueue(box.buffer) - - if !self.hasReceivedFrame { - self.hasReceivedFrame = true - } - - if let desc = CMSampleBufferGetFormatDescription(box.buffer) { - let dims = CMVideoFormatDescriptionGetDimensions(desc) - let size = CGSize(width: CGFloat(dims.width), height: CGFloat(dims.height)) - if self.framePixelSize != size { - self.framePixelSize = size - } - } - } - } - - func flush() { - displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true) - } -} - -/// Wraps `CMSampleBuffer` for safe cross-isolation transfer. -private struct UncheckedSendableBuffer: @unchecked Sendable { - nonisolated(unsafe) let buffer: CMSampleBuffer - nonisolated init(_ buffer: CMSampleBuffer) { self.buffer = buffer } -} - -// MARK: - Layer Host View - -private struct ZeroCopyPreviewLayerView: NSViewRepresentable { - let renderer: ZeroCopyPreviewRenderer - - func makeNSView(context: Context) -> ZeroCopyHostView { - let view = ZeroCopyHostView() - view.hostDisplayLayer(renderer.displayLayer) - return view - } - - func updateNSView(_: ZeroCopyHostView, context: Context) {} -} - -private final class ZeroCopyHostView: NSView { - private weak var displayLayer: AVSampleBufferDisplayLayer? - - func hostDisplayLayer(_ layer: AVSampleBufferDisplayLayer) { - wantsLayer = true - layerContentsRedrawPolicy = .duringViewResize - layer.frame = bounds - self.layer?.addSublayer(layer) - displayLayer = layer - syncLayerScale() - } - - override func layout() { - super.layout() - CATransaction.begin() - CATransaction.setDisableActions(true) - displayLayer?.frame = bounds - CATransaction.commit() - syncLayerScale() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - syncLayerScale() - } - - override func viewDidChangeBackingProperties() { - super.viewDidChangeBackingProperties() - syncLayerScale() - } - - private func syncLayerScale() { - let scale = max(1, window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) - layer?.contentsScale = scale - displayLayer?.contentsScale = scale - } -} - // MARK: - Window Accessor /// Invisible helper that resolves the hosting `NSWindow` reference diff --git a/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift b/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift new file mode 100644 index 0000000..b7d6db1 --- /dev/null +++ b/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift @@ -0,0 +1,139 @@ +import CoreGraphics +import Foundation + +struct CapturePreviewGeometry { + struct InitialContentSizeInput { + let aspect: CGSize + let framePixelSize: CGSize + let targetContentWidth: CGFloat? + let visibleFrameSize: CGSize + let chromeSize: CGSize + let layoutInsetSize: CGSize + let scaleFactor: CGFloat + } + + nonisolated static func parseResolution(_ text: String?) -> CGSize? { + guard let text else { return nil } + let separators: [Character] = ["×", "x", "X", "*"] + guard let separator = separators.first(where: { text.contains($0) }) else { return nil } + let parts = text.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 static func preferredAspect( + resolutionText: String?, + framePixelSize: CGSize + ) -> CGSize { + parseResolution(resolutionText) ?? framePixelSize + } + + nonisolated static func nativeFrameSizeInPoints( + framePixelSize: CGSize, + scaleFactor: CGFloat, + fallbackAspect: CGSize + ) -> CGSize { + guard framePixelSize.width > 0, framePixelSize.height > 0 else { + return CGSize(width: max(1, fallbackAspect.width), height: max(1, fallbackAspect.height)) + } + let sanitizedScale = max(1, scaleFactor) + return CGSize( + width: max(1, framePixelSize.width / sanitizedScale), + height: max(1, framePixelSize.height / sanitizedScale) + ) + } + + nonisolated static func initialContentSize(input: InitialContentSizeInput) -> CGSize? { + guard input.aspect.width > 0, input.aspect.height > 0 else { return nil } + + let maxPreviewWidth = max( + 320, + input.visibleFrameSize.width - input.chromeSize.width - input.layoutInsetSize.width - 16 + ) + let maxPreviewHeight = max( + 180, + input.visibleFrameSize.height - input.chromeSize.height - input.layoutInsetSize.height - 16 + ) + + let ratio = input.aspect.width / input.aspect.height + let scale = max(1, input.scaleFactor) + let pixelSize = input.framePixelSize + let defaultPreviewWidth = max(320, maxPreviewWidth * 0.70) + let defaultPreviewHeight = defaultPreviewWidth / ratio + var previewWidth = defaultPreviewWidth + var previewHeight = defaultPreviewHeight + + if pixelSize.width > 0, pixelSize.height > 0 { + previewWidth = pixelSize.width / scale + previewHeight = pixelSize.height / scale + } + + if let overriddenWidth = input.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 + } + + return CGSize( + width: previewWidth + input.layoutInsetSize.width, + height: previewHeight + input.layoutInsetSize.height + ) + } + + nonisolated static func aspectLockedContentSize( + aspect: CGSize, + proposedContentSize: CGSize, + layoutInsetSize: CGSize, + scaleFactor: CGFloat + ) -> CGSize? { + guard aspect.width > 0, aspect.height > 0 else { return nil } + let scale = max(1, scaleFactor) + + let insetWidthPixels = max(0, Int((layoutInsetSize.width * scale).rounded())) + let insetHeightPixels = max(0, Int((layoutInsetSize.height * scale).rounded())) + let proposedPreviewWidthPixels = max( + 1, + Int(((proposedContentSize.width - layoutInsetSize.width) * scale).rounded(.down)) + ) + let proposedPreviewHeightPixels = max( + 1, + Int(((proposedContentSize.height - layoutInsetSize.height) * 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)) + ) + } + + return CGSize( + width: CGFloat(previewWidthPixels + insetWidthPixels) / scale, + height: CGFloat(previewHeightPixels + insetHeightPixels) / scale + ) + } +} diff --git a/VoidDisplay/Features/Capture/Views/CapturePreviewWindowCoordinator.swift b/VoidDisplay/Features/Capture/Views/CapturePreviewWindowCoordinator.swift new file mode 100644 index 0000000..6f7a380 --- /dev/null +++ b/VoidDisplay/Features/Capture/Views/CapturePreviewWindowCoordinator.swift @@ -0,0 +1,127 @@ +import AppKit + +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? { + let currentContentRect = window.contentRect(forFrameRect: window.frame) + let currentLayoutRect = window.contentLayoutRect + let proposedContentRect = window.contentRect( + forFrameRect: NSRect(origin: .zero, size: proposedFrameSize) + ) + let targetContentSize = CapturePreviewGeometry.aspectLockedContentSize( + aspect: aspect, + proposedContentSize: proposedContentRect.size, + layoutInsetSize: CGSize( + width: max(0, currentContentRect.width - currentLayoutRect.width), + height: max(0, currentContentRect.height - currentLayoutRect.height) + ), + scaleFactor: max(1, window.backingScaleFactor) + ) + guard let targetContentSize else { return nil } + return NSSize(width: targetContentSize.width, height: targetContentSize.height) + } + + 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) + } +} diff --git a/VoidDisplay/Features/Capture/Views/ZeroCopyPreviewRenderer.swift b/VoidDisplay/Features/Capture/Views/ZeroCopyPreviewRenderer.swift new file mode 100644 index 0000000..76ea630 --- /dev/null +++ b/VoidDisplay/Features/Capture/Views/ZeroCopyPreviewRenderer.swift @@ -0,0 +1,199 @@ +import AppKit +import AVFoundation +import SwiftUI +import Synchronization + +@Observable +final class ZeroCopyPreviewRenderer: @unchecked Sendable, DisplayPreviewSink { + struct MetricsSnapshot: Sendable { + var receivedFrameCount: UInt64 + var renderedFrameCount: UInt64 + var droppedFrameCount: UInt64 + var latestRenderLatencyMilliseconds: Double? + var pendingSlotOccupied: Bool + } + + private struct PendingFrame { + let buffer: UncheckedSendableBuffer + let submittedAtNanoseconds: UInt64 + let generation: UInt64 + } + + private struct State { + var pendingFrame: PendingFrame? + var isDraining = false + var activeDrainToken: UInt64? + var nextDrainToken: UInt64 = 0 + var generation: UInt64 = 0 + var receivedFrameCount: UInt64 = 0 + var renderedFrameCount: UInt64 = 0 + var droppedFrameCount: UInt64 = 0 + var latestRenderLatencyMilliseconds: Double? + } + + var framePixelSize: CGSize = .zero + var hasReceivedFrame = false + + let displayLayer: AVSampleBufferDisplayLayer = { + let layer = AVSampleBufferDisplayLayer() + layer.videoGravity = .resizeAspect + layer.preventsDisplaySleepDuringVideoPlayback = false + return layer + }() + + nonisolated private let state = Mutex(State()) + @MainActor var willEnqueueFrameForTesting: (() -> Void)? + + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { + let box = UncheckedSendableBuffer(sampleBuffer) + let drainToken = state.withLock { state -> UInt64? in + state.receivedFrameCount &+= 1 + if state.pendingFrame != nil { + state.droppedFrameCount &+= 1 + } + state.pendingFrame = PendingFrame( + buffer: box, + submittedAtNanoseconds: DispatchTime.now().uptimeNanoseconds, + generation: state.generation + ) + guard !state.isDraining else { return nil } + state.isDraining = true + state.nextDrainToken &+= 1 + state.activeDrainToken = state.nextDrainToken + return state.nextDrainToken + } + guard let drainToken else { return } + + Task { @MainActor [weak self] in + self?.drainLoop(drainToken: drainToken) + } + } + + func flush() { + state.withLock { state in + state.pendingFrame = nil + state.generation &+= 1 + state.activeDrainToken = nil + state.isDraining = false + } + displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true) + } + + nonisolated func metricsSnapshot() -> MetricsSnapshot { + state.withLock { state in + .init( + receivedFrameCount: state.receivedFrameCount, + renderedFrameCount: state.renderedFrameCount, + droppedFrameCount: state.droppedFrameCount, + latestRenderLatencyMilliseconds: state.latestRenderLatencyMilliseconds, + pendingSlotOccupied: state.pendingFrame != nil + ) + } + } + + @MainActor + private func drainLoop(drainToken: UInt64) { + while true { + let nextFrame = state.withLock { state -> PendingFrame? in + guard state.activeDrainToken == drainToken else { + return nil + } + guard let pendingFrame = state.pendingFrame else { + state.activeDrainToken = nil + state.isDraining = false + return nil + } + state.pendingFrame = nil + return pendingFrame + } + guard let nextFrame else { return } + + willEnqueueFrameForTesting?() + let shouldRender = state.withLock { state in + state.activeDrainToken == drainToken && state.generation == nextFrame.generation + } + guard shouldRender else { continue } + + let renderer = displayLayer.sampleBufferRenderer + if renderer.status == .failed { + renderer.flush() + } + renderer.enqueue(nextFrame.buffer.buffer) + + if !hasReceivedFrame { + hasReceivedFrame = true + } + + if let desc = CMSampleBufferGetFormatDescription(nextFrame.buffer.buffer) { + let dims = CMVideoFormatDescriptionGetDimensions(desc) + let size = CGSize(width: CGFloat(dims.width), height: CGFloat(dims.height)) + if framePixelSize != size { + framePixelSize = size + } + } + + let latencyMilliseconds = Double( + DispatchTime.now().uptimeNanoseconds &- nextFrame.submittedAtNanoseconds + ) / 1_000_000 + state.withLock { state in + state.renderedFrameCount &+= 1 + state.latestRenderLatencyMilliseconds = latencyMilliseconds + } + } + } +} + +struct UncheckedSendableBuffer: @unchecked Sendable { + nonisolated(unsafe) let buffer: CMSampleBuffer + nonisolated init(_ buffer: CMSampleBuffer) { self.buffer = buffer } +} + +struct ZeroCopyPreviewLayerView: NSViewRepresentable { + let renderer: ZeroCopyPreviewRenderer + + func makeNSView(context: Context) -> ZeroCopyHostView { + let view = ZeroCopyHostView() + view.hostDisplayLayer(renderer.displayLayer) + return view + } + + func updateNSView(_: ZeroCopyHostView, context: Context) {} +} + +final class ZeroCopyHostView: NSView { + private weak var displayLayer: AVSampleBufferDisplayLayer? + + func hostDisplayLayer(_ layer: AVSampleBufferDisplayLayer) { + wantsLayer = true + layerContentsRedrawPolicy = .duringViewResize + layer.frame = bounds + self.layer?.addSublayer(layer) + displayLayer = layer + syncLayerScale() + } + + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + displayLayer?.frame = bounds + CATransaction.commit() + syncLayerScale() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + syncLayerScale() + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + syncLayerScale() + } + + private func syncLayerScale() { + let scale = max(1, window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) + layer?.contentsScale = scale + displayLayer?.contentsScale = scale + } +} diff --git a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift index 388cbd8..fbc25c7 100644 --- a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift +++ b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift @@ -4,6 +4,11 @@ import ScreenCaptureKit @MainActor final class DisplaySharingCoordinator { + typealias AcquireShare = @MainActor ( + SCDisplay, + DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome + struct ShareableDisplayRegistrationInput { let displayID: CGDirectDisplayID let isMain: Bool @@ -26,14 +31,23 @@ final class DisplaySharingCoordinator { private var sessionsByDisplayID: [CGDirectDisplayID: SharingSession] = [:] private var mainDisplayID: CGDirectDisplayID? private let idStore: DisplayShareIDStore - private let captureRegistry: DisplayCaptureRegistry + private let startCoordinator: DisplayStreamStartCoordinator + private let acquireShare: AcquireShare init( idStore: DisplayShareIDStore, - captureRegistry: DisplayCaptureRegistry = .shared + startCoordinator: DisplayStreamStartCoordinator = DisplayStreamStartCoordinator(), + captureRegistry: DisplayCaptureRegistry = .shared, + acquireShare: AcquireShare? = nil ) { self.idStore = idStore - self.captureRegistry = captureRegistry + self.startCoordinator = startCoordinator + self.acquireShare = acquireShare ?? { display, invalidationContext in + try await captureRegistry.acquireShare( + display: SendableDisplay(display), + invalidationContext: invalidationContext + ) + } } var hasAnyActiveSharing: Bool { @@ -44,10 +58,15 @@ final class DisplaySharingCoordinator { Set(sessionsByDisplayID.keys) } + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startCoordinator.isStarting(kind: .sharing, displayID: displayID) + } + + @discardableResult func registerShareableDisplays( _ displays: [SCDisplay], virtualSerialResolver: (CGDirectDisplayID) -> UInt32? - ) { + ) -> Set { let inputs = displays.map { display in ShareableDisplayRegistrationInput( displayID: display.displayID, @@ -55,10 +74,11 @@ final class DisplaySharingCoordinator { virtualSerial: virtualSerialResolver(display.displayID) ) } - registerShareableDisplays(inputs) + return registerShareableDisplays(inputs) } - func registerShareableDisplays(_ inputs: [ShareableDisplayRegistrationInput]) { + @discardableResult + func registerShareableDisplays(_ inputs: [ShareableDisplayRegistrationInput]) -> Set { var nextRegistrationsByDisplayID: [CGDirectDisplayID: DisplayRegistration] = [:] var nextDisplayIDsByShareID: [UInt32: CGDirectDisplayID] = [:] var resolvedMainDisplayID: CGDirectDisplayID? @@ -106,45 +126,100 @@ final class DisplaySharingCoordinator { nextDisplayIDsByShareID[shareID] = input.displayID } + let currentRegistrationsByDisplayID = registrationsByDisplayID + let invalidatedTargets = invalidatedConcreteTargets( + current: currentRegistrationsByDisplayID, + next: nextRegistrationsByDisplayID + ) + let displayIDsToInvalidate = invalidatedDisplayIDs( + current: currentRegistrationsByDisplayID, + next: nextRegistrationsByDisplayID + ) registrationsByDisplayID = nextRegistrationsByDisplayID displayIDsByShareID = nextDisplayIDsByShareID mainDisplayID = resolvedMainDisplayID ?? mainDisplayID + for displayID in displayIDsToInvalidate { + startCoordinator.invalidate(kind: .sharing, displayID: displayID) + } let registeredDisplayIDs = Set(nextRegistrationsByDisplayID.keys) for displayID in Array(sessionsByDisplayID.keys) where !registeredDisplayIDs.contains(displayID) { stopSharing(displayID: displayID) } + return invalidatedTargets } - 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 + func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome { + let displayID = display.displayID + guard registrationsByDisplayID[displayID] != nil else { + throw SharingStartError.displayNotRegistered(displayID) + } + + return try await startCoordinator.start( + kind: .sharing, + displayID: displayID + ) { [self, acquireShare] invalidationContext in + guard self.registrationsByDisplayID[displayID] != nil else { + return .invalidated + } + self.stopActiveSharingSession(displayID: displayID) + + let subscription: DisplayShareSubscription + switch try await acquireShare(display, invalidationContext) { + case .invalidated: + return .invalidated + case .started(let acquiredSubscription): + subscription = acquiredSubscription + } + + if invalidationContext.isInvalidated() || self.registrationsByDisplayID[displayID] == nil { + subscription.cancel() + return .invalidated + } + + switch try await subscription.prepareForSharing(invalidationContext: invalidationContext) { + case .invalidated: + return .invalidated + case .started: + break + } + + if invalidationContext.isInvalidated() || self.registrationsByDisplayID[displayID] == nil { + subscription.cancel() + return .invalidated + } + self.sessionsByDisplayID[displayID] = SharingSession(display: display, subscription: subscription) + if CGDisplayIsMain(displayID) != 0 { + self.mainDisplayID = displayID + } + return .started(()) } } func stopSharing(displayID: CGDirectDisplayID) { + startCoordinator.invalidate(kind: .sharing, displayID: displayID) + stopActiveSharingSession(displayID: displayID) + } + + private func stopActiveSharingSession(displayID: CGDirectDisplayID) { guard let session = sessionsByDisplayID.removeValue(forKey: displayID) else { return } session.subscription.cancel() } func stopAllSharing() { + startCoordinator.invalidateAll(kind: .sharing) for displayID in Array(sessionsByDisplayID.keys) { - stopSharing(displayID: displayID) + stopActiveSharingSession(displayID: displayID) } } func state(for target: ShareTarget) -> ShareTargetState { switch target { case .main: - if let resolvedMainID = resolvedMainDisplayID(), - sessionsByDisplayID[resolvedMainID] != nil { - return .active + guard let resolvedMainID = resolvedMainDisplayID() else { + return .knownInactive } - return .knownInactive + return sessionsByDisplayID[resolvedMainID] != nil ? .active : .knownInactive case .id(let id): guard let displayID = displayIDsByShareID[id] else { return .unknown @@ -164,6 +239,20 @@ final class DisplaySharingCoordinator { } } + func resolveConcreteTarget(for target: ShareTarget) -> ShareTarget? { + switch target { + case .main: + guard let resolvedMainID = resolvedMainDisplayID(), + let shareID = registrationsByDisplayID[resolvedMainID]?.shareID else { + return nil + } + return .id(shareID) + case .id(let id): + guard displayIDsByShareID[id] != nil else { return nil } + return .id(id) + } + } + func shareID(for displayID: CGDirectDisplayID) -> UInt32? { registrationsByDisplayID[displayID]?.shareID } @@ -196,6 +285,44 @@ final class DisplaySharingCoordinator { return nil } + private func invalidatedDisplayIDs( + current: [CGDirectDisplayID: DisplayRegistration], + next: [CGDirectDisplayID: DisplayRegistration] + ) -> Set { + let allDisplayIDs = Set(current.keys).union(next.keys) + return Set( + allDisplayIDs.filter { displayID in + switch (current[displayID], next[displayID]) { + case (.none, .some), (.some, .none): + true + case (.some(let old), .some(let new)): + old.shareID != new.shareID || old.isMain != new.isMain + case (.none, .none): + false + } + } + ) + } + + private func invalidatedConcreteTargets( + current: [CGDirectDisplayID: DisplayRegistration], + next: [CGDirectDisplayID: DisplayRegistration] + ) -> Set { + Set( + current.compactMap { displayID, registration in + switch next[displayID] { + case .none: + return .id(registration.shareID) + case .some(let nextRegistration): + guard registration.shareID != nextRegistration.shareID else { + return nil + } + return .id(registration.shareID) + } + } + ) + } + private func makeIdentityKey(for displayID: CGDirectDisplayID) -> String { if let cfUUID = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() { let uuidString = CFUUIDCreateString(nil, cfUUID) as String diff --git a/VoidDisplay/Features/Sharing/Services/SharingService.swift b/VoidDisplay/Features/Sharing/Services/SharingService.swift index 480c39f..2af8659 100644 --- a/VoidDisplay/Features/Sharing/Services/SharingService.swift +++ b/VoidDisplay/Features/Sharing/Services/SharingService.swift @@ -3,6 +3,17 @@ import ScreenCaptureKit import OSLog import CoreGraphics +enum SharingStartError: LocalizedError, Equatable { + case displayNotRegistered(CGDirectDisplayID) + + var errorDescription: String? { + switch self { + case .displayNotRegistered: + String(localized: "Selected display is no longer available for sharing.") + } + } +} + @MainActor protocol SharingServiceProtocol: AnyObject { var webServicePortValue: UInt16 { get } @@ -10,10 +21,15 @@ protocol SharingServiceProtocol: AnyObject { var onWebServiceLifecycleStateChanged: (@MainActor @Sendable (WebServiceLifecycleState) -> Void)? { get set } var webServiceLifecycleState: WebServiceLifecycleState { get } var isWebServiceRunning: Bool { get } + var sharingStateSnapshot: SharingStateSnapshot { get } var activeStreamClientCount: Int { get } var currentWebServer: WebServer? { get } var hasAnyActiveSharing: Bool { get } var activeSharingDisplayIDs: Set { get } + func isStarting(displayID: CGDirectDisplayID) -> Bool + func subscribeSharingState( + _ observer: @escaping @MainActor @Sendable (SharingStateSnapshot) -> Void + ) -> SharingStateSubscription @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult @@ -22,7 +38,7 @@ protocol SharingServiceProtocol: AnyObject { _ displays: [SCDisplay], virtualSerialResolver: (CGDirectDisplayID) -> UInt32? ) - func startSharing(display: SCDisplay) async throws + func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome func stopSharing(displayID: CGDirectDisplayID) func stopAllSharing() func isSharing(displayID: CGDirectDisplayID) -> Bool @@ -35,13 +51,16 @@ protocol SharingServiceProtocol: AnyObject { final class SharingService: SharingServiceProtocol { private let sharingCoordinator: DisplaySharingCoordinator private let webServiceController: any WebServiceControllerProtocol + private let sharingStateAggregator: SharingStateAggregator init( webServiceController: (any WebServiceControllerProtocol)? = nil, - sharingCoordinator: DisplaySharingCoordinator + sharingCoordinator: DisplaySharingCoordinator, + sharingStateAggregator: SharingStateAggregator = SharingStateAggregator() ) { self.webServiceController = webServiceController ?? WebServiceController() self.sharingCoordinator = sharingCoordinator + self.sharingStateAggregator = sharingStateAggregator } var webServicePortValue: UInt16 { @@ -66,12 +85,16 @@ final class SharingService: SharingServiceProtocol { webServiceController.isRunning } + var sharingStateSnapshot: SharingStateSnapshot { + sharingStateAggregator.currentSnapshot + } + var activeStreamClientCount: Int { - webServiceController.activeStreamClientCount + sharingStateAggregator.currentSnapshot.streamingPeers } func streamClientCount(for target: ShareTarget) -> Int { - webServiceController.streamClientCount(for: target) + sharingStateAggregator.currentSnapshot.streamingPeersByTarget[target] ?? 0 } var currentWebServer: WebServer? { @@ -86,21 +109,44 @@ final class SharingService: SharingServiceProtocol { sharingCoordinator.activeSharingDisplayIDs } + func isStarting(displayID: CGDirectDisplayID) -> Bool { + sharingCoordinator.isStarting(displayID: displayID) + } + + func subscribeSharingState( + _ observer: @escaping @MainActor @Sendable (SharingStateSnapshot) -> Void + ) -> SharingStateSubscription { + sharingStateAggregator.subscribe(observer) + } + @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult { + let shouldResetBeforeStart = !webServiceController.isRunning + if shouldResetBeforeStart { + sharingStateAggregator.reset() + } let result = await webServiceController.start( requestedPort: requestedPort, targetStateProvider: { [weak self] target in self?.sharingCoordinator.state(for: target) ?? .unknown }, + concreteTargetResolver: { [weak self] target in + self?.sharingCoordinator.resolveConcreteTarget(for: target) + }, sessionHubProvider: { [weak self] target in self?.sharingCoordinator.sessionHub(for: target) + }, + sharingEventSink: { [weak self] event in + Task { @MainActor [weak self] in + self?.recordSharingEvent(event) + } } ) if case .failed(let failure) = result { AppLog.sharing.error( "Failed to start web sharing service (requestedPort: \(requestedPort, privacy: .public), reason: \(String(describing: failure), privacy: .public))." ) + sharingStateAggregator.reset() } return result } @@ -108,21 +154,24 @@ final class SharingService: SharingServiceProtocol { func stopWebService() { stopAllSharing() webServiceController.stop() + sharingStateAggregator.reset() } func registerShareableDisplays( _ displays: [SCDisplay], virtualSerialResolver: (CGDirectDisplayID) -> UInt32? ) { - sharingCoordinator.registerShareableDisplays( + let invalidatedTargets = sharingCoordinator.registerShareableDisplays( displays, virtualSerialResolver: virtualSerialResolver ) + guard !invalidatedTargets.isEmpty else { return } + webServiceController.disconnectStreamClients(for: invalidatedTargets) } - func startSharing(display: SCDisplay) async throws { + func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome { AppLog.sharing.info("Begin sharing stream for display \(display.displayID, privacy: .public).") - try await sharingCoordinator.startSharing(display: display) + return try await sharingCoordinator.startSharing(display: display) } func stopSharing(displayID: CGDirectDisplayID) { @@ -145,4 +194,8 @@ final class SharingService: SharingServiceProtocol { func shareTarget(for displayID: CGDirectDisplayID) -> ShareTarget? { sharingCoordinator.target(for: displayID) } + + private func recordSharingEvent(_ event: SharingSessionEvent) { + sharingStateAggregator.record(event) + } } diff --git a/VoidDisplay/Features/Sharing/Services/SharingState.swift b/VoidDisplay/Features/Sharing/Services/SharingState.swift new file mode 100644 index 0000000..40345de --- /dev/null +++ b/VoidDisplay/Features/Sharing/Services/SharingState.swift @@ -0,0 +1,235 @@ +import Foundation + +enum SharingSessionEventSource: String, Sendable, Equatable, Codable { + case webSocket + case peerConnection +} + +enum SharingPeerPhase: String, Sendable, Equatable, Codable { + case signalingConnected + case offerReceived + case peerConnected + case peerDisconnected + case peerFailed + case closed +} + +struct SharingSessionEvent: Sendable, Equatable { + let target: ShareTarget + let clientID: String + let sessionEpoch: UInt64 + let sequence: UInt64 + let phase: SharingPeerPhase + let source: SharingSessionEventSource + let timestamp: Date + + nonisolated var recordedPhase: SharingPeerPhase { + phase + } + + nonisolated var recordedSequence: UInt64 { + sequence + } + + nonisolated var recordedSessionEpoch: UInt64 { + sessionEpoch + } + + nonisolated init( + target: ShareTarget, + clientID: String, + sessionEpoch: UInt64 = 0, + sequence: UInt64 = 0, + phase: SharingPeerPhase, + source: SharingSessionEventSource, + timestamp: Date = Date() + ) { + self.target = target + self.clientID = clientID + self.sessionEpoch = sessionEpoch + self.sequence = sequence + self.phase = phase + self.source = source + self.timestamp = timestamp + } +} + +struct SharingClientState: Sendable, Equatable { + let target: ShareTarget + let clientID: String + let phase: SharingPeerPhase + let source: SharingSessionEventSource + let lastUpdatedAt: Date + + nonisolated var recordedPhase: SharingPeerPhase { + phase + } + + nonisolated init( + target: ShareTarget, + clientID: String, + phase: SharingPeerPhase, + source: SharingSessionEventSource, + lastUpdatedAt: Date + ) { + self.target = target + self.clientID = clientID + self.phase = phase + self.source = source + self.lastUpdatedAt = lastUpdatedAt + } + + var hasActiveSignalingConnection: Bool { + phase != .closed + } + + var hasActiveStreamingPeer: Bool { + phase == .peerConnected + } +} + +struct SharingStateSnapshot: Sendable, Equatable { + let signalingConnections: Int + let streamingPeers: Int + let signalingConnectionsByTarget: [ShareTarget: Int] + let streamingPeersByTarget: [ShareTarget: Int] + let clientsByTarget: [ShareTarget: [String: SharingClientState]] + let lastUpdatedAt: Date? + + static let empty = SharingStateSnapshot( + signalingConnections: 0, + streamingPeers: 0, + signalingConnectionsByTarget: [:], + streamingPeersByTarget: [:], + clientsByTarget: [:], + lastUpdatedAt: nil + ) +} + +@MainActor +final class SharingStateSubscription { + private let cancelClosure: @MainActor () -> Void + private var isCancelled = false + + init(cancelClosure: @escaping @MainActor () -> Void) { + self.cancelClosure = cancelClosure + } + + func cancel() { + guard !isCancelled else { return } + isCancelled = true + cancelClosure() + } +} + +@MainActor +final class SharingStateAggregator { + typealias Observer = @MainActor @Sendable (SharingStateSnapshot) -> Void + nonisolated static let closedClientTombstoneLimit = 512 + + private struct ConnectionKey: Hashable { + let clientID: String + let sessionEpoch: UInt64 + } + + private var clientStatesByConnectionKey: [ConnectionKey: SharingClientState] = [:] + private var lastAcceptedSequenceByConnectionKey: [ConnectionKey: UInt64] = [:] + private var closedClientTombstoneSequenceByConnectionKey: [ConnectionKey: UInt64] = [:] + private var closedClientTombstoneOrder: [(key: ConnectionKey, sequence: UInt64)] = [] + private var observers: [UUID: Observer] = [:] + private var snapshot = SharingStateSnapshot.empty + + var currentSnapshot: SharingStateSnapshot { + snapshot + } + + var closedClientTombstoneCountForTesting: Int { + closedClientTombstoneSequenceByConnectionKey.count + } + + func record(_ event: SharingSessionEvent) { + let key = ConnectionKey(clientID: event.clientID, sessionEpoch: event.sessionEpoch) + if let lastAcceptedSequence = lastAcceptedSequenceByConnectionKey[key], + event.sequence <= lastAcceptedSequence { + return + } + if closedClientTombstoneSequenceByConnectionKey[key] != nil { + return + } + if event.phase == .closed { + clientStatesByConnectionKey.removeValue(forKey: key) + lastAcceptedSequenceByConnectionKey.removeValue(forKey: key) + closedClientTombstoneSequenceByConnectionKey[key] = event.sequence + closedClientTombstoneOrder.append((key, event.sequence)) + pruneClosedClientTombstonesIfNeeded() + } else { + lastAcceptedSequenceByConnectionKey[key] = event.sequence + let nextState = SharingClientState( + target: event.target, + clientID: event.clientID, + phase: event.phase, + source: event.source, + lastUpdatedAt: event.timestamp + ) + clientStatesByConnectionKey[key] = nextState + } + rebuildSnapshot(lastUpdatedAt: event.timestamp) + } + + func reset() { + clientStatesByConnectionKey.removeAll() + lastAcceptedSequenceByConnectionKey.removeAll() + closedClientTombstoneSequenceByConnectionKey.removeAll() + closedClientTombstoneOrder.removeAll() + rebuildSnapshot(lastUpdatedAt: nil) + } + + func subscribe(_ observer: @escaping Observer) -> SharingStateSubscription { + let id = UUID() + observers[id] = observer + observer(snapshot) + return SharingStateSubscription { [weak self] in + self?.observers.removeValue(forKey: id) + } + } + + private func rebuildSnapshot(lastUpdatedAt: Date?) { + var clientsByTarget: [ShareTarget: [String: SharingClientState]] = [:] + var signalingConnectionsByTarget: [ShareTarget: Int] = [:] + var streamingPeersByTarget: [ShareTarget: Int] = [:] + + for clientState in clientStatesByConnectionKey.values { + clientsByTarget[clientState.target, default: [:]][clientState.clientID] = clientState + if clientState.hasActiveSignalingConnection { + signalingConnectionsByTarget[clientState.target, default: 0] += 1 + } + if clientState.hasActiveStreamingPeer { + streamingPeersByTarget[clientState.target, default: 0] += 1 + } + } + + let signalingConnections = signalingConnectionsByTarget.values.reduce(0, +) + let streamingPeers = streamingPeersByTarget.values.reduce(0, +) + snapshot = SharingStateSnapshot( + signalingConnections: signalingConnections, + streamingPeers: streamingPeers, + signalingConnectionsByTarget: signalingConnectionsByTarget, + streamingPeersByTarget: streamingPeersByTarget, + clientsByTarget: clientsByTarget, + lastUpdatedAt: lastUpdatedAt + ) + + for observer in observers.values { + observer(snapshot) + } + } + + private func pruneClosedClientTombstonesIfNeeded() { + while closedClientTombstoneSequenceByConnectionKey.count > Self.closedClientTombstoneLimit, + let oldest = closedClientTombstoneOrder.first { + closedClientTombstoneOrder.removeFirst() + guard closedClientTombstoneSequenceByConnectionKey[oldest.key] == oldest.sequence else { continue } + closedClientTombstoneSequenceByConnectionKey.removeValue(forKey: oldest.key) + } + } +} diff --git a/VoidDisplay/Features/Sharing/Services/WebServiceController.swift b/VoidDisplay/Features/Sharing/Services/WebServiceController.swift index 76fcb6f..bfd0224 100644 --- a/VoidDisplay/Features/Sharing/Services/WebServiceController.swift +++ b/VoidDisplay/Features/Sharing/Services/WebServiceController.swift @@ -35,10 +35,13 @@ protocol WebServiceControllerProtocol: AnyObject { func start( requestedPort: UInt16, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, - sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, + sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, + sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void ) async -> WebServiceStartResult func stop() func disconnectAllStreamClients() + func disconnectStreamClients(for targets: Set) } @MainActor @@ -46,6 +49,7 @@ protocol WebServiceServerProtocol: AnyObject { func startListener() async -> WebServer.ListenerStartResult func stopListener(reason: WebServiceServerStopReason) func disconnectAllStreamClients() + func disconnectStreamClients(for targets: Set) var activeStreamClientCount: Int { get } func streamClientCount(for target: ShareTarget) -> Int } @@ -69,7 +73,9 @@ final class WebServiceController: WebServiceControllerProtocol { typealias WebServiceServerFactory = @MainActor @Sendable ( _ port: NWEndpoint.Port, _ targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + _ concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, _ sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, + _ sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void, _ onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? ) throws -> any WebServiceServerProtocol @@ -91,12 +97,16 @@ final class WebServiceController: WebServiceControllerProtocol { webServiceServerFactory: @escaping WebServiceServerFactory = { port, targetStateProvider, + concreteTargetResolver, sessionHubProvider, + sharingEventSink, onListenerStopped in try WebServer( using: port, targetStateProvider: targetStateProvider, + concreteTargetResolver: concreteTargetResolver, sessionHubProvider: sessionHubProvider, + sharingEventSink: sharingEventSink, onListenerStopped: onListenerStopped ) } @@ -139,7 +149,9 @@ final class WebServiceController: WebServiceControllerProtocol { func start( requestedPort: UInt16, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, - sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, + sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, + sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void ) async -> WebServiceStartResult { if case .running(let binding) = state, activeServer != nil { AppLog.web.debug("Start requested while web service is already running.") @@ -168,7 +180,9 @@ final class WebServiceController: WebServiceControllerProtocol { requestedPort: requestedPort, operationNonce: nonce, targetStateProvider: targetStateProvider, - sessionHubProvider: sessionHubProvider + concreteTargetResolver: concreteTargetResolver, + sessionHubProvider: sessionHubProvider, + sharingEventSink: sharingEventSink ) } @@ -208,11 +222,18 @@ final class WebServiceController: WebServiceControllerProtocol { activeServer?.disconnectAllStreamClients() } + func disconnectStreamClients(for targets: Set) { + guard !targets.isEmpty else { return } + activeServer?.disconnectStreamClients(for: targets) + } + private func startInternal( requestedPort: UInt16, operationNonce: UInt64, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, - sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, + sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, + sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void ) async -> WebServiceStartResult { lastRequestedPort = requestedPort @@ -244,7 +265,9 @@ final class WebServiceController: WebServiceControllerProtocol { let server = try webServiceServerFactory( port, targetStateProvider, + concreteTargetResolver, sessionHubProvider, + sharingEventSink, { [weak self] reason in self?.handleServerStop(serverToken: serverToken, reason: reason) } diff --git a/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift b/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift index e3f9b37..8c6bc8e 100644 --- a/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift +++ b/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift @@ -8,10 +8,9 @@ import OSLog @MainActor @Observable final class ShareViewModel { - typealias LoadErrorInfo = ScreenCaptureDisplayCatalogLoadErrorInfo - struct SharingQueries { var isWebServiceRunning: @MainActor () -> Bool + var isStartingDisplayID: @MainActor (CGDirectDisplayID) -> Bool var sharePageAddress: @MainActor (CGDirectDisplayID) -> String? var preferredWebServicePort: @MainActor () -> UInt16 } @@ -20,7 +19,7 @@ final class ShareViewModel { var startWebService: @MainActor (UInt16) async -> WebServiceStartResult var stopWebService: @MainActor () -> Void var registerShareableDisplays: @MainActor ([SCDisplay], @escaping (CGDirectDisplayID) -> UInt32?) -> Void - var beginSharing: @MainActor (SCDisplay) async throws -> Void + var beginSharing: @MainActor (SCDisplay) async throws -> DisplayStartOutcome var stopSharing: @MainActor (CGDirectDisplayID) -> Void } @@ -40,6 +39,7 @@ final class ShareViewModel { .init( sharingQueries: .init( isWebServiceRunning: { sharing.isWebServiceRunning }, + isStartingDisplayID: { displayID in sharing.isStarting(displayID: displayID) }, sharePageAddress: { displayID in sharing.sharePageAddress(for: displayID) }, preferredWebServicePort: { sharing.preferredWebServicePort } ), @@ -81,62 +81,24 @@ final class ShareViewModel { } var portInputErrorMessage: String? var isStartingService = false - var startingDisplayIDs: Set = [] var userFacingAlert: UserFacingAlertState? - private let topologyCoordinator: ScreenCaptureCatalogTopologyCoordinator + @ObservationIgnored private let activeDisplayIDsProvider: @MainActor () -> Set @ObservationIgnored private let dependencies: Dependencies - @ObservationIgnored private let catalogLoader: ScreenCaptureDisplayCatalogLoader init( catalogState: ScreenCaptureDisplayCatalogState? = nil, - permissionProvider: (any ScreenCapturePermissionProvider)? = nil, - loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, activeDisplayIDsProvider: @escaping @MainActor () -> Set = { Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) }, dependencies: Dependencies ) { - let catalog = catalogState ?? ScreenCaptureDisplayCatalogState() - self.catalog = catalog - self.topologyCoordinator = ScreenCaptureCatalogTopologyCoordinator( - state: catalog, - activeDisplayIDsProvider: activeDisplayIDsProvider - ) + self.catalog = catalogState ?? ScreenCaptureDisplayCatalogState() + self.activeDisplayIDsProvider = activeDisplayIDsProvider self.dependencies = dependencies - self.catalogLoader = ScreenCaptureDisplayCatalogLoader( - state: catalog, - permissionProvider: permissionProvider, - loadShareableDisplays: loadShareableDisplays, - logOperation: "Load shareable displays (sharing)", - logger: AppLog.capture - ) self.servicePortInput = String(dependencies.sharingQueries.preferredWebServicePort()) } - func syncForCurrentState( - clearDisplaysWhenPermissionDenied: Bool = true, - clearDisplaysWhenServiceStopped: Bool = true - ) { - guard catalog.hasScreenCapturePermission == true else { - if clearDisplaysWhenPermissionDenied { - catalogLoader.clearDisplaysAndCancel() - } else { - catalogLoader.cancelInFlightDisplayLoad() - } - return - } - guard dependencies.sharingQueries.isWebServiceRunning() else { - if clearDisplaysWhenServiceStopped { - catalogLoader.clearDisplaysAndCancel() - } else { - catalogLoader.cancelInFlightDisplayLoad() - } - return - } - refreshDisplaysForCurrentTopologyIfNeeded() - } - func startService() { Task { @MainActor in isStartingService = true @@ -162,133 +124,64 @@ final class ShareViewModel { } servicePortInput = String(requestedPort) portInputErrorMessage = nil - syncForCurrentState() } } func stopService() { - catalogLoader.cancelInFlightDisplayLoad() dependencies.sharingActions.stopWebService() - syncForCurrentState() - } - - func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { - catalogLoader.openScreenCapturePrivacySettings(openURL: openURL) - } - - func requestScreenCapturePermission() { - let granted = catalogLoader.requestPermission() - - AppLog.capture.notice( - "Screen capture permission request (sharing): requestResult=\((self.catalog.lastRequestPermission ?? false), privacy: .public), preflightResult=\(granted, privacy: .public)" - ) - - if !granted { - catalogLoader.clearDisplaysAndCancel() - catalog.loadErrorMessage = String(localized: "Failed to load displays. Check permission and try again.") - AppLog.capture.notice("Screen capture permission request denied (sharing).") - return - } - syncForCurrentState() - } - - func refreshPermissionAndMaybeLoad() { - let granted = catalogLoader.refreshPermission() - if !granted { - catalogLoader.clearDisplaysAndCancel() - return - } - syncForCurrentState(clearDisplaysWhenServiceStopped: false) - } - - func loadDisplaysIfNeeded() { - guard dependencies.sharingQueries.isWebServiceRunning() else { return } - catalogLoader.loadDisplaysIfNeeded { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } - } - - func loadDisplays() { - catalogLoader.loadDisplays { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } - } - - func refreshDisplays() { - guard dependencies.sharingQueries.isWebServiceRunning() else { return } - catalogLoader.loadDisplays(preserveExistingDisplays: true) { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } - } - - func refreshDisplaysBackgroundSafe() { - guard dependencies.sharingQueries.isWebServiceRunning() else { return } - guard !catalog.isLoadingDisplays else { return } - guard topologyCoordinator.needsRefresh() else { return } - if catalog.displays == nil { - loadDisplaysIfNeeded() - return - } - catalogLoader.loadDisplays(preserveExistingDisplays: true) { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } } func visibleDisplays(from displays: [SCDisplay]) -> [SCDisplay] { - topologyCoordinator.visibleDisplays(from: displays) + let activeDisplayIDs = activeDisplayIDsProvider() + return displays.filter { activeDisplayIDs.contains($0.displayID) } } - @discardableResult - func withDisplayStartLock( - displayID: CGDirectDisplayID, - operation: () async -> Void - ) async -> Bool { - guard !startingDisplayIDs.contains(displayID) else { return false } - startingDisplayIDs.insert(displayID) - defer { startingDisplayIDs.remove(displayID) } - await operation() - return true + func isStarting(displayID: CGDirectDisplayID) -> Bool { + dependencies.sharingQueries.isStartingDisplayID(displayID) } func startSharing(display: SCDisplay) async { - _ = await withDisplayStartLock(displayID: display.displayID) { - let ready: Bool - if dependencies.sharingQueries.isWebServiceRunning() { - ready = true - } else { - let requestedPort: UInt16 - switch SharePortValidationError.parse(servicePortInput) { - case .success(let parsed): - requestedPort = parsed - case .failure(let validationError): - presentPortInputError(validationError.userMessage) - return - } - let result = await dependencies.sharingActions.startWebService(requestedPort) - if case .failed(let failure) = result { - presentPortInputError(failure.userMessage) - return - } - ready = true + guard !isStarting(displayID: display.displayID) else { return } + + let ready: Bool + if dependencies.sharingQueries.isWebServiceRunning() { + ready = true + } else { + let requestedPort: UInt16 + switch SharePortValidationError.parse(servicePortInput) { + case .success(let parsed): + requestedPort = parsed + case .failure(let validationError): + presentPortInputError(validationError.userMessage) + return } - guard ready else { - presentError( - title: String(localized: "Share Failed"), - message: String(localized: "Web service is not running.") - ) + let result = await dependencies.sharingActions.startWebService(requestedPort) + if case .failed(let failure) = result { + presentPortInputError(failure.userMessage) return } + ready = true + } + guard ready else { + presentError( + title: String(localized: "Share Failed"), + message: String(localized: "Web service is not running.") + ) + return + } - do { - try await dependencies.sharingActions.beginSharing(display) - } catch { - dependencies.sharingActions.stopSharing(display.displayID) - AppErrorMapper.logFailure("Start sharing", error: error, logger: AppLog.sharing) - presentError( - title: String(localized: "Share Failed"), - message: AppErrorMapper.userMessage(for: error, fallback: String(localized: "Failed to start sharing.")) - ) + do { + let outcome = try await dependencies.sharingActions.beginSharing(display) + if case .invalidated = outcome { + return } + } catch { + dependencies.sharingActions.stopSharing(display.displayID) + AppErrorMapper.logFailure("Start sharing", error: error, logger: AppLog.sharing) + presentError( + title: String(localized: "Share Failed"), + message: AppErrorMapper.userMessage(for: error, fallback: String(localized: "Failed to start sharing.")) + ) } } @@ -304,32 +197,6 @@ final class ShareViewModel { userFacingAlert = nil } - func cancelInFlightDisplayLoad() { - catalogLoader.cancelInFlightDisplayLoad() - } - - private func refreshDisplaysForCurrentTopologyIfNeeded() { - guard topologyCoordinator.needsRefresh() else { return } - if catalog.displays == nil { - loadDisplaysIfNeeded() - return - } - catalogLoader.loadDisplays(preserveExistingDisplays: true) { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } - } - - private func handleDisplaysLoaded(_ displays: [SCDisplay]) { - topologyCoordinator.commitLoadedTopologySignature() - registerShareableDisplays(displays) - } - - private func registerShareableDisplays(_ displays: [SCDisplay]) { - dependencies.sharingActions.registerShareableDisplays(displays) { [weak self] displayID in - self?.dependencies.virtualDisplayQueries.virtualSerialForManagedDisplay(displayID) - } - } - private func presentError(title: String, message: String) { userFacingAlert = UserFacingAlertState(title: title, message: message) } diff --git a/VoidDisplay/Features/Sharing/Views/ShareDisplayList.swift b/VoidDisplay/Features/Sharing/Views/ShareDisplayList.swift index 6d3acb0..d5c511d 100644 --- a/VoidDisplay/Features/Sharing/Views/ShareDisplayList.swift +++ b/VoidDisplay/Features/Sharing/Views/ShareDisplayList.swift @@ -62,6 +62,7 @@ struct ShareDisplayList: View { let displayURL = displayAddress.flatMap(URL.init(string:)) let displayClientCount = sharing.sharingClientCounts[display.displayID] ?? 0 let isPrimaryDisplay = CGDisplayIsMain(display.displayID) != 0 + let isStartingDisplay = sharing.isStarting(displayID: display.displayID) let isMonitoring = capture.screenCaptureSessions.contains { $0.displayID == display.displayID } @@ -103,7 +104,8 @@ struct ShareDisplayList: View { displayAddress: displayAddress, displayURL: displayURL, displayClientCount: displayClientCount, - isSharingDisplay: isSharingDisplay + isSharingDisplay: isSharingDisplay, + isStartingDisplay: isStartingDisplay ) } } @@ -114,7 +116,8 @@ struct ShareDisplayList: View { displayAddress: String?, displayURL: URL?, displayClientCount: Int, - isSharingDisplay: Bool + isSharingDisplay: Bool, + isStartingDisplay: Bool ) -> some View { ViewThatFits(in: .horizontal) { HStack(alignment: .center, spacing: AppUI.Spacing.medium) { @@ -127,7 +130,11 @@ struct ShareDisplayList: View { openURLAction: openURLAction ) - shareActionButton(display: display, isSharingDisplay: isSharingDisplay) + shareActionButton( + display: display, + isSharingDisplay: isSharingDisplay, + isStartingDisplay: isStartingDisplay + ) } VStack(alignment: .trailing, spacing: AppUI.Spacing.small) { @@ -140,14 +147,22 @@ struct ShareDisplayList: View { openURLAction: openURLAction ) - shareActionButton(display: display, isSharingDisplay: isSharingDisplay) + shareActionButton( + display: display, + isSharingDisplay: isSharingDisplay, + isStartingDisplay: isStartingDisplay + ) } } .frame(maxWidth: 560, alignment: .trailing) } @ViewBuilder - private func shareActionButton(display: SCDisplay, isSharingDisplay: Bool) -> some View { + private func shareActionButton( + display: SCDisplay, + isSharingDisplay: Bool, + isStartingDisplay: Bool + ) -> some View { Button { if isSharingDisplay { viewModel.stopSharing(displayID: display.displayID) @@ -159,19 +174,23 @@ struct ShareDisplayList: View { } label: { ZStack { Label(String(localized: "Share"), systemImage: "play.fill").hidden() + Label(String(localized: "Starting"), systemImage: "hourglass").hidden() Label(String(localized: "Stop"), systemImage: "stop.fill").hidden() if isSharingDisplay { Label(String(localized: "Stop"), systemImage: "stop.fill") + } else if isStartingDisplay { + Label(String(localized: "Starting"), systemImage: "hourglass") } else { Label(String(localized: "Share"), systemImage: "play.fill") } } } .appActionButtonStyle(variant: isSharingDisplay ? .danger : .primary) + .disabled(isStartingDisplay) .accessibilityIdentifier("share_action_button_\(display.displayID)") .accessibilityValue( - Text(verbatim: isSharingDisplay ? ShareAccessibilityState.sharing : ShareAccessibilityState.idle) + Text(verbatim: isSharingDisplay ? ShareAccessibilityState.sharing : (isStartingDisplay ? "starting" : ShareAccessibilityState.idle)) ) } } diff --git a/VoidDisplay/Features/Sharing/Views/SharePerformanceModePicker.swift b/VoidDisplay/Features/Sharing/Views/SharePerformanceModePicker.swift new file mode 100644 index 0000000..87bce98 --- /dev/null +++ b/VoidDisplay/Features/Sharing/Views/SharePerformanceModePicker.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct SharePerformanceModePicker: View { + @Environment(CapturePerformancePreferences.self) private var capturePerformancePreferences + + var body: some View { + VStack(spacing: AppUI.Spacing.small) { + HStack(alignment: .center, spacing: AppUI.Spacing.medium) { + Text("Share smoothness") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + Picker("Capture Performance", selection: modeBinding) { + Text("Automatic").tag(CapturePerformanceMode.automatic) + Text("Smooth").tag(CapturePerformanceMode.smooth) + Text("Power Efficient").tag(CapturePerformanceMode.powerEfficient) + } + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.small) + } + .frame(maxWidth: .infinity, alignment: .center) + + Text("Automatic adapts frame rate for mixed preview and sharing.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("share_capture_performance_picker") + } + + private var modeBinding: Binding { + Binding( + get: { capturePerformancePreferences.mode }, + set: { capturePerformancePreferences.saveMode($0) } + ) + } +} diff --git a/VoidDisplay/Features/Sharing/Views/ShareView.swift b/VoidDisplay/Features/Sharing/Views/ShareView.swift index 0bf5701..96e9bd4 100644 --- a/VoidDisplay/Features/Sharing/Views/ShareView.swift +++ b/VoidDisplay/Features/Sharing/Views/ShareView.swift @@ -5,21 +5,21 @@ import SwiftUI import ScreenCaptureKit -import Combine import AppKit import CoreGraphics struct ShareView: View { @Bindable private var sharing: SharingController @State private var viewModel: ShareViewModel - @State private var lifecycle: ShareViewLifecycleController + @State private var lifecycle: DisplayTopologyRefreshLifecycleController @Environment(\.openURL) private var openURL - private let sharingStatsTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + private let screenCatalogOrchestrator: ScreenCatalogOrchestrator init( sharing: SharingController, virtualDisplay: VirtualDisplayController, - lifecycle: ShareViewLifecycleController = ShareViewLifecycleController() + screenCatalogOrchestrator: ScreenCatalogOrchestrator, + lifecycle: DisplayTopologyRefreshLifecycleController = DisplayTopologyRefreshLifecycleController() ) { _sharing = Bindable(sharing) _viewModel = State( @@ -29,6 +29,7 @@ struct ShareView: View { ) ) _lifecycle = State(initialValue: lifecycle) + self.screenCatalogOrchestrator = screenCatalogOrchestrator } var body: some View { @@ -45,7 +46,7 @@ struct ShareView: View { if sharing.isWebServiceRunning { if lifecycle.showToolbarRefresh { Button("Refresh", systemImage: "arrow.clockwise") { - viewModel.refreshDisplays() + Task { await screenCatalogOrchestrator.forceRefresh(source: .sharingPage) } } } Button("Stop Service") { @@ -55,20 +56,18 @@ struct ShareView: View { } } .onAppear { - lifecycle.handleAppear(viewModel: viewModel) + Task { await screenCatalogOrchestrator.handleAppear(source: .sharingPage) } + lifecycle.handleAppear { + guard viewModel.catalog.hasScreenCapturePermission == true else { return } + Task { await screenCatalogOrchestrator.handleTopologyChanged() } + } } .onDisappear { - lifecycle.handleDisappear(viewModel: viewModel) - } - .onChange(of: sharing.isWebServiceRunning) { _, _ in - viewModel.syncForCurrentState() - } - .onChange(of: sharing.isSharing) { _, _ in - viewModel.syncForCurrentState() + Task { await screenCatalogOrchestrator.handleDisappear(source: .sharingPage) } + lifecycle.handleDisappear() } - .onReceive(sharingStatsTimer) { _ in - guard sharing.isWebServiceRunning else { return } - sharing.refreshSharingClientCount() + .onChange(of: sharing.isWebServiceRunning) { _, isRunning in + Task { await screenCatalogOrchestrator.handleSharingServiceStateChanged(isRunning: isRunning) } } .alert(item: $bindableViewModel.userFacingAlert) { alert in Alert( @@ -122,6 +121,7 @@ struct ShareView: View { private var serviceStoppedState: some View { @Bindable var bindableViewModel = viewModel + let contentColumnWidth: CGFloat = 440 return stateContainer { VStack(spacing: AppUI.Spacing.medium + 2) { @@ -136,27 +136,33 @@ struct ShareView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .frame(maxWidth: 300) + .frame(width: contentColumnWidth) - VStack(spacing: 4) { - HStack(spacing: AppUI.Spacing.small) { - Text("Port") + VStack(spacing: AppUI.Spacing.medium) { + SharePerformanceModePicker() + .frame(width: contentColumnWidth) + + VStack(spacing: 4) { + HStack(spacing: AppUI.Spacing.small) { + Text("Port") + .font(.caption) + .foregroundStyle(.secondary) + TextField("8089", text: $bindableViewModel.servicePortInput) + .textFieldStyle(.roundedBorder) + .frame(width: 84) + .accessibilityIdentifier("share_port_input") + } + .frame(width: contentColumnWidth, alignment: .center) + + Text(viewModel.portInputErrorMessage ?? " ") .font(.caption) - .foregroundStyle(.secondary) - TextField("8089", text: $bindableViewModel.servicePortInput) - .textFieldStyle(.roundedBorder) - .frame(width: 84) - .accessibilityIdentifier("share_port_input") + .foregroundStyle(viewModel.portInputErrorMessage == nil ? .clear : .red) + .lineLimit(1) + .truncationMode(.tail) + .multilineTextAlignment(.center) + .frame(minWidth: contentColumnWidth, maxWidth: contentColumnWidth, minHeight: 14, maxHeight: 14, alignment: .center) + .accessibilityIdentifier("share_port_error_text") } - - Text(viewModel.portInputErrorMessage ?? " ") - .font(.caption) - .foregroundStyle(viewModel.portInputErrorMessage == nil ? .clear : .red) - .lineLimit(1) - .truncationMode(.tail) - .multilineTextAlignment(.center) - .frame(maxWidth: 360, minHeight: 14, maxHeight: 14, alignment: .center) - .accessibilityIdentifier("share_port_error_text") } Button("Start Service") { @@ -185,20 +191,18 @@ struct ShareView: View { ScreenCapturePermissionGuideView( loadErrorMessage: viewModel.catalog.loadErrorMessage, onOpenSettings: { - viewModel.openScreenCapturePrivacySettings { url in + screenCatalogOrchestrator.openScreenCapturePrivacySettings { url in openURL(url) } }, onRequestPermission: { - viewModel.requestScreenCapturePermission() + Task { await screenCatalogOrchestrator.requestPermission(source: .sharingPage) } }, onRefresh: { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.refreshPermission(source: .sharingPage) } }, onRetry: (viewModel.catalog.loadErrorMessage != nil || viewModel.catalog.lastLoadError != nil) ? { - // User-initiated retry: attempt to load the display list. - // If permission is still missing, macOS may prompt here (expected). - viewModel.loadDisplays() + Task { await screenCatalogOrchestrator.forceRefresh(source: .sharingPage) } } : nil, isDebugInfoExpanded: $bindableCatalog.showDebugInfo, debugItems: sharingPermissionDebugItems, @@ -219,8 +223,10 @@ struct ShareView: View { stateContainer { VStack(spacing: AppUI.Spacing.medium) { Text("No screen to share") + SharePerformanceModePicker() + .frame(maxWidth: 360) Button("Refresh") { - viewModel.refreshDisplays() + Task { await screenCatalogOrchestrator.forceRefresh(source: .sharingPage) } } .appActionButtonStyle(variant: .default) .accessibilityIdentifier("share_empty_refresh_button") @@ -272,8 +278,13 @@ struct ShareView: View { #Preview { let env = AppBootstrap.makeEnvironment(preview: true, isRunningUnderXCTestOverride: false) - ShareView(sharing: env.sharing, virtualDisplay: env.virtualDisplay) + ShareView( + sharing: env.sharing, + virtualDisplay: env.virtualDisplay, + screenCatalogOrchestrator: env.screenCatalog + ) .environment(env.capture) + .environment(env.capturePerformancePreferences) .environment(env.sharing) .environment(env.virtualDisplay) } diff --git a/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift b/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift index 249df41..5ea21b2 100644 --- a/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift +++ b/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift @@ -1,6 +1,7 @@ import CoreVideo import Foundation import Network +import OSLog import Synchronization #if canImport(WebRTC) @@ -73,22 +74,48 @@ nonisolated private struct SignalingOutboundMessage: Encodable { } final class WebRTCSessionHub: Sendable { + enum AddClientResult: Sendable, Equatable { + case accepted(clientID: String) + case rejected(reason: String) + } + nonisolated struct PeerCallbacks: Sendable { let onAnswer: @Sendable (String) -> Void let onLocalCandidate: @Sendable (_ sdp: String, _ sdpMid: String?, _ sdpMLineIndex: Int32) -> Void + let onConnected: @Sendable () -> Void let onFailure: @Sendable (String) -> Void let onDisconnected: @Sendable () -> Void } + typealias SharingEventSink = @Sendable (SharingSessionEvent) -> Void + typealias PeerFactory = @Sendable (PeerCallbacks) -> (any WebRTCPeerSessioning)? private nonisolated struct QueuedSignal: Sendable { let text: String let disconnectAfterSend: Bool + let coalescingKey: CoalescingKey? + } + + private nonisolated enum CoalescingKey: Sendable, Equatable { + case answer + case stopped + } + + private nonisolated enum EnqueueDecision { + case sendNow(QueuedSignal) + case queued + case overflow + case dropped } private nonisolated struct ClientState { nonisolated(unsafe) let connection: any SignalSocketConnection + let clientID: String + let sessionEpoch: UInt64 + let target: ShareTarget + let eventSink: SharingEventSink + var nextEventSequence: UInt64 = 0 var isSending = false var pendingSignals: [QueuedSignal] = [] var peer: (any WebRTCPeerSessioning)? @@ -96,12 +123,13 @@ final class WebRTCSessionHub: Sendable { private nonisolated struct State: ~Copyable { var clients: [ObjectIdentifier: ClientState] = [:] + var nextSessionEpoch: UInt64 = 0 var onDemandChanged: @Sendable (Bool) -> Void } nonisolated private let state: Mutex - nonisolated private let maxClients = 10 nonisolated private let peerFactory: PeerFactory + nonisolated private static let maxPendingSignalsPerClient = 256 #if canImport(WebRTC) nonisolated private let mediaPipeline = WebRTCMediaPipeline() @@ -124,6 +152,7 @@ final class WebRTCSessionHub: Sendable { Int32(candidate.sdpMLineIndex) ) }, + onConnected: callbacks.onConnected, onFailure: callbacks.onFailure, onDisconnected: callbacks.onDisconnected ) @@ -146,38 +175,64 @@ final class WebRTCSessionHub: Sendable { state.withLock { $0.onDemandChanged = onDemandChanged } } - nonisolated func addClient(_ connection: any SignalSocketConnection) { + nonisolated func addClient( + _ connection: any SignalSocketConnection, + target: ShareTarget, + makeClientID: @escaping @Sendable () -> String = { UUID().uuidString }, + eventSink: @escaping SharingEventSink + ) -> AddClientResult { let key = ObjectIdentifier(connection as AnyObject) - let (added, shouldSignalDemand, callback) = state.withLock { state -> (Bool, Bool, @Sendable (Bool) -> Void) in - guard state.clients.count < maxClients else { - return (false, false, state.onDemandChanged) - } + let (result, acceptedClientID, shouldSignalDemand, callback) = state.withLock { + state -> (AddClientResult, String?, Bool, @Sendable (Bool) -> Void) in let wasEmpty = state.clients.isEmpty - state.clients[key] = ClientState(connection: connection) - return (true, wasEmpty, state.onDemandChanged) + let clientID = makeClientID() + state.nextSessionEpoch &+= 1 + state.clients[key] = ClientState( + connection: connection, + clientID: clientID, + sessionEpoch: state.nextSessionEpoch, + target: target, + eventSink: eventSink + ) + return (.accepted(clientID: clientID), clientID, wasEmpty, state.onDemandChanged) } - if !added { - send( - message: SignalingOutboundMessage(type: .error, reason: "too_many_viewers"), - to: connection, - completion: nil - ) - connection.cancelSocket() - return + guard case .accepted = result, let clientID = acceptedClientID else { + return result } if shouldSignalDemand { callback(true) } + let sessionEpoch = state.withLock { $0.clients[key]?.sessionEpoch } ?? 0 + emitEvent( + SharingSessionEvent( + target: target, + clientID: clientID, + sessionEpoch: sessionEpoch, + sequence: nextEventSequence(for: key), + phase: .signalingConnected, + source: .webSocket + ), + for: key + ) send(message: SignalingOutboundMessage(type: .ready), to: connection, completion: nil) + return .accepted(clientID: clientID) } nonisolated func removeClient(_ connection: any SignalSocketConnection) { removeClient(for: ObjectIdentifier(connection as AnyObject), cancelConnection: false) } + nonisolated func sendRejection(reason: String, to connection: any SignalSocketConnection) { + send( + message: SignalingOutboundMessage(type: .error, reason: reason), + to: connection, + completion: nil + ) + } + nonisolated func disconnectAllClients() { let keys = state.withLock { Array($0.clients.keys) } for key in keys { @@ -237,6 +292,7 @@ final class WebRTCSessionHub: Sendable { send(message: SignalingOutboundMessage(type: .error, reason: "missing_offer_sdp"), to: key) return } + emitEvent(phase: .offerReceived, source: .peerConnection, for: key) ensurePeer(for: key)?.handleRemoteOffer(sdp: sdp) case .iceCandidate: guard let candidate = message.candidate else { @@ -280,11 +336,18 @@ final class WebRTCSessionHub: Sendable { to: key ) }, + onConnected: { [weak self] in + self?.emitEvent(phase: .peerConnected, source: .peerConnection, for: key) + }, onFailure: { [weak self] reason in - self?.send(message: SignalingOutboundMessage(type: .error, reason: reason), to: key) + self?.emitEvent(phase: .peerFailed, source: .peerConnection, for: key) + AppLog.web.warning( + "WebRTC peer failed; closing signaling socket to trigger reconnect (reason: \(reason, privacy: .public))." + ) self?.removeClient(for: key, cancelConnection: true) }, onDisconnected: { [weak self] in + self?.emitEvent(phase: .peerDisconnected, source: .peerConnection, for: key) self?.removeClient(for: key, cancelConnection: true) } ) @@ -353,6 +416,7 @@ final class WebRTCSessionHub: Sendable { } return } + let coalescingKey = coalescingKey(for: message) let connection = state.withLock { $0.clients[key]?.connection } guard let connection else { return } @@ -361,7 +425,8 @@ final class WebRTCSessionHub: Sendable { to: key, connection: connection, disconnectAfterSend: disconnectAfterSend, - replacePending: replacePending + replacePending: replacePending, + coalescingKey: coalescingKey ) } @@ -370,31 +435,70 @@ final class WebRTCSessionHub: Sendable { to key: ObjectIdentifier, connection: any SignalSocketConnection, disconnectAfterSend: Bool, - replacePending: Bool + replacePending: Bool, + coalescingKey: CoalescingKey? ) { let queuedSignal = QueuedSignal( text: text, - disconnectAfterSend: disconnectAfterSend + disconnectAfterSend: disconnectAfterSend, + coalescingKey: coalescingKey ) - let nextToSend = state.withLock { state -> QueuedSignal? in - guard var current = state.clients[key] else { return nil } + let decision = state.withLock { state -> EnqueueDecision in + guard var current = state.clients[key] else { return .dropped } if current.isSending { if replacePending { current.pendingSignals = [queuedSignal] - } else { - current.pendingSignals.append(queuedSignal) + state.clients[key] = current + return .queued + } + + if let coalescingKey { + if let index = current.pendingSignals.lastIndex(where: { $0.coalescingKey == coalescingKey }) { + current.pendingSignals[index] = queuedSignal + state.clients[key] = current + return .queued + } + } + + if current.pendingSignals.count >= Self.maxPendingSignalsPerClient { + return .overflow } + current.pendingSignals.append(queuedSignal) state.clients[key] = current - return nil + return .queued } current.isSending = true state.clients[key] = current - return queuedSignal + return .sendNow(queuedSignal) } - guard let nextToSend else { return } - send(signal: nextToSend, to: key, connection: connection) + switch decision { + case .sendNow(let nextToSend): + send(signal: nextToSend, to: key, connection: connection) + case .queued: + return + case .overflow: + AppLog.web.warning( + "WebRTC signaling backlog overflow; disconnecting client to prevent unbounded queue growth." + ) + removeClient(for: key, cancelConnection: true) + case .dropped: + return + } + } + + nonisolated private func coalescingKey( + for message: SignalingOutboundMessage + ) -> CoalescingKey? { + switch message.type { + case .answer: + return .answer + case .stopped: + return .stopped + default: + return nil + } } nonisolated private func send( @@ -440,6 +544,16 @@ final class WebRTCSessionHub: Sendable { } guard let removed else { return } + removed.eventSink( + SharingSessionEvent( + target: removed.target, + clientID: removed.clientID, + sessionEpoch: removed.sessionEpoch, + sequence: removed.nextEventSequence + 1, + phase: .closed, + source: .webSocket + ) + ) removed.peer?.close() if cancelConnection { removed.connection.cancelSocket() @@ -448,20 +562,84 @@ final class WebRTCSessionHub: Sendable { callback(false) } } -} -#if canImport(WebRTC) + nonisolated private func emitEvent( + _ event: SharingSessionEvent, + for key: ObjectIdentifier + ) { + let sink = state.withLock { $0.clients[key]?.eventSink } + sink?(event) + } + + nonisolated private func emitEvent( + phase: SharingPeerPhase, + source: SharingSessionEventSource, + for key: ObjectIdentifier + ) { + let payload = state.withLock { + state -> (target: ShareTarget, clientID: String, sessionEpoch: UInt64, sequence: UInt64, sink: SharingEventSink)? in + guard var client = state.clients[key] else { return nil } + client.nextEventSequence += 1 + let sequence = client.nextEventSequence + state.clients[key] = client + return (client.target, client.clientID, client.sessionEpoch, sequence, client.eventSink) + } + guard let payload else { return } + payload.sink( + SharingSessionEvent( + target: payload.target, + clientID: payload.clientID, + sessionEpoch: payload.sessionEpoch, + sequence: payload.sequence, + phase: phase, + source: source + ) + ) + } -private enum WebRTCIceServerProvider { + nonisolated private func nextEventSequence(for key: ObjectIdentifier) -> UInt64 { + state.withLock { state -> UInt64 in + guard var client = state.clients[key] else { return 0 } + client.nextEventSequence += 1 + let sequence = client.nextEventSequence + state.clients[key] = client + return sequence + } + } +} + +enum WebRTCIceServerProvider { // Reserved extension point: defaults to host candidates only for LAN P2P. - nonisolated static func configuredServers() -> [RTCIceServer] { + nonisolated static func configuredURLStrings() -> [String] { guard let raw = ProcessInfo.processInfo.environment["VOIDDISPLAY_WEBRTC_ICE_SERVERS"] else { return [] } - let urls = raw + return raw .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + } + + nonisolated static func browserBootstrapJSON() -> String { + let urls = configuredURLStrings() + let payload: [String: Any] = [ + "iceServers": urls.isEmpty ? [] : [["urls": urls]] + ] + guard JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload), + var json = String(data: data, encoding: .utf8) else { + return #"{"iceServers":[]}"# + } + json = json.replacingOccurrences(of: "", with: "<\\/script>") + return json + } +} + +#if canImport(WebRTC) + +private extension WebRTCIceServerProvider { + nonisolated static func configuredServers() -> [RTCIceServer] { + let urls = configuredURLStrings() guard !urls.isEmpty else { return [] } return [RTCIceServer(urlStrings: urls)] } @@ -537,7 +715,7 @@ private final class WebRTCMediaPipeline: @unchecked Sendable { private final class WebRTCPeerSession: NSObject, @unchecked Sendable, WebRTCPeerSessioning { nonisolated private static let desktopMinBitrateBps = NSNumber(value: 2_000_000) nonisolated private static let desktopMaxBitrateBps = NSNumber(value: 24_000_000) - nonisolated private static let desktopMaxFramerate = NSNumber(value: 30) + nonisolated private static let desktopMaxFramerate = NSNumber(value: 60) nonisolated private static let maintainResolutionPreference = NSNumber( value: RTCDegradationPreference.maintainResolution.rawValue ) @@ -545,6 +723,7 @@ private final class WebRTCPeerSession: NSObject, @unchecked Sendable, WebRTCPeer nonisolated(unsafe) private let peerConnection: RTCPeerConnection private let onAnswer: @Sendable (String) -> Void private let onLocalCandidate: @Sendable (RTCIceCandidate) -> Void + private let onConnected: @Sendable () -> Void private let onFailure: @Sendable (String) -> Void private let onDisconnected: @Sendable () -> Void @@ -552,6 +731,7 @@ private final class WebRTCPeerSession: NSObject, @unchecked Sendable, WebRTCPeer mediaPipeline: WebRTCMediaPipeline, onAnswer: @escaping @Sendable (String) -> Void, onLocalCandidate: @escaping @Sendable (RTCIceCandidate) -> Void, + onConnected: @escaping @Sendable () -> Void, onFailure: @escaping @Sendable (String) -> Void, onDisconnected: @escaping @Sendable () -> Void ) { @@ -559,6 +739,7 @@ private final class WebRTCPeerSession: NSObject, @unchecked Sendable, WebRTCPeer self.peerConnection = peerConnection self.onAnswer = onAnswer self.onLocalCandidate = onLocalCandidate + self.onConnected = onConnected self.onFailure = onFailure self.onDisconnected = onDisconnected super.init() @@ -636,12 +817,18 @@ extension WebRTCPeerSession: RTCPeerConnectionDelegate { nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { + if newState == .connected { + onConnected() + } if newState == .failed || newState == .closed || newState == .disconnected { onDisconnected() } } nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + if newState == .connected || newState == .completed { + onConnected() + } if newState == .failed || newState == .closed || newState == .disconnected { onDisconnected() } diff --git a/VoidDisplay/Features/Sharing/Web/WebServer.swift b/VoidDisplay/Features/Sharing/Web/WebServer.swift index 83b0ae7..a72ba38 100644 --- a/VoidDisplay/Features/Sharing/Web/WebServer.swift +++ b/VoidDisplay/Features/Sharing/Web/WebServer.swift @@ -94,7 +94,9 @@ final class WebServer { private struct ActiveConnection { let target: ShareTarget + let clientID: String let connection: NWConnection + let sessionHub: WebRTCSessionHub } private var listener: NWListener? @@ -103,7 +105,9 @@ final class WebServer { private var activeConnections: [ObjectIdentifier: ActiveConnection] = [:] private var signalDecodersByConnectionKey: [ObjectIdentifier: WebSocketFrameDecoder] = [:] private let targetStateProvider: @MainActor @Sendable (ShareTarget) -> ShareTargetState + private let concreteTargetResolver: @MainActor @Sendable (ShareTarget) -> ShareTarget? private let sessionHubProvider: @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? + private let sharingEventSink: @Sendable (SharingSessionEvent) -> Void private let onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? private var didNotifyListenerStopped = false private var startupWaiter: CheckedContinuation? @@ -116,11 +120,15 @@ final class WebServer { init( using port: NWEndpoint.Port = .http, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, + sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void, onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? = nil ) throws { self.targetStateProvider = targetStateProvider + self.concreteTargetResolver = concreteTargetResolver self.sessionHubProvider = sessionHubProvider + self.sharingEventSink = sharingEventSink self.onListenerStopped = onListenerStopped displayPageTemplate = try Self.loadDisplayPageTemplate() @@ -192,14 +200,9 @@ final class WebServer { } func disconnectAllStreamClients() { - for client in activeConnections.values { - if let hub = sessionHub(for: client.target) { - hub.removeClient(client.connection) - } - client.connection.cancel() + for key in Array(activeConnections.keys) { + disconnectActiveConnection(forKey: key, cancelConnection: true) } - activeConnections.removeAll() - signalDecodersByConnectionKey.removeAll() } var activeStreamClientCount: Int { @@ -210,6 +213,16 @@ final class WebServer { activeConnections.values.filter { $0.target == target }.count } + func disconnectStreamClients(for targets: Set) { + guard !targets.isEmpty else { return } + let keysToDisconnect = activeConnections.compactMap { key, connection in + targets.contains(connection.target) ? key : nil + } + for key in keysToDisconnect { + disconnectActiveConnection(forKey: key, cancelConnection: true) + } + } + func stopListener(reason: WebServiceServerStopReason = .requested) { notifyListenerStoppedIfNeeded(reason: reason) completeStartupWaiter(result: .failed(error: LifecycleError.listenerCancelled)) @@ -281,14 +294,21 @@ final class WebServer { private func removeSignalClient(_ connection: NWConnection, cancelConnection: Bool) { let key = connectionKey(for: connection) + disconnectActiveConnection(forKey: key, cancelConnection: cancelConnection, fallbackConnection: connection) + } + + private func disconnectActiveConnection( + forKey key: ObjectIdentifier, + cancelConnection: Bool, + fallbackConnection: NWConnection? = nil + ) { signalDecodersByConnectionKey.removeValue(forKey: key) + let connection = activeConnections[key]?.connection ?? fallbackConnection if let active = activeConnections.removeValue(forKey: key) { - if let hub = sessionHub(for: active.target) { - hub.removeClient(connection) - } + active.sessionHub.removeClient(active.connection) } if cancelConnection { - connection.cancel() + connection?.cancel() } } @@ -296,17 +316,21 @@ final class WebServer { sessionHubProvider(target) } + private func concreteTarget(for target: ShareTarget) -> ShareTarget? { + concreteTargetResolver(target) + } + private func displayPage(for target: ShareTarget) -> String { - let title: String - switch target { - case .main: - title = "Main Display" - case .id(let id): - title = "Display \(id)" - } + _ = target + let title = "Screen Share" return displayPageTemplate .replacingOccurrences(of: "__PAGE_TITLE__", with: title) .replacingOccurrences(of: "__SIGNAL_PATH__", with: target.signalPath) + .replacingOccurrences(of: "__BOOTSTRAP_JSON__", with: makeDisplayPageBootstrapJSON()) + } + + private func makeDisplayPageBootstrapJSON() -> String { + WebRTCIceServerProvider.browserBootstrapJSON() } private func processRequest(_ content: Data?, on connection: NWConnection) { @@ -344,15 +368,31 @@ final class WebServer { failureContext: "Send root page response" ) case .showDisplayPage(let target): + guard let concreteTarget = concreteTarget(for: target) else { + sendResponseAndClose( + requestHandler.responseData(for: .notFound), + on: connection, + failureContext: "Reject unresolved display target" + ) + return + } sendResponseAndClose( requestHandler.responseData( - for: decision, - htmlBody: displayPage(for: target) + for: .showDisplayPage(concreteTarget), + htmlBody: displayPage(for: concreteTarget) ), on: connection, failureContext: "Send display page response" ) case .openSignalSocket(let target): + guard let concreteTarget = concreteTarget(for: target) else { + sendResponseAndClose( + requestHandler.responseData(for: .notFound), + on: connection, + failureContext: "Reject unresolved websocket target" + ) + return + } guard isValidWebSocketUpgrade(request.headers) else { sendResponseAndClose( requestHandler.responseData(for: .badRequest), @@ -361,7 +401,7 @@ final class WebServer { ) return } - openSignalSocket(on: connection, target: target, headers: request.headers) + openSignalSocket(on: connection, target: concreteTarget, headers: request.headers) case .badRequest, .sharingUnavailable, .methodNotAllowed, .notFound: sendResponseAndClose( requestHandler.responseData(for: decision), @@ -424,20 +464,48 @@ final class WebServer { return } AppLog.web.info("WebServer: [\(endpoint)] WebSocket upgrade succeeded.") - hub.addClient(connection) + let sharingEventSink = self.sharingEventSink + let addResult = hub.addClient( + connection, + target: target, + makeClientID: { UUID().uuidString }, + eventSink: { event in + sharingEventSink(event) + } + ) + guard case .accepted(let clientID) = addResult else { + if case .rejected(let reason) = addResult { + hub.sendRejection(reason: reason, to: connection) + } + connection.cancel() + return + } let key = self.connectionKey(for: connection) - self.activeConnections[key] = ActiveConnection(target: target, connection: connection) + self.activeConnections[key] = ActiveConnection( + target: target, + clientID: clientID, + connection: connection, + sessionHub: hub + ) self.signalDecodersByConnectionKey[key] = WebSocketFrameDecoder( maxFramePayloadBytes: Self.maxSignalBufferBytes, maxContinuationPayloadBytes: Self.maxSignalBufferBytes, requiresMaskedFrames: true ) - self.startSignalReceiveLoop(on: connection, target: target) + self.startSignalReceiveLoop( + on: connection, + target: target, + sessionHub: hub + ) } }) } - nonisolated private func startSignalReceiveLoop(on connection: NWConnection, target: ShareTarget) { + nonisolated private func startSignalReceiveLoop( + on connection: NWConnection, + target: ShareTarget, + sessionHub: WebRTCSessionHub + ) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in if let error = error { AppLog.web.warning("WebServer: WebSocket receive error: \(error)") @@ -480,7 +548,7 @@ final class WebServer { for frame in decoded.frames { switch frame { case .text(let text): - self.sessionHub(for: target)?.receiveSignalText(text, from: connection) + sessionHub.receiveSignalText(text, from: connection) case .ping(let payload): connection.send( content: encodeWebSocketPongFrame(payload), @@ -511,7 +579,11 @@ final class WebServer { guard self.activeConnections[key] != nil else { return } - self.startSignalReceiveLoop(on: connection, target: target) + self.startSignalReceiveLoop( + on: connection, + target: target, + sessionHub: sessionHub + ) } } } diff --git a/VoidDisplay/Features/Sharing/Web/displayPage.html b/VoidDisplay/Features/Sharing/Web/displayPage.html index a584a71..8e81972 100644 --- a/VoidDisplay/Features/Sharing/Web/displayPage.html +++ b/VoidDisplay/Features/Sharing/Web/displayPage.html @@ -171,8 +171,7 @@
-

VoidDisplay Live

-

__PAGE_TITLE__

+

VoidDisplay Live

Connecting…
@@ -193,15 +192,19 @@

Connecting…

-

Use `1:1` for original size and `Fullscreen` for immersive view.

+

Use `1:1` for original size and `Fullscreen` for immersive view.

+ ", + range: scriptOpenRange.upperBound.. 0 { + await retainGate?.wait() + } else { + releaseCounter?.increment() + } + } + + nonisolated func stop() async {} +} + +private final class DisplaySharingCoordinatorCounter: @unchecked Sendable { + nonisolated(unsafe) var value = 0 + + nonisolated func increment() { + value += 1 + } +} + +private actor DisplaySharingCoordinatorAsyncGate { + private var waitCount = 0 + private var continuations: [CheckedContinuation] = [] + + func wait() async { + waitCount += 1 + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func releaseOne() { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } + + func currentWaitCount() -> Int { + waitCount + } +} + +private actor DisplaySharingCoordinatorOutcomeBox { + private var outcome: DisplayStartOutcome? + + func store(_ outcome: DisplayStartOutcome) { + self.outcome = outcome + } + + func isInvalidated() -> Bool { + if case .invalidated = outcome { + return true + } + return false + } +} + +private final class DisplaySharingCoordinatorMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum DisplaySharingCoordinatorMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = DisplaySharingCoordinatorMockSCDisplayBox( + displayID: displayID, + width: width, + height: height + ) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + @Suite(.serialized) struct DisplaySharingCoordinatorTests { @MainActor @@ -31,9 +138,504 @@ struct DisplaySharingCoordinatorTests { #expect(!Set([UInt32(1), UInt32(3)]).contains(physicalShareID)) } + @MainActor + @Test func physicalDisplayShareIDStaysStableAcrossReorderedRegistration() throws { + let store = DisplayShareIDStore(storeURL: temporaryStoreURL()) + let coordinator = DisplaySharingCoordinator(idStore: store) + let firstMain: CGDirectDisplayID = 101 + let secondPhysical: CGDirectDisplayID = 102 + + coordinator.registerShareableDisplays([ + .init(displayID: firstMain, isMain: true, virtualSerial: nil), + .init(displayID: secondPhysical, isMain: false, virtualSerial: nil) + ]) + let initialMainID = try #require(coordinator.shareID(for: firstMain)) + let initialSecondaryID = try #require(coordinator.shareID(for: secondPhysical)) + + coordinator.registerShareableDisplays([ + .init(displayID: secondPhysical, isMain: false, virtualSerial: nil), + .init(displayID: firstMain, isMain: true, virtualSerial: nil) + ]) + + #expect(coordinator.shareID(for: firstMain) == initialMainID) + #expect(coordinator.shareID(for: secondPhysical) == initialSecondaryID) + } + + @MainActor + @Test func removingRegisteredDisplayStopsActiveSharingSession() async throws { + let displayID: CGDirectDisplayID = 103 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let subscription = makeSubscription(displayID: displayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in .started(subscription.subscription) } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let startOutcome = try await coordinator.startSharing(display: display) + guard case .started = startOutcome else { + Issue.record("Expected sharing start to succeed.") + return + } + coordinator.registerShareableDisplays([]) + + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingFailsForUnregisteredDisplay() async { + let displayID: CGDirectDisplayID = 104 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let coordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + + do { + _ = try await coordinator.startSharing(display: display) + Issue.record("Expected displayNotRegistered error.") + } catch let error as SharingStartError { + #expect(error == .displayNotRegistered(displayID)) + } catch { + Issue.record("Expected SharingStartError.displayNotRegistered, got \(error)") + } + + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingCancelsSubscriptionWhenRegistrationChangesDuringAcquire() async { + let displayID: CGDirectDisplayID = 105 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(acquireGate, count: 1)) + + coordinator.registerShareableDisplays([]) + let outcome = try? await task.value + if case .some(.invalidated) = outcome { + } else { + Issue.record("Expected invalidated outcome.") + } + + await acquireGate.releaseOne() + + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingInvalidatesImmediatelyWhenRegistrationChangesDuringAcquire() async { + let displayID: CGDirectDisplayID = 205 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(acquireGate, count: 1)) + + coordinator.registerShareableDisplays([]) + let invalidatedBeforeGateRelease = await waitForTaskInvalidation(task) + #expect(invalidatedBeforeGateRelease) + + await acquireGate.releaseOne() + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingCancelsSubscriptionWhenRegistrationChangesDuringPrepare() async { + let displayID: CGDirectDisplayID = 106 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let prepareGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID, retainGate: prepareGate) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in .started(subscription.subscription) } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(prepareGate, count: 1)) + + coordinator.registerShareableDisplays([]) + let outcome = try? await task.value + if case .some(.invalidated) = outcome { + } else { + Issue.record("Expected invalidated outcome.") + } + + await prepareGate.releaseOne() + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingSucceedsWhenRegistrationRefreshKeepsSameDisplay() async throws { + let targetDisplayID: CGDirectDisplayID = 107 + let otherDisplayID: CGDirectDisplayID = 108 + let targetDisplay = DisplaySharingCoordinatorMockSCDisplay.make( + displayID: targetDisplayID, + width: 1920, + height: 1080 + ) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: targetDisplayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([ + .init(displayID: targetDisplayID, isMain: true, virtualSerial: nil), + .init(displayID: otherDisplayID, isMain: false, virtualSerial: nil) + ]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: targetDisplay) + } + #expect(await waitForGate(acquireGate, count: 1)) + + coordinator.registerShareableDisplays([ + .init(displayID: otherDisplayID, isMain: false, virtualSerial: nil), + .init(displayID: targetDisplayID, isMain: true, virtualSerial: nil) + ]) + await acquireGate.releaseOne() + + let outcome = try await task.value + guard case .started = outcome else { + Issue.record("Expected sharing start to succeed after registration refresh.") + return + } + + #expect(coordinator.isSharing(displayID: targetDisplayID)) + #expect(subscription.cancelCounter.value == 0) + + coordinator.stopAllSharing() + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + } + + @MainActor + @Test func concurrentStartSharingForSameDisplayAcquiresShareOnlyOnce() async throws { + let displayID: CGDirectDisplayID = 111 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID) + let startCoordinator = DisplayStreamStartCoordinator() + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + startCoordinator: startCoordinator, + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let firstTask = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(acquireGate, count: 1)) + + let secondTask = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForCoordinatorWaiters( + startCoordinator, + kind: .sharing, + displayID: displayID, + count: 2 + )) + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow + var observedSecondAcquire = false + while DispatchTime.now().uptimeNanoseconds < deadline { + if await acquireGate.currentWaitCount() > 1 { + observedSecondAcquire = true + break + } + await Task.yield() + } + #expect(observedSecondAcquire == false) + + await acquireGate.releaseOne() + + let firstOutcome = try await firstTask.value + let secondOutcome = try await secondTask.value + if case .started = firstOutcome { + } else { + Issue.record("Expected first sharing start to succeed.") + } + if case .started = secondOutcome { + } else { + Issue.record("Expected second sharing start to reuse the in-flight start.") + } + + #expect(coordinator.isSharing(displayID: displayID)) + #expect(subscription.cancelCounter.value == 0) + + coordinator.stopAllSharing() + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + } + + @MainActor + @Test func stopAllSharingInvalidatesInFlightStartDuringAcquire() async throws { + let displayID: CGDirectDisplayID = 112 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(acquireGate, count: 1)) + + coordinator.stopAllSharing() + + #expect(await waitForTaskInvalidation(task)) + + await acquireGate.releaseOne() + let outcome = try await task.value + if case .invalidated = outcome { + } else { + Issue.record("Expected in-flight sharing start to be invalidated by stopAllSharing.") + } + + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func stopAllSharingPreventsStaleSessionWriteWhenPrepareResumesAfterInvalidation() async throws { + let displayID: CGDirectDisplayID = 113 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let prepareGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID, retainGate: prepareGate) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in .started(subscription.subscription) } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(prepareGate, count: 1)) + + coordinator.stopAllSharing() + #expect(await waitForTaskInvalidation(task)) + + await prepareGate.releaseOne() + let outcome = try await task.value + if case .invalidated = outcome { + } else { + Issue.record("Expected sharing start to stay invalidated after stopAllSharing.") + } + + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func mainTargetContractIsActiveOrKnownInactiveAndHubIsNilWhenUnresolved() async throws { + let unresolvedCoordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + #expect(unresolvedCoordinator.state(for: ShareTarget.main) == .knownInactive) + #expect(unresolvedCoordinator.sessionHub(for: ShareTarget.main) == nil) + + let inactiveCoordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + inactiveCoordinator.registerShareableDisplays([.init(displayID: 109, isMain: true, virtualSerial: nil)]) + #expect(inactiveCoordinator.state(for: ShareTarget.main) == .knownInactive) + #expect(inactiveCoordinator.sessionHub(for: ShareTarget.main) == nil) + + let activeSubscription = makeSubscription(displayID: 110) + let activeCoordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in .started(activeSubscription.subscription) } + ) + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: 110, width: 1920, height: 1080) + activeCoordinator.registerShareableDisplays([.init(displayID: 110, isMain: true, virtualSerial: nil)]) + let outcome = try await activeCoordinator.startSharing(display: display) + guard case .started = outcome else { + Issue.record("Expected active sharing start to succeed.") + return + } + + #expect(activeCoordinator.state(for: ShareTarget.main) == .active) + let hub = try #require(activeCoordinator.sessionHub(for: ShareTarget.main)) + #expect(ObjectIdentifier(hub) == ObjectIdentifier(activeSubscription.session.sessionHub)) + + activeCoordinator.stopAllSharing() + #expect(await waitUntil { activeSubscription.cancelCounter.value == 1 }) + } + + @MainActor + @Test func resolveConcreteTargetMapsMainAliasAndRejectsUnknownTargets() { + let coordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + coordinator.registerShareableDisplays([ + .init(displayID: 201, isMain: true, virtualSerial: nil), + .init(displayID: 202, isMain: false, virtualSerial: nil) + ]) + + guard let mainShareID = coordinator.shareID(for: 201), + let secondaryShareID = coordinator.shareID(for: 202) else { + Issue.record("Expected registered displays to receive concrete share IDs.") + return + } + + #expect(coordinator.resolveConcreteTarget(for: .main) == .id(mainShareID)) + #expect(coordinator.resolveConcreteTarget(for: .id(mainShareID)) == .id(mainShareID)) + #expect(coordinator.resolveConcreteTarget(for: .id(secondaryShareID)) == .id(secondaryShareID)) + #expect(coordinator.resolveConcreteTarget(for: .id(999_999)) == nil) + + let unresolvedCoordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + #expect(unresolvedCoordinator.resolveConcreteTarget(for: .main) == nil) + } + + @MainActor + @Test func registerShareableDisplaysReturnsPriorConcreteTargetWhenShareIDChanges() throws { + let displayID: CGDirectDisplayID = 301 + let coordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + + let initialInvalidatedTargets = coordinator.registerShareableDisplays([ + .init(displayID: displayID, isMain: true, virtualSerial: nil) + ]) + let originalTarget = try #require(coordinator.target(for: displayID)) + + let remappedTargets = coordinator.registerShareableDisplays([ + .init(displayID: displayID, isMain: true, virtualSerial: 77) + ]) + + #expect(initialInvalidatedTargets.isEmpty) + #expect(remappedTargets == Set([originalTarget])) + #expect(coordinator.target(for: displayID) == .id(77)) + } + private func temporaryStoreURL() -> URL { let base = FileManager.default.temporaryDirectory .appendingPathComponent("display-sharing-coordinator-tests-\(UUID().uuidString)", isDirectory: true) return base.appendingPathComponent("display-share-id-mappings.json", isDirectory: false) } + + private func makeSubscription( + displayID: CGDirectDisplayID, + retainGate: DisplaySharingCoordinatorAsyncGate? = nil + ) -> ( + subscription: DisplayShareSubscription, + session: DisplaySharingCoordinatorDummySession, + cancelCounter: DisplaySharingCoordinatorCounter + ) { + let cancelCounter = DisplaySharingCoordinatorCounter() + let releaseCounter = DisplaySharingCoordinatorCounter() + let session = DisplaySharingCoordinatorDummySession( + retainGate: retainGate, + releaseCounter: releaseCounter + ) + let subscription = DisplayShareSubscription( + displayID: displayID, + sessionHub: session.sessionHub, + cancelClosure: { cancelCounter.increment() }, + prepareForSharingClosure: { + try await session.setDemand( + DisplayCaptureDemandSnapshot( + shareTokenCount: 1, + shareCursorOverrideCount: 1, + performanceMode: .automatic + ) + ) + }, + releasePreparedShareClosure: { + try? await session.setDemand( + DisplayCaptureDemandSnapshot( + shareTokenCount: 1, + shareCursorOverrideCount: 0, + performanceMode: .automatic + ) + ) + } + ) + return (subscription, session, cancelCounter) + } + + private func waitForGate( + _ gate: DisplaySharingCoordinatorAsyncGate, + count: Int + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() >= count { + return true + } + await Task.yield() + } + return await gate.currentWaitCount() >= count + } + + private func waitForTaskInvalidation( + _ task: Task, Error> + ) async -> Bool { + let box = DisplaySharingCoordinatorOutcomeBox() + Task { + guard let outcome = try? await task.value else { return } + await box.store(outcome) + } + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion + while DispatchTime.now().uptimeNanoseconds < deadline { + if await box.isInvalidated() { + return true + } + await Task.yield() + } + return await box.isInvalidated() + } + + private func waitForCoordinatorWaiters( + _ coordinator: DisplayStreamStartCoordinator, + kind: DisplayStartKind, + displayID: CGDirectDisplayID, + count: Int + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion + while DispatchTime.now().uptimeNanoseconds < deadline { + if await MainActor.run(body: { + coordinator.waiterCountForTesting(kind: kind, displayID: displayID) >= count + }) { + return true + } + await Task.yield() + } + return await MainActor.run(body: { + coordinator.waiterCountForTesting(kind: kind, displayID: displayID) >= count + }) + } } diff --git a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift index 4e100bb..bd74edc 100644 --- a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift @@ -1,8 +1,95 @@ import Foundation import CoreGraphics +import ScreenCaptureKit import Testing @testable import VoidDisplay +private final class SharingServiceDummySession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand + } + + nonisolated func stop() async {} +} + +private final class SharingServiceCounter: @unchecked Sendable { + nonisolated(unsafe) var value = 0 + + nonisolated func increment() { + value += 1 + } +} + +private actor SharingServiceAsyncGate { + private var waitCount = 0 + private var continuations: [CheckedContinuation] = [] + + func wait() async { + waitCount += 1 + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func releaseOne() { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } + + func currentWaitCount() -> Int { + waitCount + } +} + +private actor SharingServiceOutcomeBox { + private var outcome: DisplayStartOutcome? + + func store(_ outcome: DisplayStartOutcome) { + self.outcome = outcome + } + + func isInvalidated() -> Bool { + if case .invalidated = outcome { + return true + } + return false + } +} + +private final class SharingServiceMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum SharingServiceMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = SharingServiceMockSCDisplayBox(displayID: displayID, width: width, height: height) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + struct SharingServiceTests { @MainActor @Test func startWebServiceDelegatesToControllerAndCapturesProviders() async { @@ -21,7 +108,10 @@ struct SharingServiceTests { #expect(sut.isWebServiceRunning) #expect(mock.capturedTargetStateProvider?(.main) == .knownInactive) #expect(mock.capturedTargetStateProvider?(.id(123)) == .unknown) + #expect(mock.capturedConcreteTargetResolver?(.main) == nil) + #expect(mock.capturedConcreteTargetResolver?(.id(123)) == nil) #expect(mock.capturedSessionHubProvider?(.main) == nil) + #expect(mock.capturedSharingEventSink != nil) } @MainActor @Test func startWebServiceReturnsFalseWhenControllerFails() async { @@ -48,6 +138,23 @@ struct SharingServiceTests { #expect(sut.hasAnyActiveSharing == false) } + @MainActor @Test func registerShareableDisplaysDisconnectsConnectionsForRemappedTargets() throws { + let mock = MockWebServiceController() + let sut = makeService(webServiceController: mock) + let displayID = CGDirectDisplayID(24) + let display = SharingServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + let originalTarget = try #require(sut.shareTarget(for: displayID)) + #expect(mock.disconnectTargetCallCount == 0) + + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in 77 }) + + #expect(mock.disconnectTargetCallCount == 1) + #expect(mock.disconnectedTargetsHistory == [Set([originalTarget])]) + #expect(sut.shareTarget(for: displayID) == .id(77)) + } + @MainActor @Test func stopWebServiceStopsControllerAndDisconnectsAllStreamClients() { let mock = MockWebServiceController() let sut = makeService(webServiceController: mock) @@ -59,12 +166,306 @@ struct SharingServiceTests { #expect(sut.isWebServiceRunning == false) } - @MainActor @Test func activeStreamClientCountReflectsControllerValue() { + @MainActor @Test func stopWebServiceStopsActiveSharingSessionsBeforeStoppingController() async throws { let mock = MockWebServiceController() - mock.activeStreamClientCount = 3 - let sut = makeService(webServiceController: mock) + let displayID = CGDirectDisplayID(21) + let display = SharingServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let cancelCounter = SharingServiceCounter() + let sut = makeService( + webServiceController: mock, + acquireShare: { _, _ in + .started(DisplayShareSubscription( + displayID: displayID, + sessionHub: WebRTCSessionHub(), + cancelClosure: { cancelCounter.increment() } + )) + } + ) + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + let outcome = try await sut.startSharing(display: display) + guard case .started = outcome else { + Issue.record("Expected sharing start to succeed.") + return + } + + sut.stopWebService() + + #expect(await waitUntil { cancelCounter.value == 1 }) + #expect(sut.hasAnyActiveSharing == false) + #expect(mock.disconnectCallCount == 1) + #expect(mock.stopCallCount == 1) + } + + @MainActor @Test func stopWebServiceInvalidatesInFlightSharingStart() async throws { + let mock = MockWebServiceController() + let gate = SharingServiceAsyncGate() + let displayID = CGDirectDisplayID(23) + let display = SharingServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let cancelCounter = SharingServiceCounter() + let sut = makeService( + webServiceController: mock, + acquireShare: { _, _ in + await gate.wait() + return .started(DisplayShareSubscription( + displayID: displayID, + sessionHub: WebRTCSessionHub(), + cancelClosure: { cancelCounter.increment() } + )) + } + ) + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + + let task = Task { @MainActor in + try await sut.startSharing(display: display) + } + + #expect(await waitForSharingServiceGate(gate, count: 1)) + + sut.stopWebService() + + #expect(await waitForSharingServiceTaskInvalidation(task)) + + await gate.releaseOne() + let outcome = try await task.value + if case .invalidated = outcome { + } else { + Issue.record("Expected in-flight sharing start to be invalidated when the web service stops.") + } - #expect(sut.activeStreamClientCount == 3) + #expect(await waitUntil { cancelCounter.value == 1 }) + #expect(sut.hasAnyActiveSharing == false) + #expect(mock.disconnectCallCount == 1) + #expect(mock.stopCallCount == 1) + } + + @MainActor @Test func startSharingPropagatesDisplayNotRegisteredError() async { + let displayID = CGDirectDisplayID(22) + let display = SharingServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let sut = makeService(webServiceController: MockWebServiceController()) + + do { + _ = try await sut.startSharing(display: display) + Issue.record("Expected displayNotRegistered error.") + } catch let error as SharingStartError { + #expect(error == .displayNotRegistered(displayID)) + } catch { + Issue.record("Expected SharingStartError.displayNotRegistered, got \(error)") + } + } + + @MainActor @Test func activeStreamClientCountReflectsSharingSnapshot() { + let mock = MockWebServiceController() + let aggregator = SharingStateAggregator() + aggregator.record( + SharingSessionEvent( + target: .main, + clientID: "client-1", + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: .id(7), + clientID: "client-2", + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: .id(7), + clientID: "client-3", + sequence: 1, + phase: .signalingConnected, + source: .webSocket + ) + ) + let sut = makeService(webServiceController: mock, sharingStateAggregator: aggregator) + + #expect(sut.activeStreamClientCount == 2) + #expect(sut.streamClientCount(for: .id(7)) == 1) + #expect(sut.sharingStateSnapshot.signalingConnections == 3) + } + + @MainActor @Test func subscribeSharingStateImmediatelyReplaysCurrentSnapshot() { + let mock = MockWebServiceController() + let aggregator = SharingStateAggregator() + aggregator.record( + SharingSessionEvent( + target: .main, + clientID: "client-1", + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + let sut = makeService(webServiceController: mock, sharingStateAggregator: aggregator) + var snapshots: [SharingStateSnapshot] = [] + + let subscription = sut.subscribeSharingState { snapshot in + snapshots.append(snapshot) + } + + #expect(snapshots.count == 1) + #expect(snapshots.first?.streamingPeers == 1) + subscription.cancel() + } + + @MainActor @Test func closedClientIsRemovedFromCurrentSnapshot() { + let aggregator = SharingStateAggregator() + aggregator.record( + SharingSessionEvent( + target: .id(7), + clientID: "client-1", + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: .id(7), + clientID: "client-1", + sequence: 2, + phase: .closed, + source: .webSocket + ) + ) + aggregator.record( + SharingSessionEvent( + target: .id(7), + clientID: "client-1", + sequence: 1, + phase: .peerDisconnected, + source: .peerConnection + ) + ) + + let snapshot = aggregator.currentSnapshot + #expect(snapshot.signalingConnections == 0) + #expect(snapshot.streamingPeers == 0) + #expect(snapshot.clientsByTarget[.id(7)]?.isEmpty ?? true) + #expect(snapshot.lastUpdatedAt != nil) + } + + @MainActor @Test func closedClientReconnectWithSameIDStartsFreshSession() { + let aggregator = SharingStateAggregator() + let target = ShareTarget.id(7) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 2, + phase: .closed, + source: .webSocket + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 2, + sequence: 1, + phase: .signalingConnected, + source: .webSocket + ) + ) + + let snapshot = aggregator.currentSnapshot + #expect(snapshot.signalingConnections == 1) + #expect(snapshot.streamingPeers == 0) + #expect(snapshot.clientsByTarget[target]?["client-1"]?.phase == .signalingConnected) + } + + @MainActor @Test func lateEventFromClosedSessionDoesNotOverrideReconnectedClient() { + let aggregator = SharingStateAggregator() + let target = ShareTarget.id(7) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 2, + phase: .closed, + source: .webSocket + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 2, + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 3, + phase: .peerDisconnected, + source: .peerConnection + ) + ) + + let snapshot = aggregator.currentSnapshot + #expect(snapshot.signalingConnections == 1) + #expect(snapshot.streamingPeers == 1) + #expect(snapshot.clientsByTarget[target]?["client-1"]?.phase == .peerConnected) + } + + @MainActor @Test func alreadyRunningStartPreservesCurrentSharingSnapshot() async { + let requestedPort = TestPortAllocator.randomUnprivilegedPort() + let mock = MockWebServiceController() + mock.isRunning = true + mock.lifecycleState = .running(.init(requestedPort: requestedPort, boundPort: requestedPort)) + mock.startResult = .alreadyRunning( + WebServiceBinding(requestedPort: requestedPort, boundPort: requestedPort) + ) + let aggregator = SharingStateAggregator() + aggregator.record( + SharingSessionEvent( + target: .main, + clientID: "client-1", + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + let sut = makeService(webServiceController: mock, sharingStateAggregator: aggregator) + + let result = await sut.startWebService(requestedPort: requestedPort) + + #expect(result == .alreadyRunning(.init(requestedPort: requestedPort, boundPort: requestedPort))) + #expect(sut.sharingStateSnapshot.streamingPeers == 1) + #expect(sut.sharingStateSnapshot.clientsByTarget[.main]?["client-1"] != nil) } @MainActor @Test func forwardsWebServiceRunningStateCallbackFromController() { @@ -123,16 +524,91 @@ struct SharingServiceTests { #expect(receivedStates == [true, false]) } + @MainActor @Test func closedClientTombstonesStayBounded() { + let aggregator = SharingStateAggregator() + let target = ShareTarget.id(88) + + for index in 0..<(SharingStateAggregator.closedClientTombstoneLimit + 5) { + let clientID = "client-\(index)" + aggregator.record( + SharingSessionEvent( + target: target, + clientID: clientID, + sequence: 1, + phase: .signalingConnected, + source: .webSocket + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: clientID, + sequence: 2, + phase: .closed, + source: .webSocket + ) + ) + } + + #expect(aggregator.currentSnapshot.signalingConnections == 0) + #expect(aggregator.closedClientTombstoneCountForTesting == SharingStateAggregator.closedClientTombstoneLimit) + } + @MainActor - private func makeService(webServiceController: MockWebServiceController) -> SharingService { + private func makeService( + webServiceController: MockWebServiceController, + acquireShare: DisplaySharingCoordinator.AcquireShare? = nil, + sharingStateAggregator: SharingStateAggregator = SharingStateAggregator() + ) -> SharingService { let storeURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) .appendingPathComponent("display-share-id-mappings.json", isDirectory: false) let idStore = DisplayShareIDStore(storeURL: storeURL) - let coordinator = DisplaySharingCoordinator(idStore: idStore) + let coordinator = DisplaySharingCoordinator( + idStore: idStore, + acquireShare: acquireShare + ) return SharingService( webServiceController: webServiceController, - sharingCoordinator: coordinator + sharingCoordinator: coordinator, + sharingStateAggregator: sharingStateAggregator ) } } + +private func waitForSharingServiceGate( + _ gate: SharingServiceAsyncGate, + count: Int, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 +) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() >= count { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await gate.currentWaitCount() >= count +} + +private func waitForSharingServiceTaskInvalidation( + _ task: Task, Error>, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 +) async -> Bool { + let box = SharingServiceOutcomeBox() + Task { + guard let outcome = try? await task.value else { return } + await box.store(outcome) + } + + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await box.isInvalidated() { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await box.isInvalidated() +} diff --git a/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift b/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift index 3ab4f8b..5fbd462 100644 --- a/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift @@ -33,7 +33,9 @@ struct WebServiceControllerTests { let result = await sut.start( requestedPort: 1000, targetStateProvider: { _ in .unknown }, - sessionHubProvider: { _ in nil } + concreteTargetResolver: { $0 }, + sessionHubProvider: { _ in nil }, + sharingEventSink: { _ in } ) #expect(result == .failed(.invalidPort(.outOfRange))) @@ -51,7 +53,9 @@ struct WebServiceControllerTests { _ = await sut.start( requestedPort: 999, targetStateProvider: { _ in .unknown }, - sessionHubProvider: { _ in nil } + concreteTargetResolver: { $0 }, + sessionHubProvider: { _ in nil }, + sharingEventSink: { _ in } ) #expect(states == [ @@ -71,7 +75,9 @@ struct WebServiceControllerTests { _ = await sut.start( requestedPort: 999, targetStateProvider: { _ in .unknown }, - sessionHubProvider: { _ in nil } + concreteTargetResolver: { $0 }, + sessionHubProvider: { _ in nil }, + sharingEventSink: { _ in } ) sut.stop() @@ -94,7 +100,9 @@ struct WebServiceControllerTests { _ = await sut.start( requestedPort: 999, targetStateProvider: { _ in .unknown }, - sessionHubProvider: { _ in nil } + concreteTargetResolver: { $0 }, + sessionHubProvider: { _ in nil }, + sharingEventSink: { _ in } ) sut.stop() @@ -264,11 +272,38 @@ struct WebServiceControllerTests { #expect(sut.lifecycleState == .running(.init(requestedPort: secondPort, boundPort: secondPort))) } + @Test + func disconnectStreamClientsForwardsTargetsToActiveServer() async { + let harness = WebServiceServerHarness() + let sut = WebServiceController(webServiceServerFactory: harness.makeServer) + + let requestedPort: UInt16 = 18086 + guard let startup = await beginControlledStartup( + harness: harness, + sut: sut, + requestedPort: requestedPort + ) else { + return + } + + startup.server.finishStart(with: .ready(boundPort: requestedPort)) + let result = await startup.startTask.value + guard case .started = result else { + Issue.record("Expected controlled startup to succeed before disconnecting clients.") + return + } + + sut.disconnectStreamClients(for: [.id(7)]) + + #expect(startup.server.disconnectedTargetsHistory == [Set([.id(7)])]) + } + private func beginControlledStartup( harness: WebServiceServerHarness, sut: WebServiceController, requestedPort: UInt16, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState = { _ in .unknown }, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget? = { $0 }, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? = { _ in nil } ) async -> (startTask: Task, server: ControlledWebServiceServer)? { let existingServerCount = harness.createdServers.count @@ -276,7 +311,9 @@ struct WebServiceControllerTests { await sut.start( requestedPort: requestedPort, targetStateProvider: targetStateProvider, - sessionHubProvider: sessionHubProvider + concreteTargetResolver: concreteTargetResolver, + sessionHubProvider: sessionHubProvider, + sharingEventSink: { _ in } ) } @@ -353,12 +390,16 @@ private final class WebServiceServerHarness { func makeServer( _ port: NWEndpoint.Port, _ targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + _ concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, _ sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, + _ sharingEventSink: @escaping @MainActor @Sendable (SharingSessionEvent) -> Void, _ onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? ) throws -> any WebServiceServerProtocol { _ = port _ = targetStateProvider + _ = concreteTargetResolver _ = sessionHubProvider + _ = sharingEventSink let server = ControlledWebServiceServer(onListenerStopped: onListenerStopped) createdServers.append(server) return server @@ -373,6 +414,7 @@ private final class ControlledWebServiceServer: WebServiceServerProtocol { private(set) var stopCallCount = 0 private(set) var stopReasons: [WebServiceServerStopReason] = [] private(set) var disconnectCallCount = 0 + private(set) var disconnectedTargetsHistory: [Set] = [] var activeStreamClientCount: Int = 0 init(onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)?) { @@ -412,6 +454,10 @@ private final class ControlledWebServiceServer: WebServiceServerProtocol { disconnectCallCount += 1 } + func disconnectStreamClients(for targets: Set) { + disconnectedTargetsHistory.append(targets) + } + func streamClientCount(for target: ShareTarget) -> Int { _ = target return 0 diff --git a/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift b/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift index d8407b1..e60818e 100644 --- a/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift +++ b/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift @@ -19,18 +19,26 @@ final class MockWebServiceController: WebServiceControllerProtocol { var startCallCount = 0 var stopCallCount = 0 var disconnectCallCount = 0 + var disconnectTargetCallCount = 0 + var disconnectedTargetsHistory: [Set] = [] var capturedTargetStateProvider: (@MainActor @Sendable (ShareTarget) -> ShareTargetState)? + var capturedConcreteTargetResolver: (@MainActor @Sendable (ShareTarget) -> ShareTarget?)? var capturedSessionHubProvider: (@MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?)? + var capturedSharingEventSink: (@Sendable (SharingSessionEvent) -> Void)? func start( requestedPort: UInt16, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, - sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, + sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, + sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void ) async -> WebServiceStartResult { startCallCount += 1 lastRequestedPort = requestedPort capturedTargetStateProvider = targetStateProvider + capturedConcreteTargetResolver = concreteTargetResolver capturedSessionHubProvider = sessionHubProvider + capturedSharingEventSink = sharingEventSink switch startResult { case .started(let binding), .alreadyRunning(let binding): isRunning = true @@ -57,6 +65,11 @@ final class MockWebServiceController: WebServiceControllerProtocol { disconnectCallCount += 1 } + func disconnectStreamClients(for targets: Set) { + disconnectTargetCallCount += 1 + disconnectedTargetsHistory.append(targets) + } + func streamClientCount(for target: ShareTarget) -> Int { streamClientCountByTarget[target] ?? 0 } diff --git a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift index 0bb9dc5..5e836c2 100644 --- a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift +++ b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift @@ -1,387 +1,74 @@ -import Foundation import CoreGraphics +import Foundation import ScreenCaptureKit import Testing @testable import VoidDisplay -private struct ControlledLoadFailure: Error, Sendable {} - -private actor SequencedShareDisplayLoaderGate { - enum Outcome: Sendable { - case success - case failure - } - - private struct PendingCall { - let outcome: Outcome - let continuation: CheckedContinuation - } - - private let scriptedOutcomes: [Outcome] - private var callCount = 0 - private var pendingCalls: [Int: PendingCall] = [:] - - init(scriptedOutcomes: [Outcome]) { - self.scriptedOutcomes = scriptedOutcomes - } - - func nextOutcome() async -> Outcome { - callCount += 1 - let callIndex = callCount - let outcome = scriptedOutcomes.indices.contains(callIndex - 1) - ? scriptedOutcomes[callIndex - 1] - : .success - return await withCheckedContinuation { continuation in - pendingCalls[callIndex] = PendingCall(outcome: outcome, continuation: continuation) - } - } - - func release(call callIndex: Int) { - guard let pending = pendingCalls.removeValue(forKey: callIndex) else { return } - pending.continuation.resume(returning: pending.outcome) - } - - func currentCallCount() -> Int { - callCount - } -} - @Suite(.serialized) +@MainActor struct ShareViewModelTests { - - @MainActor @Test func withDisplayStartLockRejectsConcurrentStartForSameDisplay() async { - let sut = ShareViewModel(dependencies: makeNoopShareDependencies()) - let displayID = CGDirectDisplayID(101) - var enteredCount = 0 - var firstDidEnter = false - var allowFirstToFinish = false - - let firstTask = Task { @MainActor in - await sut.withDisplayStartLock(displayID: displayID) { - enteredCount += 1 - firstDidEnter = true - while !allowFirstToFinish { - await Task.yield() - } - } - } - - while !firstDidEnter { - await Task.yield() - } - - let secondStarted = await sut.withDisplayStartLock(displayID: displayID) { - enteredCount += 1 - } - - allowFirstToFinish = true - let firstStarted = await firstTask.value - - #expect(firstStarted) - #expect(secondStarted == false) - #expect(enteredCount == 1) - #expect(sut.startingDisplayIDs.isEmpty) - } - - @MainActor @Test func withDisplayStartLockAllowsConcurrentStartForDifferentDisplays() async { - let sut = ShareViewModel(dependencies: makeNoopShareDependencies()) - let firstDisplayID = CGDirectDisplayID(201) - let secondDisplayID = CGDirectDisplayID(202) - var enteredDisplayIDs: Set = [] - var firstDidEnter = false - var allowFirstToFinish = false - - let firstTask = Task { @MainActor in - await sut.withDisplayStartLock(displayID: firstDisplayID) { - enteredDisplayIDs.insert(firstDisplayID) - firstDidEnter = true - while !allowFirstToFinish { - await Task.yield() - } - } - } - - while !firstDidEnter { - await Task.yield() - } - - let secondStarted = await sut.withDisplayStartLock(displayID: secondDisplayID) { - enteredDisplayIDs.insert(secondDisplayID) - } - - allowFirstToFinish = true - let firstStarted = await firstTask.value - - #expect(firstStarted) - #expect(secondStarted) - #expect(enteredDisplayIDs == [firstDisplayID, secondDisplayID]) - #expect(sut.startingDisplayIDs.isEmpty) - } - - @MainActor @Test func requestPermissionDeniedClearsDisplaysAndSetsErrorMessage() { - let env = makeEnvironment() - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: false, - requestResult: false - ), - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) + @Test func dependenciesLiveDelegatesToControllers() { + let sharingService = MockSharingService() + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "ShareViewModelTestsLive")!) ) - sut.catalog.displays = [] - sut.catalog.isLoadingDisplays = true - - sut.requestScreenCapturePermission() - - #expect(sut.catalog.hasScreenCapturePermission == false) - #expect(sut.catalog.lastRequestPermission == false) - #expect(sut.catalog.lastPreflightPermission == false) - #expect(sut.catalog.displays == nil) - #expect(sut.catalog.isLoadingDisplays == false) - #expect(sut.catalog.loadErrorMessage != nil) - } - - @MainActor @Test func loadDisplaysRegistersDisplaysThroughControllers() async { - let sharing = MockSharingService() - let env = makeEnvironment(sharing: sharing) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { [] }, - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) + sharingController.installStartingDisplayIDsForTesting([501]) + let virtualDisplayController = VirtualDisplayController( + virtualDisplayFacade: MockVirtualDisplayFacade(), + appliedBadgeDisplayDuration: .nanoseconds(1), + stopDependentStreamsBeforeRebuild: { _ in } ) - - sut.loadDisplays() - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - - #expect(finished) - #expect(sut.catalog.displays?.isEmpty == true) - #expect(sharing.registerShareableDisplaysCallCount == 1) - #expect(sut.catalog.lastLoadedActiveDisplayTopologySignature != nil) - } - - @MainActor @Test func refreshPermissionAndMaybeLoadKeepsCachedDisplaysWhenServiceAppearsStopped() { - let existingDisplay = MockSCDisplay.make(displayID: 9021, width: 1920, height: 1080) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) + let dependencies = ShareViewModel.Dependencies.live( + sharing: sharingController, + virtualDisplay: virtualDisplayController ) - sut.catalog.displays = [existingDisplay] - - sut.refreshPermissionAndMaybeLoad() - #expect(sut.catalog.hasScreenCapturePermission == true) - #expect(sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID]) - #expect(sut.catalog.isLoadingDisplays == false) - } - - @MainActor @Test func refreshPermissionAndMaybeLoadReloadsWhenTopologyChangesWithCachedDisplays() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 4444, width: 1920, height: 1080) - let rebuiltDisplay = MockSCDisplay.make(displayID: 5555, width: 2560, height: 1440) - var registerShareableDisplaysCallCount = 0 - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([5555]) }, - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { true }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in - registerShareableDisplaysCallCount += 1 - }, - beginSharing: { _ in }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) + #expect(dependencies.sharingQueries.isStartingDisplayID(501)) + #expect( + dependencies.sharingQueries.preferredWebServicePort() + == sharingController.preferredWebServicePort ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [4444] - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays == true) - #expect(sut.catalog.displays?.map(\.displayID) == [4444]) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [5555] - } - #expect(finished) - #expect(registerShareableDisplaysCallCount == 1) } - @MainActor @Test func refreshPermissionAndMaybeLoadReloadsWhenCachedDisplaysExistButLoadedTopologySignatureIsMissing() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 4444, width: 1920, height: 1080) - let rebuiltDisplay = MockSCDisplay.make(displayID: 5555, width: 2560, height: 1440) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([5555]) }, - dependencies: makeAlwaysRunningShareDependencies() + @Test func dependenciesLiveReflectsStopWebServiceClearingStartingState() { + let sharingService = MockSharingService() + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "ShareViewModelTestsStopWebService")!) ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = nil - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays == true) - #expect(sut.catalog.displays?.map(\.displayID) == [4444]) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [5555] - } - #expect(finished) - } - - @MainActor @Test func refreshPermissionFailureDoesNotCommitLoadedTopologySignatureAndNextRefreshRetries() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.failure, .success]) - let existingDisplay = MockSCDisplay.make(displayID: 4444, width: 1920, height: 1080) - let rebuiltDisplay = MockSCDisplay.make(displayID: 5555, width: 2560, height: 1440) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([5555]) }, - dependencies: makeAlwaysRunningShareDependencies() + sharingController.installStartingDisplayIDsForTesting([502]) + let virtualDisplayController = VirtualDisplayController( + virtualDisplayFacade: MockVirtualDisplayFacade(), + appliedBadgeDisplayDuration: .nanoseconds(1), + stopDependentStreamsBeforeRebuild: { _ in } ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [4444] - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - - await gate.release(call: 1) - let firstFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.lastLoadError != nil - } - #expect(firstFinished) - #expect(sut.catalog.displays?.map(\.displayID) == [4444]) - #expect(sut.catalog.lastLoadedActiveDisplayTopologySignature == [4444]) - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 2)) - - await gate.release(call: 2) - let secondFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.lastLoadError == nil && - sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [5555] - } - #expect(secondFinished) - } - - @MainActor @Test func syncForCurrentStateClearsDisplaysWhenServiceIsStopped() { - let existingDisplay = MockSCDisplay.make(displayID: 9022, width: 1920, height: 1080) - let sut = ShareViewModel( - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) + let dependencies = ShareViewModel.Dependencies.live( + sharing: sharingController, + virtualDisplay: virtualDisplayController ) - sut.catalog.hasScreenCapturePermission = true - sut.catalog.displays = [existingDisplay] - sut.syncForCurrentState() + #expect(dependencies.sharingQueries.isStartingDisplayID(502)) + + sharingController.stopWebService() - #expect(sut.catalog.displays == nil) - #expect(sut.catalog.isLoadingDisplays == false) + #expect(dependencies.sharingQueries.isStartingDisplayID(502) == false) } - @MainActor @Test func syncForCurrentStateCancelsLoadWithoutClearingDisplaysWhenServiceStoppedClearIsDisabled() { - let existingDisplay = MockSCDisplay.make(displayID: 9030, width: 1920, height: 1080) + @Test func isStartingDelegatesToSharingQueries() { let sut = ShareViewModel( dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { $0 == 101 }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + startWebService: { _ in .failed(.timedOut(port: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -389,70 +76,14 @@ struct ShareViewModelTests { ) ) ) - sut.catalog.hasScreenCapturePermission = true - sut.catalog.displays = [existingDisplay] - sut.catalog.isLoadingDisplays = true - - sut.syncForCurrentState(clearDisplaysWhenServiceStopped: false) - - #expect(sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID]) - #expect(sut.catalog.isLoadingDisplays == false) - } - - @MainActor @Test func refreshDisplaysBackgroundSafeStartsLoadWhenIdle() async { - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { [] }, - dependencies: makeAlwaysRunningShareDependencies() - ) - - sut.refreshDisplaysBackgroundSafe() - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - - #expect(finished) - #expect(sut.catalog.displays?.isEmpty == true) - } - - @MainActor @Test func refreshDisplaysBackgroundSafePreserveModeKeepsExistingDisplaysWhileReloading() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 3333, width: 1920, height: 1080) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: makeAlwaysRunningShareDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.refreshDisplaysBackgroundSafe() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays == true) - #expect(sut.catalog.displays?.count == 1) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - #expect(finished) + #expect(sut.isStarting(displayID: 101)) + #expect(sut.isStarting(displayID: 102) == false) } - @MainActor @Test func visibleDisplaysFiltersDisplaysMissingFromCurrentTopology() { - let displayA = MockSCDisplay.make(displayID: 1234, width: 1920, height: 1080) - let displayB = MockSCDisplay.make(displayID: 5678, width: 1920, height: 1080) + @Test func visibleDisplaysFiltersDisplaysMissingFromCurrentTopology() { + let displayA = SharedMockSCDisplay.make(displayID: 1234, width: 1920, height: 1080) + let displayB = SharedMockSCDisplay.make(displayID: 5678, width: 1920, height: 1080) let sut = ShareViewModel( activeDisplayIDsProvider: { Set([1234]) }, dependencies: makeAlwaysRunningShareDependencies() @@ -462,110 +93,11 @@ struct ShareViewModelTests { #expect(visible.map(\.displayID) == [1234]) } - @MainActor @Test func refreshDisplaysBackgroundSafeSkipsWhenLoadInFlight() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: makeAlwaysRunningShareDependencies() - ) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays) - - sut.refreshDisplaysBackgroundSafe() - let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow - var observedAdditionalCall = false - while DispatchTime.now().uptimeNanoseconds < deadline { - if await gate.currentCallCount() > 1 { - observedAdditionalCall = true - break - } - await Task.yield() - } - #expect(observedAdditionalCall == false) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - #expect(finished) - } - - @MainActor @Test func refreshDisplaysBackgroundSafeSkipsWhenServiceIsStopped() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 3344, width: 1920, height: 1080) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: makeNoopShareDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [existingDisplay.displayID] - - sut.refreshDisplaysBackgroundSafe() - - let stayedIdle = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID] - } - #expect(stayedIdle) - #expect(await gate.currentCallCount() == 0) - } - - @MainActor @Test func loadDisplaysRecordsDetailedErrorWhenLoaderFails() async { - let env = makeEnvironment() - let expected = NSError(domain: "ShareTests", code: 77) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { throw expected }, - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) - ) - - sut.loadDisplays() - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.lastLoadError != nil - } - - #expect(finished) - #expect(sut.catalog.loadErrorMessage != nil) - #expect(sut.catalog.lastLoadError?.domain == expected.domain) - #expect(sut.catalog.lastLoadError?.code == expected.code) - } - - @MainActor @Test func startServiceFailureShowsInlinePortError() async { + @Test func startServiceFailureShowsInlinePortError() async { let sharing = MockSharingService() sharing.startResult = .failed(.portInUse(port: 8081)) let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) @@ -579,15 +111,12 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func initUsesPreferredPortAsInputDefault() { + @Test func initUsesPreferredPortAsInputDefault() { let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 9099 } ), @@ -595,7 +124,7 @@ struct ShareViewModelTests { startWebService: { _ in .failed(.timedOut(port: 9099)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -607,13 +136,9 @@ struct ShareViewModelTests { #expect(sut.servicePortInput == "9099") } - @MainActor @Test func servicePortInputTruncatesToFiveCharacters() { + @Test func servicePortInputTruncatesToFiveCharacters() { let env = makeEnvironment() let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) @@ -622,14 +147,10 @@ struct ShareViewModelTests { #expect(sut.servicePortInput == "12345") } - @MainActor @Test func startServiceWithInvalidPortSkipsStartCallAndShowsValidationError() async { + @Test func startServiceWithInvalidPortSkipsStartCallAndShowsValidationError() async { let sharing = MockSharingService() let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) sut.servicePortInput = "abc" @@ -644,16 +165,12 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startServicePassesRequestedPortToSharingLayer() async { + @Test func startServicePassesRequestedPortToSharingLayer() async { let requestedPort = TestPortAllocator.randomUnprivilegedPort() let sharing = MockSharingService() sharing.startResult = .started(WebServiceBinding(requestedPort: requestedPort, boundPort: requestedPort)) let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) sut.servicePortInput = String(requestedPort) @@ -667,14 +184,43 @@ struct ShareViewModelTests { #expect(sharing.lastStartRequestedPort == requestedPort) } - @MainActor @Test func startSharingWithInvalidPortSkipsServiceStartAndSharing() async { - let display = MockSCDisplay.make(displayID: 7001, width: 1920, height: 1080) + @Test func stopServiceDelegatesToSharingLayer() { + var stopCallCount = 0 + let sut = ShareViewModel( + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in nil }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: { stopCallCount += 1 }, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in .started(()) }, + stopSharing: { _ in } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) + ) + + sut.stopService() + + #expect(stopCallCount == 1) + } + + @Test func startSharingWithInvalidPortSkipsServiceStartAndSharing() async { + let display = SharedMockSCDisplay.make(displayID: 7001, width: 1920, height: 1080) var startCallCount = 0 var beginSharingCallCount = 0 let sut = ShareViewModel( dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -687,6 +233,7 @@ struct ShareViewModelTests { registerShareableDisplays: { _, _ in }, beginSharing: { _ in beginSharingCallCount += 1 + return .started(()) }, stopSharing: { _ in } ), @@ -705,13 +252,14 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startSharingServiceStartFailureShowsInlineErrorAndSkipsSharing() async { - let display = MockSCDisplay.make(displayID: 7002, width: 1920, height: 1080) + @Test func startSharingServiceStartFailureShowsInlineErrorAndSkipsSharing() async { + let display = SharedMockSCDisplay.make(displayID: 7002, width: 1920, height: 1080) var beginSharingCallCount = 0 let sut = ShareViewModel( dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -721,6 +269,7 @@ struct ShareViewModelTests { registerShareableDisplays: { _, _ in }, beginSharing: { _ in beginSharingCallCount += 1 + return .started(()) }, stopSharing: { _ in } ), @@ -737,17 +286,14 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startSharingFailureStopsShareAndPresentsAlert() async { - enum ShareStartFailure: Error { - case failed - } - - let display = MockSCDisplay.make(displayID: 7003, width: 1920, height: 1080) + @Test func startSharingFailureStopsShareAndPresentsLocalizedAlert() async { + let display = SharedMockSCDisplay.make(displayID: 7003, width: 1920, height: 1080) var stopSharingDisplayIDs: [CGDirectDisplayID] = [] let sut = ShareViewModel( dependencies: .init( sharingQueries: .init( isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -756,7 +302,7 @@ struct ShareViewModelTests { stopWebService: {}, registerShareableDisplays: { _, _ in }, beginSharing: { _ in - throw ShareStartFailure.failed + throw SharingStartError.displayNotRegistered(display.displayID) }, stopSharing: { displayID in stopSharingDisplayIDs.append(displayID) @@ -772,116 +318,122 @@ struct ShareViewModelTests { #expect(stopSharingDisplayIDs == [display.displayID]) #expect(sut.userFacingAlert?.title == String(localized: "Share Failed")) + #expect(sut.userFacingAlert?.message == String(localized: "Selected display is no longer available for sharing.")) #expect(sut.portInputErrorMessage == nil) } - @MainActor @Test func editingPortClearsInlineErrorMessage() async { - let sharing = MockSharingService() - let env = makeEnvironment(sharing: sharing) + @Test func startSharingInvalidationEndsSilentlyWithoutStoppingShare() async { + let display = SharedMockSCDisplay.make(displayID: 7004, width: 1920, height: 1080) + var stopSharingCallCount = 0 let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in nil }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: {}, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in .invalidated }, + stopSharing: { _ in + stopSharingCallCount += 1 + } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) ) - sut.servicePortInput = "bad-port" - sut.startService() - _ = await waitUntil { sut.portInputErrorMessage != nil } - #expect(sut.portInputErrorMessage != nil) + await sut.startSharing(display: display) - sut.servicePortInput = "8081" + #expect(stopSharingCallCount == 0) + #expect(sut.userFacingAlert == nil) #expect(sut.portInputErrorMessage == nil) } - @MainActor @Test func loadDisplaysIgnoresLateResultFromSupersededRequest() async { - let gate = SequencedShareDisplayLoaderGate( - scriptedOutcomes: [.failure, .success] - ) - let env = makeEnvironment() + @Test func startSharingWithRunningServiceDelegatesToSharingLayer() async { + let display = SharedMockSCDisplay.make(displayID: 7005, width: 1920, height: 1080) + var beginSharingCallCount = 0 let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in nil }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: {}, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in + beginSharingCallCount += 1 + return .started(()) + }, + stopSharing: { _ in } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) ) - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 2)) - - await gate.release(call: 2) - let secondFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays != nil && - sut.catalog.lastLoadError == nil - } - #expect(secondFinished) + await sut.startSharing(display: display) - await gate.release(call: 1) - let staleResultIgnored = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.isEmpty == true && - sut.catalog.lastLoadError == nil - } - #expect(staleResultIgnored) + #expect(beginSharingCallCount == 1) + #expect(sut.userFacingAlert == nil) + #expect(sut.portInputErrorMessage == nil) } - @MainActor @Test func stopServiceCancelsInFlightDisplayLoadAndPreventsLateWrite() async { - let gate = SequencedShareDisplayLoaderGate( - scriptedOutcomes: [.success] - ) + @Test func editingPortClearsInlineErrorMessage() async { let sharing = MockSharingService() - sharing.isWebServiceRunning = true let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) + sut.servicePortInput = "bad-port" + sut.startService() + _ = await waitUntil { sut.portInputErrorMessage != nil } + #expect(sut.portInputErrorMessage != nil) - sut.stopService() - #expect(sut.catalog.isLoadingDisplays == false) - #expect(sut.catalog.displays == nil) + sut.servicePortInput = "8081" + #expect(sut.portInputErrorMessage == nil) + } - await gate.release(call: 1) - let lateWritePrevented = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays == nil - } - #expect(lateWritePrevented) + @Test func sharePageAddressDelegatesToSharingQueries() { + let sut = ShareViewModel( + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in "http://127.0.0.1:8081/display/1" }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: {}, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in .started(()) }, + stopSharing: { _ in } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) + ) + + #expect(sut.sharePageAddress(for: 1) == "http://127.0.0.1:8081/display/1") } - @MainActor private func makeEnvironment() -> AppEnvironment { makeEnvironment(sharing: MockSharingService()) } - @MainActor private func makeEnvironment(sharing: MockSharingService) -> AppEnvironment { AppBootstrap.makeEnvironment( preview: true, @@ -892,44 +444,11 @@ struct ShareViewModelTests { ) } - @MainActor - private func waitForLoaderCall(_ gate: SequencedShareDisplayLoaderGate, count: Int) async -> Bool { - let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion - while DispatchTime.now().uptimeNanoseconds < deadline { - if await gate.currentCallCount() >= count { - return true - } - await Task.yield() - } - return await gate.currentCallCount() >= count - } - - @MainActor - private func makeNoopShareDependencies() -> ShareViewModel.Dependencies { - .init( - sharingQueries: .init( - isWebServiceRunning: { false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .failed(.timedOut(port: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - } - - @MainActor private func makeAlwaysRunningShareDependencies() -> ShareViewModel.Dependencies { .init( sharingQueries: .init( isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -937,7 +456,7 @@ struct ShareViewModelTests { startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -946,25 +465,3 @@ struct ShareViewModelTests { ) } } - -private final class MockSCDisplayBox: NSObject { - @objc let displayID: CGDirectDisplayID - @objc let width: Int - @objc let height: Int - @objc let frame: CGRect - - init(displayID: CGDirectDisplayID, width: Int, height: Int) { - self.displayID = displayID - self.width = width - self.height = height - self.frame = CGRect(x: 0, y: 0, width: width, height: height) - super.init() - } -} - -private enum MockSCDisplay { - static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { - let box = MockSCDisplayBox(displayID: displayID, width: width, height: height) - return unsafeBitCast(box, to: SCDisplay.self) - } -} diff --git a/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift b/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift index 654433e..76752d1 100644 --- a/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift +++ b/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift @@ -1,32 +1,8 @@ import CoreGraphics -import ScreenCaptureKit +import Synchronization import Testing @testable import VoidDisplay -private actor ShareViewLoaderGate { - private var callCount = 0 - private var waiters: [CheckedContinuation] = [] - - func next() async { - callCount += 1 - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - func release() { - let pending = waiters - waiters.removeAll() - for waiter in pending { - waiter.resume() - } - } - - func currentCallCount() -> Int { - callCount - } -} - private final class ShareViewDisplayReconfigurationMonitor: DisplayReconfigurationMonitoring { private let startResults: [Bool] private(set) var startCallCount = 0 @@ -55,32 +31,22 @@ private final class ShareViewDisplayReconfigurationMonitor: DisplayReconfigurati } private final class ShareViewSignatureBox { - var value: [CGDirectDisplayID] + var value: ScreenCaptureDisplayTopologySignature - init(_ value: [CGDirectDisplayID]) { + init(_ value: ScreenCaptureDisplayTopologySignature) { self.value = value } } -private final class ShareViewMockSCDisplayBox: NSObject { - @objc let displayID: CGDirectDisplayID - @objc let width: Int - @objc let height: Int - @objc let frame: CGRect - - init(displayID: CGDirectDisplayID, width: Int, height: Int) { - self.displayID = displayID - self.width = width - self.height = height - self.frame = CGRect(x: 0, y: 0, width: width, height: height) - super.init() +private final class ShareViewTopologyChangeCounter: @unchecked Sendable { + private let count = Mutex(0) + + nonisolated func increment() { + count.withLock { $0 += 1 } } -} -private enum ShareViewMockSCDisplay { - static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { - let box = ShareViewMockSCDisplayBox(displayID: displayID, width: width, height: height) - return unsafeBitCast(box, to: SCDisplay.self) + nonisolated func snapshot() -> Int { + count.withLock { $0 } } } @@ -100,103 +66,67 @@ struct ShareViewBehaviorTests { #expect(state == .serviceStopped) } - @Test func lifecycleHandleAppearRefreshesPermissionAndEnablesToolbarFallback() { - let viewModel = makeViewModel(isWebServiceRunning: false) + @Test func lifecycleHandleAppearEnablesToolbarFallbackWhenMonitorRegistrationFails() { let monitor = ShareViewDisplayReconfigurationMonitor(startResults: [false]) - let lifecycle = ShareViewLifecycleController( + let lifecycle = DisplayTopologyRefreshLifecycleController( displayRefreshMonitor: monitor, recoveryAttemptInterval: 99 ) - lifecycle.handleAppear(viewModel: viewModel) + lifecycle.handleAppear {} - #expect(viewModel.catalog.lastPreflightPermission == true) #expect(lifecycle.showToolbarRefresh) #expect(monitor.startCallCount == 1) } - @Test func lifecycleFallbackRefreshesDisplaysAfterTopologyChange() async { - let loaderGate = ShareViewLoaderGate() - let signatureBox = ShareViewSignatureBox([101]) - let existingDisplay = ShareViewMockSCDisplay.make(displayID: 101, width: 1920, height: 1080) - let refreshedDisplay = ShareViewMockSCDisplay.make(displayID: 202, width: 2560, height: 1440) - let viewModel = makeViewModel( - isWebServiceRunning: true, - loadShareableDisplays: { - await loaderGate.next() - return [refreshedDisplay] - }, - activeDisplayIDsProvider: { Set(signatureBox.value) } - ) - viewModel.catalog.displays = [existingDisplay] - viewModel.catalog.lastLoadedActiveDisplayTopologySignature = [101] + @Test func lifecycleHandleDisappearStopsMonitor() { + let monitor = ShareViewDisplayReconfigurationMonitor(startResults: [true]) + let lifecycle = DisplayTopologyRefreshLifecycleController(displayRefreshMonitor: monitor) + + lifecycle.handleAppear {} + lifecycle.handleDisappear() + + #expect(monitor.stopCallCount == 1) + } - let lifecycle = ShareViewLifecycleController( + @Test func lifecycleFallbackDetectsConfigurationChangeForSameDisplayID() async { + let displayID = CGDirectDisplayID(707) + let signatureBox = ShareViewSignatureBox([ + makeTestDisplayTopologySignatureEntry( + displayID: displayID, + pixelWidth: 1920, + pixelHeight: 1080 + ) + ]) + let lifecycle = DisplayTopologyRefreshLifecycleController( displayRefreshMonitor: ShareViewDisplayReconfigurationMonitor(startResults: [false, false]), displayTopologySignatureProvider: { signatureBox.value }, fallbackPollingInterval: .milliseconds(20), recoveryAttemptInterval: 99 ) + let topologyChangeCount = ShareViewTopologyChangeCounter() - lifecycle.handleAppear(viewModel: viewModel) - await drainMainActorTasks() - #expect(await loaderGate.currentCallCount() == 0) - - signatureBox.value = [202] - - let requestedReload = await waitUntilAsync { - await loaderGate.currentCallCount() == 1 + lifecycle.handleAppear { + topologyChangeCount.increment() } - #expect(requestedReload) - - await loaderGate.release() - let finished = await waitUntil { - viewModel.catalog.isLoadingDisplays == false && - viewModel.catalog.displays?.map(\.displayID) == [202] - } - #expect(finished) - } - - @Test func lifecycleHandleDisappearCancelsInFlightLoadAndStopsMonitor() async { - let loaderGate = ShareViewLoaderGate() - let monitor = ShareViewDisplayReconfigurationMonitor(startResults: [true]) - let viewModel = makeViewModel( - isWebServiceRunning: true, - loadShareableDisplays: { - await loaderGate.next() - return [ShareViewMockSCDisplay.make(displayID: 303, width: 1280, height: 720)] - } - ) - let lifecycle = ShareViewLifecycleController(displayRefreshMonitor: monitor) - - viewModel.loadDisplays() - #expect(await waitUntilAsync { await loaderGate.currentCallCount() == 1 }) - - lifecycle.handleDisappear(viewModel: viewModel) - await loaderGate.release() + signatureBox.value = [ + makeTestDisplayTopologySignatureEntry( + displayID: displayID, + pixelWidth: 2560, + pixelHeight: 1440 + ) + ] - let cancelled = await waitUntil { - viewModel.catalog.isLoadingDisplays == false && viewModel.catalog.displays == nil - } - #expect(cancelled) - #expect(monitor.stopCallCount == 1) + #expect(await waitUntilAsync { topologyChangeCount.snapshot() == 1 }) + lifecycle.handleDisappear() } - private func makeViewModel( - isWebServiceRunning: Bool, - loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, - activeDisplayIDsProvider: @escaping @MainActor () -> Set = { [] } - ) -> ShareViewModel { - ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: loadShareableDisplays, - activeDisplayIDsProvider: activeDisplayIDsProvider, + @Test func viewModelSurfacesStartingStateFromSharingDependency() { + let viewModel = ShareViewModel( dependencies: .init( sharingQueries: .init( - isWebServiceRunning: { isWebServiceRunning }, + isWebServiceRunning: { true }, + isStartingDisplayID: { $0 == 404 }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -204,7 +134,7 @@ struct ShareViewBehaviorTests { startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -212,6 +142,9 @@ struct ShareViewBehaviorTests { ) ) ) + + #expect(viewModel.isStarting(displayID: 404)) + #expect(viewModel.isStarting(displayID: 405) == false) } private func waitUntilAsync( diff --git a/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift b/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift index e54d79d..a8bd3bd 100644 --- a/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift +++ b/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift @@ -76,6 +76,34 @@ private final class Counter: @unchecked Sendable { } } +private final class SharingEventRecorder: @unchecked Sendable { + private let events = Mutex<[SharingSessionEvent]>([]) + + nonisolated func record(_ event: SharingSessionEvent) { + events.withLock { $0.append(event) } + } + + func currentEvents() -> [SharingSessionEvent] { + events.withLock { $0 } + } + + func currentPhases() -> [SharingPeerPhase] { + events.withLock { $0.map(\.recordedPhase) } + } + + func currentSequences() -> [UInt64] { + events.withLock { $0.map(\.recordedSequence) } + } + + func currentSessionEpochs() -> [UInt64] { + events.withLock { $0.map(\.recordedSessionEpoch) } + } +} + +private final class PeerCallbacksBox: @unchecked Sendable { + nonisolated(unsafe) var callbacks: WebRTCSessionHub.PeerCallbacks? +} + private final class MockPeerSession: @unchecked Sendable, WebRTCPeerSessioning { private let closeCalls: Counter @@ -121,8 +149,9 @@ struct WebRTCSessionHubTests { let hub = WebRTCSessionHub() let client = MockSignalSocketConnection() - hub.addClient(client) + let result = hub.addClient(client, target: .main, eventSink: { _ in }) + #expect(isAccepted(result)) let payloads = client.decodedTextPayloads() #expect(payloads.contains(where: { $0.contains(#""type":"ready""#) })) #expect(payloads.allSatisfy { !$0.contains(#""version""#) }) @@ -131,17 +160,85 @@ struct WebRTCSessionHubTests { @MainActor @Test func malformedSignalPayloadReturnsError() { let hub = WebRTCSessionHub() let client = MockSignalSocketConnection() - hub.addClient(client) + _ = hub.addClient(client, target: .main, eventSink: { _ in }) hub.receiveSignalText("not-a-json", from: client) #expect(client.decodedTextPayloads().contains(where: { $0.contains(#""reason":"invalid_signal_payload""#) })) } + @MainActor @Test func addClientAcceptsViewerBeyondFormerCapacity() { + let hub = WebRTCSessionHub() + var clients: [MockSignalSocketConnection] = [] + let idGenerationCount = Counter() + let formerCapacity = 10 + let expectedClientCount = formerCapacity + 5 + + for index in 0.. 0 { + hub.receiveSignalText("not-a-json", from: client) + } + + #expect(hub.activeClientCount == 0) + #expect(client.cancelCallCount == 1) + } + @MainActor @Test func removedClient_offer_doesNotCreatePeer() { let peerCreateCalls = Counter() let peerCloseCalls = Counter() @@ -195,7 +305,7 @@ struct WebRTCSessionHubTests { return MockPeerSession(closeCalls: peerCloseCalls) }) let client = MockSignalSocketConnection() - hub.addClient(client) + _ = hub.addClient(client, target: .main, eventSink: { _ in }) hub.removeClient(client) hub.receiveSignalText(#"{"type":"offer","sdp":"v=0"}"#, from: client) @@ -205,6 +315,32 @@ struct WebRTCSessionHubTests { } #if canImport(WebRTC) + @MainActor @Test func answerMessagesCoalesceToLatestUnderBackpressure() throws { + let callbacksBox = PeerCallbacksBox() + let hub = WebRTCSessionHub(peerFactory: { callbacks in + callbacksBox.callbacks = callbacks + return MockPeerSession(closeCalls: Counter()) + }) + let client = MockSignalSocketConnection(autoCompleteSends: false) + _ = hub.addClient(client, target: .main, eventSink: { _ in }) + + hub.receiveSignalText("not-a-json", from: client) + hub.receiveSignalText(#"{"type":"offer","sdp":"v=0"}"#, from: client) + callbacksBox.callbacks?.onAnswer("v=1") + callbacksBox.callbacks?.onAnswer("v=2") + + #expect(client.completeNextSend()) + #expect(client.completeNextSend()) + #expect(client.completeNextSend()) + + let payloads = client.decodedTextPayloads() + let answerPayloads = payloads.filter { $0.contains(#""type":"answer""#) } + #expect(answerPayloads.count == 1) + let finalAnswer = try #require(answerPayloads.first) + #expect(finalAnswer.contains(#""sdp":"v=2""#)) + #expect(finalAnswer.contains(#""sdp":"v=1""#) == false) + } + @MainActor @Test func clientRemovedDuringEnsurePeer_closesNewPeer() { let peerCloseCalls = Counter() let box = PeerFactoryBox(closeCalls: peerCloseCalls) @@ -214,12 +350,126 @@ struct WebRTCSessionHubTests { let client = MockSignalSocketConnection() box.hub = hub box.client = client - hub.addClient(client) + _ = hub.addClient(client, target: .main, eventSink: { _ in }) hub.receiveSignalText(#"{"type":"offer","sdp":"v=0"}"#, from: client) #expect(peerCloseCalls.value() == 1) #expect(hub.activeClientCount == 0) } + + @MainActor @Test func peerFailureClosesClientWithoutTerminalErrorSignal() { + let callbacksBox = PeerCallbacksBox() + let hub = WebRTCSessionHub(peerFactory: { callbacks in + callbacksBox.callbacks = callbacks + return MockPeerSession(closeCalls: Counter()) + }) + let client = MockSignalSocketConnection() + _ = hub.addClient(client, target: .main, eventSink: { _ in }) + + hub.receiveSignalText(#"{"type":"offer","sdp":"v=0"}"#, from: client) + callbacksBox.callbacks?.onFailure("transient_peer_failure") + + let payloads = client.decodedTextPayloads() + #expect(payloads.contains(where: { $0.contains(#""type":"ready""#) })) + #expect(payloads.contains(where: { $0.contains(#""type":"error""#) }) == false) + #expect(client.cancelCallCount == 1) + #expect(hub.activeClientCount == 0) + } + + @MainActor @Test func lifecycleEventsReflectOfferConnectAndClose() async throws { + let eventRecorder = SharingEventRecorder() + let callbacksBox = PeerCallbacksBox() + let hub = WebRTCSessionHub(peerFactory: { callbacks in + callbacksBox.callbacks = callbacks + return MockPeerSession(closeCalls: Counter()) + }) + let client = MockSignalSocketConnection() + + let addResult = hub.addClient( + client, + target: .id(9), + eventSink: { event in + eventRecorder.record(event) + } + ) + #expect(isAccepted(addResult)) + + hub.receiveSignalText(#"{"type":"offer","sdp":"v=0"}"#, from: client) + callbacksBox.callbacks?.onConnected() + callbacksBox.callbacks?.onDisconnected() + + let observed = await waitUntilPhases(eventRecorder, count: 5) + #expect(observed) + let phases = eventRecorder.currentPhases() + #expect(phases == [ + .signalingConnected, + .offerReceived, + .peerConnected, + .peerDisconnected, + .closed, + ]) + let sequences = eventRecorder.currentSequences() + #expect(sequences == [1, 2, 3, 4, 5]) + let sessionEpochs = eventRecorder.currentSessionEpochs() + let firstEpoch = try #require(sessionEpochs.first) + #expect(sessionEpochs.allSatisfy { $0 == firstEpoch }) + } + + @MainActor @Test func reusedClientIDGetsNewSessionEpoch() { + let eventRecorder = SharingEventRecorder() + let hub = WebRTCSessionHub() + let firstClient = MockSignalSocketConnection() + let secondClient = MockSignalSocketConnection() + + let firstResult = hub.addClient( + firstClient, + target: .main, + makeClientID: { "client-1" }, + eventSink: { event in + eventRecorder.record(event) + } + ) + #expect(isAccepted(firstResult)) + hub.removeClient(firstClient) + + let secondResult = hub.addClient( + secondClient, + target: .main, + makeClientID: { "client-1" }, + eventSink: { event in + eventRecorder.record(event) + } + ) + #expect(isAccepted(secondResult)) + + let events = eventRecorder.currentEvents() + #expect(events.count == 3) + #expect(events[0].recordedSessionEpoch == events[1].recordedSessionEpoch) + #expect(events[2].recordedSessionEpoch > events[1].recordedSessionEpoch) + #expect(events[2].recordedSequence == 1) + } #endif } + +private func isAccepted(_ result: WebRTCSessionHub.AddClientResult) -> Bool { + if case .accepted = result { + return true + } + return false +} + +private func waitUntilPhases( + _ recorder: SharingEventRecorder, + count: Int, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion +) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if recorder.currentPhases().count >= count { + return true + } + await Task.yield() + } + return recorder.currentPhases().count >= count +} diff --git a/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift b/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift index fef3c07..208475f 100644 --- a/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift +++ b/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift @@ -8,13 +8,15 @@ struct CapturePreviewDiagnosticsRuntimeTests { let configuration = CapturePreviewDiagnosticsRuntime.configuration( environment: [ CapturePreviewDiagnosticsRuntime.sourceSizeEnvironmentKey: "3008x1692", - CapturePreviewDiagnosticsRuntime.targetContentWidthEnvironmentKey: "1180" + CapturePreviewDiagnosticsRuntime.targetContentWidthEnvironmentKey: "1180", + CapturePreviewDiagnosticsRuntime.scaleModeEnvironmentKey: "native" ] ) #expect(configuration?.sourcePixelSize == CGSize(width: 3008, height: 1692)) #expect(configuration?.targetContentWidth == 1180) #expect(configuration?.replayImageURL == nil) + #expect(configuration?.initialScaleMode == .native) } @Test @MainActor func parsedSizeAcceptsMultipleSeparators() { @@ -28,4 +30,14 @@ struct CapturePreviewDiagnosticsRuntimeTests { ) #expect(CapturePreviewDiagnosticsRuntime.parsedSize(from: "bad-value") == nil) } + + @Test @MainActor func configurationIgnoresInvalidScaleMode() { + let configuration = CapturePreviewDiagnosticsRuntime.configuration( + environment: [ + CapturePreviewDiagnosticsRuntime.scaleModeEnvironmentKey: "stretch" + ] + ) + + #expect(configuration?.initialScaleMode == nil) + } } diff --git a/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift new file mode 100644 index 0000000..de4e6a6 --- /dev/null +++ b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift @@ -0,0 +1,560 @@ +import CoreGraphics +import Foundation +import ScreenCaptureKit +import Testing +@testable import VoidDisplay + +private actor SequencedCatalogServiceLoadGate { + enum Outcome: Sendable { + case success + case failure(any Error) + } + + private struct PendingCall { + let outcome: Outcome + let continuation: CheckedContinuation + } + + private let scriptedOutcomes: [Outcome] + private var callCount = 0 + private var pendingCalls: [Int: PendingCall] = [:] + + init(scriptedOutcomes: [Outcome]) { + self.scriptedOutcomes = scriptedOutcomes + } + + func nextOutcome() async -> Outcome { + callCount += 1 + let callIndex = callCount + let outcome = scriptedOutcomes.indices.contains(callIndex - 1) + ? scriptedOutcomes[callIndex - 1] + : .success + return await withCheckedContinuation { continuation in + pendingCalls[callIndex] = PendingCall(outcome: outcome, continuation: continuation) + } + } + + func release(call callIndex: Int) { + guard let pending = pendingCalls.removeValue(forKey: callIndex) else { return } + pending.continuation.resume(returning: pending.outcome) + } + + func currentCallCount() -> Int { + callCount + } +} + +private struct CatalogServiceControlledFailure: Error, Sendable {} + +@MainActor +private final class CatalogServiceSignatureBox { + var value: ScreenCaptureDisplayTopologySignature + + init(_ value: ScreenCaptureDisplayTopologySignature) { + self.value = value + } +} + +private final class CatalogServiceMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum CatalogServiceMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = CatalogServiceMockSCDisplayBox(displayID: displayID, width: width, height: height) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + +@MainActor +@Suite(.serialized) +struct ScreenCaptureCatalogServiceTests { + @Test func defaultShareableDisplayLoaderReturnsEmptySnapshotUnderXCTestEnvironment() async throws { + let loader = ScreenCaptureShareableDisplayLoaderFactory.makeDefault( + environment: [ + PersistenceContext.xCTestConfigurationEnvironmentKey: "/tmp/VoidDisplayTests.xctest" + ] + ) + + let displays = try await loader() + + #expect(displays.isEmpty) + } + + @Test func defaultShareableDisplayLoaderUsesFixtureDisplaysUnderUITestMode() async throws { + let loader = ScreenCaptureShareableDisplayLoaderFactory.makeDefault( + environment: [ + UITestRuntime.modeEnvironmentKey: "1", + UITestRuntime.scenarioEnvironmentKey: UITestScenario.baseline.rawValue + ] + ) + + let displays = try await loader() + + #expect(!displays.isEmpty) + } + + @Test func unchangedTopologyReusesSnapshotWithoutReload() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [101] } + ) + sut.store.hasScreenCapturePermission = true + sut.store.displays = [] + sut.store.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([101]) + + let result = await sut.submitRefresh(intent: .permissionChanged) + + #expect(result == .reusedSnapshot) + #expect(sut.store.lastRefreshResult == .reusedSnapshot) + #expect(await gate.currentCallCount() == 0) + } + + @Test func permissionDeniedClearsSnapshot() async { + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: false, requestResult: false), + loadShareableDisplays: { [] }, + activeDisplayIDsProvider: { [202] } + ) + sut.store.displays = [] + sut.store.hasScreenCapturePermission = true + _ = sut.refreshPermission() + + let result = await sut.submitRefresh(intent: .permissionChanged) + + #expect(result == .clearedSnapshot) + #expect(sut.store.displays == nil) + #expect(sut.store.hasScreenCapturePermission == false) + #expect(sut.store.lastRefreshResult == .clearedSnapshot) + } + + @Test func supersededRefreshDoesNotOverwriteLatestSnapshot() async { + let gate = SequencedCatalogServiceLoadGate( + scriptedOutcomes: [ + .failure(CatalogServiceControlledFailure()), + .success + ] + ) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [303] } + ) + sut.store.hasScreenCapturePermission = true + + let firstRefresh = Task { await sut.submitRefresh(intent: .permissionChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + + let secondRefresh = Task { await sut.submitRefresh(intent: .userForcedRefresh) } + #expect(await waitForLoaderCall(gate, count: 2)) + + await gate.release(call: 2) + #expect(await secondRefresh.value == .reloadedSnapshot) + #expect(sut.store.displays?.isEmpty == true) + #expect(sut.store.lastLoadError == nil) + + await gate.release(call: 1) + #expect(await firstRefresh.value == .failed) + #expect(sut.store.displays?.isEmpty == true) + #expect(sut.store.lastLoadError == nil) + #expect(sut.store.lastRefreshResult == .reloadedSnapshot) + } + + @Test func cancelRefreshOnlyCancelsMatchingOwner() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [404] } + ) + let captureOwner = ScreenCaptureCatalogService.RefreshOwner() + let sharingOwner = ScreenCaptureCatalogService.RefreshOwner() + sut.store.hasScreenCapturePermission = true + + let refresh = Task { + await sut.submitRefresh(intent: .userForcedRefresh, owner: captureOwner) + } + #expect(await waitForLoaderCall(gate, count: 1)) + #expect(sut.store.isLoadingDisplays == true) + + await sut.cancelRefresh(owner: sharingOwner) + + #expect(sut.store.isLoadingDisplays == true) + + await gate.release(call: 1) + #expect(await refresh.value == .reloadedSnapshot) + #expect(sut.store.isLoadingDisplays == false) + #expect(sut.store.lastRefreshResult == .reloadedSnapshot) + } + + @Test func supersededRefreshDoesNotClearLoadingStateWhileReplacementRuns() async { + let gate = SequencedCatalogServiceLoadGate( + scriptedOutcomes: [ + .failure(CatalogServiceControlledFailure()), + .success + ] + ) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [505] } + ) + sut.store.hasScreenCapturePermission = true + + let firstRefresh = Task { await sut.submitRefresh(intent: .permissionChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + + let secondRefresh = Task { await sut.submitRefresh(intent: .userForcedRefresh) } + #expect(await waitForLoaderCall(gate, count: 2)) + #expect(sut.store.isLoadingDisplays == true) + + await gate.release(call: 1) + + #expect(await firstRefresh.value == .failed) + #expect(sut.store.isLoadingDisplays == true) + #expect(sut.store.lastLoadError == nil) + #expect(sut.store.lastRefreshResult == nil) + + await gate.release(call: 2) + + #expect(await secondRefresh.value == .reloadedSnapshot) + #expect(sut.store.isLoadingDisplays == false) + #expect(sut.store.lastRefreshResult == .reloadedSnapshot) + } + + @Test func matchingCachedTopologyJoinsInFlightRefreshInsteadOfReusingStaleSnapshot() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let staleDisplay = CatalogServiceMockSCDisplay.make(displayID: 707, width: 1280, height: 720) + let refreshedDisplay = CatalogServiceMockSCDisplay.make(displayID: 808, width: 1920, height: 1080) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [refreshedDisplay] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [707] } + ) + sut.store.hasScreenCapturePermission = true + sut.store.displays = [staleDisplay] + sut.store.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([707]) + + let firstRefresh = Task { + await sut.submitRefresh(intent: .userForcedRefresh) + } + #expect(await waitForLoaderCall(gate, count: 1)) + + let joinedRefresh = Task { + await sut.submitRefresh(intent: .serviceBecameRunning) + } + + let stayedSingleLoad = await staysTrue(timeoutNanoseconds: 100_000_000) { + await gate.currentCallCount() == 1 + } + #expect(stayedSingleLoad) + #expect(sut.store.isLoadingDisplays == true) + + await gate.release(call: 1) + + #expect(await firstRefresh.value == .reloadedSnapshot) + #expect(await joinedRefresh.value == .reloadedSnapshot) + #expect(sut.store.displays?.map(\.displayID) == [808]) + #expect(sut.store.lastRefreshResult == .reloadedSnapshot) + } + + @Test func deniedPermissionInvalidationCancelsInFlightRefreshBeforeClearingSnapshot() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [606] } + ) + sut.store.hasScreenCapturePermission = true + + let refresh = Task { await sut.submitRefresh(intent: .userForcedRefresh) } + #expect(await waitForLoaderCall(gate, count: 1)) + #expect(sut.store.isLoadingDisplays == true) + + await sut.clearSnapshotForDeniedPermission(loadErrorMessage: "permission denied") + #expect(sut.store.displays == nil) + #expect(sut.store.hasScreenCapturePermission == false) + #expect(sut.store.lastRefreshResult == .clearedSnapshot) + #expect(sut.store.loadErrorMessage == "permission denied") + + await gate.release(call: 1) + #expect(await refresh.value == .failed) + #expect(sut.store.displays == nil) + #expect(sut.store.hasScreenCapturePermission == false) + #expect(sut.store.lastRefreshResult == .clearedSnapshot) + #expect(sut.store.loadErrorMessage == "permission denied") + } + + @Test func changedDisplayConfigurationReloadsEvenWhenDisplayIDIsUnchanged() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let displayID = CGDirectDisplayID(909) + let signatureBox = CatalogServiceSignatureBox([ + makeTestDisplayTopologySignatureEntry( + displayID: displayID, + pixelWidth: 1920, + pixelHeight: 1080 + ) + ]) + let refreshedDisplay = CatalogServiceMockSCDisplay.make( + displayID: displayID, + width: 2560, + height: 1440 + ) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [refreshedDisplay] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [displayID] }, + displayTopologySignatureProvider: { signatureBox.value } + ) + sut.store.hasScreenCapturePermission = true + sut.store.displays = [ + CatalogServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + ] + sut.store.lastLoadedActiveDisplayTopologySignature = signatureBox.value + + signatureBox.value = [ + makeTestDisplayTopologySignatureEntry( + displayID: displayID, + pixelWidth: 2560, + pixelHeight: 1440 + ) + ] + + let refresh = Task { await sut.submitRefresh(intent: .topologyChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + await gate.release(call: 1) + + #expect(await refresh.value == .reloadedSnapshot) + #expect(sut.store.displays?.map(\.displayID) == [displayID]) + #expect(sut.store.lastLoadedActiveDisplayTopologySignature == signatureBox.value) + } + + @Test func topologyMismatchBeforeCommitRetriesAndCommitsLatestSnapshot() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success, .success]) + let initialSignature = makeTestDisplayTopologySignature([1001]) + let retriedSignature = makeTestDisplayTopologySignature([1002]) + let signatureBox = CatalogServiceSignatureBox(initialSignature) + let firstDisplay = CatalogServiceMockSCDisplay.make(displayID: 1001, width: 1280, height: 720) + let retriedDisplay = CatalogServiceMockSCDisplay.make(displayID: 1002, width: 1920, height: 1080) + var loadCallIndex = 0 + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + loadCallIndex += 1 + switch await gate.nextOutcome() { + case .success: + return loadCallIndex == 1 ? [firstDisplay] : [retriedDisplay] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [1002] }, + displayTopologySignatureProvider: { signatureBox.value } + ) + sut.store.hasScreenCapturePermission = true + + let refresh = Task { await sut.submitRefresh(intent: .topologyChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + + signatureBox.value = retriedSignature + await gate.release(call: 1) + + #expect(await waitForLoaderCall(gate, count: 2)) + await gate.release(call: 2) + + #expect(await refresh.value == .reloadedSnapshot) + #expect(await gate.currentCallCount() == 2) + #expect(sut.store.displays?.map(\.displayID) == [1002]) + #expect(sut.store.lastLoadedActiveDisplayTopologySignature == retriedSignature) + } + + @Test func topologySignatureJitterRetriesUntilCommitMatchesLatestSample() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success, .success, .success]) + let signatureA = makeTestDisplayTopologySignature([1101]) + let signatureB = makeTestDisplayTopologySignature([1102]) + let finalSignature = makeTestDisplayTopologySignature([1103]) + let signatureBox = CatalogServiceSignatureBox(signatureA) + let displays = [ + CatalogServiceMockSCDisplay.make(displayID: 1101, width: 1280, height: 720), + CatalogServiceMockSCDisplay.make(displayID: 1102, width: 1920, height: 1080), + CatalogServiceMockSCDisplay.make(displayID: 1103, width: 2560, height: 1440) + ] + var loadCallIndex = 0 + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + loadCallIndex += 1 + switch await gate.nextOutcome() { + case .success: + return [displays[loadCallIndex - 1]] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [1103] }, + displayTopologySignatureProvider: { signatureBox.value } + ) + sut.store.hasScreenCapturePermission = true + + let refresh = Task { await sut.submitRefresh(intent: .topologyChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + + signatureBox.value = signatureB + await gate.release(call: 1) + + #expect(await waitForLoaderCall(gate, count: 2)) + signatureBox.value = finalSignature + await gate.release(call: 2) + + #expect(await waitForLoaderCall(gate, count: 3)) + await gate.release(call: 3) + + #expect(await refresh.value == .reloadedSnapshot) + #expect(await gate.currentCallCount() == 3) + #expect(sut.store.displays?.map(\.displayID) == [1103]) + #expect(sut.store.lastLoadedActiveDisplayTopologySignature == finalSignature) + } + + @Test func repeatedTopologyMismatchFailsWithoutOverwritingStableSnapshot() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success, .success, .success, .success]) + let stableSignature = makeTestDisplayTopologySignature([1200]) + let signatureBox = CatalogServiceSignatureBox(makeTestDisplayTopologySignature([1201])) + let stableDisplay = CatalogServiceMockSCDisplay.make(displayID: 1200, width: 1440, height: 900) + var loadCallIndex = 0 + let retrySignatures = [ + makeTestDisplayTopologySignature([1202]), + makeTestDisplayTopologySignature([1203]), + makeTestDisplayTopologySignature([1204]), + makeTestDisplayTopologySignature([1205]) + ] + let refreshedDisplays = [ + CatalogServiceMockSCDisplay.make(displayID: 1201, width: 1280, height: 720), + CatalogServiceMockSCDisplay.make(displayID: 1202, width: 1600, height: 900), + CatalogServiceMockSCDisplay.make(displayID: 1203, width: 1920, height: 1080), + CatalogServiceMockSCDisplay.make(displayID: 1204, width: 2560, height: 1440) + ] + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + loadCallIndex += 1 + switch await gate.nextOutcome() { + case .success: + return [refreshedDisplays[loadCallIndex - 1]] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [1205] }, + displayTopologySignatureProvider: { signatureBox.value } + ) + sut.store.hasScreenCapturePermission = true + sut.store.displays = [stableDisplay] + sut.store.lastLoadedActiveDisplayTopologySignature = stableSignature + sut.store.lastRefreshResult = .reusedSnapshot + + let refresh = Task { await sut.submitRefresh(intent: .topologyChanged) } + + for (index, retrySignature) in retrySignatures.enumerated() { + #expect(await waitForLoaderCall(gate, count: index + 1)) + signatureBox.value = retrySignature + await gate.release(call: index + 1) + } + + #expect(await refresh.value == .failed) + #expect(await gate.currentCallCount() == 4) + #expect(sut.store.displays?.map(\.displayID) == [1200]) + #expect(sut.store.lastLoadedActiveDisplayTopologySignature == stableSignature) + #expect(sut.store.lastRefreshResult == .failed) + #expect(sut.store.lastLoadError == nil) + } + + private func waitForLoaderCall( + _ gate: SequencedCatalogServiceLoadGate, + count: Int + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + 1_000_000_000 + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentCallCount() >= count { + return true + } + await Task.yield() + } + return await gate.currentCallCount() >= count + } + + private func staysTrue( + timeoutNanoseconds: UInt64, + condition: @escaping @Sendable () async -> Bool + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await condition() == false { + return false + } + await Task.yield() + } + return await condition() + } +} diff --git a/VoidDisplayTests/Shared/ScreenCapturePermissionProviderTests.swift b/VoidDisplayTests/Shared/ScreenCapturePermissionProviderTests.swift new file mode 100644 index 0000000..872013b --- /dev/null +++ b/VoidDisplayTests/Shared/ScreenCapturePermissionProviderTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import VoidDisplay + +@MainActor +struct ScreenCapturePermissionProviderTests { + @Test func makeDefaultUsesUITestScenarioProviderWhenUITestModeIsEnabled() { + let provider = ScreenCapturePermissionProviderFactory.makeDefault( + environment: [ + UITestRuntime.modeEnvironmentKey: "1", + UITestRuntime.scenarioEnvironmentKey: UITestScenario.permissionDenied.rawValue + ] + ) + + #expect(provider.preflight() == false) + #expect(provider.request() == false) + } + + @Test func makeDefaultUsesNonInteractiveProviderWhenRunningUnderXCTest() { + let provider = ScreenCapturePermissionProviderFactory.makeDefault( + environment: [ + PersistenceContext.xCTestConfigurationEnvironmentKey: "/tmp/test.xctestconfiguration" + ] + ) + + #expect(provider.preflight() == false) + #expect(provider.request() == false) + } +} diff --git a/VoidDisplayTests/TestSupport/MockSCDisplay.swift b/VoidDisplayTests/TestSupport/MockSCDisplay.swift new file mode 100644 index 0000000..557bbfd --- /dev/null +++ b/VoidDisplayTests/TestSupport/MockSCDisplay.swift @@ -0,0 +1,25 @@ +import CoreGraphics +import Foundation +import ScreenCaptureKit + +final class SharedMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +enum SharedMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = SharedMockSCDisplayBox(displayID: displayID, width: width, height: height) + return unsafeBitCast(box, to: SCDisplay.self) + } +} diff --git a/VoidDisplayTests/TestSupport/TestServiceMocks.swift b/VoidDisplayTests/TestSupport/TestServiceMocks.swift index ec45862..d97cbaa 100644 --- a/VoidDisplayTests/TestSupport/TestServiceMocks.swift +++ b/VoidDisplayTests/TestSupport/TestServiceMocks.swift @@ -3,6 +3,32 @@ import Foundation import ScreenCaptureKit @testable import VoidDisplay +@MainActor +func makeTestDisplayTopologySignature( + _ displayIDs: [CGDirectDisplayID] +) -> ScreenCaptureDisplayTopologySignature { + displayIDs.map { ScreenCaptureDisplayTopologySignatureEntry(displayID: $0) } +} + +@MainActor +func makeTestDisplayTopologySignatureEntry( + displayID: CGDirectDisplayID, + isMain: Bool = false, + pixelWidth: Int = 0, + pixelHeight: Int = 0, + refreshRateMilliHertz: Int? = nil, + mirrorsDisplayID: CGDirectDisplayID? = nil +) -> ScreenCaptureDisplayTopologySignatureEntry { + .init( + displayID: displayID, + isMain: isMain, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + refreshRateMilliHertz: refreshRateMilliHertz, + mirrorsDisplayID: mirrorsDisplayID + ) +} + @MainActor final class MockCaptureMonitoringService: CaptureMonitoringServiceProtocol { var currentSessions: [ScreenMonitoringSession] = [] @@ -54,15 +80,19 @@ final class MockCaptureMonitoringService: CaptureMonitoringServiceProtocol { @MainActor final class MockSharingService: SharingServiceProtocol { + typealias StartSharingHandler = @MainActor (SCDisplay) async throws -> DisplayStartOutcome + var webServicePortValue: UInt16 = 8081 var onWebServiceRunningStateChanged: (@MainActor @Sendable (Bool) -> Void)? var onWebServiceLifecycleStateChanged: (@MainActor @Sendable (WebServiceLifecycleState) -> Void)? var webServiceLifecycleState: WebServiceLifecycleState = .stopped var isWebServiceRunning = false var activeStreamClientCount = 0 + var sharingStateSnapshot: SharingStateSnapshot = .empty var currentWebServer: WebServer? var hasAnyActiveSharing = false var activeSharingDisplayIDs: Set = [] + var startingDisplayIDs: Set = [] var startResult: WebServiceStartResult = .started( WebServiceBinding(requestedPort: 8081, boundPort: 8081) @@ -77,6 +107,24 @@ final class MockSharingService: SharingServiceProtocol { var streamClientCountsByTarget: [ShareTarget: Int] = [:] var shareIDByDisplayID: [CGDirectDisplayID: UInt32] = [:] var shareTargetByDisplayID: [CGDirectDisplayID: ShareTarget] = [:] + var onStopSharing: (@MainActor @Sendable (CGDirectDisplayID) -> Void)? + var startSharingHandler: StartSharingHandler? + private var sharingStateObservers: [UUID: @MainActor @Sendable (SharingStateSnapshot) -> Void] = [:] + + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startingDisplayIDs.contains(displayID) + } + + func subscribeSharingState( + _ observer: @escaping @MainActor @Sendable (SharingStateSnapshot) -> Void + ) -> SharingStateSubscription { + let id = UUID() + sharingStateObservers[id] = observer + observer(sharingStateSnapshot) + return SharingStateSubscription { [weak self] in + self?.sharingStateObservers.removeValue(forKey: id) + } + } @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult { @@ -91,8 +139,19 @@ final class MockSharingService: SharingServiceProtocol { isWebServiceRunning = false webServiceLifecycleState = .failed(startResult.failure ?? .listenerFailed(port: requestedPort, message: "mock_failure")) } + if sharingStateSnapshot == .empty && (activeStreamClientCount > 0 || !streamClientCountsByTarget.isEmpty) { + sharingStateSnapshot = SharingStateSnapshot( + signalingConnections: activeStreamClientCount, + streamingPeers: activeStreamClientCount, + signalingConnectionsByTarget: streamClientCountsByTarget, + streamingPeersByTarget: streamClientCountsByTarget, + clientsByTarget: [:], + lastUpdatedAt: Date() + ) + } onWebServiceRunningStateChanged?(isWebServiceRunning) onWebServiceLifecycleStateChanged?(webServiceLifecycleState) + notifySharingStateObservers() return startResult } @@ -102,6 +161,8 @@ final class MockSharingService: SharingServiceProtocol { webServiceLifecycleState = .stopped onWebServiceRunningStateChanged?(false) onWebServiceLifecycleStateChanged?(webServiceLifecycleState) + sharingStateSnapshot = .empty + notifySharingStateObservers() } func registerShareableDisplays( @@ -113,15 +174,20 @@ final class MockSharingService: SharingServiceProtocol { _ = virtualSerialResolver(CGDirectDisplayID(0)) } - func startSharing(display: SCDisplay) async throws { + func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome { + if let startSharingHandler { + return try await startSharingHandler(display) + } hasAnyActiveSharing = true activeSharingDisplayIDs.insert(display.displayID) + return .started(()) } func stopSharing(displayID: CGDirectDisplayID) { stopSharingCallCount += 1 activeSharingDisplayIDs.remove(displayID) hasAnyActiveSharing = !activeSharingDisplayIDs.isEmpty + onStopSharing?(displayID) } func stopAllSharing() { @@ -145,6 +211,19 @@ final class MockSharingService: SharingServiceProtocol { func streamClientCount(for target: ShareTarget) -> Int { streamClientCountsByTarget[target] ?? 0 } + + func updateSharingStateSnapshot(_ snapshot: SharingStateSnapshot) { + sharingStateSnapshot = snapshot + activeStreamClientCount = snapshot.streamingPeers + streamClientCountsByTarget = snapshot.streamingPeersByTarget + notifySharingStateObservers() + } + + private func notifySharingStateObservers() { + for observer in sharingStateObservers.values { + observer(sharingStateSnapshot) + } + } } @MainActor diff --git a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift index 7f35241..a5f6414 100644 --- a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift +++ b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift @@ -1,6 +1,31 @@ import XCTest final class CapturePreviewDiagnosticsTests: XCTestCase { + private enum PreviewScaleMode: String, CaseIterable { + case fit + case native + + var segmentLabel: String { + switch self { + case .fit: + "Fit" + case .native: + "1:1" + } + } + + static func fromAccessibilityValue(_ rawValue: String) -> Self? { + let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized.contains("fit") || normalized.contains("适应") { + return .fit + } + if normalized.contains("native") || normalized.contains("1:1") { + return .native + } + return nil + } + } + private struct DiagnosticCase { let id: String let sourceSize: String @@ -20,12 +45,25 @@ final class CapturePreviewDiagnosticsTests: XCTestCase { } @MainActor - func testCapturePreviewLayoutMatrix() throws { + func testCapturePreviewLayoutMatrixFit() throws { + runLayoutMatrix(scaleMode: .fit) + } + + @MainActor + func testCapturePreviewLayoutMatrixNative() throws { + runLayoutMatrix(scaleMode: .native) + } +} + +private extension CapturePreviewDiagnosticsTests { + @MainActor + private func runLayoutMatrix(scaleMode: PreviewScaleMode) { for testCase in diagnosticCases { - XCTContext.runActivity(named: testCase.id) { _ in + XCTContext.runActivity(named: "\(testCase.id)-\(scaleMode.rawValue)") { _ in let app = launchCapturePreviewDiagnosticsApp( sourceSize: testCase.sourceSize, - targetContentWidth: testCase.targetContentWidth + targetContentWidth: testCase.targetContentWidth, + scaleMode: scaleMode ) defer { app.terminate() } @@ -33,30 +71,97 @@ final class CapturePreviewDiagnosticsTests: XCTestCase { let scalePicker = smokeElement(app, identifier: "capture_preview_scale_mode_picker") XCTAssertTrue(scalePicker.waitForExistence(timeout: 4)) XCTAssertTrue(preview.waitForExistence(timeout: 4)) + assertScaleModeSelection( + scalePicker: scalePicker, + expectedMode: scaleMode + ) let screenshot = preview.screenshot() let attachment = XCTAttachment(screenshot: screenshot) - attachment.name = "capture-preview-\(testCase.id)" + attachment.name = "capture-preview-\(scaleMode.rawValue)-\(testCase.id)" attachment.lifetime = .keepAlways add(attachment) } } } -} -private extension CapturePreviewDiagnosticsTests { @MainActor - func launchCapturePreviewDiagnosticsApp( + private func launchCapturePreviewDiagnosticsApp( sourceSize: String, - targetContentWidth: Int + targetContentWidth: Int, + scaleMode: PreviewScaleMode ) -> XCUIApplication { let app = XCUIApplication() - app.launchEnvironment["VOIDDISPLAY_UI_TEST_MODE"] = "1" - app.launchEnvironment["VOIDDISPLAY_TEST_ISOLATION_ID"] = UUID().uuidString + configureAppForUITestLaunch(app) 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.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_SCALE_MODE"] = scaleMode.rawValue app.launch() + app.activate() return app } + + @MainActor + private func assertScaleModeSelection( + scalePicker: XCUIElement, + expectedMode: PreviewScaleMode, + file: StaticString = #filePath, + line: UInt = #line + ) { + let actualMode = selectedScaleMode(from: scalePicker) + XCTAssertEqual( + actualMode, + expectedMode, + """ + Scale mode mismatch. expected=\(expectedMode.rawValue), actual=\(actualMode?.rawValue ?? "nil"), \ + pickerValue=\(String(describing: scalePicker.value)), picker=\(scalePicker.debugDescription) + """, + file: file, + line: line + ) + guard let actualMode else { return } + XCTContext.runActivity( + named: "Scale mode assertion passed: expected=\(expectedMode.rawValue), actual=\(actualMode.rawValue)" + ) { _ in } + } + + @MainActor + private func selectedScaleMode(from scalePicker: XCUIElement) -> PreviewScaleMode? { + if let value = scalePicker.value { + let text = String(describing: value) + if let mode = PreviewScaleMode.fromAccessibilityValue(text) { + return mode + } + } + + for mode in PreviewScaleMode.allCases { + let labeledElements = scalePicker + .descendants(matching: .any) + .matching(NSPredicate(format: "label == %@", mode.segmentLabel)) + .allElementsBoundByIndex + if labeledElements.contains(where: isAccessibilityElementSelected) { + return mode + } + } + return nil + } + + @MainActor + private func isAccessibilityElementSelected(_ element: XCUIElement) -> Bool { + if element.isSelected { + return true + } + guard let value = element.value else { return false } + let normalized = String(describing: value) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if normalized == "1" || normalized == "true" { + return true + } + if normalized.contains("selected") || normalized.contains("on") { + return true + } + return false + } } diff --git a/VoidDisplayUITests/Smoke/HomeSmokeTests.swift b/VoidDisplayUITests/Smoke/HomeSmokeTests.swift index 2395123..db7ff77 100644 --- a/VoidDisplayUITests/Smoke/HomeSmokeTests.swift +++ b/VoidDisplayUITests/Smoke/HomeSmokeTests.swift @@ -28,9 +28,6 @@ final class HomeSmokeTests: XCTestCase { let virtualDisplaySidebar = smokeElement(app, identifier: "sidebar_virtual_display") let monitorSidebar = smokeElement(app, identifier: "sidebar_monitor_screen") let sharingSidebar = smokeElement(app, identifier: "sidebar_screen_sharing") - let monitorDetail = smokeElement(app, identifier: "detail_monitor_screen") - let sharingDetail = smokeElement(app, identifier: "detail_screen_sharing") - let virtualDisplayRibbon = smokeElement(app, identifier: "virtual_display_primary_ribbon") assertAllExist( app, @@ -43,7 +40,7 @@ final class HomeSmokeTests: XCTestCase { "detail_screen", "displays_open_system_settings" ], - timeout: 3 + timeout: 6 ) virtualDisplaySidebar.tap() @@ -58,25 +55,45 @@ final class HomeSmokeTests: XCTestCase { ) monitorSidebar.tap() - assertElementsExist([("detail_monitor_screen", monitorDetail)], timeout: 1.2) + let didShowMonitorDetail = waitForIdentifierByPolling( + app, + identifier: "detail_monitor_screen", + timeout: 1.2, + activateBeforePolling: true + ) + if !didShowMonitorDetail { + print("AX DEBUG START") + print(app.debugDescription) + print("AX DEBUG END") + } + XCTAssertTrue( + didShowMonitorDetail, + """ + detail_monitor_screen did not appear after tapping sidebar_monitor_screen. + detailStates=\(detailVisibilitySummary(in: app)) + """.trimmingCharacters(in: .whitespacesAndNewlines) + ) sharingSidebar.tap() - assertElementsExist([("detail_screen_sharing", sharingDetail)], timeout: 1.2) + assertAllExist( + app, + identifiers: ["detail_screen_sharing"], + timeout: 1.2 + ) virtualDisplaySidebar.tap() - assertElementsExist([("virtual_display_primary_ribbon", virtualDisplayRibbon)], timeout: 1.2) + assertAllExist( + app, + identifiers: ["virtual_display_primary_ribbon"], + timeout: 1.2 + ) } @MainActor func testVirtualDisplayEditSmoke_directSaveActionsWithoutConfirmationAlert() throws { let app = launchAppForSmoke(scenario: .baseline) let detail = openVirtualDisplayDetail(in: app) - let openEditButton = smokeElement(app, identifier: "virtual_display_open_edit_test_button") - let initialEditState = openVirtualDisplayEditForm( - in: app, - detail: detail, - openEditButton: openEditButton - ) + let initialEditState = openVirtualDisplayEditForm(in: app, detail: detail) let initialValue = boolValue(forToggle: initialEditState.toggle) let initialRebuildCount = rebuildRequestCount(in: detail) tapFast( @@ -97,11 +114,7 @@ final class HomeSmokeTests: XCTestCase { XCTAssertTrue(waitForDisappearance(of: initialEditState.form, timeout: 1.5)) XCTAssertEqual(rebuildRequestCount(in: detail), initialRebuildCount) - let saveOnlyPersistedState = reopenEditFormAndReadHiDPI( - in: app, - detail: detail, - openEditButton: openEditButton - ) + let saveOnlyPersistedState = reopenEditFormAndReadHiDPI(in: app, detail: detail) XCTAssertEqual(saveOnlyPersistedState.value, !initialValue) tapFast( saveOnlyPersistedState.toggle, @@ -124,11 +137,7 @@ final class HomeSmokeTests: XCTestCase { ) ) - let saveAndRebuildPersistedState = reopenEditFormAndReadHiDPI( - in: app, - detail: detail, - openEditButton: openEditButton - ) + let saveAndRebuildPersistedState = reopenEditFormAndReadHiDPI(in: app, detail: detail) XCTAssertEqual(saveAndRebuildPersistedState.value, initialValue) tapFast( saveAndRebuildPersistedState.cancelButton, @@ -144,16 +153,6 @@ final class HomeSmokeTests: XCTestCase { let app = launchAppForSmoke(scenario: .permissionDenied) let monitorSidebar = smokeElement(app, identifier: "sidebar_monitor_screen") let sharingSidebar = smokeElement(app, identifier: "sidebar_screen_sharing") - let monitorDetail = smokeElement(app, identifier: "detail_monitor_screen") - let captureGuide = smokeElement(app, identifier: "capture_permission_guide") - let captureOpenSettings = smokeElement(app, identifier: "capture_open_settings_button") - let captureRequest = smokeElement(app, identifier: "capture_request_permission_button") - let captureRefresh = smokeElement(app, identifier: "capture_refresh_button") - let sharingDetail = smokeElement(app, identifier: "detail_screen_sharing") - let shareGuide = smokeElement(app, identifier: "share_permission_guide") - let shareOpenSettings = smokeElement(app, identifier: "share_open_settings_button") - let shareRequest = smokeElement(app, identifier: "share_request_permission_button") - let shareRefresh = smokeElement(app, identifier: "share_refresh_button") assertAllExist( app, @@ -161,25 +160,27 @@ final class HomeSmokeTests: XCTestCase { timeout: 2 ) monitorSidebar.tap() - assertElementsExist( - [ - ("detail_monitor_screen", monitorDetail), - ("capture_permission_guide", captureGuide), - ("capture_open_settings_button", captureOpenSettings), - ("capture_request_permission_button", captureRequest), - ("capture_refresh_button", captureRefresh) + assertAllExist( + app, + identifiers: [ + "detail_monitor_screen", + "capture_permission_guide", + "capture_open_settings_button", + "capture_request_permission_button", + "capture_refresh_button" ], timeout: 1.2 ) sharingSidebar.tap() - assertElementsExist( - [ - ("detail_screen_sharing", sharingDetail), - ("share_permission_guide", shareGuide), - ("share_open_settings_button", shareOpenSettings), - ("share_request_permission_button", shareRequest), - ("share_refresh_button", shareRefresh) + assertAllExist( + app, + identifiers: [ + "detail_screen_sharing", + "share_permission_guide", + "share_open_settings_button", + "share_request_permission_button", + "share_refresh_button" ], timeout: 1.2 ) @@ -199,11 +200,7 @@ final class HomeSmokeTests: XCTestCase { ) let monitorSidebar = smokeElement(app, identifier: "sidebar_monitor_screen") let sharingSidebar = smokeElement(app, identifier: "sidebar_screen_sharing") - let monitorDetail = smokeElement(app, identifier: "detail_monitor_screen") - let captureLoading = smokeElement(app, identifier: "capture_loading_displays") - let sharingDetail = smokeElement(app, identifier: "detail_screen_sharing") let startServiceButton = smokeElement(app, identifier: "share_start_service_button") - let shareLoading = smokeElement(app, identifier: "share_loading_displays") assertAllExist( app, @@ -211,25 +208,31 @@ final class HomeSmokeTests: XCTestCase { timeout: 2 ) monitorSidebar.tap() - assertElementsExist( - [ - ("detail_monitor_screen", monitorDetail), - ("capture_loading_displays", captureLoading) + assertAllExist( + app, + identifiers: [ + "detail_monitor_screen", + "capture_loading_displays" ], timeout: 1.2 ) sharingSidebar.tap() - assertElementsExist( - [ - ("detail_screen_sharing", sharingDetail), - ("share_start_service_button", startServiceButton) + assertAllExist( + app, + identifiers: [ + "detail_screen_sharing", + "share_start_service_button" ], timeout: 1.2 ) startServiceButton.tap() - if waitForCondition(timeout: 1.0, condition: { shareLoading.exists }) { + if waitForIdentifierByPolling( + app, + identifier: "share_loading_displays", + timeout: 1.0 + ) { app.terminate() return } @@ -286,6 +289,25 @@ final class HomeSmokeTests: XCTestCase { XCTAssertTrue(retryButton.isEnabled) } + @MainActor + func testVirtualDisplaySmoke_rebuildFailedRowShowsRetry() throws { + let app = launchAppForSmoke(scenario: .virtualDisplayRebuildFailed) + _ = openVirtualDisplayDetail(in: app) + + assertAllExist( + app, + identifiers: [ + "detail_virtual_display", + "virtual_display_rebuild_retry_button" + ], + timeout: 1.2 + ) + + let retryButton = smokeElement(app, identifier: "virtual_display_rebuild_retry_button") + XCTAssertTrue(retryButton.isEnabled) + XCTAssertFalse(smokeElement(app, identifier: "virtual_display_rebuild_progress").exists) + } + @MainActor private func boolValue(forToggle toggle: XCUIElement) -> Bool { if let numberValue = toggle.value as? NSNumber { @@ -306,19 +328,24 @@ final class HomeSmokeTests: XCTestCase { @MainActor private func openVirtualDisplayDetail(in app: XCUIApplication) -> XCUIElement { - let virtualDisplaySidebar = smokeElement(app, identifier: "sidebar_virtual_display") - let detail = smokeElement(app, identifier: "detail_virtual_display") - assertElementsExist([("sidebar_virtual_display", virtualDisplaySidebar)], timeout: 1.2) - virtualDisplaySidebar.tap() - assertElementsExist([("detail_virtual_display", detail)], timeout: 1.2) - return detail + assertAllExist( + app, + identifiers: ["sidebar_virtual_display"], + timeout: 1.2 + ) + smokeElement(app, identifier: "sidebar_virtual_display").tap() + assertAllExist( + app, + identifiers: ["detail_virtual_display"], + timeout: 1.2 + ) + return smokeElement(app, identifier: "detail_virtual_display") } @MainActor private func openVirtualDisplayEditForm( in app: XCUIApplication, - detail: XCUIElement, - openEditButton: XCUIElement + detail: XCUIElement ) -> ( form: XCUIElement, toggle: XCUIElement, @@ -331,33 +358,48 @@ final class HomeSmokeTests: XCTestCase { let saveOnlyButton = smokeElement(app, identifier: "virtual_display_edit_save_only_button") let saveAndRebuildButton = smokeElement(app, identifier: "virtual_display_edit_save_and_rebuild_button") let cancelButton = smokeElement(app, identifier: "virtual_display_edit_cancel_button") - let formElements: [SmokeNamedElement] = [ - ("edit_virtual_display_form", form), - ("virtual_display_edit_mode_hidpi_toggle", toggle), - ("virtual_display_edit_save_only_button", saveOnlyButton), - ("virtual_display_edit_save_and_rebuild_button", saveAndRebuildButton), - ("virtual_display_edit_cancel_button", cancelButton) - ] assertAllExist( app, identifiers: ["detail_virtual_display", "virtual_display_open_edit_test_button"], timeout: 1.2 ) XCTAssertTrue(detail.exists, "Virtual display detail is unavailable.") + let openEditButton = smokeElement(app, identifier: "virtual_display_open_edit_test_button") tapByCoordinate( openEditButton, timeout: 1, requireExistenceCheck: false ) if waitForIdentifierByPolling(app, identifier: "edit_virtual_display_form", timeout: 0.9) { - assertElementsExist(formElements, timeout: 0.6) + assertAllExist( + app, + identifiers: [ + "edit_virtual_display_form", + "virtual_display_edit_mode_hidpi_toggle", + "virtual_display_edit_save_only_button", + "virtual_display_edit_save_and_rebuild_button", + "virtual_display_edit_cancel_button" + ], + timeout: 0.6 + ) } else { + let retryOpenEditButton = smokeElement(app, identifier: "virtual_display_open_edit_test_button") tapByCoordinate( - openEditButton, + retryOpenEditButton, timeout: 0.6, requireExistenceCheck: false ) - assertElementsExist(formElements, timeout: 1.5) + assertAllExist( + app, + identifiers: [ + "edit_virtual_display_form", + "virtual_display_edit_mode_hidpi_toggle", + "virtual_display_edit_save_only_button", + "virtual_display_edit_save_and_rebuild_button", + "virtual_display_edit_cancel_button" + ], + timeout: 1.5 + ) } return ( form: form, @@ -371,8 +413,7 @@ final class HomeSmokeTests: XCTestCase { @MainActor private func reopenEditFormAndReadHiDPI( in app: XCUIApplication, - detail: XCUIElement, - openEditButton: XCUIElement + detail: XCUIElement ) -> ( form: XCUIElement, toggle: XCUIElement, @@ -381,11 +422,7 @@ final class HomeSmokeTests: XCTestCase { cancelButton: XCUIElement, value: Bool ) { - let state = openVirtualDisplayEditForm( - in: app, - detail: detail, - openEditButton: openEditButton - ) + let state = openVirtualDisplayEditForm(in: app, detail: detail) return ( state.form, state.toggle, @@ -463,6 +500,22 @@ final class HomeSmokeTests: XCTestCase { return markers.contains { normalizedMessage.contains($0) } } + @MainActor + private func detailVisibilitySummary(in app: XCUIApplication) -> String { + [ + "detail_screen", + "detail_virtual_display", + "detail_monitor_screen", + "detail_screen_sharing", + "capture_choose_root", + "share_content_root" + ] + .map { identifier in + "\(identifier)=\(smokeElement(app, identifier: identifier).exists)" + } + .joined(separator: ", ") + } + @MainActor private func sharePageVisibleStates(_ app: XCUIApplication) -> [String] { let identifiers = [ diff --git a/VoidDisplayUITests/Smoke/SmokeTestHelpers.swift b/VoidDisplayUITests/Smoke/SmokeTestHelpers.swift index bd01a0e..2a7c276 100644 --- a/VoidDisplayUITests/Smoke/SmokeTestHelpers.swift +++ b/VoidDisplayUITests/Smoke/SmokeTestHelpers.swift @@ -12,6 +12,18 @@ enum SmokeScenario: String { } extension XCTestCase { + @MainActor + func configureAppForUITestLaunch(_ app: XCUIApplication) { + app.launchArguments = [ + "-ApplePersistenceIgnoreState", + "YES", + "-NSQuitAlwaysKeepsWindows", + "NO" + ] + app.launchEnvironment["VOIDDISPLAY_UI_TEST_MODE"] = "1" + app.launchEnvironment["VOIDDISPLAY_TEST_ISOLATION_ID"] = UUID().uuidString + } + @MainActor func smokeElement( _ app: XCUIApplication, @@ -28,16 +40,16 @@ extension XCTestCase { preferredPort: UInt16? = nil ) -> XCUIApplication { let app = XCUIApplication() - app.launchEnvironment["VOIDDISPLAY_UI_TEST_MODE"] = "1" - app.launchEnvironment["VOIDDISPLAY_TEST_ISOLATION_ID"] = UUID().uuidString + configureAppForUITestLaunch(app) app.launchEnvironment["VOIDDISPLAY_UI_TEST_SCENARIO"] = scenario.rawValue if let preferredPort { - app.launchArguments = [ + app.launchArguments.append(contentsOf: [ "-sharing.preferredPort", String(preferredPort) - ] + ]) } app.launch() + app.activate() return app } @@ -84,25 +96,26 @@ extension XCTestCase { file: StaticString = #filePath, line: UInt = #line ) { - let elements = identifiers.map { identifier in - app.descendants(matching: .any) - .matching(identifier: identifier) - .firstMatch - } let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - let missing = zip(identifiers, elements) - .filter { !$0.1.exists } - .map(\.0) + let missing = identifiers.filter { identifier in + !app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .exists + } if missing.isEmpty { return } RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) } - let missing = zip(identifiers, elements) - .filter { !$0.1.exists } - .map(\.0) + let missing = identifiers.filter { identifier in + !app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .exists + } XCTAssertTrue(missing.isEmpty, "Missing identifiers: \(missing.joined(separator: ", "))", file: file, line: line) } @@ -217,20 +230,23 @@ extension XCTestCase { pollInterval: TimeInterval = 0.1, activateBeforePolling: Bool = false ) -> Bool { - let element = app.descendants(matching: .any) - .matching(identifier: identifier) - .firstMatch let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if activateBeforePolling { app.activate() } - if element.exists { + if app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .exists { return true } RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) } - return element.exists + return app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .exists } @MainActor diff --git a/docs/Readme_cn-zh.md b/docs/Readme_cn-zh.md index 115917d..6dc0323 100644 --- a/docs/Readme_cn-zh.md +++ b/docs/Readme_cn-zh.md @@ -127,7 +127,7 @@ UI 入口:`HomeView` 包含四个标签页 — **显示器**、**虚拟显示 | 功能区域 | 文件 | |---------|------| | 虚拟显示器 | `VirtualDisplayService.swift`、`CreateVirtualDisplayObjectView.swift`、`EditVirtualDisplayConfigView.swift` | -| 屏幕采集 | `CaptureChooseViewModel.swift`、`ScreenCaptureFunction.swift` | +| 屏幕采集 | `CaptureChooseViewModel.swift`、`DisplayCaptureRegistry.swift`、`DisplayCaptureSession.swift`、`DisplayStartCoordinator.swift` | | 局域网共享 | `ShareViewModel.swift`、`SharingService.swift`、`Features/Sharing/Web/WebServer.swift` | 统一日志(`Logger`,subsystem `com.developerchen.voiddisplay`): diff --git a/docs/capture_preview_black_bar_fix_notes.md b/docs/capture_preview_black_bar_fix_notes.md index f6d1f66..9a6daf7 100644 --- a/docs/capture_preview_black_bar_fix_notes.md +++ b/docs/capture_preview_black_bar_fix_notes.md @@ -168,6 +168,23 @@ window.frameRect(forContentRect: targetContentSize) - [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) +### 诊断环境变量说明 + +以下环境变量仅用于预览诊断与 UI 测试场景: + +| 变量名 | 取值示例 | 含义 | +| --- | --- | --- | +| `VOIDDISPLAY_CAPTURE_PREVIEW_SOURCE_SIZE` | `3008x1692` | 注入的诊断画面像素尺寸。 | +| `VOIDDISPLAY_CAPTURE_PREVIEW_TARGET_CONTENT_WIDTH` | `1180` | 初始窗口目标内容宽度覆盖值(point)。 | +| `VOIDDISPLAY_CAPTURE_PREVIEW_REPLAY_IMAGE_PATH` | `/abs/path/frame.png` | 用指定图片替代内置诊断图。 | +| `VOIDDISPLAY_CAPTURE_PREVIEW_RECORD_DIRECTORY` | `/abs/path/recordings` | 预览录制输出目录。 | +| `VOIDDISPLAY_CAPTURE_PREVIEW_SCALE_MODE` | `fit` 或 `native` | 预览缩放模式。`fit` 表示适应模式,`native` 表示 `1:1` 模式。 | + +使用建议: + +1. 常规自检直接运行 `zsh scripts/test/capture_preview_self_check.sh`,脚本会自动跑 `fit` 与 `native` 两轮。 +2. 手动跑单轮 UI 诊断时,通过 app launch environment 设置 `VOIDDISPLAY_CAPTURE_PREVIEW_SCALE_MODE`。 + ### 自验证思路 1. UI test 场景下注入假的监听会话 @@ -259,6 +276,100 @@ window.frameRect(forContentRect: targetContentSize) - `contentAspectRatio` - 预览层所在视图实际 bounds +## UI 测试授权排查 + +预览诊断链路依赖 macOS 的自动化与截图能力。 + +如果在运行 [CapturePreviewDiagnosticsTests.swift](/Users/syc/Project/VoidDisplay/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift) 或 `scripts/test/capture_preview_self_check.sh` 时,系统弹出与以下能力相关的授权窗口: + +- 屏幕录制 +- 辅助功能 +- 自动化控制 + +必须先完成授权,再判断是不是代码问题。 + +### 常见现象 + +如果授权弹窗出现但没有及时允许,常见现象包括: + +- `XCUIElement.screenshot()` 失败 +- 诊断矩阵只在截图步骤失败 +- 日志里出现 “Failed to create screenshot” 一类错误 +- UI 元素存在性检查正常,但 attachment 生成失败 + +### 排查顺序 + +遇到这类失败时,先按下面顺序检查: + +1. 是否有未处理的系统授权弹窗 +2. `Xcode`、测试 Runner、目标应用是否已经被授予需要的权限 +3. 重新运行同一条测试,确认失败是否可复现 +4. 权限与弹窗都确认无误后,再继续怀疑代码实现 + +### 结论 + +这类失败经常来自权限与弹窗环境,不应直接判定为代码回归。 + +## 额外排查信号 + +除了左右黑边和裁切问题,这条链路还积累过两类很容易混淆的现象。它们适合保留为排查信号,不适合把旧实现里的修法直接当成当前结论。 + +### 1. `适应` 正常,但 `1:1` 明显偏小 + +这类回归的识别信号通常是: + +- `适应` 铺满逻辑正常 +- 切到 `1:1` 后内容缩成居中的小画面 +- 工具栏、滚动宿主、窗口外观都正常 +- 预期应当能看到滚动条,实际没有出现 + +出现这种组合时,优先怀疑尺寸语义链路,不要先去改 `ScrollView`、`videoGravity` 或窗口约束。 + +建议按这个顺序查: + +1. `session.resolutionText` 是否表达原生像素尺寸 +2. `renderer.framePixelSize` 是否更接近逻辑尺寸 +3. 首帧 `CMVideoFormatDescriptionGetDimensions` 是否和前两者一致 +4. `SCStreamConfiguration.width/height` 是否已经在上游偏小 +5. 虚拟 HiDPI 场景下,`CGDisplayMode`、`CGDisplayPixelsWide`、`CGDisplayPixelsHigh` 是否互相矛盾 + +这类问题的核心不是视图层观感,关键在于“当前拿来喂给 `1:1` 的尺寸语义到底是什么”。 + +### 2. `1:1` 尺寸看起来对,但画面仍然发糊 + +这类问题要区分两件事: + +1. 预览窗口按多大尺寸显示一帧 +2. `SCStream` 实际交付的这一帧有多少有效像素 + +如果第 1 项正确、第 2 项偏低,最终效果仍然会糊。 + +建议按这个顺序查: + +1. `SCStreamConfiguration.width/height` +2. `SCContentFilter.pointPixelScale` +3. 首帧 `CMVideoFormatDescriptionGetDimensions` +4. 首帧 `SCStreamFrameInfo.scaleFactor` +5. 首帧 `SCStreamFrameInfo.contentScale` +6. 预览窗口的 `resolutionText` +7. 预览窗口的 `renderer.framePixelSize` + +如果看到下面这种组合: + +- `SCStreamConfiguration.width/height` 很大 +- `resolutionText` 也很大 +- 首帧 `dimensions` 仍然偏小 + +优先查采集链路本身。 + +如果看到下面这种组合: + +- 首帧 `dimensions` 已经接近原生像素尺寸 +- 预览尺寸也匹配 +- 视觉上仍然发糊 + +优先查被监听的源内容是否本身就没有以 HiDPI 方式渲染。 + ## 这轮新增经验 这轮又补了三个和预览窗口观感直接相关的问题: diff --git a/docs/capture_sharing_baseline_sampling.md b/docs/capture_sharing_baseline_sampling.md new file mode 100644 index 0000000..03f483b --- /dev/null +++ b/docs/capture_sharing_baseline_sampling.md @@ -0,0 +1,29 @@ +# Capture Sharing Baseline Sampling + +## 环境约束 +- 固定一台代表机型,记录 CPU 型号、内存、macOS 版本、显示器规格。 +- 使用 `Release` 构建与同一套网络环境。 +- 每个场景预热 `10s` 后再采样 `60s`。 +- 日志输出目录固定为 `.ai-tmp/perf-baseline//`。 + +## 采样场景 +- `previewOnly`,单预览窗口 +- `shareOnly`,单分享目标与单 `streamingPeer` +- `mixed`,单预览窗口与两个 `streamingPeers` + +## 记录项 +- 进程 CPU 中位数 +- `SCShareableContent` 加载次数 +- `profileReconfigurationCount` +- `cursorOverrideReconfigurationCount` +- preview 渲染延迟 `p95` +- preview 丢帧率 + +## 验收门槛 +- 权限状态与拓扑签名未变化、且没有 `userForcedRefresh` 时,`SCShareableContent` 加载次数不超过 `1` 次。 +- 稳定状态下 `profileReconfigurationCount` 为 `0`;任意 `5s` 窗口内不超过 `1` 次。 +- `streamingPeers` 在 `10s` 内从 `0 -> 3 -> 0` 波动时,`profileReconfigurationCount` 不超过 `1` 次。 +- `previewOnly` 场景 preview 渲染延迟 `p95 <= 120ms`,`mixed` 场景 `p95 <= 180ms`。 +- `previewOnly` 场景丢帧率不超过 `10%`,`mixed` 场景不超过 `20%`。 +- `mixed` 场景进程 CPU 中位数不得高于基线 `5%`,目标下降 `10%`。 +- `cursorOverrideReconfigurationCount` 单独记录,不计入 profile 频控门槛。 diff --git a/docs/capture_sharing_optimization_plan.md b/docs/capture_sharing_optimization_plan.md new file mode 100644 index 0000000..0be96c7 --- /dev/null +++ b/docs/capture_sharing_optimization_plan.md @@ -0,0 +1,465 @@ +# 屏幕监听与屏幕共享优化方案 + +> 记录日期:2026-03-25 +> 适用范围:`VoidDisplay/Features/Capture`、`VoidDisplay/Features/Sharing`、`VoidDisplay/App`、`VoidDisplay/Shared/ScreenCapture` +> 分析基线:当前工作区实现,包含本地未提交改动 + +## 1. 目标 + +这份文档解决两个问题: + +1. 当前屏幕监听与屏幕共享已经在底层采集层合流,上层状态与目录管理仍然分裂,导致重复加载、状态分叉、恢复路径复杂。 +2. 当前采集、预览、WebRTC 发送三段规格不一致,造成资源浪费与稳定性风险。 + +本轮优化目标如下: + +1. 收敛屏幕目录、权限、拓扑状态,建立单一真相源。 +2. 把采集配置变成按消费方动态决策的策略层。 +3. 给本地预览链路补齐背压与丢帧控制,避免主线程被高频帧淹没。 +4. 让共享连接状态改成事件驱动,去掉 UI 轮询。 +5. 修正网页端 WebRTC 配置缺口,贯通服务端与浏览器端的 ICE/TURN 配置。 +6. 补足监听与共享联合负载场景的测试覆盖。 +7. 给关键指标补量化验收门槛,并保证自动化测试保持非交互执行。 + +## 2. 当前问题归因 + +### 2.1 目录与权限状态分裂 + +`CaptureController` 和 `SharingController` 各自持有一份 `ScreenCaptureDisplayCatalogState`。 + +相关位置: + +1. `VoidDisplay/App/CaptureController.swift` +2. `VoidDisplay/App/SharingController.swift` +3. `VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift` +4. `VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift` +5. `VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogLoader.swift` + +后果: + +1. 监听页与共享页会分别触发 `SCShareableContent` 加载。 +2. 权限状态与最近一次加载错误会分叉。 +3. 页面切换后可能出现一边已刷新、一边仍持有旧目录的情况。 + +### 2.2 采集规格与发送规格失配 + +当前链路表现如下: + +1. `DisplayCaptureSession` 采集帧率按显示器刷新率设置,最高到 `120fps`。 +2. WebRTC media pipeline 适配输出格式时固定为 `60fps`。 +3. RTP sender 编码参数又把最高帧率压到 `30fps`。 + +相关位置: + +1. `VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift` +2. `VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift` +3. `VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift` + +后果: + +1. 采集负载高于实际发送需要。 +2. 编码端和采集端之间存在长期浪费。 +3. 当监听和共享并存时,单一路径很难兼顾本地预览流畅度与网络稳定性。 + +### 2.3 本地预览链路没有背压 + +当前预览帧 fanout 方式是逐帧同步派发,`ZeroCopyPreviewRenderer` 对每一帧再创建一个 `Task` 切到 `MainActor`。 + +相关位置: + +1. `VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift` +2. `VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift` +3. `VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift` + +后果: + +1. 高频帧输入时,主线程任务可能堆积。 +2. 多个预览 sink 并存时,慢 sink 会拖累整条链路。 +3. 监听窗口压力会反向影响共享稳定性。 + +### 2.4 共享状态传播仍是轮询模型 + +共享页通过每秒定时器刷新观看人数和 target 计数。 + +相关位置: + +1. `VoidDisplay/Features/Sharing/Views/ShareView.swift` +2. `VoidDisplay/App/SharingController.swift` +3. `VoidDisplay/Features/Sharing/Web/WebServer.swift` +4. `VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift` + +后果: + +1. UI 层存在固定频率的无效刷新。 +2. 状态变化不能即时反馈。 +3. target 客户端数与真实连接状态可能短时失真。 + +### 2.5 浏览器端 WebRTC 配置不完整 + +服务端已经预留 ICE server 配置扩展点,网页端仍然硬编码 `iceServers: []`。 + +相关位置: + +1. `VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift` +2. `VoidDisplay/Features/Sharing/Web/displayPage.html` + +后果: + +1. 当前共享能力仍然依赖 host candidate 与局域网入口。 +2. 配置扩展只到达服务端,没有贯通到浏览器端。 + +### 2.6 拓扑刷新路径两套实现 + +共享页有 `DisplayTopologyRefreshLifecycleController`、去抖回调注册与轮询回退;监听页也已经复用同一套拓扑监听机制。 + +相关位置: + +1. `VoidDisplay/Shared/ScreenCapture/DisplayTopologyRefreshLifecycleController.swift` +2. `VoidDisplay/Features/Capture/Views/CaptureChoose.swift` +3. `VoidDisplay/Shared/Services/DebouncingDisplayReconfigurationMonitor.swift` + +后果: + +1. 同一类显示拓扑问题维护两套逻辑。 +2. 监听页的恢复与去抖策略弱于共享页。 + +## 3. 设计原则 + +后续重构必须遵守下面几条: + +1. 目录、权限、拓扑签名只保留一个 app 级真相源。 +2. 采集配置由消费需求决定,不能写死在单一场景。 +3. 预览链路必须允许丢帧,优先保留最新帧。 +4. 共享状态必须事件驱动,避免 UI 自行轮询补状态。 +5. 监听与共享可以共用底层采集,但上层读模型要继续隔离。 +6. 每轮结构调整都要补联合场景测试,不能只测单一模块。 +7. 自动化测试必须非交互,不能触发屏幕录制授权弹窗。 +8. 关键路径要同时定义观测指标、验收门槛和回退条件。 +9. 原始 display catalog snapshot 只表达权限、拓扑和显示器列表,不能承载页面 gating。 +10. 性能型重配与光标显隐这类正确性配置要分层处理。 +11. 共享统计必须明确口径,至少区分 signaling 连接数与稳定收流数。 + +## 4. 分阶段执行方案 + +### 阶段 1:收敛屏幕目录、权限、拓扑状态 + +新增 app 级 `ScreenCaptureCatalogService`,内部拆成 `CatalogStore` 与 `CatalogRefreshCoordinator`,统一管理: + +1. 权限状态 +2. `SCDisplay` 列表 +3. 最近一次加载错误 +4. 拓扑签名 +5. in-flight load task +6. 刷新去抖与取消 + +执行约束: + +1. service 使用 `actor` 串行化刷新请求。 +2. 刷新入口只接受明确意图,例如 `permissionChanged`、`topologyChanged`、`serviceBecameRunning`、`userForcedRefresh`。 +3. 主去重键只包含权限状态、当前拓扑签名和是否为强制刷新。 +4. 只有当前 request 才能提交 display snapshot 与 load error,过期结果直接丢弃。 +5. 新意图覆盖旧意图时取消旧 task;权限丢失时允许主动清空 snapshot;服务停止只取消共享页相关 refresh,不清空全局 snapshot。 +6. `refresh intent` 只用于优先级、取消策略和日志,不作为是否复用 snapshot 的判定条件。 +7. 每个 refresh intent 都必须产出一个结果事件,结果类型至少区分 `reloadedSnapshot`、`reusedSnapshot`、`clearedSnapshot`、`failed`。 + +调整方向: + +1. `CaptureChooseViewModel` 改为订阅 store,并提交监听页自己的 refresh intent。 +2. `ShareViewModel` 改为订阅 store,并只在服务可用时提交共享页自己的 refresh intent。 +3. 共享页的 `serviceStopped` 等页面状态继续由共享页派生状态决定,不通过清空全局 catalog 实现。 +4. 页面私有 lifecycle 只保留触发时机与 fallback 策略,不再持有独立 loader 状态。 +5. 让监听页和共享页共享同一份原始 display snapshot。 +6. 共享页在订阅建立、`serviceBecameRunning`、`reusedSnapshot` 命中时,都必须用当前 snapshot 重放一次 `registerShareableDisplays`。 + +预期收益: + +1. 减少重复 `SCShareableContent` 调用。 +2. 消除权限状态分叉。 +3. 简化目录刷新后的状态收敛。 + +### 阶段 2:建立采集 profile 策略层 + +在 `DisplayCaptureRegistry` 之上增加 profile 决策,至少区分: + +1. `previewOnly` +2. `shareOnly` +3. `mixed` + +profile 输入建议包含: + +1. preview sink 数量 +2. 当前 display 是否处于 `sharingActive`,定义为该 display 已建立 sharing session 且 session 尚未停止 +3. 显示器分辨率与刷新率上限 +4. 上一次稳定 profile 与最近一次切换时间 + +profile 输出建议包含: + +1. 采集尺寸 +2. 采集帧率 +3. `queueDepth` +4. 本次是否允许调用 `updateConfiguration` + +建议策略: + +1. `previewOnly` 优先本地流畅度与低切换成本。 +2. `shareOnly` 优先编码稳定性和带宽可控。 +3. `mixed` 在可接受清晰度下限制帧率,避免高刷浪费。 + +约束: + +1. profile 只允许在 `previewOnly`、`shareOnly`、`mixed` 三个稳定状态之间切换。 +2. 切换规则必须同时定义进入阈值、退出阈值、最小驻留时间。 +3. profile 型 `updateConfiguration` 最大重配频率限制为每 `5s` 不超过 `1` 次。 +4. 光标显隐走独立的 cursor override 通道,允许即时生效,不受 profile 驻留时间与重配频率门槛限制。 +5. `streamingPeers` 只作为观测指标,当前阶段不直接驱动 `updateConfiguration`。 +6. `signalingConnections`、协商失败、重连抖动都不能单独驱动 profile 切换。 + +### 阶段 3:给预览链路加背压与观测 + +优化方向: + +1. `DisplaySampleFanout` 为每个 sink 提供有界邮箱。 +2. 默认容量建议 `1` 或 `2`。 +3. 新帧到达时覆盖旧帧,保留最新帧。 +4. `ZeroCopyPreviewRenderer` 改为“最新帧槽位 + 单 drain loop”模型。 +5. 主线程只负责 dequeue 与 layer enqueue,不再为每一帧创建无界 `Task`。 + +新增指标: + +1. 收到帧数 +2. 渲染帧数 +3. 丢弃帧数 +4. 最近渲染延迟 +5. renderer 待处理帧槽位占用情况 + +预期收益: + +1. 高刷输入下不再无限堆积主线程任务。 +2. 慢预览 sink 不再拖垮整体链路。 + +### 阶段 4:共享状态改为事件驱动 + +优化方向: + +1. `WebServer` 只在 websocket upgrade 成功且连接通过容量校验后分配 `clientID`,并向服务层发出带 `target` 与 `clientID` 的 signaling 生命周期事件。 +2. `WebRTCSessionHub` 只消费 `WebServer` 分配的 `clientID`,并向服务层发出同一 `clientID` 的 peer phase 事件。 +3. `SharingService` 或 `WebServiceController` 暴露统一的共享统计快照订阅面。 +4. 快照至少包含 `signalingConnections`、`streamingPeers`、`signalingConnectionsByTarget`、`streamingPeersByTarget` 与时间戳。 +5. 原始事件载荷至少包含 `target`、`clientID`、`peerPhase`、事件来源、时间戳。 +6. peer phase 至少定义 `signalingConnected`、`offerReceived`、`peerConnected`、`peerDisconnected`、`peerFailed`、`closed`。 +7. 服务层必须维护以 `clientID` 为键的聚合状态,字段至少包含 `target`、`hasSignalingConnection`、`peerPhase`、最近更新时间戳。 +8. `signalingConnections` 与 `streamingPeers` 只能由聚合状态投影得到,不能直接按原始事件做加减。 +9. `streamingPeers` 只统计处于 `peerConnected` 的 client;进入 `peerDisconnected`、`peerFailed`、`closed` 时必须从聚合状态移出或降级。 +10. 同一 websocket 断开后再次连接时必须分配新的 `clientID`;重复断连、失败、关闭事件必须按 `clientID` 幂等处理。 +11. 被 `too_many_viewers` 或其他准入校验拒绝的连接不得分配 `clientID`,也不得计入 `signalingConnections`、`streamingPeers` 或其 per-target 计数;如需观测,单列 rejected attempt 指标。 +12. 订阅建立时立即返回当前聚合快照,随后再接收增量事件。 +13. `WebRTCSessionHub` 与 `WebServer` 只向服务层提供原始事件,不直接作为 UI 真相源。 +14. `SharingController` 直接订阅服务快照;状态面板使用全局 `streamingPeers`,每个 display 行使用 `streamingPeersByTarget[target]`,诊断视图按需读取 `signalingConnections` 与 `signalingConnectionsByTarget`。 +15. 去掉 `ShareView` 的每秒轮询定时器。 + +预期收益: + +1. UI 状态更新更及时。 +2. 共享统计口径更清楚,UI 可以区分信令连接与稳定收流。 +3. 界面刷新频率下降。 + +### 阶段 5:补齐浏览器端 WebRTC 配置 + +优化方向: + +1. 用同一份 bootstrap 配置同时驱动服务端与网页端的 ICE server。 +2. 网页端缺少该字段时默认回退到空数组,保持当前 host candidate 行为。 +3. 当前阶段只处理可配置 ICE/TURN;公网入口、TLS、认证另开子计划。 +4. 明确 `stopped`、`error`、`disconnected`、`failed` 的终态和重连边界。 +5. 保持现有 signaling message type,不在本轮引入协议版本分叉。 + +可选增强: + +1. 页面展示连接状态、分辨率、`signalingConnections` / `streamingPeers` 状态。 +2. 页面区分“服务已停止”和“网络暂时断开”。 + +### 阶段 6:统一拓扑监听机制并补联合压测 + +优化方向: + +1. 监听页复用共享页的拓扑监听与回退策略。 +2. 统一拓扑变化后的执行顺序: + 1. catalog 刷新 + 2. sharing registration 更新 + 3. 已失效 session 收敛 + 4. UI 读模型刷新 + +补测重点: + +1. 监听已开,再开启共享。 +2. 共享中增加多个 `streamingPeers`。 +3. 拓扑变化时 registry 仍保持一致。 +4. 服务重启后目录、sharing registration 与页面派生状态正确恢复,先前活跃 sharing session 保持停止态,不自动恢复。 + +## 5. 推荐实施顺序 + +推荐顺序如下: + +1. `ScreenCaptureCatalogService` +2. 采集 profile 策略层 +3. 预览背压 +4. 共享状态事件化 +5. ICE 与网页端策略 +6. 联合压测与观测 + +原因: + +1. 状态收敛是后续所有优化的基础。 +2. 采集规格决策和预览背压直接决定性能上限。 +3. 事件化与网页端调整建立在较稳定的服务层语义之上。 +4. 量化验收要建立在可观测的稳定实现之上。 + +## 6. 风险评估 + +### 6.1 结构风险 + +1. catalog service 收敛后,页面级测试会有一批需要改写。 +2. 采集 profile 动态切换会引入黑帧或短时抖动风险。 +3. 共享状态事件化后,部分 UI 依赖的轮询时序会发生变化。 +4. 如果把页面策略全部吞进全局 service,复杂度可能继续上升。 + +### 6.2 实现风险 + +1. `SCStream.updateConfiguration` 频繁调用可能带来额外不稳定性。 +2. preview sink 丢帧策略若设计不当,可能出现“窗口卡住但后台仍在刷新”的假象。 +3. ICE 配置下发涉及网页协议面,需防止旧页面行为退化。 +4. catalog 刷新并发语义若定义不清,可能出现过期结果覆盖新结果。 + +## 7. 验证矩阵 + +### 7.1 单元测试 + +新增或补强以下测试: + +1. catalog service 的去重、取消、过期结果丢弃测试 +2. 服务停止不会清空全局 catalog snapshot,且共享页仍能进入 `serviceStopped` 派生状态测试 +3. 监听页与共享页共享 snapshot 的状态收敛测试 +4. `reusedSnapshot` 结果事件会触发共享注册重放测试 +5. profile 状态机测试,覆盖进入阈值、退出阈值、最小驻留时间、最大重配频率 +6. `streamingPeers` 突发变化不会直接触发配置抖动测试 +7. signaling 已连通但协商失败、`streamingPeers = 0` 时不切换 profile 测试 +8. cursor override 不受 profile 节流限制且仍能即时生效测试 +9. preview 背压与 renderer 单消费者测试 +10. 共享统计订阅“先给当前快照,再给增量事件”测试 +11. 共享统计快照同时包含全局与 per-target 两组计数测试 +12. 同一 target 断开后重连会生成新的 `clientID`,重复断连事件仍保持幂等测试 +13. 超出 viewer 上限的连接会被拒绝,且不会生成 `clientID` 或进入共享统计快照测试 +14. sharing 原始事件载荷包含 `target`、`clientID`、`peerPhase` 测试 +15. ICE bootstrap 配置存在与缺失两种路径测试 + +### 7.2 集成测试 + +重点补以下组合场景: + +1. 监听窗口已开,再开启共享 +2. 共享中连接多个 `streamingPeers` +3. 共享中发生显示拓扑变化 +4. 停止共享、停止服务、重新启动服务 +5. 监听与共享共用同一 display catalog +6. 自动化测试必须使用测试权限 provider,执行期间不得触发系统屏幕录制授权弹窗 +7. 两个 target 同时共享时,`streamingPeersByTarget` 与 `signalingConnectionsByTarget` 不串扰 +8. 网页端收到 `stopped` 后进入终态且不再重连 +9. 网页端遇到 `disconnected` 或 `failed` 时进入退避重连 +10. 网页端收到 `error` 时 overlay 与连接清理行为符合文档约束 +11. viewer 超限时连接请求被拒绝,`signalingConnections` 不增加 + +### 7.3 基线采样 + +只在固定基准机上记录当前基线,再做优化后对比: + +环境约束: + +1. 固定一台代表机型,记录 CPU 型号、内存、macOS 版本、显示器规格。 +2. 使用 `Release` 构建与同一套网络环境。 +3. 每个场景预热 `10s` 后再采样 `60s`。 +4. 采样工具、日志路径与统计口径在实施前固定,后续对比沿用同一协议。 + +采样场景: + +1. `previewOnly`,单预览窗口 +2. `shareOnly`,单分享目标与单 `streamingPeer` +3. `mixed`,单预览窗口与两个 `streamingPeers` + +记录项: + +1. 进程 CPU 中位数 +2. `SCShareableContent` 加载次数 +3. `profileReconfigurationCount` +4. `cursorOverrideReconfigurationCount` +5. preview 渲染延迟 `p95` +6. preview 丢帧率 + +### 7.4 验收门槛 + +固定基准机上的通过条件建议写成硬门槛: + +1. 在权限状态与拓扑签名均未变化、且没有 `userForcedRefresh` 的前提下,`SCShareableContent` 加载次数不超过 `1` 次。 +2. 稳定状态下 `profileReconfigurationCount` 为 `0`;任意 `5s` 窗口内不超过 `1` 次。 +3. `streamingPeers` 在 `10s` 内从 `0 -> 3 -> 0` 波动时,`profileReconfigurationCount` 不超过 `1` 次。 +4. `previewOnly` 场景 preview 渲染延迟 `p95 <= 120ms`,`mixed` 场景 `p95 <= 180ms`。 +5. `previewOnly` 场景丢帧率不超过 `10%`,`mixed` 场景不超过 `20%`。 +6. `mixed` 场景进程 CPU 中位数不得高于基线 `5%`,目标下降 `10%`。 +7. `cursorOverrideReconfigurationCount` 单独记录,不计入 profile 频控门槛。 + +CI 与常规本地验证只检查稳定不变量: + +1. 去重、取消、过期结果丢弃语义正确。 +2. `profileReconfigurationCount` 频率限制与 `cursorOverrideReconfigurationCount` 例外规则正确。 +3. 共享统计快照包含 signaling 与 streaming 的全局计数和 per-target 计数。 +4. 自动化测试全程不触发屏幕录制授权弹窗。 + +### 7.5 运行时观测与回退 + +建议新增日志或指标: + +1. display catalog 加载次数 +2. catalog 复用次数 +3. `profileReconfigurationCount` +4. `cursorOverrideReconfigurationCount` +5. profile 切换次数 +6. preview 丢帧数 +7. 当前 `streamingPeers` +8. 当前 `signalingConnections` +9. 当前 session 采集规格 + +回退条件: + +1. `previewOnly` 或 `mixed` 连续 `3` 个观测窗口超出延迟门槛时,自动降到更保守的 fps 档位。 +2. 连续 `3` 个观测窗口超出丢帧率门槛时,记录告警并降级 profile。 +3. 若 CPU 无法满足门槛,保留观测日志并暂停默认启用更激进的 profile 规则。 + +## 8. 建议的交付拆分 + +为了控制回归范围,建议拆成三批提交: + +1. 状态收敛层 + - `ScreenCaptureCatalogService` + - `CatalogStore` / `CatalogRefreshCoordinator` + - 监听页与共享页目录状态统一 +2. 采集与预览性能层 + - profile 状态机 + - preview 背压、renderer drain loop 与量化门槛 +3. 共享协议与联合验证层 + - 快照驱动的共享状态传播 + - ICE bootstrap 配置下发与兼容 + - 联合测试、权限隔离与验收补齐 + +## 9. 当前结论 + +如果只选最值得优先落地的三项,建议先做: + +1. `ScreenCaptureCatalogService` +2. 采集 profile 状态机 +3. preview 背压与 renderer 单消费者模型 + +原因很直接: + +1. 这三项决定后续重构是否能在更低复杂度下推进。 +2. 它们能同时改善重复加载、资源浪费和界面稳定性。 +3. 它们对监听与共享两条链路都会立刻产生收益。 +4. 它们也是后续量化验收与回退策略的基础。 diff --git a/docs/capture_sharing_refactor_retrospective.md b/docs/capture_sharing_refactor_retrospective.md new file mode 100644 index 0000000..343942c --- /dev/null +++ b/docs/capture_sharing_refactor_retrospective.md @@ -0,0 +1,334 @@ +# 屏幕监听与共享重构复盘 + +## 1. 背景与结论 + +这份复盘文档对应的对象是未合并分支 `codex/capture-cursor-config-serialize`。 +截至 2026-03-19,`codex/extract-capture-cursor-config-serialize` 与 `main` 几乎没有实现差异,不能代表那次失败重构的主体。 + +这次重构最终放弃合并,核心原因很直接:结构风险大于可保留收益。分支试图同时重写屏幕监听、屏幕共享、Web 服务生命周期、共享注册、主屏别名路由、窗口预览几何、测试隔离与回退机制,变更面过大,耦合面过密,后续只能靠连续补丁维持稳定。 +这份复盘的重点是给下一轮重构划清边界,明确不可为方向,同时保留可以复用的做法与资产。 + +可以直接作为证据的时间线如下: + +1. `7aed33c`:先把屏幕监听与屏幕共享结构一起收拢,并改写预览窗口支持。 +2. `42ee1d7`:进一步引入 `ScreenPipelineRuntime`,统一接管监听、共享、Web 生命周期与观看人数。 +3. `6e78b11`:开始修监听与共享状态一致性。 +4. `6226ceb`:开始修主屏别名与共享注册真值源。 +5. `7709f7a`:继续修并发收敛问题并补回归测试。 +6. `cb00555`:最后又修虚拟 HiDPI 屏幕监听回归。 + +从这个顺序可以看出,问题集中在重构后的结构本身。初始重构完成后,分支很快进入长时间的稳定性补丁阶段。 + +对照当前主线,风险更容易看清: + +1. 当前监听状态仍然由 [CaptureMonitoringService.swift](../VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift) 负责,职责边界单一。 +2. 当前共享注册与共享会话仍然由 [DisplaySharingCoordinator.swift](../VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift) 负责,主屏解析和 shareID 分配也留在同一模块。 +3. 当前预览窗口几何问题已经沉淀为单独说明文档 [capture_preview_black_bar_fix_notes.md](capture_preview_black_bar_fix_notes.md),说明主线最终选择了分问题修复,不再继续沿用那套大一统 runtime。 + +## 2. 这次重构做错了什么 + +### 2.1 一次性把四类运行时职责压进同一个总入口 + +结论分类:不应该做 + +`42ee1d7` 引入 `VoidDisplay/Features/ScreenPipeline/ScreenPipelineRuntime.swift`,同次提交又删除 `CaptureMonitoringService.swift`、`SharingService.swift`、`DisplaySharingCoordinator.swift`。 +证据很明确:`ScreenPipelineRuntime` 同时定义监听状态、共享状态、Web 服务状态、viewer 统计、注册更新、命令等待、错误语义和快照分发,文件体量与职责密度都明显过高。 + +后续修补模式也很明显: + +1. `6e78b11` 修状态一致性。 +2. `b175bf6` 修共享注册冲突与失效回写。 +3. `6b66075` 修监听移除收敛与共享页拓扑误刷新。 +4. `7709f7a` 修并发收敛。 + +这说明重构后新增的复杂度没有在统一入口内自然收敛,后续只能继续向同一个入口追加状态规则。 + +### 2.2 让监听链路与共享链路互相污染状态 + +结论分类:不应该做 + +分支里的 `ScreenPipelineSnapshot` 把监听会话、共享状态、共享失败、主屏、Web 服务端口、viewer 数都揉进同一个快照模型。 +`CaptureController.swift` 与 `SharingController.swift` 都改成订阅同一个 runtime 快照,再从中拆出自己关心的状态。 + +证据: + +1. `42ee1d7` 的 `CaptureController.swift` 与 `SharingController.swift` 都新增 runtime 快照订阅。 +2. `6e78b11` 与 `a086cbf` 的提交信息已经直接指向“状态一致性”“链路状态污染”。 + +这种结构会让监听与共享在读模型上失去隔离。一个链路的补丁很容易波及另一个链路的呈现与收敛。 + +### 2.3 把共享目录注册和权限回退做成持续摆动的协调层 + +结论分类:不应该做 + +共享可注册显示器这件事,本来只需要明确“谁负责加载”“谁负责注册”“权限缺失时谁清空状态”。分支后来引入 `ShareableDisplayRegistrationCoordinator`,把权限检查、拓扑签名、回退轮询、恢复重试、同步串行化全部包进一个协调器。 + +证据: + +1. `6226ceb` 新增 `VoidDisplay/App/ShareableDisplayRegistrationCoordinator.swift`。 +2. 文件内同时存在 `fallbackPollingTask`、`recoveryRetryTask`、`pendingSync`、`lastAppliedTopologySignature`、`lastPermissionGranted`、`needsRetryAfterFailure`。 +3. `acca06c`、`962a5d5`、`a086cbf` 又继续围绕目录权限、目录状态、链路污染补丁。 + +这类结构会把“注册逻辑”演变成一个长期运行的补偿系统,后续任何权限、拓扑、加载失败都可能落进协调状态机。 + +### 2.4 过早抽象主屏别名路由 + +结论分类:不应该做 + +主屏分享链接规则在用户价值上很轻,技术风险却不轻。分支在 `6226ceb` 里新增 `docs/main_display_share_link_rules.md`,并把 `/display` 与 `/signal` 的主屏别名语义纳入注册真值源与路由代理。 + +证据: + +1. `6226ceb` 同时修改注册协调器、runtime、路由测试与文档。 +2. `ScreenPipelineRuntimeTests.swift` 在这一阶段新增了主屏别名、shareID 保持、并发注册保序等大量测试。 + +这类抽象提高了共享路由层的状态耦合,却没有为监听或共享稳定性带来基础收益。 + +### 2.5 在并发命令问题上建立过重的串行语义 + +结论分类:不应该做 + +分支后期的核心修补几乎都在处理命令排队、取消、等待注册槽位、停止中禁止启动、共享开始与停止 FIFO 次序、移除中的 in flight 命令回滚。 + +证据: + +1. `d3f81dd`、`7709f7a` 都集中修共享并发状态机与收敛。 +2. `ScreenPipelineRuntimeTests.swift` 中后期新增了大量 `concurrent`、`cancelled`、`waitingForRegistrationSlot`、`webServiceStopping` 相关测试。 + +这说明抽象层级已经高到需要专门证明“命令之间不会互相踩踏”。此时重构已经偏离了“让代码更容易推断”的目标。 + +## 3. 哪些属于过度设计 + +### 3.1 `ScreenPipelineRuntime` + +结论分类:过度设计 + +`ScreenPipelineRuntime` 在同一文件里承担了以下职责: + +1. 显示器描述注册。 +2. 监听会话生命周期。 +3. 共享会话生命周期。 +4. Web 服务生命周期。 +5. viewer 指标聚合。 +6. 路由代理回写。 +7. 错误语义定义。 +8. 快照流分发。 +9. 并发命令顺序控制。 + +证据:`42ee1d7` 的 `VoidDisplay/Features/ScreenPipeline/ScreenPipelineRuntime.swift`。 + +这类“总 runtime”看起来统一,实际把多个变化频率不同的问题绑到同一次改动里。监听和共享的修补无法独立演进。 + +### 3.2 `ShareableDisplayRegistrationCoordinator` + +结论分类:过度设计 + +显示器注册本质上是共享页的输入刷新逻辑。分支把它上升成跨权限、跨拓扑、跨失败恢复的协调器,包含轮询、恢复重试、状态签名缓存、同步去抖与串行化执行。 + +证据:`6226ceb` 的 `VoidDisplay/App/ShareableDisplayRegistrationCoordinator.swift`。 + +这会让一个本应短路径、短状态的输入同步问题,演变成新的长期运行状态机。 + +### 3.3 主屏别名规则 + +结论分类:过度设计 + +`/display` 和 `/signal` 的主屏别名本身是附加入口。分支围绕它新增了“当前主屏已存在于注册集时才可用”“主屏切换时别名跟随,具体地址不变”等规则,还同步修改路由解析与注册真值源。 + +证据:`6226ceb` 与 `docs/main_display_share_link_rules.md`。 + +这个规则复杂度与用户收益不对称,放在失败重构里只会继续放大状态耦合。 + +### 3.4 围绕状态收敛建立补偿路径 + +结论分类:过度设计 + +从 `6e78b11` 到 `7709f7a`,提交标题已经反复出现“收敛”“一致性”“回写”“误刷新”“并发收敛”。 +这说明重构后的结构需要靠补偿路径维持一致性。补偿路径一旦成为常态,系统的真实语义就会越来越难推断。 + +证据: + +1. `6e78b11` +2. `962a5d5` +3. `b175bf6` +4. `6b66075` +5. `7709f7a` + +同一主题连续出现,本身就是过度设计的信号。 + +## 4. `1:1` 预览失稳专项复盘 + +这次 `1:1` 失稳问题,不能只归结为某一行计算错误。更关键的问题在于,分支把“真实窗口承载区几何”和“采集元数据推断”缠在了一起,导致窗口大小计算失去了单一可信几何事实。 + +### 4.1 问题是怎样形成的 + +分支里的 `CaptureDisplayView.swift` 同时引入了两层推断: + +1. `nativeFrameSizeInPoints` 先走 `CapturePreviewNativeScaleResolver.resolve`,没有直接采用原始帧尺寸。 +2. `preferredAspect()` 也优先采用 `resolutionText` 解析结果,再回退到首帧像素尺寸。 + +证据: + +1. `codex/capture-cursor-config-serialize` 分支中的 `VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift` 第 41 行到第 56 行。 +2. 同文件第 243 行到第 257 行。 + +窗口计算又被抽到 `CapturePreviewWindowSupport.swift`,这里的 `CapturePreviewWindowMetrics` 同时接受 `aspect`、`framePixelSize`、`targetContentWidth`、`shouldLockAspect`。 +初始窗口大小在 `applyInitialWindowSizeIfNeeded()` 中只应用一次,后续还要继续参与 `snapWindowToAspect()` 与 resize 过程。 + +证据: + +1. 分支中的 `VoidDisplay/Features/Capture/Views/CapturePreviewWindowSupport.swift` 第 4 行到第 20 行。 +2. 同文件第 68 行到第 135 行。 +3. 同文件第 149 行到第 193 行。 + +### 4.2 为什么会失稳 + +`1:1` 模式真正需要的是一个稳定的几何基准:预览层承载区到底应该用哪组宽高。 +分支却把这个问题交给了“首帧像素尺寸”和“`resolutionText` 推断后的原生尺寸”共同决定。 + +这会带来三个后果: + +1. 当 `resolutionText` 与首帧尺寸处于 HiDPI、虚拟显示器、异常首帧、元数据延迟到达等组合场景时,`resolve` 的结果可能变化。 +2. `preferredAspect()` 与 `nativeFrameSizeInPoints` 的依据并不完全一致,一个偏向 `resolutionText`,一个偏向推断后的 native size。 +3. `applyInitialWindowSizeIfNeeded()` 只在第一次满足条件时落地窗口大小,首次采用的推断如果偏了,后续很难自动回到正确几何。 + +所以这次 bug 的本质不只是“公式写错”。更深一层的问题,是“窗口几何”和“采集元数据解释”被耦合到了同一个决策层。 + +### 4.3 主线后来做对了什么 + +主线修复预览问题时,重点放回到真实内容承载区几何。 +[capture_preview_black_bar_fix_notes.md](capture_preview_black_bar_fix_notes.md) 已经明确记录了两个关键点: + +1. 用 `contentRect` 与 `contentLayoutRect` 的差值计算真实布局 inset。 +2. 用真实预览承载区去反推窗口 frame。 + +这条思路更稳,因为它把问题收回到了窗口几何本身。 +几何由几何事实决定,采集元数据只负责提供可信的宽高比输入,不再参与多轮推断。 + +### 4.4 这类问题后续应该怎样处理 + +结论分类:应该做 + +后续如果再动 `1:1` 预览,只能遵守下面四条: + +1. 先固定几何真相,明确承载层的真实布局区域。 +2. 把采集尺寸解析单独封装为输入归一化,不得和窗口 frame 决策混写在一起。 +3. 当元数据存在多来源时,必须先定义单一真值源,再进入窗口 sizing。 +4. 必须保留预览诊断链路与像素级自验证,不能退回人工截图反馈。 + +证据: + +1. 主线的 [capture_preview_black_bar_fix_notes.md](capture_preview_black_bar_fix_notes.md)。 +2. `cb00555` 又一次修虚拟 HiDPI 回归,说明这块没有资格靠肉眼试错。 + +## 5. 后续重构应该怎么做 + +### 5.1 监听、共享、Web 服务、窗口 sizing 四块分治 + +结论分类:应该做 + +后续重构必须拆成四块独立问题: + +1. 监听内部状态与会话管理。 +2. 共享注册、shareID、共享会话管理。 +3. Web 服务启动停止与路由绑定。 +4. 预览窗口 sizing 与渲染诊断。 + +任何一个阶段都不能同时改这四块。 + +### 5.2 共享只抽共享,监听只抽监听 + +结论分类:应该做 + +监听和共享都依赖显示器输入,但它们的运行语义不同: + +1. 监听关心预览订阅、窗口、cursor、会话移除。 +2. 共享关心 shareID、路由、sessionHub、viewer 统计、服务启动状态。 + +公共层只应保留低层原语,例如显示器描述、底层采集句柄、可测试的 registry 能力。 +禁止再引入一个同时替代监听服务和共享服务的总 runtime。 + +### 5.3 目录注册逻辑保持短路径 + +结论分类:应该做 + +共享目录注册只保留三步: + +1. 读权限状态。 +2. 读取当前可共享显示器集合。 +3. 用单次结果刷新共享注册。 + +如果需要失败重试,重试逻辑必须留在调用层或测试层,不能升级为新的长期运行协调器。 + +### 5.4 主屏别名规则延后 + +结论分类:应该做 + +`/display`、`/signal` 这类主屏别名只在共享主链路稳定后才有资格进入。 +下一轮重构的第一批目标里不应包含这类语义扩展。 + +### 5.5 失败分支里可取且可复用的资产 + +结论分类:应该做 + +这次分支失败,仍有几类资产值得保留到下一轮重构: + +1. 并发语义测试样例。`ScreenPipelineRuntimeTests.swift` 中围绕 `concurrent`、`cancelled`、`waitingForRegistrationSlot`、`webServiceStopping` 的场景覆盖,适合迁移为监听链路和共享链路各自的回归基线。 +2. 预览自验证链路。`CapturePreviewDiagnosticsRuntime`、预览录制 sink、`scripts/test/capture_preview_self_check.sh`、`scripts/test/capture_preview_analyze.swift` 已经证明能量化几何与渲染问题,应该直接复用。 +3. 测试隔离约束。`ea383d5`、`a086cbf` 对“测试期间不触发录屏授权弹窗”的处理是有效约束,下一轮必须继续保持。 +4. 低层原语抽离方向。`DisplayCaptureRegistry`、`DisplayCaptureSession` 这类底层能力可以继续保留为公共层输入,前提是禁止把监听与共享的上层状态语义重新绑回同一运行时入口。 + +## 6. 重构重启门槛 + +### 6.1 分阶段顺序 + +下一轮重构执行顺序固定如下: + +1. 先收口监听内部实现,不动共享路由、不动 Web 服务、不动主屏别名。 +2. 再收口共享内部实现,不碰监听窗口几何。 +3. 再抽监听和共享都确实需要的低层原语。 +4. 最后才考虑跨模块统一状态接口。 + +这个顺序不能倒置。尤其不能一上来先做大一统 runtime。 + +### 6.2 每阶段验收项 + +每个阶段都要满足以下门槛后才能继续: + +1. 当前阶段改动只落在单条链路,另一条链路只允许适配型最小改动。 +2. 相关回归测试先补齐,再做结构调整。 +3. 编译零错误、零警告。 +4. 新增状态语义必须能用一句话描述清楚真值源和更新时机。 + +### 6.3 必须先补的回归测试 + +下次重构前,至少要保留并优先补齐以下测试能力: + +1. 监听会话添加、激活、移除、cursor 状态回写。 +2. 共享注册刷新、shareID 稳定、主屏切换、显示器移除。 +3. Web 服务启动、停止、端口冲突、停止中拒绝新共享。 +4. 共享并发场景,包括同屏重复启动、停止中启动、取消中的回滚。 +5. 虚拟 HiDPI 与 `1:1` 预览链路。 + +### 6.4 必须保留的本地自验证链路 + +以下链路不得删除: + +1. 预览诊断 runtime。 +2. 预览录制 sink。 +3. UI 诊断测试。 +4. `scripts/test/capture_preview_self_check.sh` +5. `scripts/test/capture_preview_analyze.swift` + +这套链路已经证明,屏幕预览问题需要可重复、可量化的本地验证。 + +### 6.5 最终约束清单 + +为了避免 `main` 的下一轮重构重蹈覆辙,执行前必须先确认下面四类结论: + +1. 不应该做:一次性统一监听、共享、Web 生命周期、共享注册、窗口几何。 +2. 应该做:按链路分治,先补测试,再做结构调整,公共层只保留稳定原语。 +3. 过度设计:总 runtime、长期运行注册协调器、主屏别名规则、围绕收敛建立的大量补偿路径。 +4. 可取资产:并发回归样例、预览自验证链路、测试授权隔离、低层采集原语抽离。 + +只要重构方案重新出现这些特征,就应该立刻停下,重新拆分范围。 diff --git a/docs/macos_sidebar_detail_toolbar_styling_notes.md b/docs/macos_sidebar_detail_toolbar_styling_notes.md index ebfa048..449b1d0 100644 --- a/docs/macos_sidebar_detail_toolbar_styling_notes.md +++ b/docs/macos_sidebar_detail_toolbar_styling_notes.md @@ -37,6 +37,9 @@ - 想让主窗口材质和 titlebar 完全走系统行为时,额外的 `NSWindow` bridge、`NSVisualEffectView` 和自定义 window chrome 会偏离“通用做法”。 If the goal is to keep window material and titlebar fully system-driven, extra `NSWindow` bridges, `NSVisualEffectView`, and custom window chrome move away from the most general best-practice path. +- 2026-03 阶段 4 到 6 重构期间,曾把 `DisplayTopologyChangeCoordinator` 通过根层 `.environment(...)` 挂到主 `WindowGroup -> HomeView`。这没有直接改 toolbar 样式,却触发了 unified toolbar 下方分隔线回归。回退后横线消失。后续若要给主窗口根层追加 environment、scene modifier 或根容器装配改动,必须把它视为 toolbar/detail 样式风险项。 + During the 2026-03 phase 4-6 refactor, `DisplayTopologyChangeCoordinator` was injected at the main `WindowGroup -> HomeView` root via `.environment(...)`. That did not directly change toolbar styling, but it brought back the separator under the unified toolbar. Removing that root injection cleared the line. Any future root-level environment, scene modifier, or root-container wiring change must be treated as a toolbar/detail styling risk. + ## 3. 系统表现应该怎么理解 / How To Read the Native macOS Look - `sidebar` 和 `detail` 有轻微材质差异是正常现象。 @@ -68,6 +71,9 @@ - 自定义背景只放在局部组件上,例如卡片、状态条、空状态面板。 Limit custom backgrounds to local components such as cards, status bars, and empty-state panels. +- 需要把额外协调器或服务传给 detail 页面时,优先使用显式参数传递,尽量不要给主 `WindowGroup` 或 `HomeView` 根层追加新的 `.environment(...)`。 + When a coordinator or service must reach detail pages, prefer explicit parameter passing and avoid adding new `.environment(...)` injections at the main `WindowGroup` or `HomeView` root. + ## 5. 这次最终保留和移除的内容 / What We Kept And Removed - 保留:sidebar 结构、系统 sidebar toggle、detail 标题显示、统一 toolbar 风格。 @@ -120,3 +126,6 @@ - 如果某个窗口样式改动导致 UI smoke 主入口 identifier 消失,优先回退 scene-level 风格改动,再查业务视图。 If a window-style change causes UI smoke to lose primary identifiers, revert the scene-level styling first before debugging business views. + +- 如果工具栏下方横线突然回归,优先检查最近是否给主 `WindowGroup`、`HomeView` 根层或 `NavigationSplitView` 外层追加了新的 environment 注入、scene modifier 或容器包装。 + If the separator under the toolbar suddenly comes back, first inspect whether a new environment injection, scene modifier, or wrapper view was recently added around the main `WindowGroup`, the `HomeView` root, or the outer `NavigationSplitView`. diff --git a/docs/main_display_share_link_rules.md b/docs/main_display_share_link_rules.md new file mode 100644 index 0000000..52275dd --- /dev/null +++ b/docs/main_display_share_link_rules.md @@ -0,0 +1,65 @@ +# 主屏分享链接规则 + +## 目标 + +这份文档定义屏幕共享链接的统一规则,尤其是主屏别名路由的语义边界。 + +## 统一规则 + +所有屏幕的真实共享目标都基于 `shareID`。 + +具体屏幕的标准地址格式: + +- `/display/{shareID}` +- `/signal/{shareID}` + +这条规则对主屏和非主屏完全一致。 + +## 主屏额外别名 + +主屏比其他屏幕多两个别名地址: + +- `/display` +- `/signal` + +这两个地址只表示“当前系统主屏”。 + +内部解析语义: + +- `/display` 映射到当前主屏对应的 `/display/{shareID}` +- `/signal` 映射到当前主屏对应的 `/signal/{shareID}` + +## 主屏切换语义 + +当系统当前主屏发生变化时: + +- `/display` 跟随新的主屏 +- `/signal` 跟随新的主屏 + +具体屏幕地址不会因为主屏切换而变化: + +- `/display/{shareID}` 继续指向对应那块屏幕 +- `/signal/{shareID}` 继续指向对应那块屏幕 + +## 可用性边界 + +主屏别名只有在“当前系统主屏已存在于当前共享注册集”时才可用。 + +如果当前系统主屏不在注册集内: + +- `/display` 不可用 +- `/signal` 不可用 + +此时具体屏幕地址是否可用,仍然只取决于对应 `shareID` 是否处于当前注册和路由状态。 + +## 前端展示规则 + +前端默认继续展示具体屏幕地址: + +- `/display/{shareID}` + +主屏别名属于额外入口,用于让用户输入 `/display` 时自动命中当前主屏,不替代具体地址展示。 + +## 一句话总结 + +主屏没有单独的底层共享标识规则,只有额外的别名路由规则。 diff --git a/docs/testing/ci-workflows.md b/docs/testing/ci-workflows.md index 724253d..538af65 100644 --- a/docs/testing/ci-workflows.md +++ b/docs/testing/ci-workflows.md @@ -20,7 +20,7 @@ The repository does not use GitHub merge queue. `merge_group` is intentionally o Default Xcode selection is centralized in `.github/actions/xcode-select` and prefers: -- `/Applications/Xcode_26.2.app/Contents/Developer` +- `/Applications/Xcode_26.3.app/Contents/Developer` - fallback: `/Applications/Xcode.app/Contents/Developer` ## Branch Protection Gate @@ -45,7 +45,7 @@ UI smoke failure behavior: Release build check behavior: - `release-build-check` runs only on PRs targeting `main` with code-relevant changes -- It performs an unsigned `Release` build for `arm64` +- It performs unsigned `Release` builds for `arm64` and `x86_64` through a 2-job matrix - It does not package DMG and does not publish artifacts Unit coverage guard behavior: diff --git a/docs/testing/coverage-baseline.json b/docs/testing/coverage-baseline.json index 5e87c9e..55dd763 100644 --- a/docs/testing/coverage-baseline.json +++ b/docs/testing/coverage-baseline.json @@ -5,7 +5,7 @@ "capture_controller": "VoidDisplay/App/CaptureController.swift", "virtual_display_controller": "VoidDisplay/App/VirtualDisplayController.swift", "share_view_model": "VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift", - "share_view_lifecycle_controller": "VoidDisplay/Features/Sharing/Views/ShareViewLifecycleController.swift", + "share_view_lifecycle_controller": "VoidDisplay/Shared/ScreenCapture/DisplayTopologyRefreshLifecycleController.swift", "capture_choose_view_model": "VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift", "capture_monitoring_service": "VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift", "virtual_display_orchestrator": "VoidDisplay/Features/VirtualDisplay/Services/VirtualDisplayOrchestrator.swift", diff --git a/scripts/test/capture_preview_analyze.swift b/scripts/test/capture_preview_analyze.swift index 4076665..2b5ab35 100644 --- a/scripts/test/capture_preview_analyze.swift +++ b/scripts/test/capture_preview_analyze.swift @@ -20,15 +20,26 @@ struct RGBAColor { } } +enum AnalyzerMode: String { + case fit + case native +} + enum AnalyzerError: LocalizedError { case missingArgument + case invalidMode(String) + case invalidArgument(String) case imageLoadFailed(String) case bitmapUnavailable(String) var errorDescription: String? { switch self { case .missingArgument: - return "Usage: capture_preview_analyze.swift " + return "Usage: capture_preview_analyze.swift [--mode fit|native] " + case .invalidMode(let mode): + return "Unsupported mode: \(mode). Expected fit or native." + case .invalidArgument(let argument): + return "Invalid argument: \(argument)" case .imageLoadFailed(let path): return "Failed to load image at path: \(path)" case .bitmapUnavailable(let path): @@ -37,6 +48,22 @@ enum AnalyzerError: LocalizedError { } } +struct AnalyzerArguments { + let mode: AnalyzerMode + let imagePath: String +} + +struct EdgeObservation { + let name: String + let distance: Double + let actual: RGBAColor +} + +struct CornerObservation { + let name: String + let distance: Double +} + 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), @@ -55,11 +82,9 @@ 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 arguments = try parseArguments() - let imagePath = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL.path + let imagePath = URL(fileURLWithPath: arguments.imagePath).standardizedFileURL.path let bitmap = try loadBitmap(path: imagePath) let width = bitmap.pixelsWide let height = bitmap.pixelsHigh @@ -71,7 +96,7 @@ func main() throws { ("bottom", normalizedRect(0.10, 0.96, 0.80, 0.04, imageWidth: width, imageHeight: height)) ] - var failures: [String] = [] + var edgeObservations: [EdgeObservation] = [] for (name, rect) in edgeSearchRegions { let expected = expectedColors[name]! let (distance, actual) = nearestColorMatch( @@ -79,12 +104,7 @@ func main() throws { 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") - } + edgeObservations.append(.init(name: name, distance: distance, actual: actual)) } let cornerSearchRegions: [(String, CGRect)] = [ @@ -94,21 +114,82 @@ func main() throws { ("bottomRightCorner", normalizedRect(0.78, 0.78, 0.20, 0.20, imageWidth: width, imageHeight: height)) ] + var cornerObservations: [CornerObservation] = [] 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 distance = nearestColorDistance(bitmap: bitmap, rect: rect, expected: expectedColors[name]!) + cornerObservations.append(.init(name: name, distance: distance)) } let circleBounds = detectMagentaCircleBounds( bitmap: bitmap, searchRect: normalizedRect(0.25, 0.25, 0.50, 0.50, imageWidth: width, imageHeight: height) ) + let centerColor = averageColor(bitmap: bitmap, normalizedX: 0.5, normalizedY: 0.5, radius: 4) + + var leftBlackColumns = 0 + var rightBlackColumns = 0 + if arguments.mode == .fit { + leftBlackColumns = leadingBlackColumns(bitmap: bitmap, normalizedY: 0.5) + rightBlackColumns = trailingBlackColumns(bitmap: bitmap, normalizedY: 0.5) + } + + let failures = switch arguments.mode { + case .fit: + validateFit( + edgeObservations: edgeObservations, + cornerObservations: cornerObservations, + circleBounds: circleBounds, + leftBlackColumns: leftBlackColumns, + rightBlackColumns: rightBlackColumns, + imageWidth: width + ) + case .native: + validateNative( + edgeObservations: edgeObservations, + cornerObservations: cornerObservations, + circleBounds: circleBounds, + centerColor: centerColor + ) + } + + if failures.isEmpty { + print( + "PASS mode=\(arguments.mode.rawValue) \(imagePath) size=\(width)x\(height) leftBlack=\(leftBlackColumns) rightBlack=\(rightBlackColumns)" + ) + return + } + + print("FAIL mode=\(arguments.mode.rawValue) \(imagePath)") + for failure in failures { + print(" - \(failure)") + } + exit(1) +} + +func validateFit( + edgeObservations: [EdgeObservation], + cornerObservations: [CornerObservation], + circleBounds: CGRect?, + leftBlackColumns: Int, + rightBlackColumns: Int, + imageWidth: Int +) -> [String] { + var failures: [String] = [] + + for observation in edgeObservations where observation.distance > colorTolerance { + failures.append( + "\(observation.name) expected close to diagnostic color, actual=(\(format(observation.actual.red)), \(format(observation.actual.green)), \(format(observation.actual.blue)))" + ) + } + for observation in edgeObservations + where (observation.name == "left" || observation.name == "right") + && observation.actual.luminance < blackLuminanceThreshold { + failures.append("\(observation.name) edge looks black, likely side letterboxing remains") + } + for observation in cornerObservations where observation.distance > cornerTolerance { + failures.append("\(observation.name) marker not found in expected quadrant") + } + if let circleBounds { let ratio = Double(circleBounds.width) / Double(circleBounds.height) if abs(ratio - 1) > 0.12 { @@ -118,25 +199,86 @@ func main() throws { 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) { + if leftBlackColumns > max(2, imageWidth / 200) { failures.append("left black bar width=\(leftBlackColumns)px") } - if rightBlackColumns > max(2, width / 200) { + if rightBlackColumns > max(2, imageWidth / 200) { failures.append("right black bar width=\(rightBlackColumns)px") } + return failures +} - if failures.isEmpty { - print("PASS \(imagePath) size=\(width)x\(height) leftBlack=\(leftBlackColumns) rightBlack=\(rightBlackColumns)") - return +func validateNative( + edgeObservations: [EdgeObservation], + cornerObservations: [CornerObservation], + circleBounds: CGRect?, + centerColor: RGBAColor +) -> [String] { + var failures: [String] = [] + + let rightEdgeClipped = edgeObservations.first(where: { $0.name == "right" })?.distance ?? 0 > colorTolerance + let topRightMissing = cornerObservations.first(where: { $0.name == "topRightCorner" })?.distance ?? 0 > cornerTolerance + let bottomRightMissing = cornerObservations.first(where: { $0.name == "bottomRightCorner" })?.distance ?? 0 > cornerTolerance + let centerCircleClipped = circleBounds == nil + + let clippingSignals = [ + rightEdgeClipped, + topRightMissing, + bottomRightMissing, + centerCircleClipped + ] + let clippingSignalCount = clippingSignals.filter { $0 }.count + if clippingSignalCount < 3 { + failures.append( + "native mode clipping signature too weak: expected at least 3 clipped signals, actual=\(clippingSignalCount)" + ) } - print("FAIL \(imagePath)") - for failure in failures { - print(" - \(failure)") + if centerColor.luminance < blackLuminanceThreshold { + failures.append("native mode center region is unexpectedly dark") } - exit(1) + + return failures +} + +func parseArguments() throws -> AnalyzerArguments { + let rawArguments = Array(CommandLine.arguments.dropFirst()) + guard !rawArguments.isEmpty else { + throw AnalyzerError.missingArgument + } + + var mode: AnalyzerMode = .fit + var imagePath: String? + var index = 0 + + while index < rawArguments.count { + let argument = rawArguments[index] + if argument == "--mode" { + guard index + 1 < rawArguments.count else { + throw AnalyzerError.missingArgument + } + let rawMode = rawArguments[index + 1].lowercased() + guard let resolvedMode = AnalyzerMode(rawValue: rawMode) else { + throw AnalyzerError.invalidMode(rawMode) + } + mode = resolvedMode + index += 2 + continue + } + + if imagePath == nil { + imagePath = argument + index += 1 + continue + } + + throw AnalyzerError.invalidArgument(argument) + } + + guard let imagePath else { + throw AnalyzerError.missingArgument + } + return AnalyzerArguments(mode: mode, imagePath: imagePath) } func loadBitmap(path: String) throws -> NSBitmapImageRep { diff --git a/scripts/test/capture_preview_self_check.sh b/scripts/test/capture_preview_self_check.sh index 9307fb1..722ae94 100644 --- a/scripts/test/capture_preview_self_check.sh +++ b/scripts/test/capture_preview_self_check.sh @@ -3,43 +3,72 @@ 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 + +# This self-check always runs both diagnostics scale modes: +# - fit: adaptive scaling +# - native: 1:1 scaling +# If you need a single mode for debugging, run `run_mode ` manually. + +run_mode() { + local mode="$1" + local test_name + if [[ "$mode" == "fit" ]]; then + test_name="testCapturePreviewLayoutMatrixFit" + elif [[ "$mode" == "native" ]]; then + test_name="testCapturePreviewLayoutMatrixNative" + else + echo "Unsupported mode: $mode" >&2 + exit 1 + fi + + local mode_dir="$TMP_DIR/$mode" + local derived_data_dir="$mode_dir/DerivedData" + local build_log="$mode_dir/test.log" + local attachments_dir="$mode_dir/attachments" + + rm -rf "$mode_dir" + mkdir -p "$mode_dir" + + xcodebuild \ + -project "$ROOT_DIR/VoidDisplay.xcodeproj" \ + -scheme VoidDisplay \ + -configuration Debug \ + -derivedDataPath "$derived_data_dir" \ + -destination 'platform=macOS' \ + -only-testing:VoidDisplayUITests/CapturePreviewDiagnosticsTests/$test_name \ + test \ + > "$build_log" 2>&1 + + local result_bundle + 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 for mode=$mode. See $build_log" >&2 + exit 1 + fi + + bash "$ROOT_DIR/scripts/test/xcresult_test_count_guard.sh" \ + --xcresult "$result_bundle" \ + --label "Capture preview diagnostics ($mode)" + + 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 screenshots for mode=$mode. See $build_log and $result_bundle" >&2 + exit 1 + fi + + for image in "${images[@]}"; do + swift "$ROOT_DIR/scripts/test/capture_preview_analyze.swift" --mode "$mode" "$image" + done +} + +run_mode fit +run_mode native diff --git a/scripts/test/xcresult_test_count_guard.sh b/scripts/test/xcresult_test_count_guard.sh new file mode 100755 index 0000000..2de1b8c --- /dev/null +++ b/scripts/test/xcresult_test_count_guard.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +XCRESULT_PATH="" +LABEL="Test run" + +while [[ $# -gt 0 ]]; do + case "$1" in + --xcresult) + XCRESULT_PATH="$2" + shift 2 + ;; + --label) + LABEL="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$XCRESULT_PATH" ]]; then + echo "Missing required --xcresult argument." >&2 + exit 1 +fi + +if [[ ! -d "$XCRESULT_PATH" ]]; then + echo "$LABEL invalid: missing xcresult bundle at $XCRESULT_PATH" >&2 + exit 1 +fi + +SUMMARY="$(xcrun xcresulttool get test-results summary --path "$XCRESULT_PATH")" + +extract_metric() { + local key="$1" + local fallback="$2" + local line value + if command -v rg >/dev/null 2>&1; then + line="$(printf '%s\n' "$SUMMARY" | rg "\"$key\"" | tail -n 1)" || true + else + line="$(printf '%s\n' "$SUMMARY" | grep "\"$key\"" | tail -n 1)" || true + fi + if [[ -z "$line" ]]; then + printf '%s' "$fallback" + return 0 + fi + value="$(printf '%s\n' "$line" | awk -F': ' '{print $2}' | tr -d ',\"')" + if [[ -z "$value" ]]; then + printf '%s' "$fallback" + else + printf '%s' "$value" + fi +} + +TOTAL_TESTS="$(extract_metric totalTestCount 0)" +FAILED_TESTS="$(extract_metric failedTests 0)" +RESULT_STATUS="$(extract_metric result unknown)" + +echo "$LABEL summary: result=$RESULT_STATUS totalTestCount=$TOTAL_TESTS failedTests=$FAILED_TESTS" + +if [[ "$TOTAL_TESTS" == "0" ]]; then + echo "$LABEL invalid: totalTestCount == 0 (possible selector mismatch)." >&2 + exit 1 +fi