diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj index d21c3ba..251f434 100644 --- a/Gaze.xcodeproj/project.pbxproj +++ b/Gaze.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; }; + 27SPARKLE00000000003 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27SPARKLE00000000002 /* Sparkle */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,6 +71,7 @@ buildActionMask = 2147483647; files = ( 275915892F132A9200D0E60D /* Lottie in Frameworks */, + 27SPARKLE00000000003 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -131,6 +133,7 @@ name = Gaze; packageProductDependencies = ( 27AE10B12F10B1FC00E00DBC /* Lottie */, + 27SPARKLE00000000002 /* Sparkle */, ); productName = Gaze; productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */; @@ -216,6 +219,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */, + 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */, ); preferredProjectObjectVersion = 77; productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */; @@ -435,7 +439,6 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.4.1; - OTHER_SWIFT_FLAGS = "-D APPSTORE"; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -472,7 +475,6 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.4.1; - OTHER_SWIFT_FLAGS = "-D APPSTORE"; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -615,6 +617,14 @@ minimumVersion = 4.6.0; }; }; + 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = exactVersion; + version = 2.8.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -623,6 +633,11 @@ package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */; productName = Lottie; }; + 27SPARKLE00000000002 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */; diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift index 8c2224d..655bfe9 100644 --- a/Gaze/Models/AppSettings.swift +++ b/Gaze/Models/AppSettings.swift @@ -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 diff --git a/Gaze/Services/CameraAccessService.swift b/Gaze/Services/CameraAccessService.swift new file mode 100644 index 0000000..faecd44 --- /dev/null +++ b/Gaze/Services/CameraAccessService.swift @@ -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." + } + } +} diff --git a/Gaze/Services/SettingsManager.swift b/Gaze/Services/SettingsManager.swift index aa6f6f9..c4e65a4 100644 --- a/Gaze/Services/SettingsManager.swift +++ b/Gaze/Services/SettingsManager.swift @@ -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 diff --git a/Gaze/Views/Setup/GeneralSetupView.swift b/Gaze/Views/Setup/GeneralSetupView.swift index 604b315..fb75a72 100644 --- a/Gaze/Views/Setup/GeneralSetupView.swift +++ b/Gaze/Views/Setup/GeneralSetupView.swift @@ -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 } } diff --git a/Gaze/Views/Setup/LookAwaySetupView.swift b/Gaze/Views/Setup/LookAwaySetupView.swift index fdc8110..1166c9f 100644 --- a/Gaze/Views/Setup/LookAwaySetupView.swift +++ b/Gaze/Views/Setup/LookAwaySetupView.swift @@ -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) diff --git a/meta/Screenshot 2026-01-13 at 7.07.22 PM.png b/meta/Screenshot 2026-01-13 at 7.07.22 PM.png new file mode 100644 index 0000000..08ab8d2 Binary files /dev/null and b/meta/Screenshot 2026-01-13 at 7.07.22 PM.png differ diff --git a/meta/Screenshot 2026-01-13 at 7.08.34 PM.png b/meta/Screenshot 2026-01-13 at 7.08.34 PM.png new file mode 100644 index 0000000..84dd335 Binary files /dev/null and b/meta/Screenshot 2026-01-13 at 7.08.34 PM.png differ diff --git a/meta/Screenshot 2026-01-13 at 7.08.38 PM.png b/meta/Screenshot 2026-01-13 at 7.08.38 PM.png new file mode 100644 index 0000000..044c4b7 Binary files /dev/null and b/meta/Screenshot 2026-01-13 at 7.08.38 PM.png differ diff --git a/meta/Screenshot 2026-01-13 at 7.09.09 PM.png b/meta/Screenshot 2026-01-13 at 7.09.09 PM.png new file mode 100644 index 0000000..6988ec0 Binary files /dev/null and b/meta/Screenshot 2026-01-13 at 7.09.09 PM.png differ