This commit is contained in:
Michael Freno
2026-01-27 14:12:24 -05:00
parent fda136f3d4
commit f8868c9253
31 changed files with 2030 additions and 1790 deletions

View File

@@ -19,26 +19,3 @@ struct SystemTimeProvider: TimeProviding {
Date()
}
}
/// Test implementation that allows manual time control
final class MockTimeProvider: TimeProviding, @unchecked Sendable {
private var currentTime: Date
init(startTime: Date = Date()) {
self.currentTime = startTime
}
func now() -> Date {
currentTime
}
/// Advances time by the specified interval
func advance(by interval: TimeInterval) {
currentTime = currentTime.addingTimeInterval(interval)
}
/// Sets the current time to a specific date
func setTime(_ date: Date) {
currentTime = date
}
}

View File

@@ -0,0 +1,95 @@
//
// EnforceCameraController.swift
// Gaze
//
// Manages camera lifecycle for enforce mode sessions.
//
import Combine
import Foundation
protocol EnforceCameraControllerDelegate: AnyObject {
func cameraControllerDidTimeout(_ controller: EnforceCameraController)
func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool)
}
@MainActor
final class EnforceCameraController: ObservableObject {
@Published private(set) var isCameraActive = false
@Published private(set) var lastFaceDetectionTime: Date = .distantPast
weak var delegate: EnforceCameraControllerDelegate?
private let eyeTrackingService: EyeTrackingService
private var cancellables = Set<AnyCancellable>()
private var faceDetectionTimer: Timer?
var faceDetectionTimeout: TimeInterval = 5.0
init(eyeTrackingService: EyeTrackingService) {
self.eyeTrackingService = eyeTrackingService
setupObservers()
}
func startCamera() async throws {
guard !isCameraActive else { return }
try await eyeTrackingService.startEyeTracking()
isCameraActive = true
lastFaceDetectionTime = Date()
startFaceDetectionTimer()
}
func stopCamera() {
guard isCameraActive else { return }
eyeTrackingService.stopEyeTracking()
isCameraActive = false
stopFaceDetectionTimer()
}
func resetFaceDetectionTimer() {
lastFaceDetectionTime = Date()
}
private func setupObservers() {
eyeTrackingService.$userLookingAtScreen
.sink { [weak self] lookingAtScreen in
guard let self else { return }
self.delegate?.cameraController(self, didUpdateLookingAtScreen: lookingAtScreen)
}
.store(in: &cancellables)
eyeTrackingService.$faceDetected
.sink { [weak self] faceDetected in
guard let self else { return }
if faceDetected {
self.lastFaceDetectionTime = Date()
}
}
.store(in: &cancellables)
}
private func startFaceDetectionTimer() {
stopFaceDetectionTimer()
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.checkFaceDetectionTimeout()
}
}
}
private func stopFaceDetectionTimer() {
faceDetectionTimer?.invalidate()
faceDetectionTimer = nil
}
private func checkFaceDetectionTimeout() {
guard isCameraActive else {
stopFaceDetectionTimer()
return
}
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
if timeSinceLastDetection > faceDetectionTimeout {
delegate?.cameraControllerDidTimeout(self)
}
}
}

View File

@@ -0,0 +1,53 @@
//
// EnforcePolicyEvaluator.swift
// Gaze
//
// Policy evaluation for enforce mode behavior.
//
import Foundation
enum ComplianceResult {
case compliant
case notCompliant
case faceNotDetected
}
final class EnforcePolicyEvaluator {
private let settingsProvider: any SettingsProviding
init(settingsProvider: any SettingsProviding) {
self.settingsProvider = settingsProvider
}
var isEnforcementEnabled: Bool {
settingsProvider.settings.enforcementMode
}
func shouldEnforce(timerIdentifier: TimerIdentifier) -> Bool {
guard isEnforcementEnabled else { return false }
switch timerIdentifier {
case .builtIn(let type):
return type == .lookAway
case .user:
return false
}
}
func shouldPreActivateCamera(
timerIdentifier: TimerIdentifier,
secondsRemaining: Int
) -> Bool {
guard secondsRemaining <= 3 else { return false }
return shouldEnforce(timerIdentifier: timerIdentifier)
}
func evaluateCompliance(
isLookingAtScreen: Bool,
faceDetected: Bool
) -> ComplianceResult {
guard faceDetected else { return .faceNotDetected }
return isLookingAtScreen ? .notCompliant : .compliant
}
}

View File

@@ -18,39 +18,21 @@ class EnforceModeService: ObservableObject {
@Published var isTestMode = false
private var settingsManager: SettingsManager
private var eyeTrackingService: EyeTrackingService
private let policyEvaluator: EnforcePolicyEvaluator
private let cameraController: EnforceCameraController
private var timerEngine: TimerEngine?
private var cancellables = Set<AnyCancellable>()
private var faceDetectionTimer: Timer?
private var lastFaceDetectionTime: Date = Date.distantPast
private let faceDetectionTimeout: TimeInterval = 5.0 // 5 seconds to consider person lost
private init() {
self.settingsManager = SettingsManager.shared
self.eyeTrackingService = EyeTrackingService.shared
setupObservers()
self.policyEvaluator = EnforcePolicyEvaluator(settingsProvider: SettingsManager.shared)
self.cameraController = EnforceCameraController(eyeTrackingService: EyeTrackingService.shared)
self.cameraController.delegate = self
initializeEnforceModeState()
}
private func setupObservers() {
eyeTrackingService.$userLookingAtScreen
.sink { [weak self] lookingAtScreen in
self?.handleGazeChange(lookingAtScreen: lookingAtScreen)
}
.store(in: &cancellables)
// Observe face detection changes to track person presence
eyeTrackingService.$faceDetected
.sink { [weak self] faceDetected in
self?.handleFaceDetectionChange(faceDetected: faceDetected)
}
.store(in: &cancellables)
}
private func initializeEnforceModeState() {
let cameraService = CameraAccessService.shared
let settingsEnabled = settingsManager.settings.enforcementMode
let settingsEnabled = policyEvaluator.isEnforcementEnabled
// If settings say it's enabled AND camera is authorized, mark as enabled
if settingsEnabled && cameraService.isCameraAuthorized {
@@ -104,27 +86,17 @@ class EnforceModeService: ObservableObject {
func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool {
guard isEnforceModeEnabled else { return false }
guard settingsManager.settings.enforcementMode else { return false }
switch timerIdentifier {
case .builtIn(let type):
return type == .lookAway
case .user:
return false
}
return policyEvaluator.shouldEnforce(timerIdentifier: timerIdentifier)
}
func startCameraForLookawayTimer(secondsRemaining: Int) async {
guard isEnforceModeEnabled else { return }
guard !isCameraActive else { return }
logDebug("👁️ Starting camera for lookaway reminder (T-\(secondsRemaining)s)")
do {
try await eyeTrackingService.startEyeTracking()
isCameraActive = true
lastFaceDetectionTime = Date() // Reset grace period
startFaceDetectionTimer()
try await cameraController.startCamera()
isCameraActive = cameraController.isCameraActive
logDebug("✓ Camera active")
} catch {
logError("⚠️ Failed to start camera: \(error.localizedDescription)")
@@ -135,11 +107,9 @@ class EnforceModeService: ObservableObject {
guard isCameraActive else { return }
logDebug("👁️ Stopping camera")
eyeTrackingService.stopEyeTracking()
cameraController.stopCamera()
isCameraActive = false
userCompliedWithBreak = false
stopFaceDetectionTimer()
}
func checkUserCompliance() {
@@ -147,54 +117,18 @@ class EnforceModeService: ObservableObject {
userCompliedWithBreak = false
return
}
let compliance = policyEvaluator.evaluateCompliance(
isLookingAtScreen: EyeTrackingService.shared.userLookingAtScreen,
faceDetected: EyeTrackingService.shared.faceDetected
)
let lookingAway = !eyeTrackingService.userLookingAtScreen
userCompliedWithBreak = lookingAway
}
private func handleGazeChange(lookingAtScreen: Bool) {
guard isCameraActive else { return }
checkUserCompliance()
}
private func handleFaceDetectionChange(faceDetected: Bool) {
// Update the last face detection time only when a face is actively detected
if faceDetected {
lastFaceDetectionTime = Date()
}
}
private func startFaceDetectionTimer() {
stopFaceDetectionTimer()
// Check every 1 second
faceDetectionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) {
[weak self] _ in
Task { @MainActor [weak self] in
self?.checkFaceDetectionTimeout()
}
}
}
private func stopFaceDetectionTimer() {
faceDetectionTimer?.invalidate()
faceDetectionTimer = nil
}
private func checkFaceDetectionTimeout() {
guard isEnforceModeEnabled && isCameraActive else {
stopFaceDetectionTimer()
return
}
let timeSinceLastDetection = Date().timeIntervalSince(lastFaceDetectionTime)
// If person has not been detected for too long, temporarily disable enforce mode
if timeSinceLastDetection > faceDetectionTimeout {
logDebug(
"⏰ Person not detected for \(faceDetectionTimeout)s. Temporarily disabling enforce mode."
)
disableEnforceMode()
switch compliance {
case .compliant:
userCompliedWithBreak = true
case .notCompliant:
userCompliedWithBreak = false
case .faceNotDetected:
userCompliedWithBreak = false
}
}
@@ -208,16 +142,13 @@ class EnforceModeService: ObservableObject {
func startTestMode() async {
guard isEnforceModeEnabled else { return }
guard !isCameraActive else { return }
logDebug("🧪 Starting test mode")
isTestMode = true
do {
try await eyeTrackingService.startEyeTracking()
isCameraActive = true
lastFaceDetectionTime = Date() // Reset grace period
startFaceDetectionTimer()
try await cameraController.startCamera()
isCameraActive = cameraController.isCameraActive
logDebug("✓ Test mode camera active")
} catch {
logError("⚠️ Failed to start test mode camera: \(error.localizedDescription)")
@@ -233,3 +164,17 @@ class EnforceModeService: ObservableObject {
isTestMode = false
}
}
extension EnforceModeService: EnforceCameraControllerDelegate {
func cameraControllerDidTimeout(_ controller: EnforceCameraController) {
logDebug(
"⏰ Person not detected for \(controller.faceDetectionTimeout)s. Temporarily disabling enforce mode."
)
disableEnforceMode()
}
func cameraController(_ controller: EnforceCameraController, didUpdateLookingAtScreen: Bool) {
guard isCameraActive else { return }
checkUserCompliance()
}
}

View File

@@ -0,0 +1,36 @@
//
// CalibrationBridge.swift
// Gaze
//
// Thread-safe calibration access for eye tracking.
//
import Foundation
final class CalibrationBridge: @unchecked Sendable {
nonisolated var thresholds: GazeThresholds? {
CalibrationState.shared.thresholds
}
nonisolated var isComplete: Bool {
CalibrationState.shared.isComplete
}
nonisolated func submitSample(
leftRatio: Double,
rightRatio: Double,
leftVertical: Double?,
rightVertical: Double?,
faceWidthRatio: Double
) {
Task { @MainActor in
CalibrationManager.shared.collectSample(
leftRatio: leftRatio,
rightRatio: rightRatio,
leftVertical: leftVertical,
rightVertical: rightVertical,
faceWidthRatio: faceWidthRatio
)
}
}
}

View File

@@ -0,0 +1,123 @@
//
// CameraSessionManager.swift
// Gaze
//
// Manages AVCaptureSession lifecycle for eye tracking.
//
import AVFoundation
import Combine
import Foundation
protocol CameraSessionDelegate: AnyObject {
nonisolated func cameraSession(
_ manager: CameraSessionManager,
didOutput pixelBuffer: CVPixelBuffer,
imageSize: CGSize
)
}
final class CameraSessionManager: NSObject, ObservableObject {
@Published private(set) var isRunning = false
weak var delegate: CameraSessionDelegate?
private var captureSession: AVCaptureSession?
private var videoOutput: AVCaptureVideoDataOutput?
private let videoDataOutputQueue = DispatchQueue(
label: "com.gaze.videoDataOutput",
qos: .userInitiated
)
private var _previewLayer: AVCaptureVideoPreviewLayer?
var previewLayer: AVCaptureVideoPreviewLayer? {
guard let session = captureSession else {
_previewLayer = nil
return nil
}
if let existing = _previewLayer, existing.session === session {
return existing
}
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
_previewLayer = layer
return layer
}
@MainActor
func start() async throws {
guard !isRunning else { return }
let cameraService = CameraAccessService.shared
if !cameraService.isCameraAuthorized {
try await cameraService.requestCameraAccess()
}
guard cameraService.isCameraAuthorized else {
throw CameraAccessError.accessDenied
}
try setupCaptureSession()
captureSession?.startRunning()
isRunning = true
}
@MainActor
func stop() {
captureSession?.stopRunning()
captureSession = nil
videoOutput = nil
_previewLayer = nil
isRunning = false
}
private func setupCaptureSession() throws {
let session = AVCaptureSession()
session.sessionPreset = .vga640x480
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
throw EyeTrackingError.noCamera
}
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
guard session.canAddInput(videoInput) else {
throw EyeTrackingError.cannotAddInput
}
session.addInput(videoInput)
let output = AVCaptureVideoDataOutput()
output.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
output.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
output.alwaysDiscardsLateVideoFrames = true
guard session.canAddOutput(output) else {
throw EyeTrackingError.cannotAddOutput
}
session.addOutput(output)
self.captureSession = session
self.videoOutput = output
}
}
extension CameraSessionManager: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
let size = CGSize(
width: CVPixelBufferGetWidth(pixelBuffer),
height: CVPixelBufferGetHeight(pixelBuffer)
)
delegate?.cameraSession(self, didOutput: pixelBuffer, imageSize: size)
}
}

View File

@@ -0,0 +1,101 @@
//
// 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
}
}

View File

@@ -0,0 +1,21 @@
//
// 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?
}

View File

@@ -0,0 +1,331 @@
//
// GazeDetector.swift
// Gaze
//
// Gaze detection logic and pupil analysis.
//
import Foundation
import Vision
import simd
final class GazeDetector: @unchecked Sendable {
struct GazeResult: Sendable {
let isLookingAway: Bool
let isEyesClosed: Bool
let leftPupilRatio: Double?
let rightPupilRatio: Double?
let leftVerticalRatio: Double?
let rightVerticalRatio: Double?
let yaw: Double?
let pitch: Double?
}
struct Configuration: Sendable {
let thresholds: GazeThresholds?
let isCalibrationComplete: Bool
let eyeClosedEnabled: Bool
let eyeClosedThreshold: CGFloat
let yawEnabled: Bool
let yawThreshold: Double
let pitchUpEnabled: Bool
let pitchUpThreshold: Double
let pitchDownEnabled: Bool
let pitchDownThreshold: Double
let pixelGazeEnabled: Bool
let pixelGazeMinRatio: Double
let pixelGazeMaxRatio: Double
let boundaryForgivenessMargin: Double
let distanceSensitivity: Double
let defaultReferenceFaceWidth: Double
}
private let lock = NSLock()
private var configuration: Configuration
init(configuration: Configuration) {
self.configuration = configuration
}
func updateConfiguration(_ configuration: Configuration) {
lock.lock()
self.configuration = configuration
lock.unlock()
}
nonisolated func process(
analysis: VisionPipeline.FaceAnalysis,
pixelBuffer: CVPixelBuffer
) -> EyeTrackingProcessingResult {
let config: Configuration
lock.lock()
config = configuration
lock.unlock()
guard analysis.faceDetected, let face = analysis.face else {
return EyeTrackingProcessingResult(
faceDetected: false,
isEyesClosed: false,
userLookingAtScreen: false,
leftPupilRatio: nil,
rightPupilRatio: nil,
leftVerticalRatio: nil,
rightVerticalRatio: nil,
yaw: analysis.debugYaw,
pitch: analysis.debugPitch,
faceWidthRatio: nil
)
}
let landmarks = face.landmarks
let yaw = face.yaw?.doubleValue ?? 0.0
let pitch = face.pitch?.doubleValue ?? 0.0
var isEyesClosed = false
if let leftEye = landmarks?.leftEye, let rightEye = landmarks?.rightEye {
isEyesClosed = detectEyesClosed(leftEye: leftEye, rightEye: rightEye, configuration: config)
}
let gazeResult = detectLookingAway(
face: face,
landmarks: landmarks,
imageSize: analysis.imageSize,
pixelBuffer: pixelBuffer,
configuration: config
)
let lookingAway = gazeResult.lookingAway
let userLookingAtScreen = !lookingAway
return EyeTrackingProcessingResult(
faceDetected: true,
isEyesClosed: isEyesClosed,
userLookingAtScreen: userLookingAtScreen,
leftPupilRatio: gazeResult.leftPupilRatio,
rightPupilRatio: gazeResult.rightPupilRatio,
leftVerticalRatio: gazeResult.leftVerticalRatio,
rightVerticalRatio: gazeResult.rightVerticalRatio,
yaw: gazeResult.yaw ?? yaw,
pitch: gazeResult.pitch ?? pitch,
faceWidthRatio: face.boundingBox.width
)
}
private func detectEyesClosed(
leftEye: VNFaceLandmarkRegion2D,
rightEye: VNFaceLandmarkRegion2D,
configuration: Configuration
) -> Bool {
guard configuration.eyeClosedEnabled else { return false }
guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { return false }
let leftEyeHeight = calculateEyeHeight(leftEye)
let rightEyeHeight = calculateEyeHeight(rightEye)
let closedThreshold = configuration.eyeClosedThreshold
return leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold
}
private func calculateEyeHeight(_ eye: VNFaceLandmarkRegion2D) -> CGFloat {
let points = eye.normalizedPoints
guard points.count >= 2 else { return 0 }
let yValues = points.map { $0.y }
let maxY = yValues.max() ?? 0
let minY = yValues.min() ?? 0
return abs(maxY - minY)
}
private struct GazeDetectionResult: Sendable {
var lookingAway: Bool = false
var leftPupilRatio: Double?
var rightPupilRatio: Double?
var leftVerticalRatio: Double?
var rightVerticalRatio: Double?
var yaw: Double?
var pitch: Double?
}
private func detectLookingAway(
face: VNFaceObservation,
landmarks: VNFaceLandmarks2D?,
imageSize: CGSize,
pixelBuffer: CVPixelBuffer,
configuration: Configuration
) -> GazeDetectionResult {
var result = GazeDetectionResult()
let yaw = face.yaw?.doubleValue ?? 0.0
let pitch = face.pitch?.doubleValue ?? 0.0
result.yaw = yaw
result.pitch = pitch
var poseLookingAway = false
if face.pitch != nil {
if configuration.yawEnabled {
let yawThreshold = configuration.yawThreshold
if abs(yaw) > yawThreshold {
poseLookingAway = true
}
}
if !poseLookingAway {
var pitchLookingAway = false
if configuration.pitchUpEnabled && pitch > configuration.pitchUpThreshold {
pitchLookingAway = true
}
if configuration.pitchDownEnabled && pitch < configuration.pitchDownThreshold {
pitchLookingAway = true
}
poseLookingAway = pitchLookingAway
}
}
var eyesLookingAway = false
if let landmarks,
let leftEye = landmarks.leftEye,
let rightEye = landmarks.rightEye,
configuration.pixelGazeEnabled {
var leftGazeRatio: Double? = nil
var rightGazeRatio: Double? = nil
var leftVerticalRatio: Double? = nil
var rightVerticalRatio: Double? = nil
if let leftResult = PupilDetector.detectPupil(
in: pixelBuffer,
eyeLandmarks: leftEye,
faceBoundingBox: face.boundingBox,
imageSize: imageSize,
side: 0
) {
leftGazeRatio = calculateGazeRatio(
pupilPosition: leftResult.pupilPosition,
eyeRegion: leftResult.eyeRegion
)
leftVerticalRatio = calculateVerticalRatio(
pupilPosition: leftResult.pupilPosition,
eyeRegion: leftResult.eyeRegion
)
}
if let rightResult = PupilDetector.detectPupil(
in: pixelBuffer,
eyeLandmarks: rightEye,
faceBoundingBox: face.boundingBox,
imageSize: imageSize,
side: 1
) {
rightGazeRatio = calculateGazeRatio(
pupilPosition: rightResult.pupilPosition,
eyeRegion: rightResult.eyeRegion
)
rightVerticalRatio = calculateVerticalRatio(
pupilPosition: rightResult.pupilPosition,
eyeRegion: rightResult.eyeRegion
)
}
result.leftPupilRatio = leftGazeRatio
result.rightPupilRatio = rightGazeRatio
result.leftVerticalRatio = leftVerticalRatio
result.rightVerticalRatio = rightVerticalRatio
if let leftRatio = leftGazeRatio,
let rightRatio = rightGazeRatio {
let avgH = (leftRatio + rightRatio) / 2.0
let avgV = (leftVerticalRatio != nil && rightVerticalRatio != nil)
? (leftVerticalRatio! + rightVerticalRatio!) / 2.0
: 0.5
if configuration.isCalibrationComplete,
let thresholds = configuration.thresholds {
let currentFaceWidth = face.boundingBox.width
let refFaceWidth = thresholds.referenceFaceWidth
var distanceScale = 1.0
if refFaceWidth > 0 && currentFaceWidth > 0 {
let rawScale = refFaceWidth / currentFaceWidth
distanceScale = 1.0 + (rawScale - 1.0) * configuration.distanceSensitivity
distanceScale = max(0.5, min(2.0, distanceScale))
}
let centerH = (thresholds.screenLeftBound + thresholds.screenRightBound) / 2.0
let centerV = (thresholds.screenTopBound + thresholds.screenBottomBound) / 2.0
let deltaH = (avgH - centerH) * distanceScale
let deltaV = (avgV - centerV) * distanceScale
let normalizedH = centerH + deltaH
let normalizedV = centerV + deltaV
let margin = configuration.boundaryForgivenessMargin
let isLookingLeft = normalizedH > (thresholds.screenLeftBound + margin)
let isLookingRight = normalizedH < (thresholds.screenRightBound - margin)
let isLookingUp = normalizedV < (thresholds.screenTopBound - margin)
let isLookingDown = normalizedV > (thresholds.screenBottomBound + margin)
eyesLookingAway = isLookingLeft || isLookingRight || isLookingUp || isLookingDown
} else {
let currentFaceWidth = face.boundingBox.width
let refFaceWidth = configuration.defaultReferenceFaceWidth
var distanceScale = 1.0
if refFaceWidth > 0 && currentFaceWidth > 0 {
let rawScale = refFaceWidth / currentFaceWidth
distanceScale = 1.0 + (rawScale - 1.0) * configuration.distanceSensitivity
distanceScale = max(0.5, min(2.0, distanceScale))
}
let centerH = (configuration.pixelGazeMinRatio + configuration.pixelGazeMaxRatio) / 2.0
let normalizedH = centerH + (avgH - centerH) * distanceScale
let lookingRight = normalizedH <= configuration.pixelGazeMinRatio
let lookingLeft = normalizedH >= configuration.pixelGazeMaxRatio
eyesLookingAway = lookingRight || lookingLeft
}
}
}
result.lookingAway = poseLookingAway || eyesLookingAway
return result
}
private func calculateGazeRatio(
pupilPosition: PupilPosition,
eyeRegion: EyeRegion
) -> Double {
let pupilX = Double(pupilPosition.x)
let eyeCenterX = Double(eyeRegion.center.x)
let denominator = (eyeCenterX * 2.0 - 10.0)
guard denominator > 0 else {
let eyeLeft = Double(eyeRegion.frame.minX)
let eyeRight = Double(eyeRegion.frame.maxX)
let eyeWidth = eyeRight - eyeLeft
guard eyeWidth > 0 else { return 0.5 }
return (pupilX - eyeLeft) / eyeWidth
}
let ratio = pupilX / denominator
return max(0.0, min(1.0, ratio))
}
private func calculateVerticalRatio(
pupilPosition: PupilPosition,
eyeRegion: EyeRegion
) -> Double {
let pupilX = Double(pupilPosition.x)
let eyeWidth = Double(eyeRegion.frame.width)
guard eyeWidth > 0 else { return 0.5 }
let ratio = pupilX / eyeWidth
return max(0.0, min(1.0, ratio))
}
}

View File

@@ -0,0 +1,67 @@
//
// VisionPipeline.swift
// Gaze
//
// Vision processing pipeline for face detection.
//
import Foundation
import Vision
final class VisionPipeline: @unchecked Sendable {
struct FaceAnalysis: Sendable {
let faceDetected: Bool
let face: VNFaceObservation?
let imageSize: CGSize
let debugYaw: Double?
let debugPitch: Double?
}
nonisolated func analyze(
pixelBuffer: CVPixelBuffer,
imageSize: CGSize
) -> FaceAnalysis {
let request = VNDetectFaceLandmarksRequest()
request.revision = VNDetectFaceLandmarksRequestRevision3
if #available(macOS 14.0, *) {
request.constellation = .constellation76Points
}
let handler = VNImageRequestHandler(
cvPixelBuffer: pixelBuffer,
orientation: .upMirrored,
options: [:]
)
do {
try handler.perform([request])
} catch {
return FaceAnalysis(
faceDetected: false,
face: nil,
imageSize: imageSize,
debugYaw: nil,
debugPitch: nil
)
}
guard let face = (request.results as? [VNFaceObservation])?.first else {
return FaceAnalysis(
faceDetected: false,
face: nil,
imageSize: imageSize,
debugYaw: nil,
debugPitch: nil
)
}
return FaceAnalysis(
faceDetected: true,
face: face,
imageSize: imageSize,
debugYaw: face.yaw?.doubleValue,
debugPitch: face.pitch?.doubleValue
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,9 @@
// Dependency injection container for managing service instances.
//
import Combine
import Foundation
/// A simple dependency injection container for managing service instances.
/// Supports both production and test configurations.
@MainActor
final class ServiceContainer {
@@ -34,27 +32,22 @@ final class ServiceContainer {
/// The usage tracking service
private(set) var usageTrackingService: UsageTrackingService?
/// Whether this container is configured for testing
let isTestEnvironment: Bool
/// Creates a production container with real services
private init() {
self.isTestEnvironment = false
self.settingsManager = SettingsManager.shared
self.enforceModeService = EnforceModeService.shared
}
/// Creates a test container with injectable dependencies
/// Creates a container with injectable dependencies
/// - Parameters:
/// - settingsManager: The settings manager to use
/// - enforceModeService: The enforce mode service to use
init(
settingsManager: any SettingsProviding,
enforceModeService: EnforceModeService? = nil
enforceModeService: EnforceModeService
) {
self.isTestEnvironment = true
self.settingsManager = settingsManager
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
self.enforceModeService = enforceModeService
}
/// Gets or creates the timer engine
@@ -65,17 +58,12 @@ final class ServiceContainer {
let engine = TimerEngine(
settingsManager: settingsManager,
enforceModeService: enforceModeService,
timeProvider: isTestEnvironment ? MockTimeProvider() : SystemTimeProvider()
timeProvider: SystemTimeProvider()
)
_timerEngine = engine
return engine
}
/// Sets a custom timer engine (useful for testing)
func setTimerEngine(_ engine: TimerEngine) {
_timerEngine = engine
}
/// Sets up smart mode services
func setupSmartModeServices() {
let settings = settingsManager.settings
@@ -102,85 +90,4 @@ final class ServiceContainer {
}
}
/// Resets the container for testing purposes
func reset() {
_timerEngine?.stop()
_timerEngine = nil
fullscreenService = nil
idleService = nil
usageTrackingService = nil
}
/// Creates a new container configured for testing with default mock settings
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)
}
}
/// A mock settings manager for use in ServiceContainer.forTesting()
/// This is a minimal implementation - use the full MockSettingsManager from tests for more features
@MainActor
@Observable
final class MockSettingsManager: SettingsProviding {
var settings: AppSettings
@ObservationIgnored
private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
var settingsPublisher: AnyPublisher<AppSettings, Never> {
_settingsSubject.eraseToAnyPublisher()
}
@ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
.lookAway: \.lookAwayTimer,
.blink: \.blinkTimer,
.posture: \.postureTimer,
]
convenience init() {
self.init(settings: AppSettings())
}
init(settings: AppSettings) {
self.settings = settings
self._settingsSubject = CurrentValueSubject(settings)
}
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)")
}
return settings[keyPath: keyPath]
}
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) {
guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)")
}
settings[keyPath: keyPath] = configuration
_settingsSubject.send(settings)
}
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
var configs: [TimerType: TimerConfiguration] = [:]
for (type, keyPath) in timerConfigKeyPaths {
configs[type] = settings[keyPath: keyPath]
}
return configs
}
func save() { _settingsSubject.send(settings) }
func saveImmediately() { _settingsSubject.send(settings) }
func load() {}
func resetToDefaults() {
settings = .defaults
_settingsSubject.send(settings)
}
}

View File

@@ -1,72 +0,0 @@
//
// SmartModeManager.swift
// Gaze
//
// Handles smart mode features like idle detection and fullscreen detection.
//
import Combine
import Foundation
@MainActor
class SmartModeManager {
private var fullscreenService: FullscreenDetectionService?
private var idleService: IdleMonitoringService?
private var timerEngine: TimerEngine?
private var cancellables = Set<AnyCancellable>()
func setupSmartMode(
timerEngine: TimerEngine,
fullscreenService: FullscreenDetectionService?,
idleService: IdleMonitoringService?
) {
self.timerEngine = timerEngine
self.fullscreenService = fullscreenService
self.idleService = idleService
// Subscribe to fullscreen state changes
fullscreenService?.$isFullscreenActive
.sink { [weak self] isFullscreen in
Task { @MainActor in
self?.handleFullscreenChange(isFullscreen: isFullscreen)
}
}
.store(in: &cancellables)
// Subscribe to idle state changes
idleService?.$isIdle
.sink { [weak self] isIdle in
Task { @MainActor in
self?.handleIdleChange(isIdle: isIdle)
}
}
.store(in: &cancellables)
}
private func handleFullscreenChange(isFullscreen: Bool) {
guard let timerEngine = timerEngine else { return }
guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnFullscreen else { return }
if isFullscreen {
timerEngine.pauseAllTimers(reason: .fullscreen)
logInfo("⏸️ Timers paused: fullscreen detected")
} else {
timerEngine.resumeAllTimers(reason: .fullscreen)
logInfo("▶️ Timers resumed: fullscreen exited")
}
}
private func handleIdleChange(isIdle: Bool) {
guard let timerEngine = timerEngine else { return }
guard timerEngine.settingsProviderForTesting.settings.smartMode.autoPauseOnIdle else { return }
if isIdle {
timerEngine.pauseAllTimers(reason: .idle)
logInfo("⏸️ Timers paused: user idle")
} else {
timerEngine.resumeAllTimers(reason: .idle)
logInfo("▶️ Timers resumed: user active")
}
}
}

View File

@@ -0,0 +1,56 @@
//
// ReminderTriggerService.swift
// Gaze
//
// Creates reminder events and coordinates enforce mode behavior.
//
import Foundation
@MainActor
final class ReminderTriggerService {
private let settingsProvider: any SettingsProviding
private let enforceModeService: EnforceModeService?
init(
settingsProvider: any SettingsProviding,
enforceModeService: EnforceModeService?
) {
self.settingsProvider = settingsProvider
self.enforceModeService = enforceModeService
}
func reminderEvent(for identifier: TimerIdentifier) -> ReminderEvent? {
switch identifier {
case .builtIn(let type):
switch type {
case .lookAway:
return .lookAwayTriggered(
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds
)
case .blink:
return .blinkTriggered
case .posture:
return .postureTriggered
}
case .user(let id):
guard let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) else {
return nil
}
return .userTimerTriggered(userTimer)
}
}
func shouldPrepareEnforceMode(for identifier: TimerIdentifier, secondsRemaining: Int) -> Bool {
guard secondsRemaining <= 3 else { return false }
return enforceModeService?.shouldEnforceBreak(for: identifier) ?? false
}
func prepareEnforceMode(secondsRemaining: Int) async {
await enforceModeService?.startCameraForLookawayTimer(secondsRemaining: secondsRemaining)
}
func handleReminderDismissed() {
enforceModeService?.handleReminderDismissed()
}
}

View File

@@ -0,0 +1,85 @@
//
// SmartModeCoordinator.swift
// Gaze
//
// Coordinates smart mode pause/resume behavior.
//
import Combine
import Foundation
protocol SmartModeCoordinatorDelegate: AnyObject {
func smartModeDidRequestPauseAll(_ coordinator: SmartModeCoordinator, reason: PauseReason)
func smartModeDidRequestResumeAll(_ coordinator: SmartModeCoordinator, reason: PauseReason)
}
@MainActor
final class SmartModeCoordinator {
weak var delegate: SmartModeCoordinatorDelegate?
private var fullscreenService: FullscreenDetectionService?
private var idleService: IdleMonitoringService?
private var cancellables = Set<AnyCancellable>()
private var settingsProvider: (any SettingsProviding)?
init() {}
func setup(
fullscreenService: FullscreenDetectionService?,
idleService: IdleMonitoringService?,
settingsProvider: any SettingsProviding
) {
self.fullscreenService = fullscreenService
self.idleService = idleService
self.settingsProvider = settingsProvider
fullscreenService?.$isFullscreenActive
.sink { [weak self] isFullscreen in
Task { @MainActor in
self?.handleFullscreenChange(isFullscreen: isFullscreen)
}
}
.store(in: &cancellables)
idleService?.$isIdle
.sink { [weak self] isIdle in
Task { @MainActor in
self?.handleIdleChange(isIdle: isIdle)
}
}
.store(in: &cancellables)
}
func teardown() {
cancellables.removeAll()
fullscreenService = nil
idleService = nil
settingsProvider = nil
}
private func handleFullscreenChange(isFullscreen: Bool) {
guard let settingsProvider else { return }
guard settingsProvider.settings.smartMode.autoPauseOnFullscreen else { return }
if isFullscreen {
delegate?.smartModeDidRequestPauseAll(self, reason: .fullscreen)
logInfo("⏸️ Timers paused: fullscreen detected")
} else {
delegate?.smartModeDidRequestResumeAll(self, reason: .fullscreen)
logInfo("▶️ Timers resumed: fullscreen exited")
}
}
private func handleIdleChange(isIdle: Bool) {
guard let settingsProvider else { return }
guard settingsProvider.settings.smartMode.autoPauseOnIdle else { return }
if isIdle {
delegate?.smartModeDidRequestPauseAll(self, reason: .idle)
logInfo("⏸️ Timers paused: user idle")
} else {
delegate?.smartModeDidRequestResumeAll(self, reason: .idle)
logInfo("▶️ Timers resumed: user active")
}
}
}

View File

@@ -0,0 +1,44 @@
//
// TimerScheduler.swift
// Gaze
//
// Schedules timer ticks for TimerEngine.
//
import Combine
import Foundation
protocol TimerSchedulerDelegate: AnyObject {
func schedulerDidTick(_ scheduler: TimerScheduler)
}
@MainActor
final class TimerScheduler {
weak var delegate: TimerSchedulerDelegate?
private var timerSubscription: AnyCancellable?
private let timeProvider: TimeProviding
init(timeProvider: TimeProviding) {
self.timeProvider = timeProvider
}
var isRunning: Bool {
timerSubscription != nil
}
func start() {
guard timerSubscription == nil else { return }
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self else { return }
self.delegate?.schedulerDidTick(self)
}
}
func stop() {
timerSubscription?.cancel()
timerSubscription = nil
}
}

View File

@@ -0,0 +1,162 @@
//
// TimerStateManager.swift
// Gaze
//
// Manages timer state transitions and reminder state.
//
import Combine
import Foundation
@MainActor
final class TimerStateManager: ObservableObject {
@Published private(set) var timerStates: [TimerIdentifier: TimerState] = [:]
@Published private(set) var activeReminder: ReminderEvent?
func initializeTimers(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) {
var newStates: [TimerIdentifier: TimerState] = [:]
for (identifier, config) in configurations where config.enabled {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
)
}
for userTimer in userTimers where userTimer.enabled {
let identifier = TimerIdentifier.user(id: userTimer.id)
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: userTimer.intervalMinutes * 60,
isPaused: false,
isActive: true
)
}
timerStates = newStates
}
func updateConfigurations(using configurations: [TimerIdentifier: TimerConfiguration], userTimers: [UserTimer]) {
var newStates: [TimerIdentifier: TimerState] = [:]
for (identifier, config) in configurations {
if config.enabled {
if let existingState = timerStates[identifier] {
if existingState.originalIntervalSeconds != config.intervalSeconds {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
newStates[identifier] = existingState
}
} else {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
)
}
}
}
for userTimer in userTimers {
let identifier = TimerIdentifier.user(id: userTimer.id)
let newIntervalSeconds = userTimer.intervalMinutes * 60
if userTimer.enabled {
if let existingState = timerStates[identifier] {
if existingState.originalIntervalSeconds != newIntervalSeconds {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
newStates[identifier] = existingState
}
} else {
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: false,
isActive: true
)
}
}
}
timerStates = newStates
}
func decrementTimer(identifier: TimerIdentifier) -> TimerState? {
guard var state = timerStates[identifier] else { return nil }
state.remainingSeconds -= 1
timerStates[identifier] = state
return state
}
func setReminder(_ reminder: ReminderEvent?) {
activeReminder = reminder
}
func pauseAll(reason: PauseReason) {
for (id, var state) in timerStates {
state.pauseReasons.insert(reason)
state.isPaused = true
timerStates[id] = state
}
}
func resumeAll(reason: PauseReason) {
for (id, var state) in timerStates {
state.pauseReasons.remove(reason)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[id] = state
}
}
func pauseTimer(identifier: TimerIdentifier, reason: PauseReason) {
guard var state = timerStates[identifier] else { return }
state.pauseReasons.insert(reason)
state.isPaused = true
timerStates[identifier] = state
}
func resumeTimer(identifier: TimerIdentifier, reason: PauseReason) {
guard var state = timerStates[identifier] else { return }
state.pauseReasons.remove(reason)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[identifier] = state
}
func resetTimer(identifier: TimerIdentifier, intervalSeconds: Int) {
guard let state = timerStates[identifier] else { return }
timerStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: intervalSeconds,
isPaused: state.isPaused,
isActive: state.isActive
)
}
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
guard let state = timerStates[identifier] else { return 0 }
return TimeInterval(state.remainingSeconds)
}
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
return timerStates[identifier]?.isPaused ?? true
}
func clearAll() {
timerStates.removeAll()
activeReminder = nil
}
}

View File

@@ -13,24 +13,12 @@ class TimerEngine: ObservableObject {
@Published var timerStates: [TimerIdentifier: TimerState] = [:]
@Published var activeReminder: ReminderEvent?
private var timerSubscription: AnyCancellable?
private let settingsProvider: any SettingsProviding
// Expose the settings provider for components that need it (like SmartModeManager)
var settingsProviderForTesting: any SettingsProviding {
return settingsProvider
}
private var sleepStartTime: Date?
/// Time provider for deterministic testing (defaults to system time)
private let timeProvider: TimeProviding
// For enforce mode integration
private var enforceModeService: EnforceModeService?
// Smart Mode services
private var fullscreenService: FullscreenDetectionService?
private var idleService: IdleMonitoringService?
private let stateManager = TimerStateManager()
private let scheduler: TimerScheduler
private let reminderService: ReminderTriggerService
private let smartModeCoordinator = SmartModeCoordinator()
private var cancellables = Set<AnyCancellable>()
convenience init(
@@ -50,127 +38,66 @@ class TimerEngine: ObservableObject {
timeProvider: TimeProviding
) {
self.settingsProvider = settingsManager
self.enforceModeService = enforceModeService ?? EnforceModeService.shared
self.timeProvider = timeProvider
self.scheduler = TimerScheduler(timeProvider: timeProvider)
self.reminderService = ReminderTriggerService(
settingsProvider: settingsManager,
enforceModeService: enforceModeService ?? EnforceModeService.shared
)
Task { @MainActor in
self.enforceModeService?.setTimerEngine(self)
enforceModeService?.setTimerEngine(self)
}
scheduler.delegate = self
smartModeCoordinator.delegate = self
stateManager.$timerStates
.sink { [weak self] states in
self?.timerStates = states
}
.store(in: &cancellables)
stateManager.$activeReminder
.sink { [weak self] reminder in
self?.activeReminder = reminder
}
.store(in: &cancellables)
}
func setupSmartMode(
fullscreenService: FullscreenDetectionService?,
idleService: IdleMonitoringService?
) {
self.fullscreenService = fullscreenService
self.idleService = idleService
// Subscribe to fullscreen state changes
fullscreenService?.$isFullscreenActive
.sink { [weak self] isFullscreen in
Task { @MainActor in
self?.handleFullscreenChange(isFullscreen: isFullscreen)
}
}
.store(in: &cancellables)
// Subscribe to idle state changes
idleService?.$isIdle
.sink { [weak self] isIdle in
Task { @MainActor in
self?.handleIdleChange(isIdle: isIdle)
}
}
.store(in: &cancellables)
}
private func handleFullscreenChange(isFullscreen: Bool) {
guard settingsProvider.settings.smartMode.autoPauseOnFullscreen else { return }
if isFullscreen {
pauseAllTimers(reason: .fullscreen)
logInfo("⏸️ Timers paused: fullscreen detected")
} else {
resumeAllTimers(reason: .fullscreen)
logInfo("▶️ Timers resumed: fullscreen exited")
}
}
private func handleIdleChange(isIdle: Bool) {
guard settingsProvider.settings.smartMode.autoPauseOnIdle else { return }
if isIdle {
pauseAllTimers(reason: .idle)
logInfo("⏸️ Timers paused: user idle")
} else {
resumeAllTimers(reason: .idle)
logInfo("▶️ Timers resumed: user active")
}
smartModeCoordinator.setup(
fullscreenService: fullscreenService,
idleService: idleService,
settingsProvider: settingsProvider
)
}
func pauseAllTimers(reason: PauseReason) {
for (id, var state) in timerStates {
state.pauseReasons.insert(reason)
state.isPaused = true
timerStates[id] = state
}
stateManager.pauseAll(reason: reason)
}
func resumeAllTimers(reason: PauseReason) {
for (id, var state) in timerStates {
state.pauseReasons.remove(reason)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[id] = state
}
stateManager.resumeAll(reason: reason)
}
func start() {
// If timers are already running, just update configurations without resetting
if timerSubscription != nil {
if scheduler.isRunning {
updateConfigurations()
return
}
// Initial start - create all timer states
stop()
var newStates: [TimerIdentifier: TimerState] = [:]
// Add built-in timers (using unified approach)
for timerType in TimerType.allCases {
let config = settingsProvider.timerConfiguration(for: timerType)
if config.enabled {
let identifier = TimerIdentifier.builtIn(timerType)
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
)
}
}
// Add user timers (using unified approach)
for userTimer in settingsProvider.settings.userTimers where userTimer.enabled {
let identifier = TimerIdentifier.user(id: userTimer.id)
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: userTimer.intervalMinutes * 60,
isPaused: false,
isActive: true
)
}
// Assign the entire dictionary at once to trigger @Published
timerStates = newStates
timerSubscription = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
Task { @MainActor in
self?.handleTick()
}
}
stateManager.initializeTimers(
using: timerConfigurations(),
userTimers: settingsProvider.settings.userTimers
)
scheduler.start()
}
/// Check if enforce mode is active and should affect timer behavior
@@ -180,130 +107,36 @@ class TimerEngine: ObservableObject {
private func updateConfigurations() {
logDebug("Updating timer configurations")
var newStates: [TimerIdentifier: TimerState] = [:]
// Update built-in timers (using unified approach)
for timerType in TimerType.allCases {
let config = settingsProvider.timerConfiguration(for: timerType)
let identifier = TimerIdentifier.builtIn(timerType)
if config.enabled {
if let existingState = timerStates[identifier] {
// Timer exists - check if interval changed
if existingState.originalIntervalSeconds != config.intervalSeconds {
// Interval changed - reset with new interval
logDebug("Timer interval changed")
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
// Interval unchanged - keep existing state
newStates[identifier] = existingState
}
} else {
// Timer was just enabled - create new state
logDebug("Timer enabled")
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: config.intervalSeconds,
isPaused: false,
isActive: true
)
}
}
// If config.enabled is false and timer exists, it will be removed
}
// Update user timers (using unified approach)
for userTimer in settingsProvider.settings.userTimers {
let identifier = TimerIdentifier.user(id: userTimer.id)
let newIntervalSeconds = userTimer.intervalMinutes * 60
if userTimer.enabled {
if let existingState = timerStates[identifier] {
// Check if interval changed
if existingState.originalIntervalSeconds != newIntervalSeconds {
// Interval changed - reset with new interval
logDebug("User timer interval changed")
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: existingState.isPaused,
isActive: true
)
} else {
// Interval unchanged - keep existing state
newStates[identifier] = existingState
}
} else {
// New timer - create state
logDebug("User timer created")
newStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: newIntervalSeconds,
isPaused: false,
isActive: true
)
}
}
// If timer is disabled, it will be removed
}
// Assign the entire dictionary at once to trigger @Published
timerStates = newStates
stateManager.updateConfigurations(
using: timerConfigurations(),
userTimers: settingsProvider.settings.userTimers
)
}
func stop() {
timerSubscription?.cancel()
timerSubscription = nil
timerStates.removeAll()
scheduler.stop()
stateManager.clearAll()
}
func pause() {
for (id, var state) in timerStates {
state.pauseReasons.insert(.manual)
state.isPaused = true
timerStates[id] = state
}
stateManager.pauseAll(reason: .manual)
}
func resume() {
for (id, var state) in timerStates {
state.pauseReasons.remove(.manual)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[id] = state
}
stateManager.resumeAll(reason: .manual)
}
func pauseTimer(identifier: TimerIdentifier) {
guard var state = timerStates[identifier] else { return }
state.pauseReasons.insert(.manual)
state.isPaused = true
timerStates[identifier] = state
stateManager.pauseTimer(identifier: identifier, reason: .manual)
}
func resumeTimer(identifier: TimerIdentifier) {
guard var state = timerStates[identifier] else { return }
state.pauseReasons.remove(.manual)
state.isPaused = !state.pauseReasons.isEmpty
timerStates[identifier] = state
stateManager.resumeTimer(identifier: identifier, reason: .manual)
}
func skipNext(identifier: TimerIdentifier) {
guard let state = timerStates[identifier] else { return }
// Unified approach to get interval - no more separate handling for user timers
func skipNext(identifier: TimerIdentifier) {
let intervalSeconds = getTimerInterval(for: identifier)
timerStates[identifier] = TimerState(
identifier: identifier,
intervalSeconds: intervalSeconds,
isPaused: state.isPaused,
isActive: state.isActive
)
stateManager.resetTimer(identifier: identifier, intervalSeconds: intervalSeconds)
}
/// Unified way to get interval for any timer type
@@ -322,13 +155,13 @@ func skipNext(identifier: TimerIdentifier) {
func dismissReminder() {
guard let reminder = activeReminder else { return }
activeReminder = nil
stateManager.setReminder(nil)
let identifier = reminder.identifier
skipNext(identifier: identifier)
resumeTimer(identifier: identifier)
enforceModeService?.handleReminderDismissed()
reminderService.handleReminderDismissed()
}
private func handleTick() {
@@ -341,24 +174,22 @@ func skipNext(identifier: TimerIdentifier) {
continue
}
timerStates[identifier]?.remainingSeconds -= 1
guard let updatedState = stateManager.decrementTimer(identifier: identifier) else {
continue
}
if let updatedState = timerStates[identifier] {
// Unified approach - no more special handling needed for any timer type
if updatedState.remainingSeconds <= 3 && !updatedState.isPaused {
// Enforce mode is handled generically, not specifically for lookAway only
if enforceModeService?.shouldEnforceBreak(for: identifier) == true {
Task { @MainActor in
await enforceModeService?.startCameraForLookawayTimer(
secondsRemaining: updatedState.remainingSeconds)
}
}
if reminderService.shouldPrepareEnforceMode(
for: identifier,
secondsRemaining: updatedState.remainingSeconds
) {
Task { @MainActor in
await reminderService.prepareEnforceMode(secondsRemaining: updatedState.remainingSeconds)
}
}
if updatedState.remainingSeconds <= 0 {
triggerReminder(for: identifier)
break
}
if updatedState.remainingSeconds <= 0 {
triggerReminder(for: identifier)
break
}
}
}
@@ -367,28 +198,13 @@ func skipNext(identifier: TimerIdentifier) {
// Pause only the timer that triggered
pauseTimer(identifier: identifier)
// Unified approach to handle all timer types - no more special handling
switch identifier {
case .builtIn(let type):
switch type {
case .lookAway:
activeReminder = .lookAwayTriggered(
countdownSeconds: settingsProvider.settings.lookAwayCountdownSeconds)
case .blink:
activeReminder = .blinkTriggered
case .posture:
activeReminder = .postureTriggered
}
case .user(let id):
if let userTimer = settingsProvider.settings.userTimers.first(where: { $0.id == id }) {
activeReminder = .userTimerTriggered(userTimer)
}
if let reminder = reminderService.reminderEvent(for: identifier) {
stateManager.setReminder(reminder)
}
}
func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval {
guard let state = timerStates[identifier] else { return 0 }
return TimeInterval(state.remainingSeconds)
stateManager.getTimeRemaining(for: identifier)
}
func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String {
@@ -396,11 +212,11 @@ func skipNext(identifier: TimerIdentifier) {
}
func isTimerPaused(_ identifier: TimerIdentifier) -> Bool {
return timerStates[identifier]?.isPaused ?? true
return stateManager.isTimerPaused(identifier)
}
// System sleep/wake handling is now managed by SystemSleepManager
// This method is kept for compatibility but will be removed in future versions
// System sleep/wake handling is now managed by SystemSleepManager
// This method is kept for compatibility but will be removed in future versions
/// Handles system sleep event - deprecated
@available(*, deprecated, message: "Use SystemSleepManager instead")
func handleSystemSleep() {
@@ -414,5 +230,29 @@ func skipNext(identifier: TimerIdentifier) {
logDebug("System waking up (deprecated)")
// This functionality has been moved to SystemSleepManager
}
private func timerConfigurations() -> [TimerIdentifier: TimerConfiguration] {
var configurations: [TimerIdentifier: TimerConfiguration] = [:]
for timerType in TimerType.allCases {
let config = settingsProvider.timerConfiguration(for: timerType)
configurations[.builtIn(timerType)] = config
}
return configurations
}
}
extension TimerEngine: TimerSchedulerDelegate {
func schedulerDidTick(_ scheduler: TimerScheduler) {
handleTick()
}
}
extension TimerEngine: SmartModeCoordinatorDelegate {
func smartModeDidRequestPauseAll(_ coordinator: SmartModeCoordinator, reason: PauseReason) {
pauseAllTimers(reason: reason)
}
func smartModeDidRequestResumeAll(_ coordinator: SmartModeCoordinator, reason: PauseReason) {
resumeAllTimers(reason: reason)
}
}

View File

@@ -93,7 +93,6 @@ final class WindowManager: WindowManaging {
}
func showSettings(settingsManager: any SettingsProviding, initialTab: Int) {
// Use the existing presenter for now
if let realSettings = settingsManager as? SettingsManager {
SettingsWindowPresenter.shared.show(settingsManager: realSettings, initialTab: initialTab)
}

View File

@@ -0,0 +1,74 @@
//
// ExternalLinkButton.swift
// Gaze
//
// Reusable external link button component.
//
import SwiftUI
struct ExternalLinkButton: View {
let icon: String
var iconColor: Color = .primary
let title: String
let subtitle: String
let url: String
let tint: Color?
var body: some View {
Button(action: openURL) {
HStack {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(iconColor)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.semibold)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
}
.padding()
.frame(maxWidth: .infinity)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
tint != nil ? GlassStyle.regular.tint(tint!).interactive() : GlassStyle.regular.interactive(),
in: .rect(cornerRadius: 10)
)
}
private func openURL() {
if let url = URL(string: url) {
NSWorkspace.shared.open(url)
}
}
}
#Preview {
VStack {
ExternalLinkButton(
icon: "star.fill",
iconColor: .yellow,
title: "Example Link",
subtitle: "A subtitle describing the link",
url: "https://example.com",
tint: .blue
)
ExternalLinkButton(
icon: "link",
title: "Plain Link",
subtitle: "Without tint",
url: "https://example.com",
tint: nil
)
}
.padding()
}

View File

@@ -0,0 +1,111 @@
//
// SettingsWindowPresenter.swift
// Gaze
//
// Created by Mike Freno on 1/8/26.
//
import AppKit
import SwiftUI
@MainActor
final class SettingsWindowPresenter {
static let shared = SettingsWindowPresenter()
static let switchTabNotification = Notification.Name("SwitchToSettingsTab")
private var windowController: NSWindowController?
private init() {}
func show(settingsManager: SettingsManager, initialTab: Int = 0) {
if focusExistingWindow(tab: initialTab) { return }
createWindow(settingsManager: settingsManager, initialTab: initialTab)
}
func show(settingsManager: SettingsManager, section: SettingsSection) {
show(settingsManager: settingsManager, initialTab: section.rawValue)
}
func close() {
windowController?.close()
windowController = nil
}
var isVisible: Bool {
windowController?.window?.isVisible ?? false
}
@discardableResult
private func focusExistingWindow(tab: Int?) -> Bool {
guard let window = windowController?.window else {
windowController = nil
return false
}
DispatchQueue.main.async {
if let tab {
NotificationCenter.default.post(
name: Self.switchTabNotification,
object: tab
)
}
NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true)
if window.isMiniaturized {
window.deminiaturize(nil)
}
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
return true
}
private func createWindow(settingsManager: SettingsManager, initialTab: Int) {
let window = makeWindow()
window.contentView = NSHostingView(
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
)
let controller = NSWindowController(window: window)
controller.showWindow(nil)
NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
windowController = controller
}
private func makeWindow() -> NSWindow {
let window = NSWindow(
contentRect: NSRect(
x: 0, y: 0,
width: AdaptiveLayout.Window.defaultWidth,
height: AdaptiveLayout.Window.defaultHeight
),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.identifier = WindowIdentifiers.settings
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.toolbarStyle = .unified
window.showsToolbarButton = false
window.center()
window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false
window.collectionBehavior = [
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
]
return window
}
}

View File

@@ -7,93 +7,6 @@
import SwiftUI
@MainActor
final class SettingsWindowPresenter {
static let shared = SettingsWindowPresenter()
private var windowController: NSWindowController?
private var closeObserver: NSObjectProtocol?
func show(settingsManager: SettingsManager, initialTab: Int = 0) {
if focusExistingWindow(tab: initialTab) { return }
createWindow(settingsManager: settingsManager, initialTab: initialTab)
}
func close() {
windowController?.close()
windowController = nil
}
@discardableResult
private func focusExistingWindow(tab: Int?) -> Bool {
guard let window = windowController?.window else {
windowController = nil
return false
}
DispatchQueue.main.async {
if let tab {
NotificationCenter.default.post(
name: Notification.Name("SwitchToSettingsTab"),
object: tab
)
}
NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true)
if window.isMiniaturized {
window.deminiaturize(nil)
}
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
return true
}
private func createWindow(settingsManager: SettingsManager, initialTab: Int) {
let window = NSWindow(
contentRect: NSRect(
x: 0, y: 0,
width: AdaptiveLayout.Window.defaultWidth,
height: AdaptiveLayout.Window.defaultHeight
),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.identifier = WindowIdentifiers.settings
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.toolbarStyle = .unified
window.showsToolbarButton = false
window.center()
window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false
window.collectionBehavior = [
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
]
window.contentView = NSHostingView(
rootView: SettingsWindowView(settingsManager: settingsManager, initialTab: initialTab)
)
let controller = NSWindowController(window: window)
controller.showWindow(nil)
NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
windowController = controller
}
}
struct SettingsWindowView: View {
@Bindable var settingsManager: SettingsManager
@State private var selectedSection: SettingsSection
@@ -112,46 +25,41 @@ struct SettingsWindowView: View {
.ignoresSafeArea()
VStack(spacing: 0) {
NavigationSplitView {
List(SettingsSection.allCases, selection: $selectedSection) { section in
NavigationLink(value: section) {
Label(section.title, systemImage: section.iconName)
}
}
.listStyle(.sidebar)
} detail: {
ScrollView {
detailView(for: selectedSection)
}
}
.onReceive(
NotificationCenter.default.publisher(
for: Notification.Name("SwitchToSettingsTab"))
) { notification in
if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab)
{
selectedSection = section
}
}
settingsContent
#if DEBUG
Divider()
HStack {
Button("Retrigger Onboarding") {
retriggerOnboarding()
}
.buttonStyle(.bordered)
.controlSize(isCompact ? .small : .regular)
Spacer()
}
.padding(isCompact ? 8 : 16)
debugFooter(isCompact: isCompact)
#endif
}
}
.environment(\.isCompactLayout, isCompact)
}
.frame(minWidth: AdaptiveLayout.Window.minWidth, minHeight: AdaptiveLayout.Window.minHeight)
.onReceive(tabSwitchPublisher) { notification in
if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab) {
selectedSection = section
}
}
}
private var settingsContent: some View {
NavigationSplitView {
sidebarContent
} detail: {
ScrollView {
detailView(for: selectedSection)
}
}
}
private var sidebarContent: some View {
List(SettingsSection.allCases, selection: $selectedSection) { section in
NavigationLink(value: section) {
Label(section.title, systemImage: section.iconName)
}
}
.listStyle(.sidebar)
}
@ViewBuilder
@@ -179,14 +87,34 @@ struct SettingsWindowView: View {
}
}
private var tabSwitchPublisher: NotificationCenter.Publisher {
NotificationCenter.default.publisher(
for: SettingsWindowPresenter.switchTabNotification
)
}
#if DEBUG
private func retriggerOnboarding() {
SettingsWindowPresenter.shared.close()
settingsManager.settings.hasCompletedOnboarding = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
OnboardingWindowPresenter.shared.show(settingsManager: settingsManager)
@ViewBuilder
private func debugFooter(isCompact: Bool) -> some View {
Divider()
HStack {
Button("Retrigger Onboarding") {
retriggerOnboarding()
}
.buttonStyle(.bordered)
.controlSize(isCompact ? .small : .regular)
Spacer()
}
.padding(isCompact ? 8 : 16)
}
private func retriggerOnboarding() {
SettingsWindowPresenter.shared.close()
settingsManager.settings.hasCompletedOnboarding = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
OnboardingWindowPresenter.shared.show(settingsManager: settingsManager)
}
}
#endif
}

View File

@@ -9,14 +9,19 @@ import SwiftUI
struct GeneralSetupView: View {
@Bindable var settingsManager: SettingsManager
var updateManager = UpdateManager.shared
var isOnboarding: Bool = true
#if !APPSTORE
var updateManager = UpdateManager.shared
#endif
var body: some View {
VStack(spacing: 0) {
SetupHeader(
icon: "gearshape.fill", title: isOnboarding ? "Final Settings" : "General Settings",
color: .accentColor)
icon: "gearshape.fill",
title: isOnboarding ? "Final Settings" : "General Settings",
color: .accentColor
)
Spacer()
VStack(spacing: 30) {
@@ -25,19 +30,7 @@ struct GeneralSetupView: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 20) {
launchAtLoginToggle
#if !APPSTORE
softwareUpdatesSection
#endif
subtleReminderSizeSection
#if !APPSTORE
supportSection
#endif
}
settingsContent
}
Spacer()
}
@@ -46,201 +39,21 @@ struct GeneralSetupView: View {
.background(.clear)
}
private var launchAtLoginToggle: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Launch at Login")
.font(.headline)
Text("Start Gaze automatically when you log in")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $settingsManager.settings.launchAtLogin)
.labelsHidden()
.onChange(of: settingsManager.settings.launchAtLogin) { _, isEnabled in
applyLaunchAtLoginSetting(enabled: isEnabled)
}
@ViewBuilder
private var settingsContent: some View {
VStack(spacing: 20) {
LaunchAtLoginSection(isEnabled: $settingsManager.settings.launchAtLogin)
#if !APPSTORE
SoftwareUpdatesSection(updateManager: updateManager)
#endif
ReminderSizeSection(selectedSize: $settingsManager.settings.subtleReminderSize)
#if !APPSTORE
SupportSection()
#endif
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#if !APPSTORE
private var softwareUpdatesSection: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Software Updates")
.font(.headline)
if let lastCheck = updateManager.lastUpdateCheckDate {
Text("Last checked: \(lastCheck, style: .relative)")
.font(.caption)
.foregroundStyle(.secondary)
.italic()
} else {
Text("Never checked for updates")
.font(.caption)
.foregroundStyle(.secondary)
.italic()
}
}
Spacer()
Button("Check for Updates Now") {
updateManager.checkForUpdates()
}
.buttonStyle(.bordered)
Toggle(
"Automatically check for updates",
isOn: Binding(
get: { updateManager.automaticallyChecksForUpdates },
set: { updateManager.automaticallyChecksForUpdates = $0 }
)
)
.labelsHidden()
.help("Check for new versions of Gaze in the background")
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#endif
private var subtleReminderSizeSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size")
.font(.headline)
Text("Adjust the size of blink and posture reminders")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
ForEach(ReminderSize.allCases, id: \.self) { size in
Button(action: { settingsManager.settings.subtleReminderSize = size }) {
VStack(spacing: 8) {
Circle()
.fill(
settingsManager.settings.subtleReminderSize == size
? Color.accentColor : Color.secondary.opacity(0.3)
)
.frame(width: iconSize(for: size), height: iconSize(for: size))
Text(size.displayName)
.font(.caption)
.fontWeight(
settingsManager.settings.subtleReminderSize == size
? .semibold : .regular
)
.foregroundStyle(
settingsManager.settings.subtleReminderSize == size
? .primary : .secondary)
}
.frame(maxWidth: .infinity, minHeight: 60)
.padding(.vertical, 12)
}
.glassEffectIfAvailable(
settingsManager.settings.subtleReminderSize == size
? GlassStyle.regular.tint(.accentColor.opacity(0.3))
: GlassStyle.regular,
in: .rect(cornerRadius: 10)
)
}
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
#if !APPSTORE
private var supportSection: some View {
VStack(spacing: 12) {
Text("Support & Contribute")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
ExternalLinkButton(
icon: "chevron.left.forwardslash.chevron.right",
title: "View on GitHub",
subtitle: "Star the repo, report issues, contribute",
url: "https://github.com/mikefreno/Gaze",
tint: nil
)
ExternalLinkButton(
icon: "cup.and.saucer.fill",
iconColor: .brown,
title: "Buy Me a Coffee",
subtitle: "Support development of Gaze",
url: "https://buymeacoffee.com/mikefreno",
tint: .orange
)
}
.padding()
}
#endif
private func applyLaunchAtLoginSetting(enabled: Bool) {
do {
if enabled {
try LaunchAtLoginManager.enable()
} else {
try LaunchAtLoginManager.disable()
}
} catch {}
}
private func iconSize(for size: ReminderSize) -> CGFloat {
switch size {
case .small: return 20
case .medium: return 32
case .large: return 48
}
}
}
struct ExternalLinkButton: View {
let icon: String
var iconColor: Color = .primary
let title: String
let subtitle: String
let url: String
let tint: Color?
var body: some View {
Button(action: {
if let url = URL(string: url) {
NSWorkspace.shared.open(url)
}
}) {
HStack {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(iconColor)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.semibold)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
}
.padding()
.frame(maxWidth: .infinity)
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.glassEffectIfAvailable(
tint != nil
? GlassStyle.regular.tint(tint!).interactive() : GlassStyle.regular.interactive(),
in: .rect(cornerRadius: 10)
)
}
}

View File

@@ -0,0 +1,49 @@
//
// LaunchAtLoginSection.swift
// Gaze
//
// Launch at login toggle section.
//
import SwiftUI
struct LaunchAtLoginSection: View {
@Binding var isEnabled: Bool
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Launch at Login")
.font(.headline)
Text("Start Gaze automatically when you log in")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $isEnabled)
.labelsHidden()
.onChange(of: isEnabled) { _, newValue in
applyLaunchAtLoginSetting(enabled: newValue)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private func applyLaunchAtLoginSetting(enabled: Bool) {
do {
if enabled {
try LaunchAtLoginManager.enable()
} else {
try LaunchAtLoginManager.disable()
}
} catch {
logError("⚠️ Failed to set launch at login: \(error)")
}
}
}
#Preview {
LaunchAtLoginSection(isEnabled: .constant(true))
.padding()
}

View File

@@ -0,0 +1,81 @@
//
// ReminderSizeSection.swift
// Gaze
//
// Subtle reminder size picker section.
//
import SwiftUI
struct ReminderSizeSection: View {
@Binding var selectedSize: ReminderSize
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size")
.font(.headline)
Text("Adjust the size of blink and posture reminders")
.font(.caption)
.foregroundStyle(.secondary)
sizeButtonRow
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private var sizeButtonRow: some View {
HStack(spacing: 12) {
ForEach(ReminderSize.allCases, id: \.self) { size in
ReminderSizeButton(
size: size,
isSelected: selectedSize == size,
action: { selectedSize = size }
)
}
}
}
}
private struct ReminderSizeButton: View {
let size: ReminderSize
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Circle()
.fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3))
.frame(width: iconSize, height: iconSize)
Text(size.displayName)
.font(.caption)
.fontWeight(isSelected ? .semibold : .regular)
.foregroundStyle(isSelected ? .primary : .secondary)
}
.frame(maxWidth: .infinity, minHeight: 60)
.padding(.vertical, 12)
}
.glassEffectIfAvailable(
isSelected
? GlassStyle.regular.tint(.accentColor.opacity(0.3))
: GlassStyle.regular,
in: .rect(cornerRadius: 10)
)
}
private var iconSize: CGFloat {
switch size {
case .small: return 20
case .medium: return 32
case .large: return 48
}
}
}
#Preview {
ReminderSizeSection(selectedSize: .constant(.medium))
.padding()
}

View File

@@ -0,0 +1,64 @@
//
// SoftwareUpdatesSection.swift
// Gaze
//
// Software updates settings section.
//
#if !APPSTORE
import SwiftUI
struct SoftwareUpdatesSection: View {
@ObservedObject var updateManager: UpdateManager
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Software Updates")
.font(.headline)
lastCheckText
}
Spacer()
Button("Check for Updates Now") {
updateManager.checkForUpdates()
}
.buttonStyle(.bordered)
Toggle(
"Automatically check for updates",
isOn: Binding(
get: { updateManager.automaticallyChecksForUpdates },
set: { updateManager.automaticallyChecksForUpdates = $0 }
)
)
.labelsHidden()
.help("Check for new versions of Gaze in the background")
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
@ViewBuilder
private var lastCheckText: some View {
if let lastCheck = updateManager.lastUpdateCheckDate {
Text("Last checked: \(lastCheck, style: .relative)")
.font(.caption)
.foregroundStyle(.secondary)
.italic()
} else {
Text("Never checked for updates")
.font(.caption)
.foregroundStyle(.secondary)
.italic()
}
}
}
#Preview {
SoftwareUpdatesSection(updateManager: UpdateManager.shared)
.padding()
}
#endif

View File

@@ -0,0 +1,43 @@
//
// SupportSection.swift
// Gaze
//
// Support and contribute links section.
//
#if !APPSTORE
import SwiftUI
struct SupportSection: View {
var body: some View {
VStack(spacing: 12) {
Text("Support & Contribute")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
ExternalLinkButton(
icon: "chevron.left.forwardslash.chevron.right",
title: "View on GitHub",
subtitle: "Star the repo, report issues, contribute",
url: "https://github.com/mikefreno/Gaze",
tint: nil
)
ExternalLinkButton(
icon: "cup.and.saucer.fill",
iconColor: .brown,
title: "Buy Me a Coffee",
subtitle: "Support development of Gaze",
url: "https://buymeacoffee.com/mikefreno",
tint: .orange
)
}
.padding()
}
}
#Preview {
SupportSection()
.padding()
}
#endif

View File

@@ -0,0 +1,33 @@
//
// MockTimeProvider.swift
// GazeTests
//
// Mock time provider for deterministic timer testing.
//
import Foundation
@testable import Gaze
/// A mock time provider for deterministic testing.
/// Allows manual control over time in tests.
final class MockTimeProvider: TimeProviding, @unchecked Sendable {
private var currentTime: Date
init(startTime: Date = Date()) {
self.currentTime = startTime
}
func now() -> Date {
currentTime
}
/// Advances time by the specified interval
func advance(by interval: TimeInterval) {
currentTime = currentTime.addingTimeInterval(interval)
}
/// Sets the current time to a specific date
func setTime(_ date: Date) {
currentTime = date
}
}

View File

@@ -0,0 +1,65 @@
//
// TestServiceContainer.swift
// GazeTests
//
// Test-specific dependency injection container.
//
import Foundation
@testable import Gaze
/// A dependency injection container configured for testing.
/// Provides injectable dependencies and test-specific utilities.
@MainActor
final class TestServiceContainer {
/// The settings manager instance
private(set) var settingsManager: any SettingsProviding
/// The timer engine instance
private var _timerEngine: TimerEngine?
/// Time provider for deterministic testing
let timeProvider: TimeProviding
/// Creates a test container with default mock settings
convenience init() {
self.init(settings: AppSettings())
}
/// Creates a test container with custom settings
init(settings: AppSettings) {
self.settingsManager = EnhancedMockSettingsManager(settings: settings)
self.timeProvider = MockTimeProvider()
}
/// Creates a test container with a custom settings manager
init(settingsManager: any SettingsProviding) {
self.settingsManager = settingsManager
self.timeProvider = MockTimeProvider()
}
/// Gets or creates the timer engine for testing
var timerEngine: TimerEngine {
if let engine = _timerEngine {
return engine
}
let engine = TimerEngine(
settingsManager: settingsManager,
enforceModeService: nil,
timeProvider: timeProvider
)
_timerEngine = engine
return engine
}
/// Sets a custom timer engine
func setTimerEngine(_ engine: TimerEngine) {
_timerEngine = engine
}
/// Resets the container for test isolation
func reset() {
_timerEngine?.stop()
_timerEngine = nil
}
}

View File

@@ -14,31 +14,30 @@ final class ServiceContainerTests: XCTestCase {
func testProductionContainerCreation() {
let container = ServiceContainer.shared
XCTAssertFalse(container.isTestEnvironment)
XCTAssertNotNil(container.settingsManager)
XCTAssertNotNil(container.enforceModeService)
}
func testTestContainerCreation() {
let settings = AppSettings.onlyLookAwayEnabled
let container = ServiceContainer.forTesting(settings: settings)
let container = TestServiceContainer(settings: settings)
XCTAssertTrue(container.isTestEnvironment)
XCTAssertEqual(container.settingsManager.settings.lookAwayTimer.enabled, true)
XCTAssertEqual(container.settingsManager.settings.blinkTimer.enabled, false)
}
func testTimerEngineCreation() {
let container = ServiceContainer.forTesting()
let container = TestServiceContainer()
let timerEngine = container.timerEngine
XCTAssertNotNil(timerEngine)
// Second access should return the same instance
XCTAssertTrue(container.timerEngine === timerEngine)
XCTAssertTrue(container.timeProvider is MockTimeProvider)
}
func testCustomTimerEngineInjection() {
let container = ServiceContainer.forTesting()
let container = TestServiceContainer()
let mockSettings = EnhancedMockSettingsManager(settings: .shortIntervals)
let customEngine = TimerEngine(
settingsManager: mockSettings,
@@ -51,10 +50,10 @@ final class ServiceContainerTests: XCTestCase {
}
func testContainerReset() {
let container = ServiceContainer.forTesting()
let container = TestServiceContainer()
// Access timer engine to create it
_ = container.timerEngine
let existingEngine = container.timerEngine
// Reset should clear the timer engine
container.reset()
@@ -62,5 +61,6 @@ final class ServiceContainerTests: XCTestCase {
// Accessing again should create a new instance
let newEngine = container.timerEngine
XCTAssertNotNil(newEngine)
XCTAssertFalse(existingEngine === newEngine)
}
}

View File

@@ -202,28 +202,28 @@ extension AppSettings {
@MainActor
func createTestContainer(
settings: AppSettings = .defaults
) -> ServiceContainer {
return ServiceContainer.forTesting(settings: settings)
) -> TestServiceContainer {
return TestServiceContainer(settings: settings)
}
/// Creates a complete test environment with all mocks
@MainActor
struct TestEnvironment {
let container: ServiceContainer
let container: TestServiceContainer
let windowManager: MockWindowManager
let settingsManager: EnhancedMockSettingsManager
let timeProvider: MockTimeProvider
init(settings: AppSettings = .defaults) {
self.settingsManager = EnhancedMockSettingsManager(settings: settings)
self.container = ServiceContainer(settingsManager: settingsManager)
self.container = TestServiceContainer(settingsManager: settingsManager)
self.windowManager = MockWindowManager()
self.timeProvider = MockTimeProvider()
}
/// Creates an AppDelegate with all test dependencies
func createAppDelegate() -> AppDelegate {
return AppDelegate(serviceContainer: container, windowManager: windowManager)
return AppDelegate(serviceContainer: serviceContainer, windowManager: windowManager)
}
/// Resets all mock state
@@ -231,6 +231,13 @@ struct TestEnvironment {
windowManager.reset()
settingsManager.reset()
}
private var serviceContainer: ServiceContainer {
ServiceContainer(
settingsManager: settingsManager,
enforceModeService: EnforceModeService.shared
)
}
}
// MARK: - XCTest Extensions