feat: started on camera access for enforcement mode

This commit is contained in:
Michael Freno
2026-01-13 23:07:14 -05:00
parent 12e8ab0e90
commit c2bf326735
10 changed files with 139 additions and 34 deletions

View File

@@ -36,12 +36,12 @@ struct AppSettings: Codable, Equatable, Hashable {
var lookAwayCountdownSeconds: Int
var blinkTimer: TimerConfiguration
var postureTimer: TimerConfiguration
var enforcementMode: Bool = false
var userTimers: [UserTimer]
var subtleReminderSize: ReminderSize
// App state and behavior
var hasCompletedOnboarding: Bool
var launchAtLogin: Bool
var playSounds: Bool

View File

@@ -0,0 +1,82 @@
//
// CameraAccessService.swift
// Gaze
//
// Created by Mike Freno on 1/13/26.
//
import AVFoundation
import Combine
@MainActor
class CameraAccessService: ObservableObject {
static let shared = CameraAccessService()
@Published var isCameraAuthorized = false
@Published var cameraError: Error?
private init() {
checkCameraAuthorizationStatus()
}
func requestCameraAccess() async throws {
guard #available(macOS 12.0, *) else {
throw CameraAccessError.unsupportedOS
}
if isCameraAuthorized {
return
}
let status = await AVCaptureDevice.requestAccess(for: .video)
if !status {
throw CameraAccessError.accessDenied
}
checkCameraAuthorizationStatus()
}
func checkCameraAuthorizationStatus() {
guard #available(macOS 12.0, *) else {
isCameraAuthorized = false
return
}
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
isCameraAuthorized = true
cameraError = nil
case .notDetermined:
isCameraAuthorized = false
cameraError = nil
case .denied, .restricted:
isCameraAuthorized = false
cameraError = CameraAccessError.accessDenied
default:
isCameraAuthorized = false
cameraError = CameraAccessError.unknown
}
}
}
// MARK: - Error Handling
enum CameraAccessError: Error, LocalizedError {
case accessDenied
case unsupportedOS
case unknown
var errorDescription: String? {
switch self {
case .accessDenied:
return
"Camera access was denied. Please enable camera permissions in System Preferences."
case .unsupportedOS:
return "This feature requires macOS 12 or later."
case .unknown:
return "An unknown error occurred with camera access."
}
}
}

View File

@@ -11,9 +11,7 @@ import Foundation
@MainActor
class SettingsManager: ObservableObject {
static let shared = SettingsManager()
@Published var settings: AppSettings
private let userDefaults = UserDefaults.standard
private let settingsKey = "gazeAppSettings"
private var saveCancellable: AnyCancellable?
@@ -48,7 +46,7 @@ class SettingsManager: ObservableObject {
guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else {
return .defaults
}
do {
let settings = try JSONDecoder().decode(AppSettings.self, from: data)
return settings

View File

@@ -228,7 +228,7 @@ struct GeneralSetupView: View {
try LaunchAtLoginManager.disable()
}
} catch {
// Failed to set launch at login - handled silently in production
//TODO: see what can be done here
}
}

View File

@@ -15,6 +15,8 @@ import SwiftUI
struct LookAwaySetupView: View {
@ObservedObject var settingsManager: SettingsManager
@State private var previewWindowController: NSWindowController?
@ObservedObject var cameraAccess = CameraAccessService.shared
@State private var failedCameraAccess = false
var body: some View {
VStack(spacing: 0) {
@@ -30,34 +32,15 @@ struct LookAwaySetupView: View {
.padding(.top, 20)
.padding(.bottom, 30)
// Vertically centered content
Spacer()
VStack(spacing: 30) {
// InfoBox with link functionality
HStack(spacing: 12) {
Button(action: {
if let url = URL(
string:
"https://journals.co.za/doi/abs/10.4102/aveh.v79i1.554#:~:text=the 20/20/20 rule induces significant changes in dry eye symptoms and tear film and some limited changes for ocular surface integrity."
) {
#if os(iOS)
UIApplication.shared.open(url)
#elseif os(macOS)
NSWorkspace.shared.open(url)
#endif
}
}) {
Image(systemName: "info.circle")
.foregroundColor(.white)
}.buttonStyle(.plain)
Text("Suggested: 20-20-20 rule")
.font(.headline)
.foregroundColor(.white)
}
.padding()
.glassEffectIfAvailable(
GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8))
InfoBox(
text: "Suggested: 20-20-20 rule",
url:
"https://journals.co.za/doi/abs/10.4102/aveh.v79i1.554#:~:text=the 20/20/20 rule induces significant changes in dry eye symptoms and tear film and some limited changes for ocular surface integrity."
)
SliderSection(
intervalSettings: Binding(
@@ -68,7 +51,8 @@ struct LookAwaySetupView: View {
)
},
set: { newValue in
settingsManager.settings.lookAwayTimer.intervalSeconds = (newValue.val ?? 20) * 60
settingsManager.settings.lookAwayTimer.intervalSeconds =
(newValue.val ?? 20) * 60
}
),
countdownSettings: Binding(
@@ -89,8 +73,34 @@ struct LookAwaySetupView: View {
type: "Look away",
previewFunc: showPreviewWindow
)
}
Toggle(
"Enable enforcement mode",
isOn: Binding(
get: { settingsManager.settings.enforcementMode },
set: { settingsManager.settings.enforcementMode = $0 }
)
)
.onChange(
of: settingsManager.settings.enforcementMode,
) { newMode in
if newMode && !cameraAccess.isCameraAuthorized {
Task {
do {
try await cameraAccess.requestCameraAccess()
} catch {
failedCameraAccess = true
settingsManager.settings.enforcementMode = false
}
}
}
}
}
#if failedCameraAccess
Text(
"Camera access denied. Please enable camera access in System Settings if you want to use enforcement mode."
)
#endif
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)