general: removal of AppStoreDetector, just use build flags

This commit is contained in:
Michael Freno
2026-01-13 15:46:03 -05:00
parent ca86316c3f
commit c357e02369
14 changed files with 261 additions and 313 deletions

View File

@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; }; 275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
27SPARKLE00000000003 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27SPARKLE00000000002 /* Sparkle */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -70,6 +71,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
275915892F132A9200D0E60D /* Lottie in Frameworks */, 275915892F132A9200D0E60D /* Lottie in Frameworks */,
27SPARKLE00000000003 /* Sparkle in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -131,6 +133,7 @@
name = Gaze; name = Gaze;
packageProductDependencies = ( packageProductDependencies = (
27AE10B12F10B1FC00E00DBC /* Lottie */, 27AE10B12F10B1FC00E00DBC /* Lottie */,
27SPARKLE00000000002 /* Sparkle */,
); );
productName = Gaze; productName = Gaze;
productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */; productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */;
@@ -216,6 +219,7 @@
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */, 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */,
27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */; productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */;
@@ -435,7 +439,6 @@
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.4.0; MARKETING_VERSION = 0.4.0;
OTHER_SWIFT_FLAGS = "-D APPSTORE";
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -472,7 +475,6 @@
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.4.0; MARKETING_VERSION = 0.4.0;
OTHER_SWIFT_FLAGS = "-D APPSTORE";
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze; PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -615,6 +617,14 @@
minimumVersion = 4.6.0; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@@ -623,6 +633,11 @@
package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */; package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */;
productName = Lottie; productName = Lottie;
}; };
27SPARKLE00000000002 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 27SPARKLE00000000001 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */; rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */;

View File

@@ -1,15 +1,24 @@
{ {
"originHash": "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560", "originHash" : "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560",
"pins": [ "pins" : [
{ {
"identity": "lottie-spm", "identity" : "lottie-spm",
"kind": "remoteSourceControl", "kind" : "remoteSourceControl",
"location": "https://github.com/airbnb/lottie-spm.git", "location" : "https://github.com/airbnb/lottie-spm.git",
"state": { "state" : {
"revision": "69faaefa7721fba9e434a52c16adf4329c9084db", "revision" : "69faaefa7721fba9e434a52c16adf4329c9084db",
"version": "4.6.0" "version" : "4.6.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
"version" : "2.8.1"
} }
} }
], ],
"version": 3 "version" : 3
} }

View File

@@ -31,15 +31,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
updateManager = UpdateManager.shared updateManager = UpdateManager.shared
} }
// Detect App Store version asynchronously at launch
Task {
do {
await settingsManager.detectAppStoreVersion()
} catch {
// Handle error silently in production
}
}
setupLifecycleObservers() setupLifecycleObservers()
observeSettingsChanges() observeSettingsChanges()

View File

@@ -6,5 +6,10 @@
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -22,5 +22,15 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Mike Freno. All rights reserved.</string> <string>Copyright © 2026 Mike Freno. All rights reserved.</string>
<key>SUPublicEDKey</key>
<string>Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM=</string>
<key>SUFeedURL</key>
<string>https://freno.me/api/Gaze/appcast.xml</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>SUEnableInstallerLauncherService</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -46,9 +46,6 @@ struct AppSettings: Codable, Equatable, Hashable {
var launchAtLogin: Bool var launchAtLogin: Bool
var playSounds: Bool var playSounds: Bool
// App Store detection (cached at launch, not persisted)
var isAppStoreVersion: Bool
init( init(
lookAwayTimer: TimerConfiguration = TimerConfiguration( lookAwayTimer: TimerConfiguration = TimerConfiguration(
enabled: true, intervalSeconds: 20 * 60), enabled: true, intervalSeconds: 20 * 60),
@@ -61,8 +58,7 @@ struct AppSettings: Codable, Equatable, Hashable {
subtleReminderSize: ReminderSize = .medium, subtleReminderSize: ReminderSize = .medium,
hasCompletedOnboarding: Bool = false, hasCompletedOnboarding: Bool = false,
launchAtLogin: Bool = false, launchAtLogin: Bool = false,
playSounds: Bool = true, playSounds: Bool = true
isAppStoreVersion: Bool = true
) { ) {
self.lookAwayTimer = lookAwayTimer self.lookAwayTimer = lookAwayTimer
self.lookAwayCountdownSeconds = lookAwayCountdownSeconds self.lookAwayCountdownSeconds = lookAwayCountdownSeconds
@@ -73,7 +69,6 @@ struct AppSettings: Codable, Equatable, Hashable {
self.hasCompletedOnboarding = hasCompletedOnboarding self.hasCompletedOnboarding = hasCompletedOnboarding
self.launchAtLogin = launchAtLogin self.launchAtLogin = launchAtLogin
self.playSounds = playSounds self.playSounds = playSounds
self.isAppStoreVersion = isAppStoreVersion
} }
static var defaults: AppSettings { static var defaults: AppSettings {
@@ -86,67 +81,7 @@ struct AppSettings: Codable, Equatable, Hashable {
subtleReminderSize: .medium, subtleReminderSize: .medium,
hasCompletedOnboarding: false, hasCompletedOnboarding: false,
launchAtLogin: false, launchAtLogin: false,
playSounds: true, playSounds: true
isAppStoreVersion: false
) )
} }
/// 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
}
} }

View File

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

View File

@@ -74,9 +74,6 @@ class SettingsManager: ObservableObject {
/// Use this for critical save points like app termination or system sleep. /// Use this for critical save points like app termination or system sleep.
func saveImmediately() { func saveImmediately() {
save() save()
// Cancel any pending debounced saves
saveCancellable?.cancel()
setupDebouncedSave()
} }
func load() { func load() {
@@ -120,13 +117,4 @@ class SettingsManager: ObservableObject {
preconditionFailure("Missing timer configuration mappings for: \(missing)") 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
}
}
} }

View File

@@ -7,8 +7,9 @@
import Combine import Combine
import Foundation import Foundation
#if !APPSTORE #if !APPSTORE
import Sparkle import Sparkle
#endif #endif
@MainActor @MainActor
@@ -16,9 +17,9 @@ class UpdateManager: NSObject, ObservableObject {
static let shared = UpdateManager() static let shared = UpdateManager()
#if !APPSTORE #if !APPSTORE
private var updaterController: SPUStandardUpdaterController? private var updaterController: SPUStandardUpdaterController?
private var automaticallyChecksObservation: NSKeyValueObservation? private var automaticallyChecksObservation: NSKeyValueObservation?
private var lastCheckDateObservation: NSKeyValueObservation? private var lastCheckDateObservation: NSKeyValueObservation?
#endif #endif
@Published var automaticallyChecksForUpdates = false @Published var automaticallyChecksForUpdates = false
@@ -27,58 +28,58 @@ class UpdateManager: NSObject, ObservableObject {
private override init() { private override init() {
super.init() super.init()
#if !APPSTORE #if !APPSTORE
setupUpdater() setupUpdater()
#endif #endif
} }
#if !APPSTORE #if !APPSTORE
private func setupUpdater() { private func setupUpdater() {
updaterController = SPUStandardUpdaterController( updaterController = SPUStandardUpdaterController(
startingUpdater: true, startingUpdater: true,
updaterDelegate: nil, updaterDelegate: nil,
userDriverDelegate: nil userDriverDelegate: nil
) )
guard let updater = updaterController?.updater else { guard let updater = updaterController?.updater else {
return return
} }
automaticallyChecksObservation = updater.observe( automaticallyChecksObservation = updater.observe(
\.automaticallyChecksForUpdates, \.automaticallyChecksForUpdates,
options: [.new, .initial] options: [.new, .initial]
) { [weak self] _, change in ) { [weak self] _, change in
guard let self = self, let newValue = change.newValue else { return } guard let self = self, let newValue = change.newValue else { return }
Task { @MainActor in Task { @MainActor in
self.automaticallyChecksForUpdates = newValue 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 #endif
func checkForUpdates() { func checkForUpdates() {
#if !APPSTORE #if !APPSTORE
guard let updater = updaterController?.updater else { guard let updater = updaterController?.updater else {
return return
} }
updater.checkForUpdates() updater.checkForUpdates()
#else #else
#endif #endif
} }
deinit { deinit {
#if !APPSTORE #if !APPSTORE
automaticallyChecksObservation?.invalidate() automaticallyChecksObservation?.invalidate()
lastCheckDateObservation?.invalidate() lastCheckDateObservation?.invalidate()
#endif #endif
} }
} }

View File

@@ -123,10 +123,9 @@ struct OnboardingContainerView: View {
} }
} }
} }
.frame( .frame(
minWidth: 1000, minWidth: 1000,
minHeight: settingsManager.settings.isAppStoreVersion ? 700 : 900 minHeight: 700
) )
} }

View File

@@ -80,7 +80,7 @@ struct SettingsWindowView: View {
} }
.frame( .frame(
minWidth: 750, minWidth: 750,
minHeight: settingsManager.settings.isAppStoreVersion ? 700 : 900 minHeight: 700
) )
.onReceive( .onReceive(
NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab")) NotificationCenter.default.publisher(for: Notification.Name("SwitchToSettingsTab"))

View File

@@ -55,34 +55,34 @@ struct GeneralSetupView: View {
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
// Software Updates Section // Software Updates Section
if !settingsManager.settings.isAppStoreVersion { #if !APPSTORE
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Software Updates") Text("Software Updates")
.font(.headline) .font(.headline)
if let lastCheck = updateManager.lastUpdateCheckDate { if let lastCheck = updateManager.lastUpdateCheckDate {
Text("Last checked: \(lastCheck, style: .relative)") Text("Last checked: \(lastCheck, style: .relative)")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.italic() .italic()
} else { } else {
Text("Never checked for updates") Text("Never checked for updates")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.italic() .italic()
}
} }
}
Spacer() Spacer()
Button("Check for Updates Now") { Button("Check for Updates Now") {
updateManager.checkForUpdates() updateManager.checkForUpdates()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Toggle( Toggle(
"Automatically check for updates", "Automatically check for updates",
isOn: $updateManager.automaticallyChecksForUpdates isOn: $updateManager.automaticallyChecksForUpdates
) )
.labelsHidden() .labelsHidden()
@@ -90,7 +90,7 @@ struct GeneralSetupView: View {
} }
.padding() .padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
} #endif
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size") Text("Subtle Reminder Size")
@@ -171,41 +171,41 @@ struct GeneralSetupView: View {
.contentShape(RoundedRectangle(cornerRadius: 10)) .contentShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain) .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( .glassEffectIfAvailable(
GlassStyle.regular.tint(.orange).interactive(), GlassStyle.regular.interactive(), in: .rect(cornerRadius: 10))
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() .padding()
} }

View File

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

View File

@@ -306,4 +306,106 @@ final class TimerEngineTests: XCTestCase {
XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)]) XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)])
XCTAssertNotNil(timerEngine.timerStates[.builtIn(.posture)]) 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)))
}
} }