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
4 changes: 4 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ run = "Scripts/package.sh"
description = "Build and install the app to /Applications"
run = ["mise run build", "cp -R .build/Build/Products/Debug/CaptureThis.app /Applications/", "open /Applications/CaptureThis.app"]

[tasks.install-release]
description = "Build release and install the app to /Applications"
run = ["mise run release-build", "cp -R .build/Build/Products/Release/CaptureThis.app /Applications/", "open /Applications/CaptureThis.app"]

[tasks.reinstall]
description = "Rebuild and reinstall the app to /Applications"
run = ["mise run build", "rm -rf /Applications/CaptureThis.app", "cp -R .build/Build/Products/Debug/CaptureThis.app /Applications/", "open /Applications/CaptureThis.app"]
Expand Down
5 changes: 2 additions & 3 deletions Sources/App/AppState+RecordingFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,8 @@ extension AppState {
}

func togglePauseResume() {
if case .recording = recordingState {
errorMessage = "Pause/resume is not available yet."
}
guard case .recording = recordingState else { return }
engine.pauseResume()
}

func toggleHUD() {
Expand Down
2 changes: 1 addition & 1 deletion Sources/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,6 @@ final class AppState: ObservableObject {
}

func recordingDurationText(for date: Date) -> String {
Date().timeIntervalSince(date).formattedClock
engine.recordingDuration(since: date)
}
}
4 changes: 2 additions & 2 deletions Sources/CLI/CLIObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ final class CLIObserver: RecordingObserver {
FileHandle.standardError.write("countdown: \(remaining)\n")
case .pickingSource:
FileHandle.standardError.write("selecting content...\n")
case .recording:
FileHandle.standardError.write("recording...\n")
case let .recording(isPaused):
FileHandle.standardError.write(isPaused ? "paused\n" : "recording...\n")
case .stopping:
FileHandle.standardError.write("stopping...\n")
case let .error(msg):
Expand Down
18 changes: 18 additions & 0 deletions Sources/Core/Protocols/CaptureServicing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation
import ScreenCaptureKit

public protocol CaptureServicing: AnyObject {
func startRecording(
filter: SCContentFilter,
configuration: SCStreamConfiguration,
outputURL: URL,
options: CaptureRecordingOptions,
handlers: CaptureRecordingHandlers
) async throws

func pauseRecording() async throws
func resumeRecording() throws
func stopRecording() async throws -> URL
func discardRecording() async
func recoverPartialRecording() async -> URL?
}
5 changes: 5 additions & 0 deletions Sources/Core/Protocols/PermissionServicing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
public protocol PermissionServicing: AnyObject {
func ensureScreenRecordingAccess() -> Bool
func requestCameraAccess() async -> Bool
func requestMicrophoneAccess() async -> Bool
}
90 changes: 57 additions & 33 deletions Sources/Core/RecordingEngine+Flow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,43 @@ import Foundation
import ScreenCaptureKit

extension RecordingEngine {
func pause() async {
guard case .recording(false) = state else {
isPauseResumeTransitioning = false
return
}

defer { isPauseResumeTransitioning = false }

do {
try await captureService.pauseRecording()
lastPauseDate = nowProvider()
setState(.recording(isPaused: true))
} catch {
await reportError(error)
}
}

func resume() async {
guard case .recording(true) = state else {
isPauseResumeTransitioning = false
return
}

defer { isPauseResumeTransitioning = false }

do {
try captureService.resumeRecording()
if let lastPauseDate {
pausedDuration += nowProvider().timeIntervalSince(lastPauseDate)
}
lastPauseDate = nil
setState(.recording(isPaused: false))
} catch {
await reportError(error)
}
}

func prepareRecordingFlow() async {
do {
try await ensurePermissions()
Expand Down Expand Up @@ -87,7 +124,10 @@ extension RecordingEngine {
handlers: handlers
)

recordingStartDate = Date()
recordingStartDate = nowProvider()
pausedDuration = 0
lastPauseDate = nil
isPauseResumeTransitioning = false
setState(.recording(isPaused: false))
} catch {
await reportError(error)
Expand Down Expand Up @@ -124,17 +164,17 @@ extension RecordingEngine {
func stopRecording(discard: Bool) async {
guard state.isRecording || state == .stopping else { return }
setState(.stopping)
isPauseResumeTransitioning = false
defer { directoryProvider.stopAccessing() }

do {
let outputURL = try await captureService.stopRecording()
directoryProvider.stopAccessing()

if discard {
try? FileManager.default.removeItem(at: outputURL)
await captureService.discardRecording()
setState(.idle)
return
}

let outputURL = try await captureService.stopRecording()
let recording = makeRecording(outputURL: outputURL)
let updated = RecordingStore.add(recording, to: RecordingStore.load())
RecordingStore.save(updated)
Expand All @@ -144,7 +184,16 @@ extension RecordingEngine {
}
setState(.idle)
} catch {
if recoverRecordingIfPossible(from: error) {
if let recoveredURL = await captureService.recoverPartialRecording() {
let recording = makeRecording(outputURL: recoveredURL)
let updated = RecordingStore.add(recording, to: RecordingStore.load())
RecordingStore.save(updated)

await MainActor.run { [weak self, recording] in
self?.observer?.engineDidFinishRecording(recording)
}

setState(.idle)
return
}
await reportError(error)
Expand Down Expand Up @@ -176,32 +225,7 @@ extension RecordingEngine {

setState(.error(message))
directoryProvider.stopAccessing()
}

func recoverRecordingIfPossible(from error: Error) -> Bool {
let nsError = error as NSError
let isConnectionError = nsError.domain == "com.apple.ReplayKit.RPRecordingErrorDomain"
&& (nsError.code == -5814 || nsError.code == -5815)

guard isConnectionError, let outputURL = currentOutputURL else {
return false
}

let attributes = try? FileManager.default.attributesOfItem(atPath: outputURL.path)
guard let fileSize = attributes?[.size] as? NSNumber, fileSize.intValue > 0 else {
return false
}

let recording = makeRecording(outputURL: outputURL)
let updated = RecordingStore.add(recording, to: RecordingStore.load())
RecordingStore.save(updated)

Task { @MainActor [weak self, recording] in
self?.observer?.engineDidFinishRecording(recording)
}

setState(.idle)
directoryProvider.stopAccessing()
return true
isPauseResumeTransitioning = false
lastPauseDate = nil
}
}
49 changes: 41 additions & 8 deletions Sources/Core/RecordingEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ public final class RecordingEngine {
public var settings: RecordingSettings
public var captureSource: CaptureSource = .display

public let captureService: CaptureService
public let permissionService: PermissionService
public let captureService: any CaptureServicing
public let permissionService: any PermissionServicing

let contentSelector: ContentSelector
let directoryProvider: OutputDirectoryProvider
Expand All @@ -18,19 +18,27 @@ public final class RecordingEngine {
var currentOutputURL: URL?
var recordingStartDate: Date?
var pendingFilter: SCContentFilter?
var pausedDuration: TimeInterval = 0
var lastPauseDate: Date?
var isPauseResumeTransitioning = false
let nowProvider: () -> Date

public init(
contentSelector: ContentSelector,
directoryProvider: OutputDirectoryProvider,
observer: RecordingObserver,
settings: RecordingSettings = SettingsStore.load()
settings: RecordingSettings = SettingsStore.load(),
captureService: any CaptureServicing = CaptureService(),
permissionService: any PermissionServicing = PermissionService(),
nowProvider: @escaping () -> Date = Date.init
) {
self.contentSelector = contentSelector
self.directoryProvider = directoryProvider
self.observer = observer
self.settings = settings
captureService = CaptureService()
permissionService = PermissionService()
self.captureService = captureService
self.permissionService = permissionService
self.nowProvider = nowProvider
}

public func setObserver(_ observer: RecordingObserver) {
Expand Down Expand Up @@ -71,6 +79,20 @@ public final class RecordingEngine {
}
}

public func pauseResume() {
guard case let .recording(isPaused) = state else { return }
guard !isPauseResumeTransitioning else { return }

isPauseResumeTransitioning = true
Task { [weak self] in
if isPaused {
await self?.resume()
} else {
await self?.pause()
}
}
}

public func cancel() {
switch state {
case .countdown:
Expand Down Expand Up @@ -99,7 +121,7 @@ public final class RecordingEngine {
}

public func recordingDuration(since date: Date) -> String {
Date().timeIntervalSince(date).formattedClock
effectiveDuration(since: date).formattedClock
}

public var currentRecordingStartDate: Date? {
Expand Down Expand Up @@ -169,13 +191,24 @@ public final class RecordingEngine {
}

func makeRecording(outputURL: URL) -> Recording {
let createdAt = recordingStartDate ?? Date()
let duration = Date().timeIntervalSince(createdAt)
let createdAt = recordingStartDate ?? nowProvider()
let duration = effectiveDuration(since: createdAt)
let captureType: Recording.CaptureType = switch captureSource {
case .display: .display
case .window: .window
case .application: .application
}
return Recording(id: UUID(), url: outputURL, createdAt: createdAt, duration: duration, captureType: captureType)
}

func effectiveDuration(since date: Date) -> TimeInterval {
let now = nowProvider()
var duration = now.timeIntervalSince(date) - pausedDuration

if case .recording(true) = state, let lastPauseDate {
duration -= now.timeIntervalSince(lastPauseDate)
}

return max(duration, 0)
}
}
52 changes: 52 additions & 0 deletions Sources/Core/Services/CaptureService+Configuration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AVFoundation
import Foundation
import ScreenCaptureKit

extension CaptureService {
func preferredFileType(from available: [AVFileType], preferred: AVFileType?) -> AVFileType {
if let preferred, available.contains(preferred) {
return preferred
}
if available.contains(.mp4) {
return .mp4
}
if available.contains(.mov) {
return .mov
}
return available.first ?? .mp4
}

func preferredVideoCodec(from available: [AVVideoCodecType], preferred: AVVideoCodecType?) -> AVVideoCodecType {
if let preferred, available.contains(preferred) {
return preferred
}
if available.contains(.h264) {
return .h264
}
if available.contains(.hevc) {
return .hevc
}
return available.first ?? .h264
}

func fileExtension(for fileType: AVFileType) -> String {
switch fileType {
case .mp4:
"mp4"
case .mov:
"mov"
case .m4v:
"m4v"
default:
"mp4"
}
}
}

final class SampleStreamOutput: NSObject, SCStreamOutput {
func stream(
_: SCStream,
didOutputSampleBuffer _: CMSampleBuffer,
of _: SCStreamOutputType
) {}
}
22 changes: 22 additions & 0 deletions Sources/Core/Services/CaptureService+Delegates.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation
import ScreenCaptureKit

extension CaptureService: SCStreamDelegate {
public func stream(_: SCStream, didStopWithError error: Error) {
handleStreamDidStop(with: error)
}

public func stream(_: SCStream, outputEffectDidStart didStart: Bool) {
handleOutputEffectDidStart(didStart)
}
}

extension CaptureService: SCRecordingOutputDelegate {
public func recordingOutputDidFinishRecording(_: SCRecordingOutput) {
handleRecordingOutputDidFinish()
}

public func recordingOutput(_: SCRecordingOutput, didFailWithError error: Error) {
recordingOutputDidFail(error)
}
}
Loading