From 8e2182d3d0b6990d40cbc684e0a9376b16e9ba66 Mon Sep 17 00:00:00 2001 From: Sergey Shcherbak Date: Mon, 20 Jul 2026 16:49:45 +0400 Subject: [PATCH 1/2] fix(recording): recover capture after dark wake --- .../Recording/RecorderRecoveryPolicy.swift | 19 ++ .../Core/Recording/ScreenRecorder.swift | 224 +++++++++++------- .../RecorderRecoveryPolicyTests.swift | 76 ++++++ 3 files changed, 233 insertions(+), 86 deletions(-) create mode 100644 Dayflow/Dayflow/Core/Recording/RecorderRecoveryPolicy.swift create mode 100644 Dayflow/DayflowTests/RecorderRecoveryPolicyTests.swift diff --git a/Dayflow/Dayflow/Core/Recording/RecorderRecoveryPolicy.swift b/Dayflow/Dayflow/Core/Recording/RecorderRecoveryPolicy.swift new file mode 100644 index 00000000..c4098edb --- /dev/null +++ b/Dayflow/Dayflow/Core/Recording/RecorderRecoveryPolicy.swift @@ -0,0 +1,19 @@ +import Foundation + +enum RecorderRecoveryPolicy { + static func retryDelay(forNoDisplayAttempt attempt: Int, maxAttempts: Int) -> TimeInterval? { + guard attempt > 0, attempt < maxAttempts else { return nil } + return pow(2, Double(attempt - 1)) + } + + static func shouldRestartAfterWake( + isCapturing: Bool, + hasDisplay: Bool, + lastSuccessfulCaptureAt: Date?, + now: Date, + staleAfter: TimeInterval + ) -> Bool { + guard isCapturing, hasDisplay, let lastSuccessfulCaptureAt else { return true } + return now.timeIntervalSince(lastSuccessfulCaptureAt) > staleAfter + } +} diff --git a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift index 88807e49..ec03cf4c 100644 --- a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift +++ b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift @@ -121,7 +121,7 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { self.transition(to: .idle, context: "user disabled recording") } - rec ? self.start() : self.stop() + rec ? self.startOnQueue(attempt: 1) : self.stop() } } @@ -157,6 +157,9 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { private var tracker: ActiveDisplayTracker! private var currentDisplayID: CGDirectDisplayID? private var requestedDisplayID: CGDirectDisplayID? + private var recoveryGeneration = 0 + private var waitingForDisplay = false + private var lastSuccessfulCaptureAt: Date? // ScreenCaptureKit objects (refreshed on each capture cycle) private var cachedContent: SCShareableContent? @@ -189,28 +192,34 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { func start() { q.async { [weak self] in - guard let self else { return } - guard self.wantsRecording else { - dbg("start – suppressed (recording disabled)") - return - } - guard self.state.canStart else { - dbg("start – invalid state: \(self.state.description)") - return - } + self?.startOnQueue(attempt: 1) + } + } - self.transition(to: .starting, context: "user/system start") - Task { await self.setupCapture() } + private func startOnQueue(attempt: Int) { + guard wantsRecording else { + dbg("start – suppressed (recording disabled)") + return } + guard state.canStart else { + dbg("start – invalid state: \(state.description)") + return + } + + invalidatePendingRecovery() + transition(to: .starting, context: "user/system start") + Task { await setupCapture(attempt: attempt) } } func stop() { q.async { [weak self] in guard let self else { return } + self.invalidatePendingRecovery() self.stopCaptureTimer() self.cachedContent = nil self.cachedDisplay = nil self.currentDisplayID = nil + self.waitingForDisplay = false if self.state != .paused { self.transition(to: .idle, context: "stopped") @@ -221,7 +230,7 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { // MARK: - Capture Setup - private func setupCapture(attempt: Int = 1, maxAttempts: Int = 4) async { + private func setupCapture(attempt: Int, maxAttempts: Int = 4) async { guard ScreenRecordingPermissionNotice.isGranted else { handleMissingScreenRecordingPermission(reason: "setupCapture") return @@ -231,7 +240,6 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { // 1. Get shareable content (requires screen recording permission) let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true) - cachedContent = content // 2. Choose display: prefer requested → active. Defer if preferred is missing from the snapshot. let displaysByID: [CGDirectDisplayID: SCDisplay] = Dictionary( @@ -242,15 +250,15 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { } let preferredID = requestedDisplayID ?? trackerID - let display: SCDisplay? + let display: SCDisplay if let pid = preferredID { - display = displaysByID[pid] - if display == nil { + guard let preferredDisplay = displaysByID[pid] else { requestedDisplayID = pid dbg("setupCapture: preferred display \(pid) not in snapshot (count=\(content.displays.count)); deferring") - } else { - requestedDisplayID = nil + throw ScreenRecorderError.noDisplay } + display = preferredDisplay + requestedDisplayID = nil } else if let first = content.displays.first { display = first requestedDisplayID = nil @@ -258,32 +266,28 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { throw ScreenRecorderError.noDisplay } - cachedDisplay = display - currentDisplayID = display?.displayID - - if let d = display { - dbg("Setup complete - display \(d.displayID) (\(d.width)x\(d.height))") - } else { - dbg("Setup complete - awaiting display availability") - } - - // 3. Start capture timer + // 3. Commit a resolved display before the timer may enter the capturing state. q.async { [weak self] in guard let self else { return } guard self.state == .starting else { dbg("setupCapture completed but state changed to \(self.state.description), ignoring") return } + self.cachedContent = content + self.cachedDisplay = display + self.currentDisplayID = display.displayID + self.waitingForDisplay = false + dbg("Setup complete - display \(display.displayID) (\(display.width)x\(display.height))") + self.invalidatePendingRecovery() self.startCaptureTimer() self.transition(to: .capturing, context: "capture started") // Take first screenshot immediately Task { await self.captureScreenshot() } - } - - Task { @MainActor in - AnalyticsService.shared.withSampling(probability: 0.01) { - AnalyticsService.shared.capture("recording_started", ["mode": "screenshot"]) + Task { @MainActor in + AnalyticsService.shared.withSampling(probability: 0.01) { + AnalyticsService.shared.capture("recording_started", ["mode": "screenshot"]) + } } } @@ -295,31 +299,52 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { return } - q.async { [weak self] in - self?.transition(to: .idle, context: "setupCapture failed") - } - let nsError = error as NSError let isNoDisplay = (error as? ScreenRecorderError) == .noDisplay + let errorDomain = nsError.domain + let errorCode = nsError.code + let retryDelay = isNoDisplay + ? RecorderRecoveryPolicy.retryDelay(forNoDisplayAttempt: attempt, maxAttempts: maxAttempts) + : nil + + q.async { [weak self] in + guard let self, self.state == .starting else { return } + + self.stopCaptureTimer() + self.cachedContent = nil + self.cachedDisplay = nil + self.currentDisplayID = nil + self.waitingForDisplay = isNoDisplay + self.transition(to: .idle, context: "setupCapture failed") + + if let retryDelay { + dbg("retrying display setup in \(retryDelay)s") + self.scheduleDisplayRetry(after: retryDelay, attempt: attempt + 1) + return + } - if isNoDisplay && attempt < maxAttempts { - let delay = Double(attempt) - dbg("retrying in \(delay)s") - q.asyncAfter(deadline: .now() + delay) { [weak self] in self?.start() } - } else { Task { @MainActor in AnalyticsService.shared.capture( "recording_startup_failed", [ "attempt": attempt, - "error_domain": nsError.domain, - "error_code": nsError.code, + "error_domain": errorDomain, + "error_code": errorCode, ]) } } } } + private func scheduleDisplayRetry(after delay: TimeInterval, attempt: Int) { + let generation = recoveryGeneration + q.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, self.recoveryGeneration == generation else { return } + guard self.wantsRecording, self.waitingForDisplay else { return } + self.startOnQueue(attempt: attempt) + } + } + // MARK: - Capture Timer private func startCaptureTimer() { @@ -382,6 +407,7 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { capturedAt: captureTime, idleSecondsAtCapture: idleSecondsAtCapture ) + recordSuccessfulCapture(at: captureTime) dbg("🔒 Screenshot redacted for blocked foreground application") return } @@ -425,6 +451,7 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { capturedAt: captureTime, idleSecondsAtCapture: idleSecondsAtCapture ) + recordSuccessfulCapture(at: captureTime) dbg("📸 Screenshot saved: \(fileURL.lastPathComponent) (\(jpegData.count / 1024)KB)") @@ -444,6 +471,12 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { } } + private func recordSuccessfulCapture(at date: Date) { + q.async { [weak self] in + self?.lastSuccessfulCaptureAt = date + } + } + private func scaledCaptureSize(for display: SCDisplay) -> (width: Int, height: Int) { let aspectRatio = Double(display.width) / Double(display.height) var targetWidth = Int(Double(Config.targetHeight) * aspectRatio) @@ -513,6 +546,8 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { self.cachedContent = nil self.cachedDisplay = nil self.currentDisplayID = nil + self.waitingForDisplay = false + self.invalidatePendingRecovery() if self.state != .idle { self.transition(to: .idle, context: "missing screen recording permission") } @@ -572,6 +607,44 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { // MARK: - System Events (Sleep/Lock) + private func pauseForSystemEvent(context: String) { + invalidatePendingRecovery() + stopCaptureTimer() + cachedContent = nil + cachedDisplay = nil + currentDisplayID = nil + waitingForDisplay = false + transition(to: wantsRecording ? .paused : .idle, context: context) + } + + private func recoverAfterWake(after delay: TimeInterval, context: String) { + guard wantsRecording else { return } + + let staleAfter = max(Config.screenshotInterval * 3, 30) + guard RecorderRecoveryPolicy.shouldRestartAfterWake( + isCapturing: state == .capturing, + hasDisplay: cachedDisplay != nil, + lastSuccessfulCaptureAt: lastSuccessfulCaptureAt, + now: Date(), + staleAfter: staleAfter + ) else { + dbg("\(context) – recorder is healthy") + return + } + + stopCaptureTimer() + cachedContent = nil + cachedDisplay = nil + currentDisplayID = nil + waitingForDisplay = false + transition(to: .paused, context: "\(context) recovery") + resumeRecording(after: delay, context: context) + } + + private func invalidatePendingRecovery() { + recoveryGeneration &+= 1 + } + private func registerForSleepAndLock() { let nc = NSWorkspace.shared.notificationCenter let dnc = DistributedNotificationCenter.default() @@ -582,7 +655,13 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { object: nil, queue: nil ) { [weak self] _ in self?.q.async { [weak self] in - guard let self, self.state == .capturing else { return } + guard let self, self.wantsRecording else { return } + if self.waitingForDisplay, self.state.canStart { + dbg("didChangeScreenParameters – retrying deferred display selection") + self.startOnQueue(attempt: 1) + return + } + guard self.state == .capturing else { return } dbg("didChangeScreenParameters – refreshing display selection") Task { await self.refreshDisplay() } } @@ -597,16 +676,8 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { dbg("willSleep – pausing") self.q.async { [weak self] in - guard let self else { return } - Task { @MainActor in - if AppState.shared.isRecording { - self.q.async { [weak self] in - self?.transition(to: .paused, context: "system sleep") - } - } - } + self?.pauseForSystemEvent(context: "system sleep") } - self.stop() Task { @MainActor in AnalyticsService.shared.withSampling(probability: 0.01) { AnalyticsService.shared.capture("recording_stopped", ["stop_reason": "system_sleep"]) @@ -624,8 +695,7 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { self.q.async { [weak self] in guard let self else { return } - guard self.state == .paused else { return } - self.resumeRecording(after: 5, context: "didWake") + self.recoverAfterWake(after: 5, context: "didWake") } } @@ -638,16 +708,8 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { dbg("screen locked – pausing") self.q.async { [weak self] in - guard let self else { return } - Task { @MainActor in - if AppState.shared.isRecording { - self.q.async { [weak self] in - self?.transition(to: .paused, context: "screen locked") - } - } - } + self?.pauseForSystemEvent(context: "screen locked") } - self.stop() Task { @MainActor in AnalyticsService.shared.withSampling(probability: 0.01) { AnalyticsService.shared.capture("recording_stopped", ["stop_reason": "lock"]) @@ -665,8 +727,7 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { self.q.async { [weak self] in guard let self else { return } - guard self.state == .paused else { return } - self.resumeRecording(after: 0.5, context: "screen unlock") + self.recoverAfterWake(after: 0.5, context: "screen unlock") } } @@ -679,16 +740,8 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { dbg("screensaver started – pausing") self.q.async { [weak self] in - guard let self else { return } - Task { @MainActor in - if AppState.shared.isRecording { - self.q.async { [weak self] in - self?.transition(to: .paused, context: "screensaver started") - } - } - } + self?.pauseForSystemEvent(context: "screensaver started") } - self.stop() Task { @MainActor in AnalyticsService.shared.withSampling(probability: 0.01) { AnalyticsService.shared.capture("recording_stopped", ["stop_reason": "screensaver"]) @@ -706,22 +759,21 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { self.q.async { [weak self] in guard let self else { return } - guard self.state == .paused else { return } - self.resumeRecording(after: 0.5, context: "screensaver stop") + self.recoverAfterWake(after: 0.5, context: "screensaver stop") } } } private func resumeRecording(after delay: TimeInterval, context: String) { + invalidatePendingRecovery() + let generation = recoveryGeneration q.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } - Task { @MainActor in - guard AppState.shared.isRecording else { - dbg("\(context) – skip auto-resume (recording disabled)") - return - } - self.start() + guard let self, self.recoveryGeneration == generation else { return } + guard self.wantsRecording else { + dbg("\(context) – skip auto-resume (recording disabled)") + return } + self.startOnQueue(attempt: 1) } } } diff --git a/Dayflow/DayflowTests/RecorderRecoveryPolicyTests.swift b/Dayflow/DayflowTests/RecorderRecoveryPolicyTests.swift new file mode 100644 index 00000000..93760a5e --- /dev/null +++ b/Dayflow/DayflowTests/RecorderRecoveryPolicyTests.swift @@ -0,0 +1,76 @@ +import XCTest + +@testable import Dayflow + +final class RecorderRecoveryPolicyTests: XCTestCase { + func testMissingDisplayRestartsRecorderEvenWhenStateClaimsCapturing() { + XCTAssertTrue( + RecorderRecoveryPolicy.shouldRestartAfterWake( + isCapturing: true, + hasDisplay: false, + lastSuccessfulCaptureAt: Date(), + now: Date(), + staleAfter: 30 + ) + ) + } + + func testStaleCaptureRestartsRecorderAfterWake() { + let now = Date(timeIntervalSinceReferenceDate: 1_000) + + XCTAssertTrue( + RecorderRecoveryPolicy.shouldRestartAfterWake( + isCapturing: true, + hasDisplay: true, + lastSuccessfulCaptureAt: now.addingTimeInterval(-31), + now: now, + staleAfter: 30 + ) + ) + } + + func testNonCapturingRecorderRestartsAfterWake() { + XCTAssertTrue( + RecorderRecoveryPolicy.shouldRestartAfterWake( + isCapturing: false, + hasDisplay: true, + lastSuccessfulCaptureAt: Date(), + now: Date(), + staleAfter: 30 + ) + ) + } + + func testMissingSuccessfulCaptureRestartsRecorderAfterWake() { + XCTAssertTrue( + RecorderRecoveryPolicy.shouldRestartAfterWake( + isCapturing: true, + hasDisplay: true, + lastSuccessfulCaptureAt: nil, + now: Date(), + staleAfter: 30 + ) + ) + } + + func testRecentCaptureWithDisplayDoesNotRestartRecorder() { + let now = Date(timeIntervalSinceReferenceDate: 1_000) + + XCTAssertFalse( + RecorderRecoveryPolicy.shouldRestartAfterWake( + isCapturing: true, + hasDisplay: true, + lastSuccessfulCaptureAt: now.addingTimeInterval(-29), + now: now, + staleAfter: 30 + ) + ) + } + + func testDisplayRetriesUseBoundedExponentialBackoff() { + XCTAssertEqual(RecorderRecoveryPolicy.retryDelay(forNoDisplayAttempt: 1, maxAttempts: 4), 1) + XCTAssertEqual(RecorderRecoveryPolicy.retryDelay(forNoDisplayAttempt: 2, maxAttempts: 4), 2) + XCTAssertEqual(RecorderRecoveryPolicy.retryDelay(forNoDisplayAttempt: 3, maxAttempts: 4), 4) + XCTAssertNil(RecorderRecoveryPolicy.retryDelay(forNoDisplayAttempt: 4, maxAttempts: 4)) + } +} From df5023ebc25c150d594cdac3d4a1b64331c30b16 Mon Sep 17 00:00:00 2001 From: Sergey Shcherbak Date: Fri, 24 Jul 2026 00:20:10 +0400 Subject: [PATCH 2/2] Address PR review feedback (#329) - reject stale setup completions with a startup generation guard - keep display intent changes inside the guarded recorder queue commit - cover an old pre-sleep setup completing after a post-wake restart --- .../Recording/RecorderRecoveryPolicy.swift | 8 + .../Core/Recording/ScreenRecorder.swift | 186 ++++++++++++------ .../RecorderRecoveryPolicyTests.swift | 28 +++ 3 files changed, 160 insertions(+), 62 deletions(-) diff --git a/Dayflow/Dayflow/Core/Recording/RecorderRecoveryPolicy.swift b/Dayflow/Dayflow/Core/Recording/RecorderRecoveryPolicy.swift index c4098edb..152eb701 100644 --- a/Dayflow/Dayflow/Core/Recording/RecorderRecoveryPolicy.swift +++ b/Dayflow/Dayflow/Core/Recording/RecorderRecoveryPolicy.swift @@ -1,6 +1,14 @@ import Foundation enum RecorderRecoveryPolicy { + static func canCommitSetup( + setupGeneration: Int, + currentGeneration: Int, + isStarting: Bool + ) -> Bool { + isStarting && setupGeneration == currentGeneration + } + static func retryDelay(forNoDisplayAttempt attempt: Int, maxAttempts: Int) -> TimeInterval? { guard attempt > 0, attempt < maxAttempts else { return nil } return pow(2, Double(attempt - 1)) diff --git a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift index ec03cf4c..3c846482 100644 --- a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift +++ b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift @@ -207,8 +207,9 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { } invalidatePendingRecovery() + let setupGeneration = recoveryGeneration transition(to: .starting, context: "user/system start") - Task { await setupCapture(attempt: attempt) } + Task { await setupCapture(attempt: attempt, generation: setupGeneration) } } func stop() { @@ -230,9 +231,17 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { // MARK: - Capture Setup - private func setupCapture(attempt: Int, maxAttempts: Int = 4) async { + private func setupCapture(attempt: Int, generation: Int, maxAttempts: Int = 4) async { guard ScreenRecordingPermissionNotice.isGranted else { - handleMissingScreenRecordingPermission(reason: "setupCapture") + q.async { [weak self] in + guard let self else { return } + guard RecorderRecoveryPolicy.canCommitSetup( + setupGeneration: generation, + currentGeneration: self.recoveryGeneration, + isStarting: self.state == .starting + ) else { return } + self.handleMissingScreenRecordingPermission(reason: "setupCapture") + } return } @@ -241,38 +250,66 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true) - // 2. Choose display: prefer requested → active. Defer if preferred is missing from the snapshot. - let displaysByID: [CGDirectDisplayID: SCDisplay] = Dictionary( - uniqueKeysWithValues: content.displays.map { ($0.displayID, $0) } - ) + // 2. Read the active display without mutating recorder state outside its queue. let trackerID: CGDirectDisplayID? = await MainActor.run { [weak tracker] in tracker?.activeDisplayID } - let preferredID = requestedDisplayID ?? trackerID - - let display: SCDisplay - if let pid = preferredID { - guard let preferredDisplay = displaysByID[pid] else { - requestedDisplayID = pid - dbg("setupCapture: preferred display \(pid) not in snapshot (count=\(content.displays.count)); deferring") - throw ScreenRecorderError.noDisplay - } - display = preferredDisplay - requestedDisplayID = nil - } else if let first = content.displays.first { - display = first - requestedDisplayID = nil - } else { - throw ScreenRecorderError.noDisplay - } - // 3. Commit a resolved display before the timer may enter the capturing state. + // 3. Resolve and commit the display only if this setup is still the active generation. q.async { [weak self] in guard let self else { return } - guard self.state == .starting else { - dbg("setupCapture completed but state changed to \(self.state.description), ignoring") + guard RecorderRecoveryPolicy.canCommitSetup( + setupGeneration: generation, + currentGeneration: self.recoveryGeneration, + isStarting: self.state == .starting + ) else { + dbg( + "setupCapture completed for stale generation \(generation) " + + "(current=\(self.recoveryGeneration), state=\(self.state.description)), ignoring") + return + } + + let displaysByID: [CGDirectDisplayID: SCDisplay] = Dictionary( + uniqueKeysWithValues: content.displays.map { ($0.displayID, $0) } + ) + let preferredID = self.requestedDisplayID ?? trackerID + let display: SCDisplay + if let preferredID { + guard let preferredDisplay = displaysByID[preferredID] else { + self.requestedDisplayID = preferredID + dbg( + "setupCapture: preferred display \(preferredID) not in snapshot " + + "(count=\(content.displays.count)); deferring") + let error = ScreenRecorderError.noDisplay as NSError + self.completeSetupFailureOnQueue( + isNoDisplay: true, + errorDescription: error.localizedDescription, + errorDomain: error.domain, + errorCode: error.code, + attempt: attempt, + maxAttempts: maxAttempts, + generation: generation + ) + return + } + display = preferredDisplay + } else if let first = content.displays.first { + display = first + } else { + let error = ScreenRecorderError.noDisplay as NSError + self.completeSetupFailureOnQueue( + isNoDisplay: true, + errorDescription: error.localizedDescription, + errorDomain: error.domain, + errorCode: error.code, + attempt: attempt, + maxAttempts: maxAttempts, + generation: generation + ) return } + + self.requestedDisplayID = nil self.cachedContent = content self.cachedDisplay = display self.currentDisplayID = display.displayID @@ -292,50 +329,75 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { } } catch { - dbg("setupCapture failed [attempt \(attempt)] – \(error.localizedDescription)") - - if !ScreenRecordingPermissionNotice.isGranted { - handleMissingScreenRecordingPermission(reason: "setupCapture_failed_permission") - return - } - let nsError = error as NSError let isNoDisplay = (error as? ScreenRecorderError) == .noDisplay + let errorDescription = error.localizedDescription let errorDomain = nsError.domain let errorCode = nsError.code - let retryDelay = isNoDisplay - ? RecorderRecoveryPolicy.retryDelay(forNoDisplayAttempt: attempt, maxAttempts: maxAttempts) - : nil - q.async { [weak self] in - guard let self, self.state == .starting else { return } - - self.stopCaptureTimer() - self.cachedContent = nil - self.cachedDisplay = nil - self.currentDisplayID = nil - self.waitingForDisplay = isNoDisplay - self.transition(to: .idle, context: "setupCapture failed") - - if let retryDelay { - dbg("retrying display setup in \(retryDelay)s") - self.scheduleDisplayRetry(after: retryDelay, attempt: attempt + 1) - return - } - - Task { @MainActor in - AnalyticsService.shared.capture( - "recording_startup_failed", - [ - "attempt": attempt, - "error_domain": errorDomain, - "error_code": errorCode, - ]) - } + self?.completeSetupFailureOnQueue( + isNoDisplay: isNoDisplay, + errorDescription: errorDescription, + errorDomain: errorDomain, + errorCode: errorCode, + attempt: attempt, + maxAttempts: maxAttempts, + generation: generation + ) } } } + private func completeSetupFailureOnQueue( + isNoDisplay: Bool, + errorDescription: String, + errorDomain: String, + errorCode: Int, + attempt: Int, + maxAttempts: Int, + generation: Int + ) { + guard RecorderRecoveryPolicy.canCommitSetup( + setupGeneration: generation, + currentGeneration: recoveryGeneration, + isStarting: state == .starting + ) else { return } + + dbg("setupCapture failed [attempt \(attempt)] – \(errorDescription)") + + guard ScreenRecordingPermissionNotice.isGranted else { + handleMissingScreenRecordingPermission(reason: "setupCapture_failed_permission") + return + } + + let retryDelay = isNoDisplay + ? RecorderRecoveryPolicy.retryDelay(forNoDisplayAttempt: attempt, maxAttempts: maxAttempts) + : nil + + stopCaptureTimer() + cachedContent = nil + cachedDisplay = nil + currentDisplayID = nil + waitingForDisplay = isNoDisplay + transition(to: .idle, context: "setupCapture failed") + + if let retryDelay { + dbg("retrying display setup in \(retryDelay)s") + scheduleDisplayRetry(after: retryDelay, attempt: attempt + 1) + return + } + + Task { @MainActor in + AnalyticsService.shared.capture( + "recording_startup_failed", + [ + "attempt": attempt, + "error_domain": errorDomain, + "error_code": errorCode, + ]) + } + } + private func scheduleDisplayRetry(after delay: TimeInterval, attempt: Int) { let generation = recoveryGeneration q.asyncAfter(deadline: .now() + delay) { [weak self] in diff --git a/Dayflow/DayflowTests/RecorderRecoveryPolicyTests.swift b/Dayflow/DayflowTests/RecorderRecoveryPolicyTests.swift index 93760a5e..fc27ae5a 100644 --- a/Dayflow/DayflowTests/RecorderRecoveryPolicyTests.swift +++ b/Dayflow/DayflowTests/RecorderRecoveryPolicyTests.swift @@ -3,6 +3,34 @@ import XCTest @testable import Dayflow final class RecorderRecoveryPolicyTests: XCTestCase { + func testOldSetupCompletionCannotCommitAfterSleepAndNewStart() { + let oldSetupGeneration = 1 + let generationAfterSleep = 2 + let newSetupGeneration = 3 + + XCTAssertFalse( + RecorderRecoveryPolicy.canCommitSetup( + setupGeneration: oldSetupGeneration, + currentGeneration: generationAfterSleep, + isStarting: false + ) + ) + XCTAssertFalse( + RecorderRecoveryPolicy.canCommitSetup( + setupGeneration: oldSetupGeneration, + currentGeneration: newSetupGeneration, + isStarting: true + ) + ) + XCTAssertTrue( + RecorderRecoveryPolicy.canCommitSetup( + setupGeneration: newSetupGeneration, + currentGeneration: newSetupGeneration, + isStarting: true + ) + ) + } + func testMissingDisplayRestartsRecorderEvenWhenStateClaimsCapturing() { XCTAssertTrue( RecorderRecoveryPolicy.shouldRestartAfterWake(