general: removal of AppStoreDetector, just use build flags
This commit is contained in:
@@ -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 */;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,78 +7,79 @@
|
|||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
#if !APPSTORE
|
#if !APPSTORE
|
||||||
import Sparkle
|
import Sparkle
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class UpdateManager: NSObject, ObservableObject {
|
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
|
||||||
@Published var lastUpdateCheckDate: Date?
|
@Published var lastUpdateCheckDate: Date?
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,10 +123,9 @@ struct OnboardingContainerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.frame(
|
.frame(
|
||||||
minWidth: 1000,
|
minWidth: 1000,
|
||||||
minHeight: settingsManager.settings.isAppStoreVersion ? 700 : 900
|
minHeight: 700
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)])
|
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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user