reorg
This commit is contained in:
@@ -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<AnyCancellable>()
|
||||
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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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: View>(_ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<AnyCancellable>()
|
||||
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 {
|
||||
Reference in New Issue
Block a user