From ea3478dfb970884b375671db8502d54a37e1871d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 16 Jan 2026 01:22:31 -0500 Subject: [PATCH] cleanup --- Gaze.xcodeproj/project.pbxproj | 6 +- Gaze/Constants/EyeTrackingConstants.swift | 3 +- Gaze/GazeApp.swift | 3 +- Gaze/Services/EyeTrackingService.swift | 32 ++++++++ .../Services/FullscreenDetectionService.swift | 29 +++++-- Gaze/Services/IdleMonitoringService.swift | 3 +- Gaze/Services/PupilDetector.swift | 80 ++++++++++--------- Gaze/Services/ServiceContainer.swift | 13 ++- Gaze/Services/TimerEngine.swift | 15 +++- Gaze/Services/UsageTrackingService.swift | 3 +- .../Containers/OnboardingContainerView.swift | 6 +- Gaze/Views/Setup/UserTimersView.swift | 2 +- GazeTests/ServiceContainerTests.swift | 1 + GazeTests/Services/TimerEngineTests.swift | 1 + GazeTests/TimerEngineTestabilityTests.swift | 2 + 15 files changed, 139 insertions(+), 60 deletions(-) diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj index e3ce3ca..dc61f75 100644 --- a/Gaze.xcodeproj/project.pbxproj +++ b/Gaze.xcodeproj/project.pbxproj @@ -431,13 +431,14 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gaze/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gaze; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 0.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -467,13 +468,14 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gaze/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gaze; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 0.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Gaze/Constants/EyeTrackingConstants.swift b/Gaze/Constants/EyeTrackingConstants.swift index 2f4298d..c52585d 100644 --- a/Gaze/Constants/EyeTrackingConstants.swift +++ b/Gaze/Constants/EyeTrackingConstants.swift @@ -8,7 +8,8 @@ import Foundation /// Thread-safe configuration holder for eye tracking thresholds. -enum EyeTrackingConstants { +/// All properties are Sendable constants, safe for use in any concurrency context. +enum EyeTrackingConstants: Sendable { // MARK: - Logging /// Interval between log messages in seconds static let logInterval: TimeInterval = 0.5 diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index c8a1dfb..4175464 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -31,8 +31,7 @@ struct GazeApp: App { } } else { OnboardingContainerView(settingsManager: settingsManager) - .onChange(of: settingsManager.settings.hasCompletedOnboarding) { - completed in + .onChange(of: settingsManager.settings.hasCompletedOnboarding) { _, completed in if completed { closeAllWindows() appDelegate.onboardingCompleted() diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift index 2662944..85ca855 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTrackingService.swift @@ -68,6 +68,24 @@ class EyeTrackingService: NSObject, ObservableObject { var debugRightPupilRatio: Double? var debugYaw: Double? var debugPitch: Double? + + nonisolated init( + faceDetected: Bool = false, + isEyesClosed: Bool = false, + userLookingAtScreen: Bool = true, + debugLeftPupilRatio: Double? = nil, + debugRightPupilRatio: Double? = nil, + debugYaw: Double? = nil, + debugPitch: Double? = nil + ) { + self.faceDetected = faceDetected + self.isEyesClosed = isEyesClosed + self.userLookingAtScreen = userLookingAtScreen + self.debugLeftPupilRatio = debugLeftPupilRatio + self.debugRightPupilRatio = debugRightPupilRatio + self.debugYaw = debugYaw + self.debugPitch = debugPitch + } } func startEyeTracking() async throws { @@ -277,6 +295,20 @@ class EyeTrackingService: NSObject, ObservableObject { var rightPupilRatio: Double? var yaw: Double? var pitch: Double? + + nonisolated init( + lookingAway: Bool = false, + leftPupilRatio: Double? = nil, + rightPupilRatio: Double? = nil, + yaw: Double? = nil, + pitch: Double? = nil + ) { + self.lookingAway = lookingAway + self.leftPupilRatio = leftPupilRatio + self.rightPupilRatio = rightPupilRatio + self.yaw = yaw + self.pitch = pitch + } } /// Non-isolated gaze direction detection diff --git a/Gaze/Services/FullscreenDetectionService.swift b/Gaze/Services/FullscreenDetectionService.swift index 2a96034..4f82366 100644 --- a/Gaze/Services/FullscreenDetectionService.swift +++ b/Gaze/Services/FullscreenDetectionService.swift @@ -71,16 +71,23 @@ final class FullscreenDetectionService: ObservableObject { private let permissionManager: ScreenCapturePermissionManaging private let environmentProvider: FullscreenEnvironmentProviding - // This initializer is only for use within main actor contexts init( - permissionManager: ScreenCapturePermissionManaging = ScreenCapturePermissionManager.shared, - environmentProvider: FullscreenEnvironmentProviding = SystemFullscreenEnvironmentProvider() + permissionManager: ScreenCapturePermissionManaging, + environmentProvider: FullscreenEnvironmentProviding ) { self.permissionManager = permissionManager self.environmentProvider = environmentProvider setupObservers() } + /// Convenience initializer using default services + convenience init() { + self.init( + permissionManager: ScreenCapturePermissionManager.shared, + environmentProvider: SystemFullscreenEnvironmentProvider() + ) + } + // Factory method to safely create instances from non-main actor contexts static func create( permissionManager: ScreenCapturePermissionManaging? = nil, @@ -109,7 +116,9 @@ final class FullscreenDetectionService: ObservableObject { object: workspace, queue: .main ) { [weak self] _ in - self?.checkFullscreenState() + Task { @MainActor in + self?.checkFullscreenState() + } } observers.append(spaceObserver) @@ -118,7 +127,9 @@ final class FullscreenDetectionService: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - self?.checkFullscreenState() + Task { @MainActor in + self?.checkFullscreenState() + } } observers.append(transitionObserver) @@ -127,7 +138,9 @@ final class FullscreenDetectionService: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - self?.checkFullscreenState() + Task { @MainActor in + self?.checkFullscreenState() + } } observers.append(fullscreenObserver) @@ -136,7 +149,9 @@ final class FullscreenDetectionService: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - self?.checkFullscreenState() + Task { @MainActor in + self?.checkFullscreenState() + } } observers.append(exitFullscreenObserver) diff --git a/Gaze/Services/IdleMonitoringService.swift b/Gaze/Services/IdleMonitoringService.swift index 0d8ed8e..611e08c 100644 --- a/Gaze/Services/IdleMonitoringService.swift +++ b/Gaze/Services/IdleMonitoringService.swift @@ -33,8 +33,9 @@ class IdleMonitoringService: ObservableObject { private func startMonitoring() { timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } Task { @MainActor in - self?.checkIdleState() + self.checkIdleState() } } } diff --git a/Gaze/Services/PupilDetector.swift b/Gaze/Services/PupilDetector.swift index 1dafb49..7839238 100644 --- a/Gaze/Services/PupilDetector.swift +++ b/Gaze/Services/PupilDetector.swift @@ -38,13 +38,13 @@ final class PupilCalibration: @unchecked Sendable { private var thresholdsLeft: [Int] = [] private var thresholdsRight: [Int] = [] - var isComplete: Bool { + nonisolated var isComplete: Bool { lock.lock() defer { lock.unlock() } return thresholdsLeft.count >= targetFrames && thresholdsRight.count >= targetFrames } - func threshold(forSide side: Int) -> Int { + nonisolated func threshold(forSide side: Int) -> Int { lock.lock() defer { lock.unlock() } let thresholds = side == 0 ? thresholdsLeft : thresholdsRight @@ -52,7 +52,7 @@ final class PupilCalibration: @unchecked Sendable { return thresholds.reduce(0, +) / thresholds.count } - func evaluate(eyeData: UnsafePointer, width: Int, height: Int, side: Int) { + nonisolated func evaluate(eyeData: UnsafePointer, width: Int, height: Int, side: Int) { let bestThreshold = findBestThreshold(eyeData: eyeData, width: width, height: height) lock.lock() defer { lock.unlock() } @@ -63,7 +63,7 @@ final class PupilCalibration: @unchecked Sendable { } } - private func findBestThreshold(eyeData: UnsafePointer, width: Int, height: Int) -> Int { + private nonisolated func findBestThreshold(eyeData: UnsafePointer, width: Int, height: Int) -> Int { let averageIrisSize = 0.48 var bestThreshold = 50 var bestDiff = Double.greatestFiniteMagnitude @@ -91,7 +91,7 @@ final class PupilCalibration: @unchecked Sendable { return bestThreshold } - private static func irisSize(data: UnsafePointer, width: Int, height: Int) -> Double { + private nonisolated static func irisSize(data: UnsafePointer, width: Int, height: Int) -> Double { let margin = 5 guard width > margin * 2, height > margin * 2 else { return 0 } @@ -112,7 +112,7 @@ final class PupilCalibration: @unchecked Sendable { return totalCount > 0 ? Double(blackCount) / Double(totalCount) : 0 } - func reset() { + nonisolated func reset() { lock.lock() defer { lock.unlock() } thresholdsLeft.removeAll() @@ -143,46 +143,46 @@ final class PupilDetector: @unchecked Sendable { // MARK: - Configuration - static var enableDebugImageSaving = false - static var enablePerformanceLogging = false - static var frameSkipCount = 10 // Process every Nth frame + nonisolated(unsafe) static var enableDebugImageSaving = false + nonisolated(unsafe) static var enablePerformanceLogging = false + nonisolated(unsafe) static var frameSkipCount = 10 // Process every Nth frame // MARK: - State (protected by lock) - private static var _debugImageCounter = 0 - private static var _frameCounter = 0 - private static var _lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) = ( + private nonisolated(unsafe) static var _debugImageCounter = 0 + private nonisolated(unsafe) static var _frameCounter = 0 + private nonisolated(unsafe) static var _lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) = ( nil, nil ) - private static var _metrics = PupilDetectorMetrics() + private nonisolated(unsafe) static var _metrics = PupilDetectorMetrics() - static let calibration = PupilCalibration() + nonisolated(unsafe) static let calibration = PupilCalibration() // MARK: - Convenience Properties - private static var debugImageCounter: Int { + private nonisolated static var debugImageCounter: Int { get { _debugImageCounter } set { _debugImageCounter = newValue } } - private static var frameCounter: Int { + private nonisolated static var frameCounter: Int { get { _frameCounter } set { _frameCounter = newValue } } - private static var lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) { + private nonisolated static var lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) { get { _lastPupilPositions } set { _lastPupilPositions = newValue } } - private static var metrics: PupilDetectorMetrics { + private nonisolated static var metrics: PupilDetectorMetrics { get { _metrics } set { _metrics = newValue } } // MARK: - Precomputed Tables - private static let spatialWeightsLUT: [[Float]] = { + private nonisolated(unsafe) static let spatialWeightsLUT: [[Float]] = { let d = 10 let radius = d / 2 let sigmaSpace: Float = 15.0 @@ -197,7 +197,7 @@ final class PupilDetector: @unchecked Sendable { return weights }() - private static let colorWeightsLUT: [Float] = { + private nonisolated(unsafe) static let colorWeightsLUT: [Float] = { let sigmaColor: Float = 15.0 var lut = [Float](repeating: 0, count: 256) for diff in 0..<256 { @@ -209,12 +209,12 @@ final class PupilDetector: @unchecked Sendable { // MARK: - Reusable Buffers - private static var grayscaleBuffer: UnsafeMutablePointer? - private static var grayscaleBufferSize = 0 - private static var eyeBuffer: UnsafeMutablePointer? - private static var eyeBufferSize = 0 - private static var tempBuffer: UnsafeMutablePointer? - private static var tempBufferSize = 0 + private nonisolated(unsafe) static var grayscaleBuffer: UnsafeMutablePointer? + private nonisolated(unsafe) static var grayscaleBufferSize = 0 + private nonisolated(unsafe) static var eyeBuffer: UnsafeMutablePointer? + private nonisolated(unsafe) static var eyeBufferSize = 0 + private nonisolated(unsafe) static var tempBuffer: UnsafeMutablePointer? + private nonisolated(unsafe) static var tempBufferSize = 0 // MARK: - Public API @@ -369,7 +369,9 @@ final class PupilDetector: @unchecked Sendable { // MARK: - Buffer Management - private static func ensureBufferCapacity(frameSize: Int, eyeSize: Int) { + // MARK: - Buffer Management + + private nonisolated static func ensureBufferCapacity(frameSize: Int, eyeSize: Int) { if grayscaleBufferSize < frameSize { grayscaleBuffer?.deallocate() grayscaleBuffer = UnsafeMutablePointer.allocate(capacity: frameSize) @@ -388,7 +390,7 @@ final class PupilDetector: @unchecked Sendable { // MARK: - Optimized Grayscale Conversion (vImage) - private static func extractGrayscaleDataOptimized( + private nonisolated static func extractGrayscaleDataOptimized( from pixelBuffer: CVPixelBuffer, to output: UnsafeMutablePointer, width: Int, @@ -476,7 +478,7 @@ final class PupilDetector: @unchecked Sendable { // MARK: - Optimized Eye Isolation - private static func isolateEyeWithMaskOptimized( + private nonisolated static func isolateEyeWithMaskOptimized( frameData: UnsafePointer, frameWidth: Int, frameHeight: Int, @@ -526,7 +528,7 @@ final class PupilDetector: @unchecked Sendable { } @inline(__always) - private static func pointInPolygonFast( + private nonisolated static func pointInPolygonFast( px: Float, py: Float, edges: [(x1: Float, y1: Float, x2: Float, y2: Float)] ) -> Bool { var inside = false @@ -542,7 +544,7 @@ final class PupilDetector: @unchecked Sendable { // MARK: - Optimized Image Processing - static func imageProcessingOptimized( + nonisolated static func imageProcessingOptimized( input: UnsafePointer, output: UnsafeMutablePointer, width: Int, @@ -569,7 +571,7 @@ final class PupilDetector: @unchecked Sendable { } } - private static func gaussianBlurOptimized( + private nonisolated static func gaussianBlurOptimized( input: UnsafePointer, output: UnsafeMutablePointer, width: Int, @@ -607,7 +609,7 @@ final class PupilDetector: @unchecked Sendable { ) } - private static func erodeOptimized( + private nonisolated static func erodeOptimized( input: UnsafePointer, output: UnsafeMutablePointer, width: Int, @@ -668,7 +670,7 @@ final class PupilDetector: @unchecked Sendable { /// Optimized centroid-of-dark-pixels approach - much faster than union-find /// Returns the centroid of the largest dark region - private static func findPupilFromContoursOptimized( + private nonisolated static func findPupilFromContoursOptimized( data: UnsafePointer, width: Int, height: Int @@ -722,7 +724,7 @@ final class PupilDetector: @unchecked Sendable { // MARK: - Helper Methods - private static func landmarksToPixelCoordinates( + private nonisolated static func landmarksToPixelCoordinates( landmarks: VNFaceLandmarkRegion2D, faceBoundingBox: CGRect, imageSize: CGSize @@ -736,7 +738,7 @@ final class PupilDetector: @unchecked Sendable { } } - private static func createEyeRegion(from points: [CGPoint], imageSize: CGSize) -> EyeRegion? { + private nonisolated static func createEyeRegion(from points: [CGPoint], imageSize: CGSize) -> EyeRegion? { guard !points.isEmpty else { return nil } let margin: CGFloat = 5 @@ -777,7 +779,7 @@ final class PupilDetector: @unchecked Sendable { // MARK: - Debug Helpers - private static func saveDebugImage( + private nonisolated static func saveDebugImage( data: UnsafePointer, width: Int, height: Int, name: String ) { guard let cgImage = createCGImage(from: data, width: width, height: height) else { return } @@ -793,7 +795,7 @@ final class PupilDetector: @unchecked Sendable { print("💾 Saved debug image: \(url.path)") } - private static func createCGImage(from data: UnsafePointer, width: Int, height: Int) + private nonisolated static func createCGImage(from data: UnsafePointer, width: Int, height: Int) -> CGImage? { let mutableData = UnsafeMutablePointer.allocate(capacity: width * height) @@ -817,7 +819,7 @@ final class PupilDetector: @unchecked Sendable { } /// Clean up allocated buffers (call on app termination if needed) - static func cleanup() { + nonisolated static func cleanup() { grayscaleBuffer?.deallocate() grayscaleBuffer = nil grayscaleBufferSize = 0 diff --git a/Gaze/Services/ServiceContainer.swift b/Gaze/Services/ServiceContainer.swift index 23bcde8..ed7bd30 100644 --- a/Gaze/Services/ServiceContainer.swift +++ b/Gaze/Services/ServiceContainer.swift @@ -111,7 +111,12 @@ final class ServiceContainer { } /// Creates a new container configured for testing with default mock settings - static func forTesting(settings: AppSettings = .defaults) -> ServiceContainer { + static func forTesting() -> ServiceContainer { + forTesting(settings: AppSettings()) + } + + /// Creates a new container configured for testing with custom settings + static func forTesting(settings: AppSettings) -> ServiceContainer { let mockSettings = MockSettingsManager(settings: settings) return ServiceContainer(settingsManager: mockSettings) } @@ -138,7 +143,11 @@ final class MockSettingsManager: SettingsProviding { .posture: \.postureTimer, ] - init(settings: AppSettings = .defaults) { + convenience init() { + self.init(settings: AppSettings()) + } + + init(settings: AppSettings) { self.settings = settings self._settingsSubject = CurrentValueSubject(settings) } diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index 1d6abda..85c3999 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -32,10 +32,21 @@ class TimerEngine: ObservableObject { // Logging manager private let logger = LoggingManager.shared.timerLogger + convenience init( + settingsManager: any SettingsProviding, + enforceModeService: EnforceModeService? = nil + ) { + self.init( + settingsManager: settingsManager, + enforceModeService: enforceModeService, + timeProvider: SystemTimeProvider() + ) + } + init( settingsManager: any SettingsProviding, - enforceModeService: EnforceModeService? = nil, - timeProvider: TimeProviding = SystemTimeProvider() + enforceModeService: EnforceModeService?, + timeProvider: TimeProviding ) { self.settingsProvider = settingsManager self.enforceModeService = enforceModeService ?? EnforceModeService.shared diff --git a/Gaze/Services/UsageTrackingService.swift b/Gaze/Services/UsageTrackingService.swift index 378fbc7..dd15b63 100644 --- a/Gaze/Services/UsageTrackingService.swift +++ b/Gaze/Services/UsageTrackingService.swift @@ -75,8 +75,9 @@ class UsageTrackingService: ObservableObject { private func startTracking() { Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } Task { @MainActor in - self?.tick() + self.tick() } } } diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index a4b6939..2376990 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -86,8 +86,10 @@ final class OnboardingWindowPresenter { object: window, queue: .main ) { [weak self] _ in - self?.windowController = nil - self?.removeCloseObserver() + Task { @MainActor in + self?.windowController = nil + self?.removeCloseObserver() + } NotificationCenter.default.post(name: Notification.Name("OnboardingWindowDidClose"), object: nil) } } diff --git a/Gaze/Views/Setup/UserTimersView.swift b/Gaze/Views/Setup/UserTimersView.swift index 0c5b37c..5a6a58a 100644 --- a/Gaze/Views/Setup/UserTimersView.swift +++ b/Gaze/Views/Setup/UserTimersView.swift @@ -304,7 +304,7 @@ struct UserTimerEditSheet: View { } } .pickerStyle(.segmented) - .onChange(of: type) { newType in + .onChange(of: type) { _, newType in if newType == .subtle { timeOnScreen = 3 } else if timeOnScreen == 3 { diff --git a/GazeTests/ServiceContainerTests.swift b/GazeTests/ServiceContainerTests.swift index efe1345..4acc440 100644 --- a/GazeTests/ServiceContainerTests.swift +++ b/GazeTests/ServiceContainerTests.swift @@ -42,6 +42,7 @@ final class ServiceContainerTests: XCTestCase { let mockSettings = EnhancedMockSettingsManager(settings: .shortIntervals) let customEngine = TimerEngine( settingsManager: mockSettings, + enforceModeService: nil, timeProvider: MockTimeProvider() ) diff --git a/GazeTests/Services/TimerEngineTests.swift b/GazeTests/Services/TimerEngineTests.swift index e4a71d5..150c169 100644 --- a/GazeTests/Services/TimerEngineTests.swift +++ b/GazeTests/Services/TimerEngineTests.swift @@ -41,6 +41,7 @@ final class TimerEngineTests: XCTestCase { let timeProvider = MockTimeProvider() let engine = TimerEngine( settingsManager: testEnv.settingsManager, + enforceModeService: nil, timeProvider: timeProvider ) diff --git a/GazeTests/TimerEngineTestabilityTests.swift b/GazeTests/TimerEngineTestabilityTests.swift index 16da5b6..24d1a1e 100644 --- a/GazeTests/TimerEngineTestabilityTests.swift +++ b/GazeTests/TimerEngineTestabilityTests.swift @@ -29,6 +29,7 @@ final class TimerEngineTestabilityTests: XCTestCase { let timeProvider = MockTimeProvider() let timerEngine = TimerEngine( settingsManager: testEnv.settingsManager, + enforceModeService: nil, timeProvider: timeProvider ) @@ -59,6 +60,7 @@ final class TimerEngineTestabilityTests: XCTestCase { let timeProvider = MockTimeProvider(startTime: Date()) let timerEngine = TimerEngine( settingsManager: testEnv.settingsManager, + enforceModeService: nil, timeProvider: timeProvider )