fix: fullscreen detection working
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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] = []
|
||||
private var frontmostAppObserver: AnyCancellable?
|
||||
private let permissionManager: ScreenCapturePermissionManaging
|
||||
private let environmentProvider: FullscreenEnvironmentProviding
|
||||
|
||||
init() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
observers.append(didEnterObserver)
|
||||
observers.append(spaceObserver)
|
||||
|
||||
// Monitor when active application changes
|
||||
let didActivateObserver = notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didActivateApplicationNotification,
|
||||
object: workspace,
|
||||
let transitionObserver = notificationCenter.addObserver(
|
||||
forName: NSApplication.didChangeScreenParametersNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.checkFullscreenState()
|
||||
}
|
||||
}
|
||||
observers.append(didActivateObserver)
|
||||
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()
|
||||
}
|
||||
|
||||
// Initial check
|
||||
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 windows = environmentProvider.windowDescriptors()
|
||||
let screens = environmentProvider.screenFrames()
|
||||
|
||||
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
|
||||
for window in windows where window.ownerPID == frontmostPID && window.layer == 0 {
|
||||
if screens.contains(where: { FullscreenDetectionService.window(window.bounds, matches: $0) }) {
|
||||
setFullscreenState(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFullscreenState(false)
|
||||
}
|
||||
|
||||
isFullscreenActive = 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
120
GazeTests/FullscreenDetectionServiceTests.swift
Normal file
120
GazeTests/FullscreenDetectionServiceTests.swift
Normal file
@@ -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<ScreenCaptureAuthorizationStatus, Never> {
|
||||
Just(authorizationStatus).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(status: ScreenCaptureAuthorizationStatus) {
|
||||
self.authorizationStatus = status
|
||||
}
|
||||
|
||||
func refreshStatus() {}
|
||||
func requestAuthorizationIfNeeded() {}
|
||||
func openSystemSettings() {}
|
||||
}
|
||||
Reference in New Issue
Block a user