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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Sources/Core/Services/CaptureService+Internals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ extension CaptureService {
}

func recordingOutputDidFail(_ error: Error) {
let (continuations, fallbackHandler) = withStateLock { () -> (CaptureContinuations, ((Error) -> Void)?) in
let (continuations, fallbackHandler) = withStateLock { () -> (Continuations, ((Error) -> Void)?) in
let continuations = drainContinuationsLocked()
let handler = continuations.hasActive ? nil : errorHandler
phase = .idle
Expand All @@ -87,7 +87,7 @@ extension CaptureService {
}

func handleRecordingOutputDidFinish() {
let action: CaptureCompletionAction = withStateLock {
let action: CompletionAction = withStateLock {
finalizeActiveSegmentLocked()

if let pauseContinuation {
Expand Down Expand Up @@ -122,7 +122,7 @@ extension CaptureService {
}

func handleStreamDidStop(with error: Error) {
let (continuations, fallbackHandler) = withStateLock { () -> (CaptureContinuations, ((Error) -> Void)?) in
let (continuations, fallbackHandler) = withStateLock { () -> (Continuations, ((Error) -> Void)?) in
let continuations = drainContinuationsLocked()
let handler = continuations.hasActive ? nil : errorHandler
stream = nil
Expand Down Expand Up @@ -200,8 +200,8 @@ extension CaptureService {
activeSegmentURL = nil
}

func drainContinuationsLocked() -> CaptureContinuations {
let continuations = CaptureContinuations(
func drainContinuationsLocked() -> Continuations {
let continuations = Continuations(
pause: pauseContinuation,
finish: finishContinuation,
discard: discardContinuation
Expand All @@ -212,7 +212,7 @@ extension CaptureService {
return continuations
}

func resume(continuations: CaptureContinuations, throwing error: Error) {
func resume(continuations: Continuations, throwing error: Error) {
continuations.pause?.resume(throwing: error)
continuations.finish?.resume(throwing: error)
continuations.discard?.resume(throwing: error)
Expand Down
41 changes: 35 additions & 6 deletions Sources/Core/Services/CaptureService+Recording.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ extension CaptureService: CaptureServicing {
}

public func stopRecording() async throws -> URL {
guard await waitForOutputTransitionToSettle() else {
try? await stopAndReset(clearSegmentState: false)
throw AppError.captureFailed
}

let mode = try resolveStopMode()
try await finalizeStopIfNeeded(mode)

Expand All @@ -140,6 +145,11 @@ extension CaptureService: CaptureServicing {
}

public func discardRecording() async {
guard await waitForOutputTransitionToSettle() else {
try? await stopAndReset()
return
}

let mode = resolveDiscardMode()
guard case .noOp = mode else {
await finalizeDiscardIfNeeded(mode)
Expand Down Expand Up @@ -194,7 +204,26 @@ extension CaptureService: CaptureServicing {
}

extension CaptureService {
func resolveStopMode() throws -> CaptureStopMode {
func waitForOutputTransitionToSettle(
maxPollCount: Int = 100,
pollIntervalNanoseconds: UInt64 = 10_000_000
) async -> Bool {
for _ in 0 ..< maxPollCount {
let inTransition = withStateLock {
phase == .pausing || phase == .resuming
}
if !inTransition {
return true
}
try? await Task.sleep(nanoseconds: pollIntervalNanoseconds)
}

return withStateLock {
phase != .pausing && phase != .resuming
}
}

func resolveStopMode() throws -> StopMode {
try withStateLock {
switch phase {
case .recording:
Expand All @@ -217,7 +246,7 @@ extension CaptureService {
}
}

func finalizeStopIfNeeded(_ mode: CaptureStopMode) async throws {
func finalizeStopIfNeeded(_ mode: StopMode) async throws {
guard case let .finalize(activeStream, activeOutput) = mode else {
return
}
Expand All @@ -239,23 +268,23 @@ extension CaptureService {
}
}

func stopStitchInput() throws -> CaptureStitchInput {
func stopStitchInput() throws -> StitchInput {
try withStateLock {
guard let baseOutputURL,
let outputFileType = resolvedOutputFileType
else {
throw AppError.captureFailed
}

return CaptureStitchInput(
return StitchInput(
baseOutputURL: baseOutputURL,
outputFileType: outputFileType,
segments: segmentURLs
)
}
}

func resolveDiscardMode() -> CaptureDiscardMode {
func resolveDiscardMode() -> DiscardMode {
withStateLock {
switch phase {
case .recording:
Expand All @@ -278,7 +307,7 @@ extension CaptureService {
}
}

func finalizeDiscardIfNeeded(_ mode: CaptureDiscardMode) async {
func finalizeDiscardIfNeeded(_ mode: DiscardMode) async {
guard case let .finalize(activeStream, activeOutput) = mode else {
return
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/Core/Services/CaptureService+TestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Foundation
videoCodec: AVVideoCodecType = .h264,
segmentURLs: [URL] = [],
segmentIndex: Int = 0,
activeSegmentURL: URL? = nil,
paused: Bool
) {
withStateLock {
Expand All @@ -22,7 +23,7 @@ import Foundation
self.segmentURLs = segmentURLs
self.segmentIndex = segmentIndex
phase = paused ? .paused : .recording
activeSegmentURL = nil
self.activeSegmentURL = activeSegmentURL
}
}

Expand Down
76 changes: 38 additions & 38 deletions Sources/Core/Services/CaptureService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,51 @@ import AVFoundation
import Foundation
import ScreenCaptureKit

enum CaptureSessionPhase: String {
case idle
case recording
case pausing
case paused
case resuming
case stopping
case discarding
}
public final class CaptureService: NSObject {
enum SessionPhase: String {
case idle
case recording
case pausing
case paused
case resuming
case stopping
case discarding
}

struct CaptureContinuations {
var pause: CheckedContinuation<Void, Error>?
var finish: CheckedContinuation<Void, Error>?
var discard: CheckedContinuation<Void, Error>?
struct Continuations {
var pause: CheckedContinuation<Void, Error>?
var finish: CheckedContinuation<Void, Error>?
var discard: CheckedContinuation<Void, Error>?

var hasActive: Bool {
pause != nil || finish != nil || discard != nil
var hasActive: Bool {
pause != nil || finish != nil || discard != nil
}
}
}

enum CaptureCompletionAction {
case pause(CheckedContinuation<Void, Error>)
case stop(CheckedContinuation<Void, Error>)
case discard(CheckedContinuation<Void, Error>)
case none
}
enum CompletionAction {
case pause(CheckedContinuation<Void, Error>)
case stop(CheckedContinuation<Void, Error>)
case discard(CheckedContinuation<Void, Error>)
case none
}

enum CaptureStopMode {
case finalize(any CaptureStreamControlling, any CaptureRecordingOutputControlling)
case stitchOnly
}
enum StopMode {
case finalize(any CaptureStreamControlling, any CaptureRecordingOutputControlling)
case stitchOnly
}

enum CaptureDiscardMode {
case finalize(any CaptureStreamControlling, any CaptureRecordingOutputControlling)
case stopOnly
case noOp
}
enum DiscardMode {
case finalize(any CaptureStreamControlling, any CaptureRecordingOutputControlling)
case stopOnly
case noOp
}

struct CaptureStitchInput {
let baseOutputURL: URL
let outputFileType: AVFileType
let segments: [URL]
}
struct StitchInput {
let baseOutputURL: URL
let outputFileType: AVFileType
let segments: [URL]
}

public final class CaptureService: NSObject {
var stream: (any CaptureStreamControlling)?
var recordingOutput: (any CaptureRecordingOutputControlling)?
var activeSegmentURL: URL?
Expand All @@ -58,7 +58,7 @@ public final class CaptureService: NSObject {
var resolvedOutputFileType: AVFileType?
var resolvedVideoCodec: AVVideoCodecType?

var phase: CaptureSessionPhase = .idle
var phase: SessionPhase = .idle
var finishContinuation: CheckedContinuation<Void, Error>?
var pauseContinuation: CheckedContinuation<Void, Error>?
var discardContinuation: CheckedContinuation<Void, Error>?
Expand Down
82 changes: 82 additions & 0 deletions Tests/CoreTests/CaptureServiceRefactorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,88 @@ final class CaptureServiceRefactorTests: XCTestCase {
XCTAssertEqual(service.phaseForTesting(), "paused")
}

func testStopRecordingWaitsForPauseTransitionThenStops() async throws {
let stream = MockCaptureStream()
let service = makeService(stream: stream)
let folder = makeTemporaryDirectory()
let finalURL = folder.appendingPathComponent("final-stop.mp4")
let activeSegmentURL = folder.appendingPathComponent("segment-stop.mp4")
try Data(repeating: 7, count: 16).write(to: activeSegmentURL)

service.installTestSession(
stream: stream,
recordingOutput: MockRecordingOutput(),
baseOutputURL: finalURL,
outputFileType: .mp4,
activeSegmentURL: activeSegmentURL,
paused: false
)

let pauseTask = Task {
try await service.pauseRecording()
}

await waitUntil { stream.removeRecordingOutputCallCount == 1 }

let stopTask = Task {
try await service.stopRecording()
}

try await Task.sleep(nanoseconds: 50_000_000)
XCTAssertEqual(stream.stopCaptureCallCount, 0)
XCTAssertEqual(service.phaseForTesting(), "pausing")

service.handleRecordingOutputDidFinish()

try await pauseTask.value
let stoppedURL = try await stopTask.value
XCTAssertEqual(stoppedURL.standardizedFileURL, finalURL.standardizedFileURL)
XCTAssertEqual(stream.stopCaptureCallCount, 1)
XCTAssertEqual(service.phaseForTesting(), "idle")
XCTAssertTrue(FileManager.default.fileExists(atPath: finalURL.path))
}

func testDiscardRecordingWaitsForPauseTransitionThenTearsDown() async throws {
let stream = MockCaptureStream()
let service = makeService(stream: stream)
let folder = makeTemporaryDirectory()
let finalURL = folder.appendingPathComponent("final-discard.mp4")
let activeSegmentURL = folder.appendingPathComponent("segment-discard.mp4")
try Data(repeating: 3, count: 32).write(to: activeSegmentURL)

service.installTestSession(
stream: stream,
recordingOutput: MockRecordingOutput(),
baseOutputURL: finalURL,
outputFileType: .mp4,
activeSegmentURL: activeSegmentURL,
paused: false
)

let pauseTask = Task {
try await service.pauseRecording()
}

await waitUntil { stream.removeRecordingOutputCallCount == 1 }

let discardTask = Task {
await service.discardRecording()
}

try await Task.sleep(nanoseconds: 50_000_000)
XCTAssertEqual(stream.stopCaptureCallCount, 0)
XCTAssertEqual(service.phaseForTesting(), "pausing")

service.handleRecordingOutputDidFinish()

try await pauseTask.value
await discardTask.value

XCTAssertEqual(stream.stopCaptureCallCount, 1)
XCTAssertEqual(service.phaseForTesting(), "idle")
XCTAssertFalse(FileManager.default.fileExists(atPath: activeSegmentURL.path))
}

func testResumeRecordingFailureRollsBackPhaseAndSegmentIndex() {
let stream = MockCaptureStream()
stream.addRecordingOutputError = AppError.captureFailed
Expand Down