Skip to content
Draft
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: 12 additions & 0 deletions TimeLapze.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

/* Begin PBXBuildFile section */
3E0DCD742BAB2A5800DB969A /* ReviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E0DCD732BAB2A5800DB969A /* ReviewManager.swift */; };
3F1A0001C000000000000001 /* ScreenCaptureAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A0001C000000000000002 /* ScreenCaptureAccess.swift */; };
3E23A01E2B2FC65A0016DE8C /* TimeLapzeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E23A01D2B2FC65A0016DE8C /* TimeLapzeTests.swift */; };
3E4D87C729AD741800D3EC7B /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4D87C629AD741800D3EC7B /* Screen.swift */; };
3E4D87C929AD744C00D3EC7B /* Camera.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E4D87C829AD744C00D3EC7B /* Camera.swift */; };
Expand All @@ -17,6 +18,8 @@
3E650BE72CEFC5F500AE9F6C /* TimeLapzeUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E650BE62CEFC5F500AE9F6C /* TimeLapzeUITestsLaunchTests.swift */; };
3E7461022CEE8A9700F8511D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 3E7461012CEE8A9700F8511D /* InfoPlist.xcstrings */; };
3E7461042CEFC09400F8511D /* RecorderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7461032CEFC08B00F8511D /* RecorderViewModelTests.swift */; };
3F1A0001C000000000000003 /* ScreenCaptureIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A0001C000000000000004 /* ScreenCaptureIntegrationTests.swift */; };
3F1A0001C000000000000005 /* ScreenCaptureTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A0001C000000000000006 /* ScreenCaptureTestSupport.swift */; };
3E75E34C2AED6EC90016EB52 /* CameraRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E75E34B2AED6EC90016EB52 /* CameraRecorder.swift */; };
3E75E3502AF2005A0016EB52 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E75E34F2AF2005A0016EB52 /* PreferencesView.swift */; };
3E8144662BAFC8F300F3E90F /* PreferencesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8144652BAFC8F300F3E90F /* PreferencesViewModel.swift */; };
Expand Down Expand Up @@ -53,6 +56,7 @@

/* Begin PBXFileReference section */
3E0DCD732BAB2A5800DB969A /* ReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewManager.swift; sourceTree = "<group>"; };
3F1A0001C000000000000002 /* ScreenCaptureAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCaptureAccess.swift; sourceTree = "<group>"; };
3E23A01B2B2FC65A0016DE8C /* TimeLapzeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TimeLapzeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3E23A01D2B2FC65A0016DE8C /* TimeLapzeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLapzeTests.swift; sourceTree = "<group>"; };
3E2EB6A429AD5A4C006F4F2A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
Expand All @@ -66,6 +70,8 @@
3E650BE62CEFC5F500AE9F6C /* TimeLapzeUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLapzeUITestsLaunchTests.swift; sourceTree = "<group>"; };
3E7461012CEE8A9700F8511D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
3E7461032CEFC08B00F8511D /* RecorderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecorderViewModelTests.swift; sourceTree = "<group>"; };
3F1A0001C000000000000004 /* ScreenCaptureIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCaptureIntegrationTests.swift; sourceTree = "<group>"; };
3F1A0001C000000000000006 /* ScreenCaptureTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCaptureTestSupport.swift; sourceTree = "<group>"; };
3E75E34B2AED6EC90016EB52 /* CameraRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraRecorder.swift; sourceTree = "<group>"; };
3E75E34F2AF2005A0016EB52 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
3E8144652BAFC8F300F3E90F /* PreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -115,6 +121,8 @@
children = (
3E8497A42CFCF8E9003CFA6E /* ReviewManagerTests.swift */,
3E7461032CEFC08B00F8511D /* RecorderViewModelTests.swift */,
3F1A0001C000000000000006 /* ScreenCaptureTestSupport.swift */,
3F1A0001C000000000000004 /* ScreenCaptureIntegrationTests.swift */,
3E23A01D2B2FC65A0016DE8C /* TimeLapzeTests.swift */,
);
path = TimeLapzeTests;
Expand Down Expand Up @@ -160,6 +168,7 @@
3EE5FE1A296232C800768BF7 /* TimeLapze.entitlements */,
3EE5FE12296232C800768BF7 /* Preview Content */,
3E87A931299EBDFF00714AFC /* RecorderViewModel.swift */,
3F1A0001C000000000000002 /* ScreenCaptureAccess.swift */,
3E87A933299EBE0D00714AFC /* Recordable.swift */,
3EEFFBB829A9B662002D0040 /* Utils.swift */,
3E4D87C629AD741800D3EC7B /* Screen.swift */,
Expand Down Expand Up @@ -331,6 +340,8 @@
files = (
3E8497A52CFCF8F7003CFA6E /* ReviewManagerTests.swift in Sources */,
3E7461042CEFC09400F8511D /* RecorderViewModelTests.swift in Sources */,
3F1A0001C000000000000005 /* ScreenCaptureTestSupport.swift in Sources */,
3F1A0001C000000000000003 /* ScreenCaptureIntegrationTests.swift in Sources */,
3E23A01E2B2FC65A0016DE8C /* TimeLapzeTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand All @@ -352,6 +363,7 @@
3E8144662BAFC8F300F3E90F /* PreferencesViewModel.swift in Sources */,
3E75E3502AF2005A0016EB52 /* PreferencesView.swift in Sources */,
3E87A932299EBDFF00714AFC /* RecorderViewModel.swift in Sources */,
3F1A0001C000000000000001 /* ScreenCaptureAccess.swift in Sources */,
3E0DCD742BAB2A5800DB969A /* ReviewManager.swift in Sources */,
3E75E34C2AED6EC90016EB52 /* CameraRecorder.swift in Sources */,
3EEFFBB929A9B662002D0040 /* Utils.swift in Sources */,
Expand Down
69 changes: 56 additions & 13 deletions TimeLapze/RecorderViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,34 @@ import SwiftUI

/// Represents a synchronized session of ``Recordable`` objects
class RecorderViewModel: ObservableObject {
static let shared = RecorderViewModel()
static let shared = RecorderViewModel(config: .live)

struct RecorderViewModelConfig {
let screenCaptureAccess: ScreenCaptureAccessing
let startMonitoring: Bool
let startTimer: Bool
let initialCameraRefresh: Bool
let initialDisplayRefresh: Bool
let checkPermissionOnInit: Bool

static let live = RecorderViewModelConfig(
screenCaptureAccess: SystemScreenCaptureAccess(),
startMonitoring: true,
startTimer: true,
initialCameraRefresh: true,
initialDisplayRefresh: true,
checkPermissionOnInit: true
)

static let test = RecorderViewModelConfig(
screenCaptureAccess: NoOpScreenCaptureAccess(),
startMonitoring: true,
startTimer: false,
initialCameraRefresh: true,
initialDisplayRefresh: false,
checkPermissionOnInit: false
)
}

@Published var apps: [SCRunningApplication: Bool] = [:]

Expand All @@ -16,13 +43,15 @@ class RecorderViewModel: ObservableObject {
@Published var hasScreenPermission: Bool = false
@AppStorage("showCursor") var showCursor: Bool = false

private let screenCaptureAccess: ScreenCaptureAccessing

/// Timer which allows for asynchronous refreshing of enabled displays
private var timer: DispatchSourceTimer?

/// Checks screen recording permission using the lightweight preflight API
@discardableResult
func checkScreenPermission() -> Bool {
let permitted = CGPreflightScreenCaptureAccess()
let permitted = screenCaptureAccess.preflight()
DispatchQueue.main.async {
self.hasScreenPermission = permitted
}
Expand All @@ -31,8 +60,7 @@ class RecorderViewModel: ObservableObject {

/// Requests screen recording permission, opening System Settings if needed
func requestScreenPermission() {
CGRequestScreenCaptureAccess()
hasScreenPermission = CGPreflightScreenCaptureAccess()
hasScreenPermission = screenCaptureAccess.request()
}

/// Makes an asynchronous call to `ScreenCaptureKit` to get valid `SCScreens` and `SCRunningApplication`s connected to the computer
Expand All @@ -41,8 +69,7 @@ class RecorderViewModel: ObservableObject {
guard checkScreenPermission() else { return }

do {
let content = try await SCShareableContent.excludingDesktopWindows(
false, onScreenWindowsOnly: false)
let content = try await screenCaptureAccess.fetchShareableContent()

self.apps = convertApps(apps: content.applications)
self.screens = convertDisplays(displays: content.displays)
Expand All @@ -51,13 +78,29 @@ class RecorderViewModel: ObservableObject {
}
}

init() {
hasScreenPermission = CGPreflightScreenCaptureAccess()
getCameras()
startRefreshingDevices()
setupCameraMonitoring()
Task(priority: .userInitiated) {
await getDisplayInfo()
init(config: RecorderViewModelConfig = .live) {
self.screenCaptureAccess = config.screenCaptureAccess

if config.checkPermissionOnInit {
hasScreenPermission = screenCaptureAccess.preflight()
}

if config.initialCameraRefresh {
getCameras()
}

if config.startTimer {
startRefreshingDevices()
}

if config.startMonitoring {
setupCameraMonitoring()
}

if config.initialDisplayRefresh {
Task(priority: .userInitiated) {
await getDisplayInfo()
}
}
}

Expand Down
43 changes: 43 additions & 0 deletions TimeLapze/ScreenCaptureAccess.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import CoreGraphics
import ScreenCaptureKit

protocol ScreenCaptureAccessing {
func preflight() -> Bool
@discardableResult func request() -> Bool
func fetchShareableContent() async throws -> SCShareableContent
}

struct SystemScreenCaptureAccess: ScreenCaptureAccessing {
func preflight() -> Bool {
CGPreflightScreenCaptureAccess()
}

@discardableResult
func request() -> Bool {
CGRequestScreenCaptureAccess()
return CGPreflightScreenCaptureAccess()
}

func fetchShareableContent() async throws -> SCShareableContent {
try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
}
}

enum ScreenCaptureAccessError: Error {
case unavailable
}

struct NoOpScreenCaptureAccess: ScreenCaptureAccessing {
func preflight() -> Bool {
false
}

@discardableResult
func request() -> Bool {
false
}

func fetchShareableContent() async throws -> SCShareableContent {
throw ScreenCaptureAccessError.unavailable
}
}
2 changes: 2 additions & 0 deletions TimeLapze/TimeLapze.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.screen-recording</key>
<true/>
</dict>
</plist>
4 changes: 2 additions & 2 deletions TimeLapzeTests/RecorderViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import XCTest
final class RecorderViewModelTests: XCTestCase {
// If a device is connected, then cameras should empty
func testDeviceConnectedNotification() throws {
let viewModel = RecorderViewModel()
let viewModel = RecorderViewModel(config: .test)
let expectation = XCTestExpectation(description: "Device connected should refresh camera list.")

NotificationCenter.default.post(name: .AVCaptureDeviceWasConnected, object: nil)
Expand All @@ -27,7 +27,7 @@ final class RecorderViewModelTests: XCTestCase {
///
/// Seems redundant, but this actually caught a bug earlier
func testRecorderViewModelStateTransitions() throws {
let viewModel = RecorderViewModel()
let viewModel = RecorderViewModel(config: .test)

viewModel.state = .stopped
XCTAssertEqual(viewModel.state, .stopped)
Expand Down
16 changes: 16 additions & 0 deletions TimeLapzeTests/ScreenCaptureIntegrationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import ScreenCaptureKit
import XCTest

@testable import TimeLapze

@MainActor
final class ScreenCaptureIntegrationTests: XCTestCase {
func testShareableContentLoads() async throws {
try ScreenCaptureTestGate.requireAuthorized()

let access = SystemScreenCaptureAccess()
let content = try await access.fetchShareableContent()

XCTAssertFalse(content.displays.isEmpty, "Expected at least one display.")
}
}
60 changes: 60 additions & 0 deletions TimeLapzeTests/ScreenCaptureTestSupport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import CoreGraphics
import Security
import XCTest

enum ScreenCaptureEntitlementError: Error, CustomStringConvertible {
case missing([String])

var description: String {
switch self {
case .missing(let keys):
return "Missing required entitlements: \(keys.joined(separator: ", "))"
}
}
}

enum ScreenCaptureEntitlementChecker {
static let requiredEntitlements = ["com.apple.security.screen-recording"]

static func missingEntitlements() -> [String] {
guard let task = SecTaskCreateFromSelf(nil) else {
return requiredEntitlements
}

return requiredEntitlements.filter { key in
let value = SecTaskCopyValueForEntitlement(task, key as CFString, nil)
if let boolValue = value as? Bool {
return boolValue == false
}
if let numberValue = value as? NSNumber {
return numberValue.boolValue == false
}
return true
}
}
}

enum ScreenCaptureTestGate {
static func requireAuthorized(
file: StaticString = #filePath,
line: UInt = #line
) throws {
guard ProcessInfo.processInfo.environment["RUN_SCREEN_CAPTURE_TESTS"] == "1" else {
throw XCTSkip("Screen capture tests are disabled. Set RUN_SCREEN_CAPTURE_TESTS=1.")
}

let missing = ScreenCaptureEntitlementChecker.missingEntitlements()
if !missing.isEmpty {
XCTFail(
"Screen capture tests require entitlements. Missing: \(missing.joined(separator: ", "))",
file: file,
line: line
)
throw ScreenCaptureEntitlementError.missing(missing)
}

guard CGPreflightScreenCaptureAccess() else {
throw XCTSkip("Screen recording permission not granted for the test runner.")
}
}
}