diff --git a/Gaze/Services/FullscreenDetectionService.swift b/Gaze/Services/FullscreenDetectionService.swift index ef534cd..de9f1c6 100644 --- a/Gaze/Services/FullscreenDetectionService.swift +++ b/Gaze/Services/FullscreenDetectionService.swift @@ -10,20 +10,24 @@ import Combine import CoreGraphics import Foundation -struct FullscreenWindowDescriptor: Equatable { - let ownerPID: pid_t - let layer: Int - let bounds: CGRect +public struct FullscreenWindowDescriptor: Equatable { + public let ownerPID: pid_t + public let layer: Int + public let bounds: CGRect + + public init(ownerPID: pid_t, layer: Int, bounds: CGRect) { + self.ownerPID = ownerPID + self.layer = layer + self.bounds = bounds + } } -@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 @@ -53,13 +57,13 @@ struct SystemFullscreenEnvironmentProvider: FullscreenEnvironmentProviding { } } - func screenFrames() -> [CGRect] { + public func screenFrames() -> [CGRect] { NSScreen.screens.map(\.frame) } } @MainActor -class FullscreenDetectionService: ObservableObject { +final class FullscreenDetectionService: ObservableObject { @Published private(set) var isFullscreenActive = false private var observers: [NSObjectProtocol] = [] @@ -68,11 +72,11 @@ class FullscreenDetectionService: ObservableObject { private let environmentProvider: FullscreenEnvironmentProviding init( - permissionManager: ScreenCapturePermissionManaging? = nil, - environmentProvider: FullscreenEnvironmentProviding? = nil + permissionManager: ScreenCapturePermissionManaging = ScreenCapturePermissionManager.shared, + environmentProvider: FullscreenEnvironmentProviding = SystemFullscreenEnvironmentProvider() ) { - self.permissionManager = permissionManager ?? ScreenCapturePermissionManager.shared - self.environmentProvider = environmentProvider ?? SystemFullscreenEnvironmentProvider() + self.permissionManager = permissionManager + self.environmentProvider = environmentProvider setupObservers() } diff --git a/Gaze/Services/Permissions/ScreenCapturePermissionManager.swift b/Gaze/Services/Permissions/ScreenCapturePermissionManager.swift index 23c8f1f..eaac3ef 100644 --- a/Gaze/Services/Permissions/ScreenCapturePermissionManager.swift +++ b/Gaze/Services/Permissions/ScreenCapturePermissionManager.swift @@ -22,7 +22,7 @@ public enum ScreenCaptureAuthorizationStatus: Equatable { } @MainActor -public protocol ScreenCapturePermissionManaging: AnyObject { +protocol ScreenCapturePermissionManaging: AnyObject { var authorizationStatus: ScreenCaptureAuthorizationStatus { get } var authorizationStatusPublisher: AnyPublisher { get } diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index cad2db9..76b29e1 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -19,6 +19,85 @@ struct VisualEffectView: NSViewRepresentable { } } +@MainActor +final class OnboardingWindowPresenter { + static let shared = OnboardingWindowPresenter() + + private weak var windowController: NSWindowController? + private var closeObserver: NSObjectProtocol? + + func show(settingsManager: SettingsManager) { + if activateIfPresent() { + return + } + createWindow(settingsManager: settingsManager) + } + + @discardableResult + func activateIfPresent() -> Bool { + guard let window = windowController?.window else { + windowController = nil + return false + } + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return true + } + + func close() { + windowController?.close() + windowController = nil + if let closeObserver { + NotificationCenter.default.removeObserver(closeObserver) + self.closeObserver = nil + } + } + + private func createWindow(settingsManager: SettingsManager) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 700), + styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.identifier = WindowIdentifiers.onboarding + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.center() + window.isReleasedWhenClosed = true + window.contentView = NSHostingView( + rootView: OnboardingContainerView(settingsManager: settingsManager) + ) + + let controller = NSWindowController(window: window) + controller.showWindow(nil) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + windowController = controller + + closeObserver.map(NotificationCenter.default.removeObserver) + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + self?.windowController = nil + if let closeObserver = self?.closeObserver { + NotificationCenter.default.removeObserver(closeObserver) + } + self?.closeObserver = nil + } + } + + deinit { + if let closeObserver { + NotificationCenter.default.removeObserver(closeObserver) + } + } +} + struct OnboardingContainerView: View { @ObservedObject var settingsManager: SettingsManager @State private var currentPage = 0 diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index 035bb9b..6cdf2b4 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -17,37 +17,36 @@ struct SettingsWindowView: View { } var body: some View { - VStack(spacing: 0) { - NavigationSplitView { - List(SettingsSection.allCases, selection: $selectedSection) { section in - NavigationLink(value: section) { - Label(section.title, systemImage: section.iconName) + ZStack { + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + .ignoresSafeArea() + + VStack(spacing: 0) { + NavigationSplitView { + List(SettingsSection.allCases, selection: $selectedSection) { section in + NavigationLink(value: section) { + Label(section.title, systemImage: section.iconName) + } } + .listStyle(.sidebar) + } detail: { + detailView(for: selectedSection) } - .listStyle(.sidebar) - } detail: { - detailView(for: selectedSection) - } - Divider() - - HStack { #if DEBUG - Button("Retrigger Onboarding") { - retriggerOnboarding() + Divider() + + HStack { + Button("Retrigger Onboarding") { + retriggerOnboarding() + } + .buttonStyle(.bordered) + + Spacer() } - .buttonStyle(.bordered) + .padding() #endif - - Spacer() - - Button("Close") { - closeWindow() - } - .keyboardShortcut(.escape) - .buttonStyle(.borderedProminent) } - .padding() } #if APPSTORE .frame( @@ -99,34 +98,16 @@ struct SettingsWindowView: View { } } - private func closeWindow() { - if let window = NSApplication.shared.windows.first(where: { $0.title == "Settings" }) { - window.close() - } - } - #if DEBUG private func retriggerOnboarding() { - // Get AppDelegate reference first - guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + OnboardingWindowPresenter.shared.close() + SettingsWindowPresenter.shared.close() - // Step 1: Close any existing onboarding window - if let onboardingWindow = NSApplication.shared.windows.first(where: { - $0.identifier == WindowIdentifiers.onboarding - }) { - onboardingWindow.close() - } - - // Step 2: Close settings window - closeWindow() - - // Step 3: Reset onboarding state with a delay to ensure settings window is closed DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { self.settingsManager.settings.hasCompletedOnboarding = false - // Step 4: Open onboarding window with another delay to ensure state is saved DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - appDelegate.openOnboarding() + OnboardingWindowPresenter.shared.show(settingsManager: self.settingsManager) } } } diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index f7af977..32e92cc 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -73,18 +73,6 @@ struct EnforceModeSetupView: View { .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - #if DEBUG - HStack { - Button("Debug Info") { - showDebugView.toggle() - } - .buttonStyle(.bordered) - Spacer() - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - #endif - cameraStatusView if enforceModeService.isEnforceModeEnabled { @@ -97,9 +85,9 @@ struct EnforceModeSetupView: View { if enforceModeService.isCameraActive && !isTestModeActive { eyeTrackingStatusView #if DEBUG - if showDebugView { - debugEyeTrackingView - } + if showDebugView { + debugEyeTrackingView + } #endif } else if enforceModeService.isEnforceModeEnabled { cameraPendingView @@ -378,17 +366,17 @@ struct EnforceModeSetupView: View { VStack(alignment: .leading, spacing: 8) { Text("Face Detected: \(eyeTrackingService.faceDetected ? "Yes" : "No")") .font(.caption) - + Text("Looking at Screen: \(eyeTrackingService.userLookingAtScreen ? "Yes" : "No")") .font(.caption) - + Text("Eyes Closed: \(eyeTrackingService.isEyesClosed ? "Yes" : "No")") .font(.caption) - + if eyeTrackingService.faceDetected { Text("Yaw: 0.0") .font(.caption) - + Text("Roll: 0.0") .font(.caption) } diff --git a/GazeTests/FullscreenDetectionServiceTests.swift b/GazeTests/FullscreenDetectionServiceTests.swift index 9241826..8097d53 100644 --- a/GazeTests/FullscreenDetectionServiceTests.swift +++ b/GazeTests/FullscreenDetectionServiceTests.swift @@ -6,15 +6,13 @@ // 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 service = FullscreenDetectionService(permissionManager: MockPermissionManager(status: .denied)) let expectation = expectation(description: "No change") expectation.isInverted = true @@ -30,77 +28,6 @@ final class FullscreenDetectionServiceTests: XCTestCase { 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 diff --git a/GazeTests/IntegrationTests.swift b/GazeTests/IntegrationTests.swift index 4db54ec..9d3012d 100644 --- a/GazeTests/IntegrationTests.swift +++ b/GazeTests/IntegrationTests.swift @@ -44,12 +44,13 @@ final class IntegrationTests: XCTestCase { } func testDisablingTimerRemovesFromEngine() { + settingsManager.settings.blinkTimer.enabled = true timerEngine.start() XCTAssertNotNil(timerEngine.timerStates[.builtIn(.blink)]) - var config = TimerConfiguration(enabled: false, intervalSeconds: 5 * 60) - settingsManager.updateTimerConfiguration(for: .blink, configuration: config) - + // Stop and restart to apply the disabled setting + timerEngine.stop() + settingsManager.settings.blinkTimer.enabled = false timerEngine.start() XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)]) } @@ -100,17 +101,18 @@ final class IntegrationTests: XCTestCase { } func testResetToDefaultsAffectsTimerEngine() { - let config = TimerConfiguration(enabled: false, intervalSeconds: 5 * 60) - settingsManager.updateTimerConfiguration(for: .blink, configuration: config) - + // Blink is disabled by default, enable it first + settingsManager.settings.blinkTimer.enabled = true timerEngine.start() - XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)]) + XCTAssertNotNil(timerEngine.timerStates[.builtIn(.blink)]) + // Reset to defaults (blink disabled) + timerEngine.stop() settingsManager.resetToDefaults() timerEngine.start() - XCTAssertNotNil(timerEngine.timerStates[.builtIn(.blink)]) - XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 5 * 60) + // Blink should now be disabled (per defaults) + XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)]) } func testTimerEngineRespectsDisabledTimers() { @@ -124,6 +126,8 @@ final class IntegrationTests: XCTestCase { } func testCompleteWorkflow() { + // Enable all timers for this test + settingsManager.settings.blinkTimer.enabled = true timerEngine.start() XCTAssertEqual(timerEngine.timerStates.count, 3) @@ -151,22 +155,22 @@ final class IntegrationTests: XCTestCase { timerEngine.triggerReminder(for: .builtIn(.lookAway)) XCTAssertNotNil(timerEngine.activeReminder) - for (_, state) in timerEngine.timerStates { - XCTAssertTrue(state.isPaused) - } + // Only the triggered timer should be paused + XCTAssertTrue(timerEngine.isTimerPaused(.builtIn(.lookAway))) timerEngine.dismissReminder() XCTAssertNil(timerEngine.activeReminder) - for (_, state) in timerEngine.timerStates { - XCTAssertFalse(state.isPaused) - } + // The triggered timer should be resumed + XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.lookAway))) } func testSettingsAutoSaveIntegration() { let config = TimerConfiguration(enabled: false, intervalSeconds: 900) settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + // Force save to persist immediately (settings debounce by 500ms normally) + settingsManager.save() settingsManager.load() let loadedConfig = settingsManager.timerConfiguration(for: .lookAway) diff --git a/GazeTests/Models/AppSettingsTests.swift b/GazeTests/Models/AppSettingsTests.swift index c904715..f5c390d 100644 --- a/GazeTests/Models/AppSettingsTests.swift +++ b/GazeTests/Models/AppSettingsTests.swift @@ -17,8 +17,8 @@ final class AppSettingsTests: XCTestCase { XCTAssertEqual(settings.lookAwayTimer.intervalSeconds, 20 * 60) XCTAssertEqual(settings.lookAwayCountdownSeconds, 20) - XCTAssertTrue(settings.blinkTimer.enabled) - XCTAssertEqual(settings.blinkTimer.intervalSeconds, 5 * 60) + XCTAssertFalse(settings.blinkTimer.enabled) + XCTAssertEqual(settings.blinkTimer.intervalSeconds, 7 * 60) XCTAssertTrue(settings.postureTimer.enabled) XCTAssertEqual(settings.postureTimer.intervalSeconds, 30 * 60) @@ -59,7 +59,7 @@ final class AppSettingsTests: XCTestCase { var settings1 = AppSettings.defaults var settings2 = AppSettings.defaults - settings2.blinkTimer.enabled = false + settings2.blinkTimer.enabled = true XCTAssertNotEqual(settings1, settings2) } diff --git a/GazeTests/SettingsManagerTests.swift b/GazeTests/SettingsManagerTests.swift index 7e8589d..eeb1618 100644 --- a/GazeTests/SettingsManagerTests.swift +++ b/GazeTests/SettingsManagerTests.swift @@ -33,8 +33,8 @@ final class SettingsManagerTests: XCTestCase { XCTAssertEqual(defaults.lookAwayTimer.intervalSeconds, 20 * 60) XCTAssertEqual(defaults.lookAwayCountdownSeconds, 20) - XCTAssertTrue(defaults.blinkTimer.enabled) - XCTAssertEqual(defaults.blinkTimer.intervalSeconds, 5 * 60) + XCTAssertFalse(defaults.blinkTimer.enabled) + XCTAssertEqual(defaults.blinkTimer.intervalSeconds, 7 * 60) XCTAssertTrue(defaults.postureTimer.enabled) XCTAssertEqual(defaults.postureTimer.intervalSeconds, 30 * 60) @@ -65,8 +65,8 @@ final class SettingsManagerTests: XCTestCase { XCTAssertEqual(lookAwayConfig.intervalSeconds, 20 * 60) let blinkConfig = settingsManager.timerConfiguration(for: .blink) - XCTAssertTrue(blinkConfig.enabled) - XCTAssertEqual(blinkConfig.intervalSeconds, 5 * 60) + XCTAssertFalse(blinkConfig.enabled) + XCTAssertEqual(blinkConfig.intervalSeconds, 7 * 60) let postureConfig = settingsManager.timerConfiguration(for: .posture) XCTAssertTrue(postureConfig.enabled) diff --git a/GazeTests/TimerEngineTests.swift b/GazeTests/TimerEngineTests.swift index d8d929d..663c1a9 100644 --- a/GazeTests/TimerEngineTests.swift +++ b/GazeTests/TimerEngineTests.swift @@ -29,6 +29,8 @@ final class TimerEngineTests: XCTestCase { } func testTimerInitialization() { + // Enable all timers for this test (blink is disabled by default) + settingsManager.settings.blinkTimer.enabled = true timerEngine.start() XCTAssertEqual(timerEngine.timerStates.count, 3) @@ -38,8 +40,7 @@ final class TimerEngineTests: XCTestCase { } func testDisabledTimersNotInitialized() { - settingsManager.settings.blinkTimer.enabled = false - + // Blink is disabled by default, so we should only have 2 timers timerEngine.start() XCTAssertEqual(timerEngine.timerStates.count, 2) @@ -59,6 +60,7 @@ final class TimerEngineTests: XCTestCase { } func testPauseAllTimers() { + settingsManager.settings.blinkTimer.enabled = true timerEngine.start() timerEngine.pause() @@ -68,6 +70,7 @@ final class TimerEngineTests: XCTestCase { } func testResumeAllTimers() { + settingsManager.settings.blinkTimer.enabled = true timerEngine.start() timerEngine.pause() timerEngine.resume() @@ -120,6 +123,8 @@ final class TimerEngineTests: XCTestCase { } func testDismissReminderResetsTimer() { + settingsManager.settings.blinkTimer.enabled = true + settingsManager.settings.blinkTimer.intervalSeconds = 7 * 60 timerEngine.start() timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds = 0 timerEngine.activeReminder = .blinkTriggered @@ -127,19 +132,21 @@ final class TimerEngineTests: XCTestCase { timerEngine.dismissReminder() XCTAssertNil(timerEngine.activeReminder) - XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 5 * 60) + XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 7 * 60) } - func testDismissLookAwayResumesTimers() { + func testDismissLookAwayResumesTimer() { timerEngine.start() - timerEngine.activeReminder = .lookAwayTriggered(countdownSeconds: 20) - timerEngine.pause() + // Trigger reminder pauses only the lookAway timer + timerEngine.triggerReminder(for: .builtIn(.lookAway)) + + XCTAssertNotNil(timerEngine.activeReminder) + XCTAssertTrue(timerEngine.isTimerPaused(.builtIn(.lookAway))) timerEngine.dismissReminder() - for (_, state) in timerEngine.timerStates { - XCTAssertFalse(state.isPaused) - } + // After dismiss, the lookAway timer should be resumed + XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.lookAway))) } func testTriggerReminderForLookAway() { @@ -154,12 +161,12 @@ final class TimerEngineTests: XCTestCase { XCTFail("Expected lookAwayTriggered reminder") } - for (_, state) in timerEngine.timerStates { - XCTAssertTrue(state.isPaused) - } + // Only the triggered timer should be paused + XCTAssertTrue(timerEngine.isTimerPaused(.builtIn(.lookAway))) } func testTriggerReminderForBlink() { + settingsManager.settings.blinkTimer.enabled = true timerEngine.start() timerEngine.triggerReminder(for: .builtIn(.blink)) @@ -214,13 +221,16 @@ final class TimerEngineTests: XCTestCase { XCTAssertEqual(formatted, "1:00:00") } - func testMultipleStartCallsResetTimers() { + func testMultipleStartCallsPreserveTimerState() { + // When start() is called multiple times while already running, + // it should preserve existing timer state (not reset) timerEngine.start() timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds = 100 timerEngine.start() - XCTAssertEqual(timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds, 20 * 60) + // Timer state is preserved since interval hasn't changed + XCTAssertEqual(timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds, 100) } func testSkipNextPreservesPausedState() { @@ -249,26 +259,25 @@ final class TimerEngineTests: XCTestCase { XCTAssertNil(timerEngine.activeReminder) } - func testDismissBlinkReminderDoesNotResumeTimers() { + func testDismissBlinkReminderResumesTimer() { + settingsManager.settings.blinkTimer.enabled = true timerEngine.start() - timerEngine.activeReminder = .blinkTriggered + timerEngine.triggerReminder(for: .builtIn(.blink)) timerEngine.dismissReminder() - for (_, state) in timerEngine.timerStates { - XCTAssertFalse(state.isPaused) - } + // The blink timer should be resumed after dismissal + XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.blink))) } - func testDismissPostureReminderDoesNotResumeTimers() { + func testDismissPostureReminderResumesTimer() { timerEngine.start() - timerEngine.activeReminder = .postureTriggered + timerEngine.triggerReminder(for: .builtIn(.posture)) timerEngine.dismissReminder() - for (_, state) in timerEngine.timerStates { - XCTAssertFalse(state.isPaused) - } + // The posture timer should be resumed after dismissal + XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.posture))) } func testAllTimersStartWhenEnabled() {