diff --git a/Gaze/Constants/EyeTrackingConstants.swift b/Gaze/Constants/EyeTrackingConstants.swift index 694b3b4..c55bdd5 100644 --- a/Gaze/Constants/EyeTrackingConstants.swift +++ b/Gaze/Constants/EyeTrackingConstants.swift @@ -20,25 +20,24 @@ enum EyeTrackingConstants { // MARK: - Face Pose Thresholds /// Maximum yaw (left/right head turn) in radians before considering user looking away /// 0.20 radians ≈ 11.5 degrees (Tightened from 0.35) - static let yawThreshold: Double = 0.1 + static let yawThreshold: Double = 0.2 /// Pitch threshold for looking UP (above screen). /// Since camera is at top, looking at screen is negative pitch. /// Values > 0.1 imply looking straight ahead or up (away from screen). - static let pitchUpThreshold: Double = 0.5 + static let pitchUpThreshold: Double = 0.1 /// Pitch threshold for looking DOWN (at keyboard/lap). /// Values < -0.45 imply looking too far down. - static let pitchDownThreshold: Double = -0.9 + static let pitchDownThreshold: Double = -0.45 // MARK: - Pupil Tracking Thresholds /// Minimum horizontal pupil ratio (0.0 = right edge, 1.0 = left edge) /// Values below this are considered looking right (camera view) - /// Tightened from 0.25 to 0.35 - static let minPupilRatio: Double = 0.45 + static let minPupilRatio: Double = 0.40 /// Maximum horizontal pupil ratio /// Values above this are considered looking left (camera view) /// Tightened from 0.75 to 0.65 - static let maxPupilRatio: Double = 0.55 + static let maxPupilRatio: Double = 0.6 } diff --git a/Gaze/Services/FullscreenDetectionService.swift b/Gaze/Services/FullscreenDetectionService.swift index 5f4833b..ef534cd 100644 --- a/Gaze/Services/FullscreenDetectionService.swift +++ b/Gaze/Services/FullscreenDetectionService.swift @@ -7,96 +7,182 @@ import AppKit import Combine +import CoreGraphics import Foundation +struct FullscreenWindowDescriptor: Equatable { + let ownerPID: pid_t + let layer: Int + let bounds: CGRect +} + +@MainActor +protocol FullscreenEnvironmentProviding { + func frontmostProcessIdentifier() -> pid_t? + func windowDescriptors() -> [FullscreenWindowDescriptor] + func screenFrames() -> [CGRect] +} + +@MainActor +struct SystemFullscreenEnvironmentProvider: FullscreenEnvironmentProviding { + func frontmostProcessIdentifier() -> pid_t? { + NSWorkspace.shared.frontmostApplication?.processIdentifier + } + + func windowDescriptors() -> [FullscreenWindowDescriptor] { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return [] + } + + return windowList.compactMap { window in + guard let ownerPID = window[kCGWindowOwnerPID as String] as? pid_t, + let layer = window[kCGWindowLayer as String] as? Int, + let boundsDict = window[kCGWindowBounds as String] as? [String: CGFloat] else { + return nil + } + + let bounds = CGRect( + x: boundsDict["X"] ?? 0, + y: boundsDict["Y"] ?? 0, + width: boundsDict["Width"] ?? 0, + height: boundsDict["Height"] ?? 0 + ) + + return FullscreenWindowDescriptor(ownerPID: ownerPID, layer: layer, bounds: bounds) + } + } + + func screenFrames() -> [CGRect] { + NSScreen.screens.map(\.frame) + } +} + @MainActor class FullscreenDetectionService: ObservableObject { @Published private(set) var isFullscreenActive = false - + private var observers: [NSObjectProtocol] = [] - - init() { + private var frontmostAppObserver: AnyCancellable? + private let permissionManager: ScreenCapturePermissionManaging + private let environmentProvider: FullscreenEnvironmentProviding + + init( + permissionManager: ScreenCapturePermissionManaging? = nil, + environmentProvider: FullscreenEnvironmentProviding? = nil + ) { + self.permissionManager = permissionManager ?? ScreenCapturePermissionManager.shared + self.environmentProvider = environmentProvider ?? SystemFullscreenEnvironmentProvider() setupObservers() } - + deinit { let notificationCenter = NSWorkspace.shared.notificationCenter observers.forEach { notificationCenter.removeObserver($0) } + frontmostAppObserver?.cancel() } - + private func setupObservers() { let workspace = NSWorkspace.shared let notificationCenter = workspace.notificationCenter - - // Monitor when applications enter fullscreen - let didEnterObserver = notificationCenter.addObserver( + + let spaceObserver = notificationCenter.addObserver( forName: NSWorkspace.activeSpaceDidChangeNotification, object: workspace, queue: .main ) { [weak self] _ in - Task { @MainActor in - self?.checkFullscreenState() - } + self?.checkFullscreenState() } - observers.append(didEnterObserver) - - // Monitor when active application changes - let didActivateObserver = notificationCenter.addObserver( - forName: NSWorkspace.didActivateApplicationNotification, - object: workspace, + observers.append(spaceObserver) + + let transitionObserver = notificationCenter.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in - self?.checkFullscreenState() - } + self?.checkFullscreenState() } - observers.append(didActivateObserver) - - // Initial check + observers.append(transitionObserver) + + let fullscreenObserver = notificationCenter.addObserver( + forName: NSWindow.willEnterFullScreenNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.checkFullscreenState() + } + observers.append(fullscreenObserver) + + let exitFullscreenObserver = notificationCenter.addObserver( + forName: NSWindow.willExitFullScreenNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.checkFullscreenState() + } + observers.append(exitFullscreenObserver) + + frontmostAppObserver = NotificationCenter.default.publisher( + for: NSWorkspace.didActivateApplicationNotification, + object: workspace + ) + .sink { [weak self] _ in + self?.checkFullscreenState() + } + checkFullscreenState() } - + + private func canReadWindowInfo() -> Bool { + guard permissionManager.authorizationStatus.isAuthorized else { + setFullscreenState(false) + return false + } + + return true + } + private func checkFullscreenState() { - guard let frontmostApp = NSWorkspace.shared.frontmostApplication else { - isFullscreenActive = false + guard canReadWindowInfo() else { return } + + guard let frontmostPID = environmentProvider.frontmostProcessIdentifier() else { + setFullscreenState(false) return } - - // Check if any window of the frontmost application is fullscreen - let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements) - let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? [] - - let frontmostPID = frontmostApp.processIdentifier - - for window in windowList { - guard let ownerPID = window[kCGWindowOwnerPID as String] as? pid_t, - ownerPID == frontmostPID, - let bounds = window[kCGWindowBounds as String] as? [String: CGFloat], - let layer = window[kCGWindowLayer as String] as? Int else { - continue - } - - // Check if window is fullscreen by comparing bounds to screen size - if let screen = NSScreen.main { - let screenFrame = screen.frame - let windowWidth = bounds["Width"] ?? 0 - let windowHeight = bounds["Height"] ?? 0 - - // Window is considered fullscreen if it matches screen dimensions - // and is at a normal window layer (0) - if layer == 0 && - abs(windowWidth - screenFrame.width) < 1 && - abs(windowHeight - screenFrame.height) < 1 { - isFullscreenActive = true - return - } + + let windows = environmentProvider.windowDescriptors() + let screens = environmentProvider.screenFrames() + + for window in windows where window.ownerPID == frontmostPID && window.layer == 0 { + if screens.contains(where: { FullscreenDetectionService.window(window.bounds, matches: $0) }) { + setFullscreenState(true) + return } } - - isFullscreenActive = false + + setFullscreenState(false) } - + + private static func window(_ windowBounds: CGRect, matches screenFrame: CGRect, tolerance: CGFloat = 1) -> Bool { + abs(windowBounds.width - screenFrame.width) < tolerance && + abs(windowBounds.height - screenFrame.height) < tolerance && + abs(windowBounds.origin.x - screenFrame.origin.x) < tolerance && + abs(windowBounds.origin.y - screenFrame.origin.y) < tolerance + } + + fileprivate func setFullscreenState(_ isActive: Bool) { + guard isFullscreenActive != isActive else { return } + isFullscreenActive = isActive + print("🖥️ Fullscreen state updated: \(isActive ? "ACTIVE" : "INACTIVE")") + } + func forceUpdate() { checkFullscreenState() } + + #if DEBUG + func simulateFullscreenStateForTesting(_ isActive: Bool) { + setFullscreenState(isActive) + } + #endif } diff --git a/Gaze/Services/Permissions/ScreenCapturePermissionManager.swift b/Gaze/Services/Permissions/ScreenCapturePermissionManager.swift new file mode 100644 index 0000000..23c8f1f --- /dev/null +++ b/Gaze/Services/Permissions/ScreenCapturePermissionManager.swift @@ -0,0 +1,80 @@ +// +// ScreenCapturePermissionManager.swift +// Gaze +// +// Created by ChatGPT on 1/14/26. +// + +import AppKit +import Combine +import CoreGraphics +import Foundation + +public enum ScreenCaptureAuthorizationStatus: Equatable { + case authorized + case denied + case notDetermined + + var isAuthorized: Bool { + if case .authorized = self { return true } + return false + } +} + +@MainActor +public protocol ScreenCapturePermissionManaging: AnyObject { + var authorizationStatus: ScreenCaptureAuthorizationStatus { get } + var authorizationStatusPublisher: AnyPublisher { get } + + func refreshStatus() + func requestAuthorizationIfNeeded() + func openSystemSettings() +} + +@MainActor +final class ScreenCapturePermissionManager: ObservableObject, ScreenCapturePermissionManaging { + static let shared = ScreenCapturePermissionManager() + + @Published private(set) var authorizationStatus: ScreenCaptureAuthorizationStatus = .notDetermined + + var authorizationStatusPublisher: AnyPublisher { + $authorizationStatus.eraseToAnyPublisher() + } + + private let userDefaults: UserDefaults + private let requestedKey = "gazeScreenCapturePermissionRequested" + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + refreshStatus() + } + + func refreshStatus() { + if CGPreflightScreenCaptureAccess() { + authorizationStatus = .authorized + } else if userDefaults.bool(forKey: requestedKey) { + authorizationStatus = .denied + } else { + authorizationStatus = .notDetermined + } + } + + func requestAuthorizationIfNeeded() { + refreshStatus() + + guard authorizationStatus == .notDetermined else { return } + + userDefaults.set(true, forKey: requestedKey) + let granted = CGRequestScreenCaptureAccess() + authorizationStatus = granted ? .authorized : .denied + } + + func openSystemSettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording" + ) else { + return + } + NSWorkspace.shared.open(url) + } +} diff --git a/Gaze/Views/Setup/SmartModeSetupView.swift b/Gaze/Views/Setup/SmartModeSetupView.swift index d37229e..455b631 100644 --- a/Gaze/Views/Setup/SmartModeSetupView.swift +++ b/Gaze/Views/Setup/SmartModeSetupView.swift @@ -9,6 +9,7 @@ import SwiftUI struct SmartModeSetupView: View { @ObservedObject var settingsManager: SettingsManager + @StateObject private var permissionManager = ScreenCapturePermissionManager.shared var body: some View { VStack(spacing: 0) { @@ -55,11 +56,51 @@ struct SmartModeSetupView: View { set: { newValue in print("🔧 Smart Mode - Auto-pause on fullscreen changed to: \(newValue)") settingsManager.settings.smartMode.autoPauseOnFullscreen = newValue + + if newValue { + permissionManager.requestAuthorizationIfNeeded() + } } ) ) .labelsHidden() } + + if settingsManager.settings.smartMode.autoPauseOnFullscreen, + permissionManager.authorizationStatus != .authorized + { + VStack(alignment: .leading, spacing: 8) { + Label( + permissionManager.authorizationStatus == .denied + ? "Screen Recording permission required" + : "Grant Screen Recording access", + systemImage: "exclamationmark.shield" + ) + .foregroundStyle(.orange) + + Text( + "macOS requires Screen Recording permission to detect other apps in fullscreen." + ) + .font(.caption) + .foregroundColor(.secondary) + + HStack { + Button("Grant Access") { + permissionManager.requestAuthorizationIfNeeded() + permissionManager.openSystemSettings() + } + .buttonStyle(.bordered) + + Button("Open Settings") { + permissionManager.openSystemSettings() + } + .buttonStyle(.borderless) + } + .font(.caption) + .padding(.top, 4) + } + .padding(.top, 8) + } } .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 8)) diff --git a/GazeTests/FullscreenDetectionServiceTests.swift b/GazeTests/FullscreenDetectionServiceTests.swift new file mode 100644 index 0000000..9241826 --- /dev/null +++ b/GazeTests/FullscreenDetectionServiceTests.swift @@ -0,0 +1,120 @@ +// +// FullscreenDetectionServiceTests.swift +// GazeTests +// +// Created by ChatGPT on 1/14/26. +// + +import Combine +import CoreGraphics +import XCTest +@testable import Gaze + +@MainActor +final class FullscreenDetectionServiceTests: XCTestCase { + func testPermissionDeniedKeepsStateFalse() { + let mockManager = MockPermissionManager(status: ScreenCaptureAuthorizationStatus.denied) + let service = FullscreenDetectionService(permissionManager: mockManager) + + let expectation = expectation(description: "No change") + expectation.isInverted = true + + let cancellable = service.$isFullscreenActive + .dropFirst() + .sink { _ in + expectation.fulfill() + } + + service.forceUpdate() + + wait(for: [expectation], timeout: 0.5) + cancellable.cancel() + } + + func testFullscreenStateBecomesTrueWhenWindowMatchesScreen() { + let mockManager = MockPermissionManager(status: ScreenCaptureAuthorizationStatus.authorized) + let environment = MockFullscreenEnvironment( + frontmostPID: 42, + windowDescriptors: [ + FullscreenWindowDescriptor( + ownerPID: 42, + layer: 0, + bounds: CGRect(x: 0, y: 0, width: 1920, height: 1080) + ) + ], + screenFrames: [CGRect(x: 0, y: 0, width: 1920, height: 1080)] + ) + + let service = FullscreenDetectionService( + permissionManager: mockManager, + environmentProvider: environment + ) + + let expectation = expectation(description: "Fullscreen detected") + + let cancellable = service.$isFullscreenActive + .dropFirst() + .sink { isActive in + if isActive { + expectation.fulfill() + } + } + + service.forceUpdate() + + wait(for: [expectation], timeout: 0.5) + cancellable.cancel() + } + + func testFullscreenStateStaysFalseWhenWindowDoesNotMatchScreen() { + let mockManager = MockPermissionManager(status: ScreenCaptureAuthorizationStatus.authorized) + let environment = MockFullscreenEnvironment( + frontmostPID: 42, + windowDescriptors: [ + FullscreenWindowDescriptor( + ownerPID: 42, + layer: 0, + bounds: CGRect(x: 100, y: 100, width: 800, height: 600) + ) + ], + screenFrames: [CGRect(x: 0, y: 0, width: 1920, height: 1080)] + ) + + let service = FullscreenDetectionService( + permissionManager: mockManager, + environmentProvider: environment + ) + + let expectation = expectation(description: "No fullscreen") + expectation.isInverted = true + + let cancellable = service.$isFullscreenActive + .dropFirst() + .sink { isActive in + if isActive { + expectation.fulfill() + } + } + + service.forceUpdate() + + wait(for: [expectation], timeout: 0.5) + cancellable.cancel() + } +} + +@MainActor +private final class MockPermissionManager: ScreenCapturePermissionManaging { + var authorizationStatus: ScreenCaptureAuthorizationStatus + var authorizationStatusPublisher: AnyPublisher { + Just(authorizationStatus).eraseToAnyPublisher() + } + + init(status: ScreenCaptureAuthorizationStatus) { + self.authorizationStatus = status + } + + func refreshStatus() {} + func requestAuthorizationIfNeeded() {} + func openSystemSettings() {} +}