This commit is contained in:
Michael Freno
2026-01-15 09:56:47 -05:00
parent 5dc223ec96
commit ff0339e6fc
11 changed files with 58 additions and 40 deletions

View File

@@ -8,7 +8,10 @@
import Combine
import Foundation
class EyeTrackingConstants: ObservableObject {
/// Thread-safe configuration holder for eye tracking thresholds.
/// Uses @unchecked Sendable because all access is via the shared singleton
/// and the @Published properties are only mutated from the main thread.
final class EyeTrackingConstants: ObservableObject, @unchecked Sendable {
static let shared = EyeTrackingConstants()
// MARK: - Logging

View File

@@ -9,7 +9,7 @@ import Foundation
// MARK: - Reminder Size
enum ReminderSize: String, Codable, CaseIterable {
enum ReminderSize: String, Codable, CaseIterable, Sendable {
case small
case medium
case large
@@ -31,7 +31,7 @@ enum ReminderSize: String, Codable, CaseIterable {
}
}
struct AppSettings: Codable, Equatable, Hashable {
struct AppSettings: Codable, Equatable, Hashable, Sendable {
var lookAwayTimer: TimerConfiguration
var lookAwayCountdownSeconds: Int
var blinkTimer: TimerConfiguration

View File

@@ -7,7 +7,7 @@
import Foundation
struct SmartModeSettings: Codable, Equatable, Hashable {
struct SmartModeSettings: Codable, Equatable, Hashable, Sendable {
var autoPauseOnFullscreen: Bool
var autoPauseOnIdle: Bool
var trackUsage: Bool

View File

@@ -7,7 +7,7 @@
import Foundation
struct TimerConfiguration: Codable, Equatable, Hashable {
struct TimerConfiguration: Codable, Equatable, Hashable, Sendable {
var enabled: Bool
var intervalSeconds: Int

View File

@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
/// Represents a user-defined timer with customizable properties
struct UserTimer: Codable, Equatable, Identifiable, Hashable {
struct UserTimer: Codable, Equatable, Identifiable, Hashable, Sendable {
let id: String
var title: String
var type: UserTimerType
@@ -100,7 +100,7 @@ extension Color {
}
/// Type of user timer - subtle or overlay
enum UserTimerType: String, Codable, CaseIterable, Identifiable {
enum UserTimerType: String, Codable, CaseIterable, Identifiable, Sendable {
case subtle
case overlay

View File

@@ -8,7 +8,7 @@
import Foundation
/// Protocol for providing current time, enabling deterministic tests.
protocol TimeProviding {
protocol TimeProviding: Sendable {
/// Returns the current date/time
func now() -> Date
}
@@ -21,7 +21,7 @@ struct SystemTimeProvider: TimeProviding {
}
/// Test implementation that allows manual time control
final class MockTimeProvider: TimeProviding {
final class MockTimeProvider: TimeProviding, @unchecked Sendable {
private var currentTime: Date
init(startTime: Date = Date()) {

View File

@@ -60,7 +60,7 @@ class EyeTrackingService: NSObject, ObservableObject {
// MARK: - Processing Result
/// Result struct for off-main-thread processing
private struct ProcessingResult {
private struct ProcessingResult: Sendable {
var faceDetected: Bool = false
var isEyesClosed: Bool = false
var userLookingAtScreen: Bool = true
@@ -271,7 +271,7 @@ class EyeTrackingService: NSObject, ObservableObject {
}
/// Non-isolated gaze detection result
private struct GazeResult {
private struct GazeResult: Sendable {
var lookingAway: Bool = false
var leftPupilRatio: Double?
var rightPupilRatio: Double?

View File

@@ -83,13 +83,13 @@ final class FullscreenDetectionService: ObservableObject {
// Factory method to safely create instances from non-main actor contexts
static func create(
permissionManager: ScreenCapturePermissionManaging = ScreenCapturePermissionManager.shared,
environmentProvider: FullscreenEnvironmentProviding = SystemFullscreenEnvironmentProvider()
permissionManager: ScreenCapturePermissionManaging? = nil,
environmentProvider: FullscreenEnvironmentProviding? = nil
) async -> FullscreenDetectionService {
await MainActor.run {
return FullscreenDetectionService(
permissionManager: permissionManager,
environmentProvider: environmentProvider
permissionManager: permissionManager ?? ScreenCapturePermissionManager.shared,
environmentProvider: environmentProvider ?? SystemFullscreenEnvironmentProvider()
)
}
}

View File

@@ -20,28 +20,33 @@ import Accelerate
import ImageIO
import UniformTypeIdentifiers
struct PupilPosition: Equatable {
struct PupilPosition: Equatable, Sendable {
let x: CGFloat
let y: CGFloat
}
struct EyeRegion {
struct EyeRegion: Sendable {
let frame: CGRect
let center: CGPoint
let origin: CGPoint
}
/// Calibration state for adaptive thresholding (matches Python Calibration class)
final class PupilCalibration {
final class PupilCalibration: @unchecked Sendable {
private let lock = NSLock()
private let targetFrames = 20
private var thresholdsLeft: [Int] = []
private var thresholdsRight: [Int] = []
var isComplete: Bool {
thresholdsLeft.count >= targetFrames && thresholdsRight.count >= targetFrames
lock.lock()
defer { lock.unlock() }
return thresholdsLeft.count >= targetFrames && thresholdsRight.count >= targetFrames
}
func threshold(forSide side: Int) -> Int {
lock.lock()
defer { lock.unlock() }
let thresholds = side == 0 ? thresholdsLeft : thresholdsRight
guard !thresholds.isEmpty else { return 50 }
return thresholds.reduce(0, +) / thresholds.count
@@ -49,6 +54,8 @@ final class PupilCalibration {
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() }
if side == 0 {
thresholdsLeft.append(bestThreshold)
} else {
@@ -106,13 +113,15 @@ final class PupilCalibration {
}
func reset() {
lock.lock()
defer { lock.unlock() }
thresholdsLeft.removeAll()
thresholdsRight.removeAll()
}
}
/// Performance metrics for pupil detection
struct PupilDetectorMetrics {
struct PupilDetectorMetrics: Sendable {
var lastProcessingTimeMs: Double = 0
var averageProcessingTimeMs: Double = 0
var frameCount: Int = 0
@@ -126,7 +135,11 @@ struct PupilDetectorMetrics {
}
}
final class PupilDetector {
final class PupilDetector: @unchecked Sendable {
// MARK: - Thread Safety
private static let lock = NSLock()
// MARK: - Configuration
@@ -134,14 +147,14 @@ final class PupilDetector {
static var enablePerformanceLogging = false
static var frameSkipCount = 10 // Process every Nth frame
// MARK: - State
// MARK: - State (protected by lock)
private static var debugImageCounter = 0
private static var frameCounter = 0
private static var lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) = (nil, nil)
private static var _debugImageCounter = 0
private static var _frameCounter = 0
private static var _lastPupilPositions: (left: PupilPosition?, right: PupilPosition?) = (nil, nil)
private static var _metrics = PupilDetectorMetrics()
static let calibration = PupilCalibration()
static var metrics = PupilDetectorMetrics()
// MARK: - Precomputed Tables
@@ -182,7 +195,7 @@ final class PupilDetector {
/// Detects pupil position with frame skipping for performance
/// Returns cached result on skipped frames
static func detectPupil(
nonisolated static func detectPupil(
in pixelBuffer: CVPixelBuffer,
eyeLandmarks: VNFaceLandmarkRegion2D,
faceBoundingBox: CGRect,

View File

@@ -62,7 +62,7 @@ final class SettingsWindowPresenter {
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.toolbarStyle = .unified
window.toolbar = NSToolbar()
window.showsToolbarButton = false
window.center()
window.setFrameAutosaveName("SettingsWindow")
window.isReleasedWhenClosed = false
@@ -103,8 +103,10 @@ final class SettingsWindowPresenter {
}
deinit {
Task { @MainActor in
removeCloseObserver()
// Capture observer locally to avoid actor isolation issues
// NotificationCenter.removeObserver is thread-safe
if let observer = closeObserver {
NotificationCenter.default.removeObserver(observer)
}
}
}
@@ -133,6 +135,15 @@ struct SettingsWindowView: View {
.listStyle(.sidebar)
} detail: {
detailView(for: selectedSection)
}.onReceive(
NotificationCenter.default.publisher(
for: Notification.Name("SwitchToSettingsTab"))
) { notification in
if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab)
{
selectedSection = section
}
}
#if DEBUG
@@ -161,15 +172,6 @@ struct SettingsWindowView: View {
minHeight: 900
)
#endif
.onReceive(
NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))
) { notification in
if let tab = notification.object as? Int,
let section = SettingsSection(rawValue: tab)
{
selectedSection = section
}
}
}
@ViewBuilder