fix: fullscreen detection working

This commit is contained in:
Michael Freno
2026-01-14 21:01:44 -05:00
parent 8e5f6c6715
commit f7d8470a43
5 changed files with 390 additions and 64 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<ScreenCaptureAuthorizationStatus, Never> { 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<ScreenCaptureAuthorizationStatus, Never> {
$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)
}
}

View File

@@ -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))