This commit is contained in:
Michael Freno
2026-01-16 01:22:31 -05:00
parent 4ae8d77dab
commit ea3478dfb9
15 changed files with 139 additions and 60 deletions

View File

@@ -431,13 +431,14 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Gaze/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Gaze;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 0.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -467,13 +468,14 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Gaze/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Gaze;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 0.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -8,7 +8,8 @@
import Foundation
/// Thread-safe configuration holder for eye tracking thresholds.
enum EyeTrackingConstants {
/// All properties are Sendable constants, safe for use in any concurrency context.
enum EyeTrackingConstants: Sendable {
// MARK: - Logging
/// Interval between log messages in seconds
static let logInterval: TimeInterval = 0.5

View File

@@ -31,8 +31,7 @@ struct GazeApp: App {
}
} else {
OnboardingContainerView(settingsManager: settingsManager)
.onChange(of: settingsManager.settings.hasCompletedOnboarding) {
completed in
.onChange(of: settingsManager.settings.hasCompletedOnboarding) { _, completed in
if completed {
closeAllWindows()
appDelegate.onboardingCompleted()

View File

@@ -68,6 +68,24 @@ class EyeTrackingService: NSObject, ObservableObject {
var debugRightPupilRatio: Double?
var debugYaw: Double?
var debugPitch: Double?
nonisolated init(
faceDetected: Bool = false,
isEyesClosed: Bool = false,
userLookingAtScreen: Bool = true,
debugLeftPupilRatio: Double? = nil,
debugRightPupilRatio: Double? = nil,
debugYaw: Double? = nil,
debugPitch: Double? = nil
) {
self.faceDetected = faceDetected
self.isEyesClosed = isEyesClosed
self.userLookingAtScreen = userLookingAtScreen
self.debugLeftPupilRatio = debugLeftPupilRatio
self.debugRightPupilRatio = debugRightPupilRatio
self.debugYaw = debugYaw
self.debugPitch = debugPitch
}
}
func startEyeTracking() async throws {
@@ -277,6 +295,20 @@ class EyeTrackingService: NSObject, ObservableObject {
var rightPupilRatio: Double?
var yaw: Double?
var pitch: Double?
nonisolated init(
lookingAway: Bool = false,
leftPupilRatio: Double? = nil,
rightPupilRatio: Double? = nil,
yaw: Double? = nil,
pitch: Double? = nil
) {
self.lookingAway = lookingAway
self.leftPupilRatio = leftPupilRatio
self.rightPupilRatio = rightPupilRatio
self.yaw = yaw
self.pitch = pitch
}
}
/// Non-isolated gaze direction detection

View File

@@ -71,16 +71,23 @@ final class FullscreenDetectionService: ObservableObject {
private let permissionManager: ScreenCapturePermissionManaging
private let environmentProvider: FullscreenEnvironmentProviding
// This initializer is only for use within main actor contexts
init(
permissionManager: ScreenCapturePermissionManaging = ScreenCapturePermissionManager.shared,
environmentProvider: FullscreenEnvironmentProviding = SystemFullscreenEnvironmentProvider()
permissionManager: ScreenCapturePermissionManaging,
environmentProvider: FullscreenEnvironmentProviding
) {
self.permissionManager = permissionManager
self.environmentProvider = environmentProvider
setupObservers()
}
/// Convenience initializer using default services
convenience init() {
self.init(
permissionManager: ScreenCapturePermissionManager.shared,
environmentProvider: SystemFullscreenEnvironmentProvider()
)
}
// Factory method to safely create instances from non-main actor contexts
static func create(
permissionManager: ScreenCapturePermissionManaging? = nil,
@@ -109,8 +116,10 @@ final class FullscreenDetectionService: ObservableObject {
object: workspace,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.checkFullscreenState()
}
}
observers.append(spaceObserver)
let transitionObserver = notificationCenter.addObserver(
@@ -118,8 +127,10 @@ final class FullscreenDetectionService: ObservableObject {
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.checkFullscreenState()
}
}
observers.append(transitionObserver)
let fullscreenObserver = notificationCenter.addObserver(
@@ -127,8 +138,10 @@ final class FullscreenDetectionService: ObservableObject {
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.checkFullscreenState()
}
}
observers.append(fullscreenObserver)
let exitFullscreenObserver = notificationCenter.addObserver(
@@ -136,8 +149,10 @@ final class FullscreenDetectionService: ObservableObject {
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.checkFullscreenState()
}
}
observers.append(exitFullscreenObserver)
frontmostAppObserver = NotificationCenter.default.publisher(

View File

@@ -33,8 +33,9 @@ class IdleMonitoringService: ObservableObject {
private func startMonitoring() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
Task { @MainActor in
self?.checkIdleState()
self.checkIdleState()
}
}
}

View File

@@ -38,13 +38,13 @@ final class PupilCalibration: @unchecked Sendable {
private var thresholdsLeft: [Int] = []
private var thresholdsRight: [Int] = []
var isComplete: Bool {
nonisolated var isComplete: Bool {
lock.lock()
defer { lock.unlock() }
return thresholdsLeft.count >= targetFrames && thresholdsRight.count >= targetFrames
}
func threshold(forSide side: Int) -> Int {
nonisolated func threshold(forSide side: Int) -> Int {
lock.lock()
defer { lock.unlock() }
let thresholds = side == 0 ? thresholdsLeft : thresholdsRight
@@ -52,7 +52,7 @@ final class PupilCalibration: @unchecked Sendable {
return thresholds.reduce(0, +) / thresholds.count
}
func evaluate(eyeData: UnsafePointer<UInt8>, width: Int, height: Int, side: Int) {
nonisolated func evaluate(eyeData: UnsafePointer<UInt8>, width: Int, height: Int, side: Int) {
let bestThreshold = findBestThreshold(eyeData: eyeData, width: width, height: height)
lock.lock()
defer { lock.unlock() }
@@ -63,7 +63,7 @@ final class PupilCalibration: @unchecked Sendable {
}
}
private func findBestThreshold(eyeData: UnsafePointer<UInt8>, width: Int, height: Int) -> Int {
private nonisolated func findBestThreshold(eyeData: UnsafePointer<UInt8>, width: Int, height: Int) -> Int {
let averageIrisSize = 0.48
var bestThreshold = 50
var bestDiff = Double.greatestFiniteMagnitude
@@ -91,7 +91,7 @@ final class PupilCalibration: @unchecked Sendable {
return bestThreshold
}
private static func irisSize(data: UnsafePointer<UInt8>, width: Int, height: Int) -> Double {
private nonisolated static func irisSize(data: UnsafePointer<UInt8>, width: Int, height: Int) -> Double {
let margin = 5
guard width > margin * 2, height > margin * 2 else { return 0 }
@@ -112,7 +112,7 @@ final class PupilCalibration: @unchecked Sendable {
return totalCount > 0 ? Double(blackCount) / Double(totalCount) : 0
}
func reset() {
nonisolated func reset() {
lock.lock()
defer { lock.unlock() }
thresholdsLeft.removeAll()
@@ -143,46 +143,46 @@ final class PupilDetector: @unchecked Sendable {
// MARK: - Configuration
static var enableDebugImageSaving = false
static var enablePerformanceLogging = false
static var frameSkipCount = 10 // Process every Nth frame
nonisolated(unsafe) static var enableDebugImageSaving = false
nonisolated(unsafe) static var enablePerformanceLogging = false
nonisolated(unsafe) static var frameSkipCount = 10 // Process every Nth frame
// MARK: - State (protected by lock)
private static var _debugImageCounter = 0
private static var _frameCounter = 0
private static var _lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) = (
private nonisolated(unsafe) static var _debugImageCounter = 0
private nonisolated(unsafe) static var _frameCounter = 0
private nonisolated(unsafe) static var _lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) = (
nil, nil
)
private static var _metrics = PupilDetectorMetrics()
private nonisolated(unsafe) static var _metrics = PupilDetectorMetrics()
static let calibration = PupilCalibration()
nonisolated(unsafe) static let calibration = PupilCalibration()
// MARK: - Convenience Properties
private static var debugImageCounter: Int {
private nonisolated static var debugImageCounter: Int {
get { _debugImageCounter }
set { _debugImageCounter = newValue }
}
private static var frameCounter: Int {
private nonisolated static var frameCounter: Int {
get { _frameCounter }
set { _frameCounter = newValue }
}
private static var lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) {
private nonisolated static var lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) {
get { _lastPupilPositions }
set { _lastPupilPositions = newValue }
}
private static var metrics: PupilDetectorMetrics {
private nonisolated static var metrics: PupilDetectorMetrics {
get { _metrics }
set { _metrics = newValue }
}
// MARK: - Precomputed Tables
private static let spatialWeightsLUT: [[Float]] = {
private nonisolated(unsafe) static let spatialWeightsLUT: [[Float]] = {
let d = 10
let radius = d / 2
let sigmaSpace: Float = 15.0
@@ -197,7 +197,7 @@ final class PupilDetector: @unchecked Sendable {
return weights
}()
private static let colorWeightsLUT: [Float] = {
private nonisolated(unsafe) static let colorWeightsLUT: [Float] = {
let sigmaColor: Float = 15.0
var lut = [Float](repeating: 0, count: 256)
for diff in 0..<256 {
@@ -209,12 +209,12 @@ final class PupilDetector: @unchecked Sendable {
// MARK: - Reusable Buffers
private static var grayscaleBuffer: UnsafeMutablePointer<UInt8>?
private static var grayscaleBufferSize = 0
private static var eyeBuffer: UnsafeMutablePointer<UInt8>?
private static var eyeBufferSize = 0
private static var tempBuffer: UnsafeMutablePointer<UInt8>?
private static var tempBufferSize = 0
private nonisolated(unsafe) static var grayscaleBuffer: UnsafeMutablePointer<UInt8>?
private nonisolated(unsafe) static var grayscaleBufferSize = 0
private nonisolated(unsafe) static var eyeBuffer: UnsafeMutablePointer<UInt8>?
private nonisolated(unsafe) static var eyeBufferSize = 0
private nonisolated(unsafe) static var tempBuffer: UnsafeMutablePointer<UInt8>?
private nonisolated(unsafe) static var tempBufferSize = 0
// MARK: - Public API
@@ -369,7 +369,9 @@ final class PupilDetector: @unchecked Sendable {
// MARK: - Buffer Management
private static func ensureBufferCapacity(frameSize: Int, eyeSize: Int) {
// MARK: - Buffer Management
private nonisolated static func ensureBufferCapacity(frameSize: Int, eyeSize: Int) {
if grayscaleBufferSize < frameSize {
grayscaleBuffer?.deallocate()
grayscaleBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: frameSize)
@@ -388,7 +390,7 @@ final class PupilDetector: @unchecked Sendable {
// MARK: - Optimized Grayscale Conversion (vImage)
private static func extractGrayscaleDataOptimized(
private nonisolated static func extractGrayscaleDataOptimized(
from pixelBuffer: CVPixelBuffer,
to output: UnsafeMutablePointer<UInt8>,
width: Int,
@@ -476,7 +478,7 @@ final class PupilDetector: @unchecked Sendable {
// MARK: - Optimized Eye Isolation
private static func isolateEyeWithMaskOptimized(
private nonisolated static func isolateEyeWithMaskOptimized(
frameData: UnsafePointer<UInt8>,
frameWidth: Int,
frameHeight: Int,
@@ -526,7 +528,7 @@ final class PupilDetector: @unchecked Sendable {
}
@inline(__always)
private static func pointInPolygonFast(
private nonisolated static func pointInPolygonFast(
px: Float, py: Float, edges: [(x1: Float, y1: Float, x2: Float, y2: Float)]
) -> Bool {
var inside = false
@@ -542,7 +544,7 @@ final class PupilDetector: @unchecked Sendable {
// MARK: - Optimized Image Processing
static func imageProcessingOptimized(
nonisolated static func imageProcessingOptimized(
input: UnsafePointer<UInt8>,
output: UnsafeMutablePointer<UInt8>,
width: Int,
@@ -569,7 +571,7 @@ final class PupilDetector: @unchecked Sendable {
}
}
private static func gaussianBlurOptimized(
private nonisolated static func gaussianBlurOptimized(
input: UnsafePointer<UInt8>,
output: UnsafeMutablePointer<UInt8>,
width: Int,
@@ -607,7 +609,7 @@ final class PupilDetector: @unchecked Sendable {
)
}
private static func erodeOptimized(
private nonisolated static func erodeOptimized(
input: UnsafePointer<UInt8>,
output: UnsafeMutablePointer<UInt8>,
width: Int,
@@ -668,7 +670,7 @@ final class PupilDetector: @unchecked Sendable {
/// Optimized centroid-of-dark-pixels approach - much faster than union-find
/// Returns the centroid of the largest dark region
private static func findPupilFromContoursOptimized(
private nonisolated static func findPupilFromContoursOptimized(
data: UnsafePointer<UInt8>,
width: Int,
height: Int
@@ -722,7 +724,7 @@ final class PupilDetector: @unchecked Sendable {
// MARK: - Helper Methods
private static func landmarksToPixelCoordinates(
private nonisolated static func landmarksToPixelCoordinates(
landmarks: VNFaceLandmarkRegion2D,
faceBoundingBox: CGRect,
imageSize: CGSize
@@ -736,7 +738,7 @@ final class PupilDetector: @unchecked Sendable {
}
}
private static func createEyeRegion(from points: [CGPoint], imageSize: CGSize) -> EyeRegion? {
private nonisolated static func createEyeRegion(from points: [CGPoint], imageSize: CGSize) -> EyeRegion? {
guard !points.isEmpty else { return nil }
let margin: CGFloat = 5
@@ -777,7 +779,7 @@ final class PupilDetector: @unchecked Sendable {
// MARK: - Debug Helpers
private static func saveDebugImage(
private nonisolated static func saveDebugImage(
data: UnsafePointer<UInt8>, width: Int, height: Int, name: String
) {
guard let cgImage = createCGImage(from: data, width: width, height: height) else { return }
@@ -793,7 +795,7 @@ final class PupilDetector: @unchecked Sendable {
print("💾 Saved debug image: \(url.path)")
}
private static func createCGImage(from data: UnsafePointer<UInt8>, width: Int, height: Int)
private nonisolated static func createCGImage(from data: UnsafePointer<UInt8>, width: Int, height: Int)
-> CGImage?
{
let mutableData = UnsafeMutablePointer<UInt8>.allocate(capacity: width * height)
@@ -817,7 +819,7 @@ final class PupilDetector: @unchecked Sendable {
}
/// Clean up allocated buffers (call on app termination if needed)
static func cleanup() {
nonisolated static func cleanup() {
grayscaleBuffer?.deallocate()
grayscaleBuffer = nil
grayscaleBufferSize = 0

View File

@@ -111,7 +111,12 @@ final class ServiceContainer {
}
/// Creates a new container configured for testing with default mock settings
static func forTesting(settings: AppSettings = .defaults) -> ServiceContainer {
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)
}
@@ -138,7 +143,11 @@ final class MockSettingsManager: SettingsProviding {
.posture: \.postureTimer,
]
init(settings: AppSettings = .defaults) {
convenience init() {
self.init(settings: AppSettings())
}
init(settings: AppSettings) {
self.settings = settings
self._settingsSubject = CurrentValueSubject(settings)
}

View File

@@ -32,10 +32,21 @@ class TimerEngine: ObservableObject {
// Logging manager
private let logger = LoggingManager.shared.timerLogger
convenience init(
settingsManager: any SettingsProviding,
enforceModeService: EnforceModeService? = nil
) {
self.init(
settingsManager: settingsManager,
enforceModeService: enforceModeService,
timeProvider: SystemTimeProvider()
)
}
init(
settingsManager: any SettingsProviding,
enforceModeService: EnforceModeService? = nil,
timeProvider: TimeProviding = SystemTimeProvider()
enforceModeService: EnforceModeService?,
timeProvider: TimeProviding
) {
self.settingsProvider = settingsManager
self.enforceModeService = enforceModeService ?? EnforceModeService.shared

View File

@@ -75,8 +75,9 @@ class UsageTrackingService: ObservableObject {
private func startTracking() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
Task { @MainActor in
self?.tick()
self.tick()
}
}
}

View File

@@ -86,8 +86,10 @@ final class OnboardingWindowPresenter {
object: window,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.windowController = nil
self?.removeCloseObserver()
}
NotificationCenter.default.post(name: Notification.Name("OnboardingWindowDidClose"), object: nil)
}
}

View File

@@ -304,7 +304,7 @@ struct UserTimerEditSheet: View {
}
}
.pickerStyle(.segmented)
.onChange(of: type) { newType in
.onChange(of: type) { _, newType in
if newType == .subtle {
timeOnScreen = 3
} else if timeOnScreen == 3 {

View File

@@ -42,6 +42,7 @@ final class ServiceContainerTests: XCTestCase {
let mockSettings = EnhancedMockSettingsManager(settings: .shortIntervals)
let customEngine = TimerEngine(
settingsManager: mockSettings,
enforceModeService: nil,
timeProvider: MockTimeProvider()
)

View File

@@ -41,6 +41,7 @@ final class TimerEngineTests: XCTestCase {
let timeProvider = MockTimeProvider()
let engine = TimerEngine(
settingsManager: testEnv.settingsManager,
enforceModeService: nil,
timeProvider: timeProvider
)

View File

@@ -29,6 +29,7 @@ final class TimerEngineTestabilityTests: XCTestCase {
let timeProvider = MockTimeProvider()
let timerEngine = TimerEngine(
settingsManager: testEnv.settingsManager,
enforceModeService: nil,
timeProvider: timeProvider
)
@@ -59,6 +60,7 @@ final class TimerEngineTestabilityTests: XCTestCase {
let timeProvider = MockTimeProvider(startTime: Date())
let timerEngine = TimerEngine(
settingsManager: testEnv.settingsManager,
enforceModeService: nil,
timeProvider: timeProvider
)