diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj index 517fb0c..df1fed8 100644 --- a/Gaze.xcodeproj/project.pbxproj +++ b/Gaze.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; }; + 27SPARKLE00000000003 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27SPARKLE00000000002 /* Sparkle */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,6 +71,7 @@ buildActionMask = 2147483647; files = ( 275915892F132A9200D0E60D /* Lottie in Frameworks */, + 27SPARKLE00000000003 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -131,6 +133,7 @@ name = Gaze; packageProductDependencies = ( 27AE10B12F10B1FC00E00DBC /* Lottie */, + 27SPARKLE00000000002 /* Sparkle */, ); productName = Gaze; productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */; @@ -216,6 +219,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */, + 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */, ); preferredProjectObjectVersion = 77; productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */; @@ -435,7 +439,6 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.4.0; - OTHER_SWIFT_FLAGS = "-D APPSTORE"; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -472,7 +475,6 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.4.0; - OTHER_SWIFT_FLAGS = "-D APPSTORE"; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -615,6 +617,14 @@ minimumVersion = 4.6.0; }; }; + 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = exactVersion; + version = 2.8.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -623,6 +633,11 @@ package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */; productName = Lottie; }; + 27SPARKLE00000000002 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */; diff --git a/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 726a161..b3cf5c5 100644 --- a/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,24 @@ { - "originHash": "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560", - "pins": [ + "originHash" : "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560", + "pins" : [ { - "identity": "lottie-spm", - "kind": "remoteSourceControl", - "location": "https://github.com/airbnb/lottie-spm.git", - "state": { - "revision": "69faaefa7721fba9e434a52c16adf4329c9084db", - "version": "4.6.0" + "identity" : "lottie-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-spm.git", + "state" : { + "revision" : "69faaefa7721fba9e434a52c16adf4329c9084db", + "version" : "4.6.0" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", + "version" : "2.8.1" } } ], - "version": 3 -} \ No newline at end of file + "version" : 3 +} diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index ba4c0d1..8624b53 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -31,15 +31,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { updateManager = UpdateManager.shared } - // Detect App Store version asynchronously at launch - Task { - do { - await settingsManager.detectAppStoreVersion() - } catch { - // Handle error silently in production - } - } - setupLifecycleObservers() observeSettingsChanges() diff --git a/Gaze/Gaze.entitlements b/Gaze/Gaze.entitlements index ee95ab7..4a6c209 100644 --- a/Gaze/Gaze.entitlements +++ b/Gaze/Gaze.entitlements @@ -6,5 +6,10 @@ com.apple.security.network.client + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + diff --git a/Gaze/Info.plist b/Gaze/Info.plist index 4b71fcf..3851b18 100644 --- a/Gaze/Info.plist +++ b/Gaze/Info.plist @@ -22,5 +22,15 @@ $(MARKETING_VERSION) NSHumanReadableCopyright Copyright © 2026 Mike Freno. All rights reserved. + SUPublicEDKey + Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM= + SUFeedURL + https://freno.me/api/Gaze/appcast.xml + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 + SUEnableInstallerLauncherService + diff --git a/Gaze/Models/AppSettings.swift b/Gaze/Models/AppSettings.swift index fecfd53..8c2224d 100644 --- a/Gaze/Models/AppSettings.swift +++ b/Gaze/Models/AppSettings.swift @@ -46,9 +46,6 @@ struct AppSettings: Codable, Equatable, Hashable { var launchAtLogin: Bool var playSounds: Bool - // App Store detection (cached at launch, not persisted) - var isAppStoreVersion: Bool - init( lookAwayTimer: TimerConfiguration = TimerConfiguration( enabled: true, intervalSeconds: 20 * 60), @@ -61,8 +58,7 @@ struct AppSettings: Codable, Equatable, Hashable { subtleReminderSize: ReminderSize = .medium, hasCompletedOnboarding: Bool = false, launchAtLogin: Bool = false, - playSounds: Bool = true, - isAppStoreVersion: Bool = true + playSounds: Bool = true ) { self.lookAwayTimer = lookAwayTimer self.lookAwayCountdownSeconds = lookAwayCountdownSeconds @@ -73,7 +69,6 @@ struct AppSettings: Codable, Equatable, Hashable { self.hasCompletedOnboarding = hasCompletedOnboarding self.launchAtLogin = launchAtLogin self.playSounds = playSounds - self.isAppStoreVersion = isAppStoreVersion } static var defaults: AppSettings { @@ -86,67 +81,7 @@ struct AppSettings: Codable, Equatable, Hashable { subtleReminderSize: .medium, hasCompletedOnboarding: false, launchAtLogin: false, - playSounds: true, - isAppStoreVersion: false + playSounds: true ) } - - /// Manual Equatable implementation required because isAppStoreVersion - /// is excluded from Codable persistence but included in equality checks - static func == (lhs: AppSettings, rhs: AppSettings) -> Bool { - lhs.lookAwayTimer == rhs.lookAwayTimer - && lhs.lookAwayCountdownSeconds == rhs.lookAwayCountdownSeconds - && lhs.blinkTimer == rhs.blinkTimer - && lhs.postureTimer == rhs.postureTimer - && lhs.userTimers == rhs.userTimers - && lhs.subtleReminderSize == rhs.subtleReminderSize - && lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding - && lhs.launchAtLogin == rhs.launchAtLogin - && lhs.playSounds == rhs.playSounds - && lhs.isAppStoreVersion == rhs.isAppStoreVersion - } - - // MARK: - Custom Codable Implementation - - enum CodingKeys: String, CodingKey { - case lookAwayTimer - case lookAwayCountdownSeconds - case blinkTimer - case postureTimer - case userTimers - case subtleReminderSize - case hasCompletedOnboarding - case launchAtLogin - case playSounds - // isAppStoreVersion is intentionally excluded from persistence - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - lookAwayTimer = try container.decode(TimerConfiguration.self, forKey: .lookAwayTimer) - lookAwayCountdownSeconds = try container.decode(Int.self, forKey: .lookAwayCountdownSeconds) - blinkTimer = try container.decode(TimerConfiguration.self, forKey: .blinkTimer) - postureTimer = try container.decode(TimerConfiguration.self, forKey: .postureTimer) - userTimers = try container.decode([UserTimer].self, forKey: .userTimers) - subtleReminderSize = try container.decode(ReminderSize.self, forKey: .subtleReminderSize) - hasCompletedOnboarding = try container.decode(Bool.self, forKey: .hasCompletedOnboarding) - launchAtLogin = try container.decode(Bool.self, forKey: .launchAtLogin) - playSounds = try container.decode(Bool.self, forKey: .playSounds) - // isAppStoreVersion is not persisted, will be set at launch - isAppStoreVersion = false - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(lookAwayTimer, forKey: .lookAwayTimer) - try container.encode(lookAwayCountdownSeconds, forKey: .lookAwayCountdownSeconds) - try container.encode(blinkTimer, forKey: .blinkTimer) - try container.encode(postureTimer, forKey: .postureTimer) - try container.encode(userTimers, forKey: .userTimers) - try container.encode(subtleReminderSize, forKey: .subtleReminderSize) - try container.encode(hasCompletedOnboarding, forKey: .hasCompletedOnboarding) - try container.encode(launchAtLogin, forKey: .launchAtLogin) - try container.encode(playSounds, forKey: .playSounds) - // isAppStoreVersion is intentionally not persisted - } } diff --git a/Gaze/Services/AppStoreDetector.swift b/Gaze/Services/AppStoreDetector.swift deleted file mode 100644 index e906b47..0000000 --- a/Gaze/Services/AppStoreDetector.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// AppStoreDetector.swift -// Gaze -// -// Created by Mike Freno on 1/10/26. -// - -import Foundation -import StoreKit - -enum AppStoreDetector { - /// Returns true if the app was downloaded from the Mac App Store. - /// - /// Uses StoreKit's AppTransaction API on macOS 15+ to verify if the app is an App Store version. - /// Falls back to a heuristic receipt check on macOS versions prior to 15. - /// - /// This method is asynchronous due to the use of StoreKit's async API. - static func isAppStoreVersion() async -> Bool { - #if DEBUG - return false - #else - if #available(macOS 15.0, *) { - do { - let transaction = try await AppTransaction.shared - return true - } catch { - return false - } - } else { - // Fallback for older macOS: use legacy receipt check - - guard let receiptURL = Bundle.main.appStoreReceiptURL else { - return false - } - - do { - let fileExists = FileManager.default.fileExists(atPath: receiptURL.path) - guard fileExists else { - return false - } - - guard let receiptData = try? Data(contentsOf: receiptURL), - receiptData.count > 2 - else { - return false - } - - let bytes = [UInt8](receiptData.prefix(2)) - let isValid = bytes[0] == 0x30 && bytes[1] == 0x82 - return isValid - } catch { - return false - } - } - #endif - } - - /// Checks if the app is running in TestFlight. - /// - /// On macOS 15+, StoreKit does not expose a TestFlight receipt type. - /// This method returns false on macOS 15+ as a result. - /// On earlier versions, it checks for the presence of a "sandboxReceipt". - /// - /// This method is asynchronous for API consistency. - static func isTestFlight() async -> Bool { - #if DEBUG - return false - #else - if #available(macOS 15.0, *) { - // StoreKit does not expose TestFlight receipt type. - // As a workaround, fallback to legacy method if available, else return false. - return false // No supported TestFlight check post-macOS 15 - } else { - return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" - } - #endif - } -} diff --git a/Gaze/Services/SettingsManager.swift b/Gaze/Services/SettingsManager.swift index 387b0f1..aa6f6f9 100644 --- a/Gaze/Services/SettingsManager.swift +++ b/Gaze/Services/SettingsManager.swift @@ -74,9 +74,6 @@ class SettingsManager: ObservableObject { /// Use this for critical save points like app termination or system sleep. func saveImmediately() { save() - // Cancel any pending debounced saves - saveCancellable?.cancel() - setupDebouncedSave() } func load() { @@ -120,13 +117,4 @@ class SettingsManager: ObservableObject { preconditionFailure("Missing timer configuration mappings for: \(missing)") } } - - /// Detects and caches the App Store version status. - /// This should be called once at app launch to avoid async checks throughout the app. - func detectAppStoreVersion() async { - let isAppStore = await AppStoreDetector.isAppStoreVersion() - await MainActor.run { - settings.isAppStoreVersion = isAppStore - } - } } diff --git a/Gaze/Services/UpdateManager.swift b/Gaze/Services/UpdateManager.swift index 0b1a785..591c534 100644 --- a/Gaze/Services/UpdateManager.swift +++ b/Gaze/Services/UpdateManager.swift @@ -7,78 +7,79 @@ import Combine import Foundation + #if !APPSTORE -import Sparkle + import Sparkle #endif @MainActor class UpdateManager: NSObject, ObservableObject { static let shared = UpdateManager() - + #if !APPSTORE - private var updaterController: SPUStandardUpdaterController? - private var automaticallyChecksObservation: NSKeyValueObservation? - private var lastCheckDateObservation: NSKeyValueObservation? + private var updaterController: SPUStandardUpdaterController? + private var automaticallyChecksObservation: NSKeyValueObservation? + private var lastCheckDateObservation: NSKeyValueObservation? #endif - + @Published var automaticallyChecksForUpdates = false @Published var lastUpdateCheckDate: Date? - + private override init() { super.init() #if !APPSTORE - setupUpdater() + setupUpdater() #endif } - + #if !APPSTORE - private func setupUpdater() { - updaterController = SPUStandardUpdaterController( - startingUpdater: true, - updaterDelegate: nil, - userDriverDelegate: nil - ) - - guard let updater = updaterController?.updater else { - return - } - - automaticallyChecksObservation = updater.observe( - \.automaticallyChecksForUpdates, - options: [.new, .initial] - ) { [weak self] _, change in - guard let self = self, let newValue = change.newValue else { return } - Task { @MainActor in - self.automaticallyChecksForUpdates = newValue + private func setupUpdater() { + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + + guard let updater = updaterController?.updater else { + return + } + + automaticallyChecksObservation = updater.observe( + \.automaticallyChecksForUpdates, + options: [.new, .initial] + ) { [weak self] _, change in + guard let self = self, let newValue = change.newValue else { return } + Task { @MainActor in + self.automaticallyChecksForUpdates = newValue + } + } + + lastCheckDateObservation = updater.observe( + \.lastUpdateCheckDate, + options: [.new, .initial] + ) { [weak self] _, change in + guard let self = self else { return } + Task { @MainActor in + self.lastUpdateCheckDate = change.newValue ?? nil + } } } - - lastCheckDateObservation = updater.observe( - \.lastUpdateCheckDate, - options: [.new, .initial] - ) { [weak self] _, change in - guard let self = self else { return } - Task { @MainActor in - self.lastUpdateCheckDate = change.newValue ?? nil - } - } - } #endif - + func checkForUpdates() { #if !APPSTORE - guard let updater = updaterController?.updater else { - return - } - updater.checkForUpdates() + guard let updater = updaterController?.updater else { + return + } + updater.checkForUpdates() #else #endif } - + deinit { #if !APPSTORE - automaticallyChecksObservation?.invalidate() - lastCheckDateObservation?.invalidate() + automaticallyChecksObservation?.invalidate() + lastCheckDateObservation?.invalidate() #endif } } diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index f24e643..b07e5f7 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -123,10 +123,9 @@ struct OnboardingContainerView: View { } } } - .frame( minWidth: 1000, - minHeight: settingsManager.settings.isAppStoreVersion ? 700 : 900 + minHeight: 700 ) } diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index 2e96f95..ea18bee 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -80,7 +80,7 @@ struct SettingsWindowView: View { } .frame( minWidth: 750, - minHeight: settingsManager.settings.isAppStoreVersion ? 700 : 900 + minHeight: 700 ) .onReceive( NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab")) diff --git a/Gaze/Views/Setup/GeneralSetupView.swift b/Gaze/Views/Setup/GeneralSetupView.swift index 3b9d7a9..c50a749 100644 --- a/Gaze/Views/Setup/GeneralSetupView.swift +++ b/Gaze/Views/Setup/GeneralSetupView.swift @@ -55,34 +55,34 @@ struct GeneralSetupView: View { .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) // Software Updates Section - if !settingsManager.settings.isAppStoreVersion { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Software Updates") - .font(.headline) + #if !APPSTORE + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Software Updates") + .font(.headline) - if let lastCheck = updateManager.lastUpdateCheckDate { - Text("Last checked: \(lastCheck, style: .relative)") - .font(.caption) - .foregroundColor(.secondary) - .italic() - } else { - Text("Never checked for updates") - .font(.caption) - .foregroundColor(.secondary) - .italic() - } + if let lastCheck = updateManager.lastUpdateCheckDate { + Text("Last checked: \(lastCheck, style: .relative)") + .font(.caption) + .foregroundColor(.secondary) + .italic() + } else { + Text("Never checked for updates") + .font(.caption) + .foregroundColor(.secondary) + .italic() } + } - Spacer() + Spacer() - Button("Check for Updates Now") { - updateManager.checkForUpdates() - } - .buttonStyle(.bordered) + Button("Check for Updates Now") { + updateManager.checkForUpdates() + } + .buttonStyle(.bordered) - Toggle( - "Automatically check for updates", + Toggle( + "Automatically check for updates", isOn: $updateManager.automaticallyChecksForUpdates ) .labelsHidden() @@ -90,7 +90,7 @@ struct GeneralSetupView: View { } .padding() .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - } + #endif VStack(alignment: .leading, spacing: 12) { Text("Subtle Reminder Size") @@ -171,41 +171,41 @@ struct GeneralSetupView: View { .contentShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) - .glassEffectIfAvailable( - GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10)) - - if !settingsManager.settings.isAppStoreVersion { - Button(action: { - if let url = URL(string: "https://buymeacoffee.com/mikefreno") { - NSWorkspace.shared.open(url) - } - }) { - HStack { - Image(systemName: "cup.and.saucer.fill") - .font(.title3) - .foregroundColor(.brown) - VStack(alignment: .leading, spacing: 2) { - Text("Buy Me a Coffee") - .font(.subheadline) - .fontWeight(.semibold) - Text("Support development of Gaze") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Image(systemName: "arrow.up.right") - .font(.caption) - } - .padding() - .frame(maxWidth: .infinity) - .cornerRadius(10) - .contentShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) .glassEffectIfAvailable( - GlassStyle.regular.tint(.orange).interactive(), - in: .rect(cornerRadius: 10)) + GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10)) + + #if !APPSTORE + Button(action: { + if let url = URL(string: "https://buymeacoffee.com/mikefreno") { + NSWorkspace.shared.open(url) + } + }) { + HStack { + Image(systemName: "cup.and.saucer.fill") + .font(.title3) + .foregroundColor(.brown) + VStack(alignment: .leading, spacing: 2) { + Text("Buy Me a Coffee") + .font(.subheadline) + .fontWeight(.semibold) + Text("Support development of Gaze") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + } + .padding() + .frame(maxWidth: .infinity) + .cornerRadius(10) + .contentShape(RoundedRectangle(cornerRadius: 10)) } + .buttonStyle(.plain) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.orange).interactive(), + in: .rect(cornerRadius: 10)) + #endif } .padding() } diff --git a/GazeTests/Services/AppStoreDetectorTests.swift b/GazeTests/Services/AppStoreDetectorTests.swift deleted file mode 100644 index 50702a7..0000000 --- a/GazeTests/Services/AppStoreDetectorTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// AppStoreDetectorTests.swift -// GazeTests -// -// Created by Mike Freno on 1/10/26. -// - -@testable import Gaze -import Testing - -struct AppStoreDetectorTests { - - @Test func isAppStoreVersionReturnsFalseInDebug() async { - // In test/debug builds, should always return false - #expect(await AppStoreDetector.isAppStoreVersion() == false) - } - - @Test func isTestFlightReturnsFalseInDebug() async { - // In test/debug builds, should always return false - #expect(await AppStoreDetector.isTestFlight() == false) - } - - @Test func receiptValidationHandlesMissingReceipt() async { - // When there's no receipt (development build), should return false - // This is implicitly tested by isAppStoreVersionReturnsFalseInDebug - // but we're documenting the expected behavior - #expect(await AppStoreDetector.isAppStoreVersion() == false) - } -} diff --git a/GazeTests/TimerEngineTests.swift b/GazeTests/TimerEngineTests.swift index 914e3ce..d8d929d 100644 --- a/GazeTests/TimerEngineTests.swift +++ b/GazeTests/TimerEngineTests.swift @@ -306,4 +306,106 @@ final class TimerEngineTests: XCTestCase { XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)]) XCTAssertNotNil(timerEngine.timerStates[.builtIn(.posture)]) } + + func testMultipleReminderTypesCanTriggerSimultaneously() { + // Setup: Create a user timer with overlay type (focus-stealing) + let overlayTimer = UserTimer( + title: "Water Break", + type: .overlay, + timeOnScreenSeconds: 10, + intervalMinutes: 1, + message: "Drink water" + ) + settingsManager.settings.userTimers = [overlayTimer] + + timerEngine.start() + + // Trigger an overlay reminder (look away or user timer overlay) + timerEngine.triggerReminder(for: .user(id: overlayTimer.id)) + + // Verify overlay reminder is active + XCTAssertNotNil(timerEngine.activeReminder) + if case .userTimerTriggered(let timer) = timerEngine.activeReminder { + XCTAssertEqual(timer.id, overlayTimer.id) + XCTAssertEqual(timer.type, .overlay) + } else { + XCTFail("Expected userTimerTriggered with overlay type") + } + + // Verify the overlay timer is paused + XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) + + // Now trigger a subtle reminder (blink) while overlay is still active + let previousActiveReminder = timerEngine.activeReminder + timerEngine.triggerReminder(for: .builtIn(.blink)) + + // The activeReminder should be replaced with the blink reminder + // This is expected behavior - TimerEngine only tracks one activeReminder + XCTAssertNotNil(timerEngine.activeReminder) + if case .blinkTriggered = timerEngine.activeReminder { + XCTAssertTrue(true) + } else { + XCTFail("Expected blinkTriggered reminder") + } + + // Both timers should be paused (the one that triggered their reminder) + XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) + XCTAssertTrue(timerEngine.isTimerPaused(.builtIn(.blink))) + + // The key insight: Even though TimerEngine only tracks one activeReminder, + // AppDelegate now tracks overlay and subtle windows separately, so both + // reminders can be displayed simultaneously without interference + } + + func testOverlayReminderDoesNotBlockSubtleReminders() { + // This test verifies the fix for the bug where a subtle reminder + // would cause an overlay reminder to get stuck + + // Setup overlay user timer + let overlayTimer = UserTimer( + title: "Stand Up", + type: .overlay, + timeOnScreenSeconds: 10, + intervalMinutes: 1 + ) + settingsManager.settings.userTimers = [overlayTimer] + settingsManager.settings.blinkTimer.enabled = true + settingsManager.settings.blinkTimer.intervalSeconds = 60 + + timerEngine.start() + + // Trigger overlay reminder first + timerEngine.triggerReminder(for: .user(id: overlayTimer.id)) + XCTAssertNotNil(timerEngine.activeReminder) + XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) + + // Trigger subtle reminder while overlay is active + timerEngine.triggerReminder(for: .builtIn(.blink)) + + // The blink reminder should now be active + if case .blinkTriggered = timerEngine.activeReminder { + XCTAssertTrue(true) + } else { + XCTFail("Expected blinkTriggered reminder") + } + + // Both timers should be paused + XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) + XCTAssertTrue(timerEngine.isTimerPaused(.builtIn(.blink))) + + // Dismiss the blink reminder + timerEngine.dismissReminder() + + // After dismissing blink, the reminder should be cleared + XCTAssertNil(timerEngine.activeReminder) + + // Blink timer should be reset and resumed + XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.blink))) + XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 60) + + // The overlay timer should still be paused (user needs to dismiss it manually) + // Note: In the actual app, AppDelegate tracks this window separately and it + // remains visible even after the subtle reminder dismisses + XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) + } }