diff --git a/TimeLapze.xcodeproj/project.pbxproj b/TimeLapze.xcodeproj/project.pbxproj index 9472b78..3cb4d3e 100644 --- a/TimeLapze.xcodeproj/project.pbxproj +++ b/TimeLapze.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 */; }; @@ -53,6 +56,7 @@ /* Begin PBXFileReference section */ 3E0DCD732BAB2A5800DB969A /* ReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewManager.swift; sourceTree = ""; }; + 3F1A0001C000000000000002 /* ScreenCaptureAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCaptureAccess.swift; sourceTree = ""; }; 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 = ""; }; 3E2EB6A429AD5A4C006F4F2A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -66,6 +70,8 @@ 3E650BE62CEFC5F500AE9F6C /* TimeLapzeUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLapzeUITestsLaunchTests.swift; sourceTree = ""; }; 3E7461012CEE8A9700F8511D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 3E7461032CEFC08B00F8511D /* RecorderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecorderViewModelTests.swift; sourceTree = ""; }; + 3F1A0001C000000000000004 /* ScreenCaptureIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCaptureIntegrationTests.swift; sourceTree = ""; }; + 3F1A0001C000000000000006 /* ScreenCaptureTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCaptureTestSupport.swift; sourceTree = ""; }; 3E75E34B2AED6EC90016EB52 /* CameraRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraRecorder.swift; sourceTree = ""; }; 3E75E34F2AF2005A0016EB52 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; 3E8144652BAFC8F300F3E90F /* PreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesViewModel.swift; sourceTree = ""; }; @@ -115,6 +121,8 @@ children = ( 3E8497A42CFCF8E9003CFA6E /* ReviewManagerTests.swift */, 3E7461032CEFC08B00F8511D /* RecorderViewModelTests.swift */, + 3F1A0001C000000000000006 /* ScreenCaptureTestSupport.swift */, + 3F1A0001C000000000000004 /* ScreenCaptureIntegrationTests.swift */, 3E23A01D2B2FC65A0016DE8C /* TimeLapzeTests.swift */, ); path = TimeLapzeTests; @@ -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 */, @@ -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; @@ -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 */, diff --git a/TimeLapze/RecorderViewModel.swift b/TimeLapze/RecorderViewModel.swift index da2afd0..09538dc 100644 --- a/TimeLapze/RecorderViewModel.swift +++ b/TimeLapze/RecorderViewModel.swift @@ -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] = [:] @@ -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 } @@ -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 @@ -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) @@ -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() + } } } diff --git a/TimeLapze/ScreenCaptureAccess.swift b/TimeLapze/ScreenCaptureAccess.swift new file mode 100644 index 0000000..7a4d6e5 --- /dev/null +++ b/TimeLapze/ScreenCaptureAccess.swift @@ -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 + } +} diff --git a/TimeLapze/TimeLapze.entitlements b/TimeLapze/TimeLapze.entitlements index 37c56c4..6b2ff7c 100644 --- a/TimeLapze/TimeLapze.entitlements +++ b/TimeLapze/TimeLapze.entitlements @@ -14,5 +14,7 @@ com.apple.security.files.user-selected.read-write + com.apple.security.screen-recording + diff --git a/TimeLapzeTests/RecorderViewModelTests.swift b/TimeLapzeTests/RecorderViewModelTests.swift index 4a95656..4670f53 100644 --- a/TimeLapzeTests/RecorderViewModelTests.swift +++ b/TimeLapzeTests/RecorderViewModelTests.swift @@ -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) @@ -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) diff --git a/TimeLapzeTests/ScreenCaptureIntegrationTests.swift b/TimeLapzeTests/ScreenCaptureIntegrationTests.swift new file mode 100644 index 0000000..97017fe --- /dev/null +++ b/TimeLapzeTests/ScreenCaptureIntegrationTests.swift @@ -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.") + } +} diff --git a/TimeLapzeTests/ScreenCaptureTestSupport.swift b/TimeLapzeTests/ScreenCaptureTestSupport.swift new file mode 100644 index 0000000..4fcbda8 --- /dev/null +++ b/TimeLapzeTests/ScreenCaptureTestSupport.swift @@ -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.") + } + } +}