From 30af29e1d9bbd88e03d51cd64cb12f71dacad282 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 29 Jan 2026 19:34:55 -0500 Subject: [PATCH] reorg --- Gaze/AppDelegate.swift | 40 ++++- .../CalibrationFlowController.swift | 0 .../{ => Calibration}/CalibratorService.swift | 0 .../EyeTracking/EyeDebugStateAdapter.swift | 101 ----------- .../EyeTrackingProcessingResult.swift | 21 --- .../EyeTrackingService.swift | 94 ++++++++++ Gaze/Services/EyeTracking/GazeDetector.swift | 13 ++ .../{ => EyeTracking}/PupilDetector.swift | 0 Gaze/Services/FullscreenWindowMatcher.swift | 21 --- Gaze/Services/IdleMonitoringService.swift | 68 -------- Gaze/Services/MockWindowManager.swift | 160 ------------------ Gaze/Services/ServiceContainer.swift | 35 ---- .../{ => Settings}/CameraAccessService.swift | 0 .../{ => Settings}/LaunchAtLoginManager.swift | 0 .../{ => Settings}/SettingsManager.swift | 0 .../FullscreenDetectionService.swift | 13 ++ .../IdleMonitoringService.swift} | 117 +++++++++---- .../{ => System}/SystemSleepManager.swift | 0 .../TimerConfigurationHelper.swift | 0 Gaze/Services/{ => Timer}/TimerEngine.swift | 0 GazeTests/Helpers/MockWindowManager.swift | 64 +++++++ 21 files changed, 307 insertions(+), 440 deletions(-) rename Gaze/Services/{ => Calibration}/CalibrationFlowController.swift (100%) rename Gaze/Services/{ => Calibration}/CalibratorService.swift (100%) delete mode 100644 Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift delete mode 100644 Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift rename Gaze/Services/{ => EyeTracking}/EyeTrackingService.swift (72%) rename Gaze/Services/{ => EyeTracking}/PupilDetector.swift (100%) delete mode 100644 Gaze/Services/FullscreenWindowMatcher.swift delete mode 100644 Gaze/Services/IdleMonitoringService.swift delete mode 100644 Gaze/Services/MockWindowManager.swift rename Gaze/Services/{ => Settings}/CameraAccessService.swift (100%) rename Gaze/Services/{ => Settings}/LaunchAtLoginManager.swift (100%) rename Gaze/Services/{ => Settings}/SettingsManager.swift (100%) rename Gaze/Services/{ => System}/FullscreenDetectionService.swift (90%) rename Gaze/Services/{UsageTrackingService.swift => System/IdleMonitoringService.swift} (73%) rename Gaze/Services/{ => System}/SystemSleepManager.swift (100%) rename Gaze/Services/{ => Timer}/TimerConfigurationHelper.swift (100%) rename Gaze/Services/{ => Timer}/TimerEngine.swift (100%) create mode 100644 GazeTests/Helpers/MockWindowManager.swift diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index b4543a1..8134136 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -16,6 +16,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private let windowManager: WindowManaging private var updateManager: UpdateManager? private var systemSleepManager: SystemSleepManager? + private var fullscreenService: FullscreenDetectionService? + private var idleService: IdleMonitoringService? + private var usageTrackingService: UsageTrackingService? private var cancellables = Set() private var hasStartedTimers = false @@ -53,7 +56,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { ) systemSleepManager?.startObserving() - serviceContainer.setupSmartModeServices() + setupSmartModeServices() // Initialize update manager after onboarding is complete if settingsManager.settings.hasCompletedOnboarding { @@ -82,18 +85,45 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { .removeDuplicates() .sink { [weak self] smartMode in guard let self = self else { return } - self.serviceContainer.idleService?.updateThreshold( + self.idleService?.updateThreshold( minutes: smartMode.idleThresholdMinutes) - self.serviceContainer.usageTrackingService?.updateResetThreshold( + self.usageTrackingService?.updateResetThreshold( minutes: smartMode.usageResetAfterMinutes) // Force state check when settings change to apply immediately - self.serviceContainer.fullscreenService?.forceUpdate() - self.serviceContainer.idleService?.forceUpdate() + self.fullscreenService?.forceUpdate() + self.idleService?.forceUpdate() } .store(in: &cancellables) } + private func setupSmartModeServices() { + let settings = settingsManager.settings + + Task { @MainActor in + fullscreenService = await FullscreenDetectionService.create() + idleService = IdleMonitoringService( + idleThresholdMinutes: settings.smartMode.idleThresholdMinutes + ) + if settings.smartMode.trackUsage { + usageTrackingService = UsageTrackingService( + resetThresholdMinutes: settings.smartMode.usageResetAfterMinutes + ) + } else { + usageTrackingService = nil + } + + if let idleService = idleService { + usageTrackingService?.setupIdleMonitoring(idleService) + } + + timerEngine?.setupSmartMode( + fullscreenService: fullscreenService, + idleService: idleService + ) + } + } + func onboardingCompleted() { startTimers() diff --git a/Gaze/Services/CalibrationFlowController.swift b/Gaze/Services/Calibration/CalibrationFlowController.swift similarity index 100% rename from Gaze/Services/CalibrationFlowController.swift rename to Gaze/Services/Calibration/CalibrationFlowController.swift diff --git a/Gaze/Services/CalibratorService.swift b/Gaze/Services/Calibration/CalibratorService.swift similarity index 100% rename from Gaze/Services/CalibratorService.swift rename to Gaze/Services/Calibration/CalibratorService.swift diff --git a/Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift b/Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift deleted file mode 100644 index 40f2a67..0000000 --- a/Gaze/Services/EyeTracking/EyeDebugStateAdapter.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// EyeDebugStateAdapter.swift -// Gaze -// -// Debug state storage for eye tracking UI. -// - -import AppKit -import Foundation - -@MainActor -final class EyeDebugStateAdapter { - var leftPupilRatio: Double? - var rightPupilRatio: Double? - var leftVerticalRatio: Double? - var rightVerticalRatio: Double? - var yaw: Double? - var pitch: Double? - var enableDebugLogging: Bool = false { - didSet { - PupilDetector.enableDiagnosticLogging = enableDebugLogging - } - } - - var leftEyeInput: NSImage? - var rightEyeInput: NSImage? - var leftEyeProcessed: NSImage? - var rightEyeProcessed: NSImage? - var leftPupilPosition: PupilPosition? - var rightPupilPosition: PupilPosition? - var leftEyeSize: CGSize? - var rightEyeSize: CGSize? - var leftEyeRegion: EyeRegion? - var rightEyeRegion: EyeRegion? - var imageSize: CGSize? - - var gazeDirection: GazeDirection { - guard let leftH = leftPupilRatio, - let rightH = rightPupilRatio, - let leftV = leftVerticalRatio, - let rightV = rightVerticalRatio else { - return .center - } - - let avgHorizontal = (leftH + rightH) / 2.0 - let avgVertical = (leftV + rightV) / 2.0 - - return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical) - } - - func update(from result: EyeTrackingProcessingResult) { - leftPupilRatio = result.leftPupilRatio - rightPupilRatio = result.rightPupilRatio - leftVerticalRatio = result.leftVerticalRatio - rightVerticalRatio = result.rightVerticalRatio - yaw = result.yaw - pitch = result.pitch - } - - func updateEyeImages(from detector: PupilDetector.Type) { - if let leftInput = detector.debugLeftEyeInput { - leftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height)) - } - if let rightInput = detector.debugRightEyeInput { - rightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height)) - } - if let leftProcessed = detector.debugLeftEyeProcessed { - leftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height)) - } - if let rightProcessed = detector.debugRightEyeProcessed { - rightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height)) - } - leftPupilPosition = detector.debugLeftPupilPosition - rightPupilPosition = detector.debugRightPupilPosition - leftEyeSize = detector.debugLeftEyeSize - rightEyeSize = detector.debugRightEyeSize - leftEyeRegion = detector.debugLeftEyeRegion - rightEyeRegion = detector.debugRightEyeRegion - imageSize = detector.debugImageSize - } - - func clear() { - leftPupilRatio = nil - rightPupilRatio = nil - leftVerticalRatio = nil - rightVerticalRatio = nil - yaw = nil - pitch = nil - leftEyeInput = nil - rightEyeInput = nil - leftEyeProcessed = nil - rightEyeProcessed = nil - leftPupilPosition = nil - rightPupilPosition = nil - leftEyeSize = nil - rightEyeSize = nil - leftEyeRegion = nil - rightEyeRegion = nil - imageSize = nil - } -} diff --git a/Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift b/Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift deleted file mode 100644 index 35d0384..0000000 --- a/Gaze/Services/EyeTracking/EyeTrackingProcessingResult.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// EyeTrackingProcessingResult.swift -// Gaze -// -// Shared processing result for eye tracking pipeline. -// - -import Foundation - -struct EyeTrackingProcessingResult: Sendable { - let faceDetected: Bool - let isEyesClosed: Bool - let userLookingAtScreen: Bool - let leftPupilRatio: Double? - let rightPupilRatio: Double? - let leftVerticalRatio: Double? - let rightVerticalRatio: Double? - let yaw: Double? - let pitch: Double? - let faceWidthRatio: Double? -} diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTracking/EyeTrackingService.swift similarity index 72% rename from Gaze/Services/EyeTrackingService.swift rename to Gaze/Services/EyeTracking/EyeTrackingService.swift index d7fcdff..12a9141 100644 --- a/Gaze/Services/EyeTrackingService.swift +++ b/Gaze/Services/EyeTracking/EyeTrackingService.swift @@ -217,3 +217,97 @@ enum EyeTrackingError: Error, LocalizedError { } } } + +// MARK: - Debug State Adapter + +@MainActor +final class EyeDebugStateAdapter { + var leftPupilRatio: Double? + var rightPupilRatio: Double? + var leftVerticalRatio: Double? + var rightVerticalRatio: Double? + var yaw: Double? + var pitch: Double? + var enableDebugLogging: Bool = false { + didSet { + PupilDetector.enableDiagnosticLogging = enableDebugLogging + } + } + + var leftEyeInput: NSImage? + var rightEyeInput: NSImage? + var leftEyeProcessed: NSImage? + var rightEyeProcessed: NSImage? + var leftPupilPosition: PupilPosition? + var rightPupilPosition: PupilPosition? + var leftEyeSize: CGSize? + var rightEyeSize: CGSize? + var leftEyeRegion: EyeRegion? + var rightEyeRegion: EyeRegion? + var imageSize: CGSize? + + var gazeDirection: GazeDirection { + guard let leftH = leftPupilRatio, + let rightH = rightPupilRatio, + let leftV = leftVerticalRatio, + let rightV = rightVerticalRatio else { + return .center + } + + let avgHorizontal = (leftH + rightH) / 2.0 + let avgVertical = (leftV + rightV) / 2.0 + + return GazeDirection.from(horizontal: avgHorizontal, vertical: avgVertical) + } + + func update(from result: EyeTrackingProcessingResult) { + leftPupilRatio = result.leftPupilRatio + rightPupilRatio = result.rightPupilRatio + leftVerticalRatio = result.leftVerticalRatio + rightVerticalRatio = result.rightVerticalRatio + yaw = result.yaw + pitch = result.pitch + } + + func updateEyeImages(from detector: PupilDetector.Type) { + if let leftInput = detector.debugLeftEyeInput { + leftEyeInput = NSImage(cgImage: leftInput, size: NSSize(width: leftInput.width, height: leftInput.height)) + } + if let rightInput = detector.debugRightEyeInput { + rightEyeInput = NSImage(cgImage: rightInput, size: NSSize(width: rightInput.width, height: rightInput.height)) + } + if let leftProcessed = detector.debugLeftEyeProcessed { + leftEyeProcessed = NSImage(cgImage: leftProcessed, size: NSSize(width: leftProcessed.width, height: leftProcessed.height)) + } + if let rightProcessed = detector.debugRightEyeProcessed { + rightEyeProcessed = NSImage(cgImage: rightProcessed, size: NSSize(width: rightProcessed.width, height: rightProcessed.height)) + } + leftPupilPosition = detector.debugLeftPupilPosition + rightPupilPosition = detector.debugRightPupilPosition + leftEyeSize = detector.debugLeftEyeSize + rightEyeSize = detector.debugRightEyeSize + leftEyeRegion = detector.debugLeftEyeRegion + rightEyeRegion = detector.debugRightEyeRegion + imageSize = detector.debugImageSize + } + + func clear() { + leftPupilRatio = nil + rightPupilRatio = nil + leftVerticalRatio = nil + rightVerticalRatio = nil + yaw = nil + pitch = nil + leftEyeInput = nil + rightEyeInput = nil + leftEyeProcessed = nil + rightEyeProcessed = nil + leftPupilPosition = nil + rightPupilPosition = nil + leftEyeSize = nil + rightEyeSize = nil + leftEyeRegion = nil + rightEyeRegion = nil + imageSize = nil + } +} diff --git a/Gaze/Services/EyeTracking/GazeDetector.swift b/Gaze/Services/EyeTracking/GazeDetector.swift index cac09f9..889cbe4 100644 --- a/Gaze/Services/EyeTracking/GazeDetector.swift +++ b/Gaze/Services/EyeTracking/GazeDetector.swift @@ -9,6 +9,19 @@ import Foundation import Vision import simd +struct EyeTrackingProcessingResult: Sendable { + let faceDetected: Bool + let isEyesClosed: Bool + let userLookingAtScreen: Bool + let leftPupilRatio: Double? + let rightPupilRatio: Double? + let leftVerticalRatio: Double? + let rightVerticalRatio: Double? + let yaw: Double? + let pitch: Double? + let faceWidthRatio: Double? +} + final class GazeDetector: @unchecked Sendable { struct GazeResult: Sendable { let isLookingAway: Bool diff --git a/Gaze/Services/PupilDetector.swift b/Gaze/Services/EyeTracking/PupilDetector.swift similarity index 100% rename from Gaze/Services/PupilDetector.swift rename to Gaze/Services/EyeTracking/PupilDetector.swift diff --git a/Gaze/Services/FullscreenWindowMatcher.swift b/Gaze/Services/FullscreenWindowMatcher.swift deleted file mode 100644 index 5e8727e..0000000 --- a/Gaze/Services/FullscreenWindowMatcher.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// FullscreenWindowMatcher.swift -// Gaze -// -// Created by Mike Freno on 1/29/26. -// - -import CoreGraphics - -struct FullscreenWindowMatcher { - func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool { - screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) } - } - - private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool { - abs(windowBounds.width - screenFrame.width) < tolerance - && abs(windowBounds.height - screenFrame.height) < tolerance - && abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance - && abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance - } -} diff --git a/Gaze/Services/IdleMonitoringService.swift b/Gaze/Services/IdleMonitoringService.swift deleted file mode 100644 index 611e08c..0000000 --- a/Gaze/Services/IdleMonitoringService.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// IdleMonitoringService.swift -// Gaze -// -// Created by Mike Freno on 1/14/26. -// - -import AppKit -import Combine -import Foundation - -@MainActor -class IdleMonitoringService: ObservableObject { - @Published private(set) var isIdle = false - @Published private(set) var idleTimeSeconds: TimeInterval = 0 - - private var timer: Timer? - private var idleThresholdSeconds: TimeInterval - - init(idleThresholdMinutes: Int = 5) { - self.idleThresholdSeconds = TimeInterval(idleThresholdMinutes * 60) - startMonitoring() - } - - deinit { - timer?.invalidate() - } - - func updateThreshold(minutes: Int) { - idleThresholdSeconds = TimeInterval(minutes * 60) - checkIdleState() - } - - 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() - } - } - } - - private func checkIdleState() { - idleTimeSeconds = CGEventSource.secondsSinceLastEventType( - .combinedSessionState, - eventType: .mouseMoved - ) - - // Also check keyboard events and use the minimum - let keyboardIdleTime = CGEventSource.secondsSinceLastEventType( - .combinedSessionState, - eventType: .keyDown - ) - - idleTimeSeconds = min(idleTimeSeconds, keyboardIdleTime) - - let wasIdle = isIdle - isIdle = idleTimeSeconds >= idleThresholdSeconds - - if wasIdle != isIdle { - print("🔄 Idle state changed: \(isIdle ? "IDLE" : "ACTIVE") (idle: \(Int(idleTimeSeconds))s, threshold: \(Int(idleThresholdSeconds))s)") - } - } - - func forceUpdate() { - checkIdleState() - } -} diff --git a/Gaze/Services/MockWindowManager.swift b/Gaze/Services/MockWindowManager.swift deleted file mode 100644 index 3ecf1bb..0000000 --- a/Gaze/Services/MockWindowManager.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// MockWindowManager.swift -// Gaze -// -// Mock implementation of WindowManaging for testing purposes. -// - -import SwiftUI - -/// Mock window manager that tracks window operations without creating actual windows. -/// Useful for unit testing UI flows and state management. -@MainActor -final class MockWindowManager: WindowManaging { - - // MARK: - State Tracking - - private(set) var isOverlayReminderVisible = false - private(set) var isSubtleReminderVisible = false - - // MARK: - Operation History - - struct WindowOperation { - let timestamp: Date - let operation: Operation - - enum Operation { - case showOverlayReminder - case showSubtleReminder - case dismissOverlayReminder - case dismissSubtleReminder - case dismissAllReminders - case showSettings(initialTab: Int) - case showOnboarding - } - } - - private(set) var operations: [WindowOperation] = [] - - // MARK: - Callbacks for Testing - - var onShowOverlayReminder: (() -> Void)? - var onShowSubtleReminder: (() -> Void)? - var onDismissOverlayReminder: (() -> Void)? - var onDismissSubtleReminder: (() -> Void)? - var onShowSettings: ((Int) -> Void)? - var onShowOnboarding: (() -> Void)? - - // MARK: - WindowManaging Implementation - - func showReminderWindow(_ content: Content, windowType: ReminderWindowType) { - let operation: WindowOperation.Operation - - switch windowType { - case .overlay: - isOverlayReminderVisible = true - operation = .showOverlayReminder - onShowOverlayReminder?() - case .subtle: - isSubtleReminderVisible = true - operation = .showSubtleReminder - onShowSubtleReminder?() - } - - operations.append(WindowOperation(timestamp: Date(), operation: operation)) - } - - func dismissOverlayReminder() { - isOverlayReminderVisible = false - operations.append(WindowOperation(timestamp: Date(), operation: .dismissOverlayReminder)) - onDismissOverlayReminder?() - } - - func dismissSubtleReminder() { - isSubtleReminderVisible = false - operations.append(WindowOperation(timestamp: Date(), operation: .dismissSubtleReminder)) - onDismissSubtleReminder?() - } - - func dismissAllReminders() { - isOverlayReminderVisible = false - isSubtleReminderVisible = false - operations.append(WindowOperation(timestamp: Date(), operation: .dismissAllReminders)) - onDismissOverlayReminder?() - onDismissSubtleReminder?() - } - - func showSettings(settingsManager: any SettingsProviding, initialTab: Int) { - operations.append(WindowOperation(timestamp: Date(), operation: .showSettings(initialTab: initialTab))) - onShowSettings?(initialTab) - } - - func showOnboarding(settingsManager: any SettingsProviding) { - operations.append(WindowOperation(timestamp: Date(), operation: .showOnboarding)) - onShowOnboarding?() - } - - // MARK: - Test Helpers - - /// Resets all state for a fresh test - func reset() { - isOverlayReminderVisible = false - isSubtleReminderVisible = false - operations.removeAll() - onShowOverlayReminder = nil - onShowSubtleReminder = nil - onDismissOverlayReminder = nil - onDismissSubtleReminder = nil - onShowSettings = nil - onShowOnboarding = nil - } - - /// Returns the number of times a specific operation was performed - func operationCount(_ operationType: WindowOperation.Operation) -> Int { - operations.filter { operation in - switch (operation.operation, operationType) { - case (.showOverlayReminder, .showOverlayReminder), - (.showSubtleReminder, .showSubtleReminder), - (.dismissOverlayReminder, .dismissOverlayReminder), - (.dismissSubtleReminder, .dismissSubtleReminder), - (.dismissAllReminders, .dismissAllReminders), - (.showOnboarding, .showOnboarding): - return true - case (.showSettings(let tab1), .showSettings(let tab2)): - return tab1 == tab2 - default: - return false - } - }.count - } - - /// Returns true if the operation was performed at least once - func didPerformOperation(_ operationType: WindowOperation.Operation) -> Bool { - operationCount(operationType) > 0 - } - - /// Returns the last operation performed, if any - var lastOperation: WindowOperation? { - operations.last - } -} - -// MARK: - Equatable Conformance for Testing - -extension MockWindowManager.WindowOperation.Operation: Equatable { - static func == (lhs: MockWindowManager.WindowOperation.Operation, rhs: MockWindowManager.WindowOperation.Operation) -> Bool { - switch (lhs, rhs) { - case (.showOverlayReminder, .showOverlayReminder), - (.showSubtleReminder, .showSubtleReminder), - (.dismissOverlayReminder, .dismissOverlayReminder), - (.dismissSubtleReminder, .dismissSubtleReminder), - (.dismissAllReminders, .dismissAllReminders), - (.showOnboarding, .showOnboarding): - return true - case (.showSettings(let tab1), .showSettings(let tab2)): - return tab1 == tab2 - default: - return false - } - } -} diff --git a/Gaze/Services/ServiceContainer.swift b/Gaze/Services/ServiceContainer.swift index fc111e5..7873f30 100644 --- a/Gaze/Services/ServiceContainer.swift +++ b/Gaze/Services/ServiceContainer.swift @@ -23,15 +23,6 @@ final class ServiceContainer { /// The timer engine instance (created lazily) private var _timerEngine: TimerEngine? - /// The fullscreen detection service - private(set) var fullscreenService: FullscreenDetectionService? - - /// The idle monitoring service - private(set) var idleService: IdleMonitoringService? - - /// The usage tracking service - private(set) var usageTrackingService: UsageTrackingService? - /// Creates a production container with real services private init() { self.settingsManager = SettingsManager.shared @@ -64,30 +55,4 @@ final class ServiceContainer { return engine } - /// Sets up smart mode services - func setupSmartModeServices() { - let settings = settingsManager.settings - - Task { @MainActor in - fullscreenService = await FullscreenDetectionService.create() - idleService = IdleMonitoringService( - idleThresholdMinutes: settings.smartMode.idleThresholdMinutes - ) - usageTrackingService = UsageTrackingService( - resetThresholdMinutes: settings.smartMode.usageResetAfterMinutes - ) - - // Connect idle service to usage tracking - if let idleService = idleService { - usageTrackingService?.setupIdleMonitoring(idleService) - } - - // Connect services to timer engine - timerEngine.setupSmartMode( - fullscreenService: fullscreenService, - idleService: idleService - ) - } - } - } diff --git a/Gaze/Services/CameraAccessService.swift b/Gaze/Services/Settings/CameraAccessService.swift similarity index 100% rename from Gaze/Services/CameraAccessService.swift rename to Gaze/Services/Settings/CameraAccessService.swift diff --git a/Gaze/Services/LaunchAtLoginManager.swift b/Gaze/Services/Settings/LaunchAtLoginManager.swift similarity index 100% rename from Gaze/Services/LaunchAtLoginManager.swift rename to Gaze/Services/Settings/LaunchAtLoginManager.swift diff --git a/Gaze/Services/SettingsManager.swift b/Gaze/Services/Settings/SettingsManager.swift similarity index 100% rename from Gaze/Services/SettingsManager.swift rename to Gaze/Services/Settings/SettingsManager.swift diff --git a/Gaze/Services/FullscreenDetectionService.swift b/Gaze/Services/System/FullscreenDetectionService.swift similarity index 90% rename from Gaze/Services/FullscreenDetectionService.swift rename to Gaze/Services/System/FullscreenDetectionService.swift index a40124a..554eb64 100644 --- a/Gaze/Services/FullscreenDetectionService.swift +++ b/Gaze/Services/System/FullscreenDetectionService.swift @@ -191,3 +191,16 @@ final class FullscreenDetectionService: ObservableObject { } #endif } + +struct FullscreenWindowMatcher { + func isFullscreen(windowBounds: CGRect, screenFrames: [CGRect], tolerance: CGFloat = 1) -> Bool { + screenFrames.contains { matches(windowBounds, screenFrame: $0, tolerance: tolerance) } + } + + private func matches(_ windowBounds: CGRect, screenFrame: CGRect, tolerance: CGFloat) -> Bool { + abs(windowBounds.width - screenFrame.width) < tolerance + && abs(windowBounds.height - screenFrame.height) < tolerance + && abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance + && abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance + } +} diff --git a/Gaze/Services/UsageTrackingService.swift b/Gaze/Services/System/IdleMonitoringService.swift similarity index 73% rename from Gaze/Services/UsageTrackingService.swift rename to Gaze/Services/System/IdleMonitoringService.swift index dd15b63..705472d 100644 --- a/Gaze/Services/UsageTrackingService.swift +++ b/Gaze/Services/System/IdleMonitoringService.swift @@ -1,23 +1,82 @@ // -// UsageTrackingService.swift +// IdleMonitoringService.swift // Gaze // // Created by Mike Freno on 1/14/26. // +import AppKit import Combine import Foundation +@MainActor +class IdleMonitoringService: ObservableObject { + @Published private(set) var isIdle = false + @Published private(set) var idleTimeSeconds: TimeInterval = 0 + + private var timer: Timer? + private var idleThresholdSeconds: TimeInterval + + init(idleThresholdMinutes: Int = 5) { + self.idleThresholdSeconds = TimeInterval(idleThresholdMinutes * 60) + startMonitoring() + } + + deinit { + timer?.invalidate() + } + + func updateThreshold(minutes: Int) { + idleThresholdSeconds = TimeInterval(minutes * 60) + checkIdleState() + } + + 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() + } + } + } + + private func checkIdleState() { + idleTimeSeconds = CGEventSource.secondsSinceLastEventType( + .combinedSessionState, + eventType: .mouseMoved + ) + + // Also check keyboard events and use the minimum + let keyboardIdleTime = CGEventSource.secondsSinceLastEventType( + .combinedSessionState, + eventType: .keyDown + ) + + idleTimeSeconds = min(idleTimeSeconds, keyboardIdleTime) + + let wasIdle = isIdle + isIdle = idleTimeSeconds >= idleThresholdSeconds + + if wasIdle != isIdle { + print("🔄 Idle state changed: \(isIdle ? "IDLE" : "ACTIVE") (idle: \(Int(idleTimeSeconds))s, threshold: \(Int(idleThresholdSeconds))s)") + } + } + + func forceUpdate() { + checkIdleState() + } +} + struct UsageStatistics: Codable { var totalActiveSeconds: TimeInterval var totalIdleSeconds: TimeInterval var lastResetDate: Date var sessionStartDate: Date - + var totalActiveMinutes: Int { Int(totalActiveSeconds / 60) } - + var totalIdleMinutes: Int { Int(totalIdleSeconds / 60) } @@ -26,20 +85,20 @@ struct UsageStatistics: Codable { @MainActor class UsageTrackingService: ObservableObject { @Published private(set) var statistics: UsageStatistics - + private var lastUpdateTime: Date private var wasIdle: Bool = false private var cancellables = Set() private let userDefaults = UserDefaults.standard private let statisticsKey = "gazeUsageStatistics" private var resetThresholdMinutes: Int - + private var idleService: IdleMonitoringService? - + init(resetThresholdMinutes: Int = 60) { self.resetThresholdMinutes = resetThresholdMinutes self.lastUpdateTime = Date() - + if let data = userDefaults.data(forKey: statisticsKey), let decoded = try? JSONDecoder().decode(UsageStatistics.self, from: data) { self.statistics = decoded @@ -51,14 +110,14 @@ class UsageTrackingService: ObservableObject { sessionStartDate: Date() ) } - + checkForReset() startTracking() } - + func setupIdleMonitoring(_ idleService: IdleMonitoringService) { self.idleService = idleService - + idleService.$isIdle .sink { [weak self] isIdle in Task { @MainActor in @@ -67,12 +126,12 @@ class UsageTrackingService: ObservableObject { } .store(in: &cancellables) } - + func updateResetThreshold(minutes: Int) { resetThresholdMinutes = minutes checkForReset() } - + private func startTracking() { Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self = self else { return } @@ -81,50 +140,50 @@ class UsageTrackingService: ObservableObject { } } } - + private func tick() { let now = Date() let elapsed = now.timeIntervalSince(lastUpdateTime) lastUpdateTime = now - + let isCurrentlyIdle = idleService?.isIdle ?? false - + if isCurrentlyIdle { statistics.totalIdleSeconds += elapsed } else { statistics.totalActiveSeconds += elapsed } - + wasIdle = isCurrentlyIdle - + checkForReset() save() } - + private func updateTracking(isIdle: Bool) { let now = Date() let elapsed = now.timeIntervalSince(lastUpdateTime) - + if wasIdle { statistics.totalIdleSeconds += elapsed } else { statistics.totalActiveSeconds += elapsed } - + lastUpdateTime = now wasIdle = isIdle save() } - + private func checkForReset() { let totalMinutes = statistics.totalActiveMinutes + statistics.totalIdleMinutes - + if totalMinutes >= resetThresholdMinutes { reset() print("♻️ Usage statistics reset after \(totalMinutes) minutes (threshold: \(resetThresholdMinutes))") } } - + func reset() { statistics = UsageStatistics( totalActiveSeconds: 0, @@ -135,31 +194,31 @@ class UsageTrackingService: ObservableObject { lastUpdateTime = Date() save() } - + private func save() { if let encoded = try? JSONEncoder().encode(statistics) { userDefaults.set(encoded, forKey: statisticsKey) } } - + func getFormattedActiveTime() -> String { formatDuration(seconds: Int(statistics.totalActiveSeconds)) } - + func getFormattedIdleTime() -> String { formatDuration(seconds: Int(statistics.totalIdleSeconds)) } - + func getFormattedTotalTime() -> String { let total = Int(statistics.totalActiveSeconds + statistics.totalIdleSeconds) return formatDuration(seconds: total) } - + private func formatDuration(seconds: Int) -> String { let hours = seconds / 3600 let minutes = (seconds % 3600) / 60 let secs = seconds % 60 - + if hours > 0 { return String(format: "%dh %dm %ds", hours, minutes, secs) } else if minutes > 0 { diff --git a/Gaze/Services/SystemSleepManager.swift b/Gaze/Services/System/SystemSleepManager.swift similarity index 100% rename from Gaze/Services/SystemSleepManager.swift rename to Gaze/Services/System/SystemSleepManager.swift diff --git a/Gaze/Services/TimerConfigurationHelper.swift b/Gaze/Services/Timer/TimerConfigurationHelper.swift similarity index 100% rename from Gaze/Services/TimerConfigurationHelper.swift rename to Gaze/Services/Timer/TimerConfigurationHelper.swift diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/Timer/TimerEngine.swift similarity index 100% rename from Gaze/Services/TimerEngine.swift rename to Gaze/Services/Timer/TimerEngine.swift diff --git a/GazeTests/Helpers/MockWindowManager.swift b/GazeTests/Helpers/MockWindowManager.swift new file mode 100644 index 0000000..52ea4a3 --- /dev/null +++ b/GazeTests/Helpers/MockWindowManager.swift @@ -0,0 +1,64 @@ +// +// MockWindowManager.swift +// GazeTests +// +// Mock window manager for tests. +// + +import Foundation +import SwiftUI +@testable import Gaze + +@MainActor +final class MockWindowManager: WindowManaging { + private(set) var didShowOnboarding = false + private(set) var didShowSettings = false + private(set) var didShowReminder = false + private(set) var didDismissReminder = false + + var isOverlayReminderVisible: Bool = false + var isSubtleReminderVisible: Bool = false + + func showOnboarding(settingsManager: any SettingsProviding) { + didShowOnboarding = true + } + + func showSettings(settingsManager: any SettingsProviding, initialTab: Int) { + didShowSettings = true + } + + func showReminderWindow(_ content: Content, windowType: ReminderWindowType) { + didShowReminder = true + switch windowType { + case .overlay: + isOverlayReminderVisible = true + case .subtle: + isSubtleReminderVisible = true + } + } + + func dismissOverlayReminder() { + didDismissReminder = true + isOverlayReminderVisible = false + } + + func dismissSubtleReminder() { + didDismissReminder = true + isSubtleReminderVisible = false + } + + func dismissAllReminders() { + didDismissReminder = true + isOverlayReminderVisible = false + isSubtleReminderVisible = false + } + + func reset() { + didShowOnboarding = false + didShowSettings = false + didShowReminder = false + didDismissReminder = false + isOverlayReminderVisible = false + isSubtleReminderVisible = false + } +}