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

@@ -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 */;

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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB