feat: continued enforce mode implementation

This commit is contained in:
Michael Freno
2026-01-14 00:20:33 -05:00
parent c2bf326735
commit 9bd45a0c6b
13 changed files with 742 additions and 14 deletions

View File

@@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>

View File

@@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>

View File

@@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>

View File

@@ -32,5 +32,7 @@
<integer>86400</integer>
<key>SUEnableInstallerLauncherService</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Gaze needs camera access to detect when you look away from your screen during enforce mode. All processing happens on-device and no images are stored or transmitted.</string>
</dict>
</plist>

View File

@@ -20,20 +20,28 @@ class CameraAccessService: ObservableObject {
}
func requestCameraAccess() async throws {
print("🎥 Requesting camera access...")
guard #available(macOS 12.0, *) else {
print("⚠️ macOS version too old")
throw CameraAccessError.unsupportedOS
}
if isCameraAuthorized {
print("✓ Camera already authorized")
return
}
print("🎥 Calling AVCaptureDevice.requestAccess...")
let status = await AVCaptureDevice.requestAccess(for: .video)
print("🎥 Permission result: \(status)")
if !status {
throw CameraAccessError.accessDenied
}
checkCameraAuthorizationStatus()
print("✓ Camera access granted")
}
func checkCameraAuthorizationStatus() {
@@ -59,6 +67,13 @@ class CameraAccessService: ObservableObject {
cameraError = CameraAccessError.unknown
}
}
// New method to check if face detection is supported and available
func isFaceDetectionAvailable() -> Bool {
// On macOS, face detection requires specific Vision framework support
// For now we'll assume it's available if camera is authorized
return isCameraAuthorized
}
}
// MARK: - Error Handling

View File

@@ -0,0 +1,110 @@
//
// EnforceModeService.swift
// Gaze
//
// Created by Mike Freno on 1/13/26.
//
import Combine
import Foundation
@MainActor
class EnforceModeService: ObservableObject {
static let shared = EnforceModeService()
@Published var isEnforceModeActive = false
@Published var userCompliedWithBreak = false
private var settingsManager: SettingsManager
private var eyeTrackingService: EyeTrackingService
private var timerEngine: TimerEngine?
private var cancellables = Set<AnyCancellable>()
private init() {
self.settingsManager = SettingsManager.shared
self.eyeTrackingService = EyeTrackingService.shared
setupObservers()
}
private func setupObservers() {
eyeTrackingService.$userLookingAtScreen
.sink { [weak self] lookingAtScreen in
self?.handleGazeChange(lookingAtScreen: lookingAtScreen)
}
.store(in: &cancellables)
}
func enableEnforceMode() async {
print("🔒 enableEnforceMode called")
guard !isEnforceModeActive else {
print("⚠️ Enforce mode already active")
return
}
do {
print("🔒 Starting eye tracking...")
try await eyeTrackingService.startEyeTracking()
isEnforceModeActive = true
print("✓ Enforce mode enabled")
} catch {
print("⚠️ Failed to enable enforce mode: \(error.localizedDescription)")
isEnforceModeActive = false
}
}
func disableEnforceMode() {
guard isEnforceModeActive else { return }
eyeTrackingService.stopEyeTracking()
isEnforceModeActive = false
userCompliedWithBreak = false
print("✓ Enforce mode disabled")
}
func setTimerEngine(_ engine: TimerEngine) {
self.timerEngine = engine
}
func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool {
guard isEnforceModeActive else { return false }
guard settingsManager.settings.enforcementMode else { return false }
switch timerIdentifier {
case .builtIn(let type):
return type == .lookAway
case .user:
return false
}
}
func checkUserCompliance() {
guard isEnforceModeActive else {
userCompliedWithBreak = false
return
}
let lookingAway = !eyeTrackingService.userLookingAtScreen
userCompliedWithBreak = lookingAway
}
private func handleGazeChange(lookingAtScreen: Bool) {
guard isEnforceModeActive else { return }
checkUserCompliance()
}
func startEnforcementForActiveReminder() {
guard let engine = timerEngine else { return }
guard let activeReminder = engine.activeReminder else { return }
switch activeReminder {
case .lookAwayTriggered:
if shouldEnforceBreak(for: .builtIn(.lookAway)) {
checkUserCompliance()
}
default:
break
}
}
}

View File

@@ -0,0 +1,218 @@
//
// EyeTrackingService.swift
// Gaze
//
// Created by Mike Freno on 1/13/26.
//
import AVFoundation
import Combine
import Vision
@MainActor
class EyeTrackingService: NSObject, ObservableObject {
static let shared = EyeTrackingService()
@Published var isEyeTrackingActive = false
@Published var isEyesClosed = false
@Published var userLookingAtScreen = true
@Published var faceDetected = false
private var captureSession: AVCaptureSession?
private var videoOutput: AVCaptureVideoDataOutput?
private let videoDataOutputQueue = DispatchQueue(label: "com.gaze.videoDataOutput", qos: .userInitiated)
private override init() {
super.init()
}
func startEyeTracking() async throws {
print("👁️ startEyeTracking called")
guard !isEyeTrackingActive else {
print("⚠️ Eye tracking already active")
return
}
let cameraService = CameraAccessService.shared
print("👁️ Camera authorized: \(cameraService.isCameraAuthorized)")
if !cameraService.isCameraAuthorized {
print("👁️ Requesting camera access...")
try await cameraService.requestCameraAccess()
}
guard cameraService.isCameraAuthorized else {
print("❌ Camera access denied")
throw CameraAccessError.accessDenied
}
print("👁️ Setting up capture session...")
try await setupCaptureSession()
print("👁️ Starting capture session...")
captureSession?.startRunning()
isEyeTrackingActive = true
print("✓ Eye tracking active")
}
func stopEyeTracking() {
captureSession?.stopRunning()
captureSession = nil
videoOutput = nil
isEyeTrackingActive = false
isEyesClosed = false
userLookingAtScreen = true
faceDetected = false
}
private func setupCaptureSession() async 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
}
private func processFaceObservations(_ observations: [VNFaceObservation]?) {
guard let observations = observations, !observations.isEmpty else {
faceDetected = false
userLookingAtScreen = false
return
}
faceDetected = true
guard let face = observations.first,
let landmarks = face.landmarks else {
return
}
if let leftEye = landmarks.leftEye,
let rightEye = landmarks.rightEye {
let eyesClosed = detectEyesClosed(leftEye: leftEye, rightEye: rightEye)
self.isEyesClosed = eyesClosed
}
let lookingAway = detectLookingAway(face: face, landmarks: landmarks)
userLookingAtScreen = !lookingAway
}
private func detectEyesClosed(leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D) -> Bool {
guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else {
return false
}
let leftEyeHeight = calculateEyeHeight(leftEye)
let rightEyeHeight = calculateEyeHeight(rightEye)
let closedThreshold: CGFloat = 0.02
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 func detectLookingAway(face: VNFaceObservation, landmarks: VNFaceLandmarks2D) -> Bool {
let yaw = face.yaw?.doubleValue ?? 0.0
let roll = face.roll?.doubleValue ?? 0.0
let yawThreshold = 0.35
let rollThreshold = 0.4
let isLookingAway = abs(yaw) > yawThreshold || abs(roll) > rollThreshold
return isLookingAway
}
}
// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate
extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
let request = VNDetectFaceLandmarksRequest { [weak self] request, error in
guard let self = self else { return }
if let error = error {
print("Face detection error: \(error)")
return
}
Task { @MainActor in
self.processFaceObservations(request.results as? [VNFaceObservation])
}
}
request.revision = VNDetectFaceLandmarksRequestRevision3
let imageRequestHandler = VNImageRequestHandler(
cvPixelBuffer: pixelBuffer,
orientation: .leftMirrored,
options: [:]
)
do {
try imageRequestHandler.perform([request])
} catch {
print("Failed to perform face detection: \(error)")
}
}
}
// MARK: - Error Handling
enum EyeTrackingError: Error, LocalizedError {
case noCamera
case cannotAddInput
case cannotAddOutput
case visionRequestFailed
var errorDescription: String? {
switch self {
case .noCamera:
return "No camera device available."
case .cannotAddInput:
return "Cannot add camera input to capture session."
case .cannotAddOutput:
return "Cannot add video output to capture session."
case .visionRequestFailed:
return "Vision face detection request failed."
}
}
}

View File

@@ -17,8 +17,16 @@ class TimerEngine: ObservableObject {
private let settingsManager: SettingsManager
private var sleepStartTime: Date?
// For enforce mode integration
private var enforceModeService: EnforceModeService?
init(settingsManager: SettingsManager) {
self.settingsManager = settingsManager
self.enforceModeService = EnforceModeService.shared
Task { @MainActor in
self.enforceModeService?.setTimerEngine(self)
}
}
func start() {
@@ -70,6 +78,14 @@ class TimerEngine: ObservableObject {
}
}
/// Check if enforce mode is active and should affect timer behavior
func checkEnforceMode() {
guard let enforceService = enforceModeService else { return }
guard enforceService.isEnforceModeActive else { return }
enforceService.startEnforcementForActiveReminder()
}
private func updateConfigurations() {
var newStates: [TimerIdentifier: TimerState] = [:]
@@ -201,21 +217,13 @@ class TimerEngine: ObservableObject {
}
private func handleTick() {
// Handle all timers uniformly - only skip the timer that has an active reminder
for (identifier, state) in timerStates {
guard state.isActive && !state.isPaused else { continue }
guard !state.isPaused else { continue }
guard state.isActive else { continue }
// Skip the timer that triggered the current reminder
if let activeReminder = activeReminder, activeReminder.identifier == identifier {
continue
}
// prevent overshoot - in case user closes laptop while timer is running, we don't want to
// trigger on open
if state.targetDate < Date() - 3.0 { // slight grace
// Reset the timer when it has overshot its interval
if state.targetDate < Date() - 3.0 {
skipNext(identifier: identifier)
continue // Skip normal countdown logic after reset
continue
}
timerStates[identifier]?.remainingSeconds -= 1
@@ -225,6 +233,8 @@ class TimerEngine: ObservableObject {
break
}
}
checkEnforceMode()
}
func triggerReminder(for identifier: TimerIdentifier) {

View File

@@ -37,13 +37,19 @@ struct SettingsWindowView: View {
Label("Posture", systemImage: "figure.stand")
}
EnforceModeSetupView(settingsManager: settingsManager)
.tag(3)
.tabItem {
Label("Enforce Mode", systemImage: "video.fill")
}
UserTimersView(
userTimers: Binding(
get: { settingsManager.settings.userTimers },
set: { settingsManager.settings.userTimers = $0 }
)
)
.tag(3)
.tag(4)
.tabItem {
Label("User Timers", systemImage: "plus.circle")
}
@@ -52,7 +58,7 @@ struct SettingsWindowView: View {
settingsManager: settingsManager,
isOnboarding: false
)
.tag(4)
.tag(5)
.tabItem {
Label("General", systemImage: "gearshape.fill")
}

View File

@@ -0,0 +1,231 @@
//
// EnforceModeSetupView.swift
// Gaze
//
// Created by Mike Freno on 1/13/26.
//
import SwiftUI
struct EnforceModeSetupView: View {
@ObservedObject var settingsManager: SettingsManager
@ObservedObject var cameraService = CameraAccessService.shared
@ObservedObject var eyeTrackingService = EyeTrackingService.shared
@ObservedObject var enforceModeService = EnforceModeService.shared
@State private var isProcessingToggle = false
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 16) {
Image(systemName: "video.fill")
.font(.system(size: 60))
.foregroundColor(.accentColor)
Text("Enforce Mode")
.font(.system(size: 28, weight: .bold))
}
.padding(.top, 20)
.padding(.bottom, 30)
Spacer()
VStack(spacing: 30) {
Text("Use your camera to ensure you take breaks")
.font(.title3)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 20) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Enable Enforce Mode")
.font(.headline)
Text("Uses camera to detect when you look away from screen")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: {
settingsManager.settings.enforcementMode
},
set: { newValue in
print("🎛️ Toggle changed to: \(newValue)")
guard !isProcessingToggle else {
print("⚠️ Already processing toggle")
return
}
settingsManager.settings.enforcementMode = newValue
handleEnforceModeToggle(enabled: newValue)
}
)
)
.labelsHidden()
.disabled(isProcessingToggle)
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
cameraStatusView
if enforceModeService.isEnforceModeActive {
eyeTrackingStatusView
}
privacyInfoView
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.background(.clear)
}
private var cameraStatusView: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Camera Access")
.font(.headline)
if cameraService.isCameraAuthorized {
Label("Authorized", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundColor(.green)
} else if let error = cameraService.cameraError {
Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundColor(.orange)
} else {
Label("Not authorized", systemImage: "xmark.circle.fill")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if !cameraService.isCameraAuthorized {
Button("Request Access") {
print("📷 Request Access button clicked")
Task { @MainActor in
do {
try await cameraService.requestCameraAccess()
print("✓ Camera access granted via button")
} catch {
print("⚠️ Camera access failed: \(error.localizedDescription)")
}
}
}
.buttonStyle(.bordered)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private var eyeTrackingStatusView: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Eye Tracking Status")
.font(.headline)
HStack(spacing: 20) {
statusIndicator(
title: "Face Detected",
isActive: eyeTrackingService.faceDetected,
icon: "person.fill"
)
statusIndicator(
title: "Looking at Screen",
isActive: eyeTrackingService.userLookingAtScreen,
icon: "eye.fill"
)
statusIndicator(
title: "Eyes Closed",
isActive: eyeTrackingService.isEyesClosed,
icon: "eye.slash.fill"
)
}
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
}
private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(isActive ? .green : .secondary)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var privacyInfoView: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "lock.shield.fill")
.font(.title3)
.foregroundColor(.blue)
Text("Privacy Information")
.font(.headline)
}
VStack(alignment: .leading, spacing: 8) {
privacyBullet("All processing happens on-device")
privacyBullet("No images are stored or transmitted")
privacyBullet("Camera only active during lookaway reminders")
privacyBullet("You can disable at any time")
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.glassEffectIfAvailable(GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12))
}
private func privacyBullet(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "checkmark")
.font(.caption2)
.foregroundColor(.blue)
Text(text)
}
}
private func handleEnforceModeToggle(enabled: Bool) {
print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)")
isProcessingToggle = true
Task { @MainActor in
defer { isProcessingToggle = false }
if enabled {
print("🎛️ Enabling enforce mode...")
await enforceModeService.enableEnforceMode()
print("🎛️ Enforce mode enabled, isActive: \(enforceModeService.isEnforceModeActive)")
if !enforceModeService.isEnforceModeActive {
print("⚠️ Failed to activate, reverting toggle")
settingsManager.settings.enforcementMode = false
}
} else {
print("🎛️ Disabling enforce mode...")
enforceModeService.disableEnforceMode()
}
}
}
}
#Preview {
EnforceModeSetupView(settingsManager: SettingsManager.shared)
}

View File

@@ -0,0 +1,34 @@
//
// CameraAccessServiceTests.swift
// GazeTests
//
// Created by Mike Freno on 1/13/26.
//
import XCTest
@testable import Gaze
@MainActor
final class CameraAccessServiceTests: XCTestCase {
var cameraService: CameraAccessService!
override func setUp() async throws {
cameraService = CameraAccessService.shared
}
func testCameraServiceInitialization() {
XCTAssertNotNil(cameraService)
}
func testCheckCameraAuthorizationStatus() {
cameraService.checkCameraAuthorizationStatus()
XCTAssertFalse(cameraService.isCameraAuthorized || cameraService.cameraError != nil)
}
func testIsFaceDetectionAvailable() {
let isAvailable = cameraService.isFaceDetectionAvailable()
XCTAssertEqual(isAvailable, cameraService.isCameraAuthorized)
}
}

View File

@@ -0,0 +1,57 @@
//
// EnforceModeServiceTests.swift
// GazeTests
//
// Created by Mike Freno on 1/13/26.
//
import XCTest
@testable import Gaze
@MainActor
final class EnforceModeServiceTests: XCTestCase {
var enforceModeService: EnforceModeService!
var settingsManager: SettingsManager!
override func setUp() async throws {
settingsManager = SettingsManager.shared
enforceModeService = EnforceModeService.shared
}
override func tearDown() async throws {
enforceModeService.disableEnforceMode()
settingsManager.settings.enforcementMode = false
}
func testEnforceModeServiceInitialization() {
XCTAssertNotNil(enforceModeService)
XCTAssertFalse(enforceModeService.isEnforceModeActive)
XCTAssertFalse(enforceModeService.userCompliedWithBreak)
}
func testDisableEnforceModeResetsState() {
enforceModeService.disableEnforceMode()
XCTAssertFalse(enforceModeService.isEnforceModeActive)
XCTAssertFalse(enforceModeService.userCompliedWithBreak)
}
func testShouldEnforceBreakOnlyForLookAwayTimer() {
settingsManager.settings.enforcementMode = true
let shouldEnforceLookAway = enforceModeService.shouldEnforceBreak(for: .builtIn(.lookAway))
XCTAssertFalse(shouldEnforceLookAway)
let shouldEnforceBlink = enforceModeService.shouldEnforceBreak(for: .builtIn(.blink))
XCTAssertFalse(shouldEnforceBlink)
let shouldEnforcePosture = enforceModeService.shouldEnforceBreak(for: .builtIn(.posture))
XCTAssertFalse(shouldEnforcePosture)
}
func testCheckUserComplianceWhenNotActive() {
enforceModeService.checkUserCompliance()
XCTAssertFalse(enforceModeService.userCompliedWithBreak)
}
}

View File

@@ -0,0 +1,39 @@
//
// EyeTrackingServiceTests.swift
// GazeTests
//
// Created by Mike Freno on 1/13/26.
//
import XCTest
@testable import Gaze
@MainActor
final class EyeTrackingServiceTests: XCTestCase {
var eyeTrackingService: EyeTrackingService!
override func setUp() async throws {
eyeTrackingService = EyeTrackingService.shared
}
override func tearDown() async throws {
eyeTrackingService.stopEyeTracking()
}
func testEyeTrackingServiceInitialization() {
XCTAssertNotNil(eyeTrackingService)
XCTAssertFalse(eyeTrackingService.isEyeTrackingActive)
XCTAssertFalse(eyeTrackingService.isEyesClosed)
XCTAssertTrue(eyeTrackingService.userLookingAtScreen)
XCTAssertFalse(eyeTrackingService.faceDetected)
}
func testStopEyeTrackingResetsState() {
eyeTrackingService.stopEyeTracking()
XCTAssertFalse(eyeTrackingService.isEyeTrackingActive)
XCTAssertFalse(eyeTrackingService.isEyesClosed)
XCTAssertTrue(eyeTrackingService.userLookingAtScreen)
XCTAssertFalse(eyeTrackingService.faceDetected)
}
}