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)))
+ }
}