general: removal of AppStoreDetector, just use build flags
This commit is contained in:
@@ -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 */;
|
||||
|
||||
@@ -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
|
||||
"version" : 3
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -22,5 +22,15 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
#if !APPSTORE
|
||||
import Sparkle
|
||||
import Sparkle
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
@@ -16,9 +17,9 @@ 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
|
||||
@@ -27,58 +28,58 @@ class UpdateManager: NSObject, ObservableObject {
|
||||
private override init() {
|
||||
super.init()
|
||||
#if !APPSTORE
|
||||
setupUpdater()
|
||||
setupUpdater()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !APPSTORE
|
||||
private func setupUpdater() {
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
private func setupUpdater() {
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
|
||||
guard let updater = updaterController?.updater else {
|
||||
return
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +123,9 @@ struct OnboardingContainerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.frame(
|
||||
minWidth: 1000,
|
||||
minHeight: settingsManager.settings.isAppStoreVersion ? 700 : 900
|
||||
minHeight: 700
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user