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
8 changes: 6 additions & 2 deletions BetterCapture/Service/AssetWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
/// - url: The output file URL
/// - settings: The settings store containing encoding configuration
/// - videoSize: The dimensions of the video
func setup(url: URL, settings: SettingsStore, videoSize: CGSize) throws {

Check warning on line 60 in BetterCapture/Service/AssetWriter.swift

View workflow job for this annotation

GitHub Actions / Lint

Function body should span 50 lines or less excluding comments and whitespace: currently spans 55 lines (function_body_length)
// Ensure output directory exists
let directory = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
Expand Down Expand Up @@ -157,7 +157,7 @@
private var frameCount = 0

/// Appends a video sample buffer - called synchronously from capture queue
func appendVideoSample(_ sampleBuffer: CMSampleBuffer) {

Check warning on line 160 in BetterCapture/Service/AssetWriter.swift

View workflow job for this annotation

GitHub Actions / Lint

Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines (function_body_length)

Check warning on line 160 in BetterCapture/Service/AssetWriter.swift

View workflow job for this annotation

GitHub Actions / Lint

Function should have complexity 10 or less; currently complexity is 11 (cyclomatic_complexity)
// Check frame status first - only process complete frames
guard
let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(
Expand Down Expand Up @@ -289,7 +289,10 @@
// MARK: - Finalization

/// Finishes writing and finalizes the output file
func finishWriting() async throws -> URL {
/// - Returns: The output URL and the number of video frames written. A count of zero
/// means the file holds audio only, which happens when the capture source
/// stopped producing frames while audio kept flowing.
func finishWriting() async throws -> (url: URL, videoFrameCount: Int) {
// First critical section: validate state and mark inputs as finished
let (writerToFinish, url): (AVAssetWriter, URL)

Expand Down Expand Up @@ -351,6 +354,7 @@
logger.info(
"AssetWriter finished writing \(self.frameCount) frames to: \(url.lastPathComponent)"
)
let videoFrameCount = frameCount
frameCount = 0

// Clean up
Expand All @@ -360,7 +364,7 @@
self.audioInput = nil
self.microphoneInput = nil

return url
return (url, videoFrameCount)
}
}

Expand Down Expand Up @@ -589,4 +593,4 @@
return "No video frames were captured. Check screen recording permissions."
}
}
}

Check warning on line 596 in BetterCapture/Service/AssetWriter.swift

View workflow job for this annotation

GitHub Actions / Lint

File should contain 500 lines or less: currently contains 596 (file_length)
11 changes: 10 additions & 1 deletion BetterCapture/Service/CaptureEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ final class CaptureEngine: NSObject {
}
}

// A display selected before it was disconnected still yields a usable filter, but the
// stream never delivers frames. Fail fast instead of recording without a video track.
guard await contentFilterService.isSelectedDisplayConnected(filter) else {
throw CaptureError.selectedDisplayDisconnected
}

// Apply content filter settings (wallpaper, dock, menu bar)
logger.info("Applying content filter settings...")
let filteredContent = try await contentFilterService.applySettings(to: filter, settings: settings)
Expand Down Expand Up @@ -366,12 +372,13 @@ extension CaptureEngine: SCStreamOutput {

// MARK: - Errors

enum CaptureError: LocalizedError {
enum CaptureError: LocalizedError, Equatable {
case noContentFilterSelected
case failedToCreateStream
case captureAlreadyRunning
case screenRecordingPermissionDenied
case microphonePermissionDenied
case selectedDisplayDisconnected

var errorDescription: String? {
switch self {
Expand All @@ -385,6 +392,8 @@ enum CaptureError: LocalizedError {
return "Screen recording permission is required. Please grant permission in System Settings → Privacy & Security → Screen Recording."
case .microphonePermissionDenied:
return "Microphone permission is required. Please grant permission in System Settings → Privacy & Security → Microphone."
case .selectedDisplayDisconnected:
return "The selected display is no longer connected. Please select the content to capture again."
}
}
}
29 changes: 29 additions & 0 deletions BetterCapture/Service/ContentFilterService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,35 @@ final class ContentFilterService {
}
}

/// Checks whether the display targeted by a filter is still connected
///
/// A display picked from the content picker is captured by its `displayID`. Disconnecting
/// the display leaves the filter intact but makes it produce no frames, so it has to be
/// validated against the currently connected displays before capture starts. Window and
/// application filters are not bound to a display and always pass.
/// - Parameter filter: The filter to validate
/// - Returns: true if the filter can still be captured
func isSelectedDisplayConnected(_ filter: SCContentFilter) async -> Bool {
guard filter.style == .display,
let displayID = filter.includedDisplays.first?.displayID else {
return true
}

guard let content = try? await SCShareableContent.current else {
// Nothing to validate against - let the capture attempt surface the real failure
logger.warning("Could not read shareable content, skipping display validation")
return true
}

let isConnected = content.displays.contains { $0.displayID == displayID }

if !isConnected {
logger.warning("Selected display \(displayID) is no longer connected")
}

return isConnected
}

/// Applies user settings to a content filter for display capture
/// - Parameters:
/// - filter: The original filter from the content picker
Expand Down
29 changes: 29 additions & 0 deletions BetterCapture/Service/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,35 @@ final class NotificationService: NSObject {
}
}

/// Sends a notification for a recording that was saved without any video frames
/// - Parameter fileURL: The URL of the saved recording file
func sendRecordingMissingVideoNotification(fileURL: URL) {
let content = UNMutableNotificationContent()
content.title = "Recording Saved Without Video"
content.body = "No video was captured. Only audio was saved to \(fileURL.lastPathComponent)"
content.sound = .default
content.categoryIdentifier = NotificationIdentifier.categoryRecordingSaved

// Store the folder URL for opening when notification is clicked
let folderURL = fileURL.deletingLastPathComponent()
content.userInfo = [UserInfoKey.folderURL: folderURL.path()]

let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)

Task {
do {
try await UNUserNotificationCenter.current().add(request)
logger.info("Recording missing video notification sent")
} catch {
logger.error("Failed to send missing video notification: \(error.localizedDescription)")
}
}
}

/// Sends a notification for a failed recording
/// - Parameter error: The error that caused the recording to fail
func sendRecordingFailedNotification(error: Error) {
Expand Down
22 changes: 19 additions & 3 deletions BetterCapture/ViewModel/RecorderViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@
cameraSession.stop()
selectionBorderFrame.dismiss()
settings.stopAccessingOutputDirectory()

// The selection points at a display that is gone, so drop it. The next recording
// attempt then opens the picker instead of failing the same way again. lastError
// has no UI representation, so the reason has to be surfaced as a notification.
if error as? CaptureError == .selectedDisplayDisconnected {
await resetAreaSelection()
captureEngine.clearSelection()
notificationService.sendRecordingFailedNotification(error: error)
}

logger.error("Failed to start recording: \(error.localizedDescription)")
}
}
Expand All @@ -304,7 +314,7 @@
isPresenterOverlayActive = false

// Finalize file
let outputURL = try await assetWriter.finishWriting()
let (outputURL, videoFrameCount) = try await assetWriter.finishWriting()

state = .idle
recordingDuration = 0
Expand All @@ -314,8 +324,14 @@
// Brief delay to ensure screen sharing mode has fully stopped before sending notification
try? await Task.sleep(for: .milliseconds(100))

// Send notification
notificationService.sendRecordingSavedNotification(fileURL: outputURL)
// Send notification. The file is kept either way - an audio-only recording is
// still worth more than a deleted one - but the user has to be told about it.
if videoFrameCount == 0 {
logger.error("Recording contains no video frames: \(outputURL.lastPathComponent)")
notificationService.sendRecordingMissingVideoNotification(fileURL: outputURL)
} else {
notificationService.sendRecordingSavedNotification(fileURL: outputURL)
}

settings.stopAccessingOutputDirectory()

Expand Down Expand Up @@ -498,4 +514,4 @@
captureEngine.clearSelection()
captureEngine.deactivatePicker()
}
}

Check warning on line 517 in BetterCapture/ViewModel/RecorderViewModel.swift

View workflow job for this annotation

GitHub Actions / Lint

File should contain 500 lines or less: currently contains 517 (file_length)
189 changes: 189 additions & 0 deletions BetterCaptureTests/AssetWriterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//
// AssetWriterTests.swift
// BetterCaptureTests
//
// Created by Krishna Ramaroson on 28.07.26.
//

import AVFoundation
import ScreenCaptureKit
import Testing
@testable import BetterCapture

/// Tests for the video frame count AssetWriter reports when finishing a recording.
///
/// A capture source that stops delivering frames - a disconnected display, for example -
/// while audio keeps flowing produces a file with audio tracks and no video track. The
/// frame count is what lets the caller detect that case.
@MainActor
struct AssetWriterTests {

private let videoSize = CGSize(width: 640, height: 480)

// MARK: - Tests

@Test func audioOnlyRecordingReportsZeroVideoFrames() async throws {
let settings = makeStore()
settings.captureSystemAudio = true

let assetWriter = AssetWriter()
try assetWriter.setup(url: makeOutputURL(), settings: settings, videoSize: videoSize)
try assetWriter.startWriting()

// Audio flows for the whole session, no video sample ever arrives
for index in 0..<10 {
let presentationTime = CMTime(value: CMTimeValue(index * 1024), timescale: 48000)
assetWriter.appendAudioSample(try makeSilentAudioSampleBuffer(at: presentationTime))
}

let result = try await assetWriter.finishWriting()
defer { try? FileManager.default.removeItem(at: result.url) }

#expect(result.videoFrameCount == 0)

// The recording is kept - audio is still worth saving - and it holds no video track
#expect(FileManager.default.fileExists(atPath: result.url.path()))
let videoTracks = try await AVURLAsset(url: result.url).loadTracks(withMediaType: .video)
#expect(videoTracks.isEmpty)
}

@Test func recordingWithVideoReportsFramesWritten() async throws {
let settings = makeStore()

let assetWriter = AssetWriter()
try assetWriter.setup(url: makeOutputURL(), settings: settings, videoSize: videoSize)
try assetWriter.startWriting()

for index in 0..<5 {
let presentationTime = CMTime(value: CMTimeValue(index), timescale: 60)
assetWriter.appendVideoSample(try makeVideoSampleBuffer(at: presentationTime))
}

let result = try await assetWriter.finishWriting()
defer { try? FileManager.default.removeItem(at: result.url) }

#expect(result.videoFrameCount == 5)
}

@Test func recordingWithoutAnySampleThrows() async throws {
let settings = makeStore()

let assetWriter = AssetWriter()
try assetWriter.setup(url: makeOutputURL(), settings: settings, videoSize: videoSize)
try assetWriter.startWriting()

await #expect(throws: AssetWriterError.self) {
try await assetWriter.finishWriting()
}
}

// MARK: - Helpers

/// Creates a SettingsStore backed by a fresh, empty UserDefaults suite.
private func makeStore() -> SettingsStore {
let suiteName = "com.sattlerjoshua.BetterCaptureTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
return SettingsStore(defaults: defaults)
}

private func makeOutputURL() -> URL {
FileManager.default.temporaryDirectory.appending(path: "\(UUID().uuidString).mov")
}

/// Creates a buffer of silent 48 kHz stereo audio.
private func makeSilentAudioSampleBuffer(at presentationTime: CMTime) throws -> CMSampleBuffer {
let frameCount: AVAudioFrameCount = 1024

let format = try #require(
AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000, channels: 2, interleaved: true)
)
let pcmBuffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount))
pcmBuffer.frameLength = frameCount

var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 48000),
presentationTimeStamp: presentationTime,
decodeTimeStamp: .invalid
)

var sampleBuffer: CMSampleBuffer?
let createStatus = CMSampleBufferCreate(
allocator: kCFAllocatorDefault,
dataBuffer: nil,
dataReady: false,
makeDataReadyCallback: nil,
refcon: nil,
formatDescription: format.formatDescription,
sampleCount: CMItemCount(frameCount),
sampleTimingEntryCount: 1,
sampleTimingArray: &timing,
sampleSizeEntryCount: 0,
sampleSizeArray: nil,
sampleBufferOut: &sampleBuffer
)
#expect(createStatus == noErr)

let buffer = try #require(sampleBuffer)
let attachStatus = CMSampleBufferSetDataBufferFromAudioBufferList(
buffer,
blockBufferAllocator: kCFAllocatorDefault,
blockBufferMemoryAllocator: kCFAllocatorDefault,
flags: 0,
bufferList: pcmBuffer.mutableAudioBufferList
)
#expect(attachStatus == noErr)

return buffer
}

/// Creates an empty BGRA video frame marked complete, as ScreenCaptureKit would deliver it.
private func makeVideoSampleBuffer(at presentationTime: CMTime) throws -> CMSampleBuffer {
var pixelBuffer: CVPixelBuffer?
let pixelBufferStatus = CVPixelBufferCreate(
kCFAllocatorDefault,
Int(videoSize.width),
Int(videoSize.height),
kCVPixelFormatType_32BGRA,
[kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary] as CFDictionary,
&pixelBuffer
)
#expect(pixelBufferStatus == kCVReturnSuccess)
let imageBuffer = try #require(pixelBuffer)

var formatDescription: CMFormatDescription?
let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: imageBuffer,
formatDescriptionOut: &formatDescription
)
#expect(formatStatus == noErr)

var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 60),
presentationTimeStamp: presentationTime,
decodeTimeStamp: .invalid
)

let videoFormat = try #require(formatDescription)

var sampleBuffer: CMSampleBuffer?
let createStatus = CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: imageBuffer,
formatDescription: videoFormat,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
#expect(createStatus == noErr)
let buffer = try #require(sampleBuffer)

// appendVideoSample only accepts frames the capture engine marked complete
let attachments = try #require(
CMSampleBufferGetSampleAttachmentsArray(buffer, createIfNecessary: true) as? [NSMutableDictionary]
)
let attachment = try #require(attachments.first)
attachment[SCStreamFrameInfo.status.rawValue] = SCFrameStatus.complete.rawValue

return buffer
}
}
3 changes: 2 additions & 1 deletion BetterCaptureTests/ErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ struct ErrorTests {
.failedToCreateStream,
.captureAlreadyRunning,
.screenRecordingPermissionDenied,
.microphonePermissionDenied
.microphonePermissionDenied,
.selectedDisplayDisconnected
]

for error in cases {
Expand Down
Loading