fix: build conformed with corrected app store detector
@@ -8,7 +8,6 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
|
||||
2759160C2F132C7A00D0E60D /* Gaze.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2759160B2F132C7A00D0E60D /* Gaze.icon */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -29,7 +28,6 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2759160B2F132C7A00D0E60D /* Gaze.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Gaze.icon; sourceTree = "<group>"; };
|
||||
27A21B3C2F0F69DC0018C4F3 /* Gaze.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gaze.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
27A21B492F0F69DD0018C4F3 /* GazeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GazeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
27A21B532F0F69DD0018C4F3 /* GazeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GazeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -82,7 +80,6 @@
|
||||
27A21B332F0F69DC0018C4F3 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2759160B2F132C7A00D0E60D /* Gaze.icon */,
|
||||
27A21B3E2F0F69DC0018C4F3 /* Gaze */,
|
||||
27A21B4C2F0F69DD0018C4F3 /* GazeTests */,
|
||||
27A21B562F0F69DD0018C4F3 /* GazeUITests */,
|
||||
@@ -224,7 +221,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2759160C2F132C7A00D0E60D /* Gaze.icon in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -405,7 +401,9 @@
|
||||
27A21B5E2F0F69DD0018C4F3 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -415,6 +413,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -436,7 +435,9 @@
|
||||
27A21B5F2F0F69DD0018C4F3 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -446,6 +447,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -26,6 +26,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
settingsManager = SettingsManager.shared
|
||||
timerEngine = TimerEngine(settingsManager: settingsManager!)
|
||||
|
||||
// Detect App Store version asynchronously at launch
|
||||
Task {
|
||||
await settingsManager?.detectAppStoreVersion()
|
||||
}
|
||||
|
||||
setupLifecycleObservers()
|
||||
observeSettingsChanges()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 178 B After Width: | Height: | Size: 178 B |
|
Before Width: | Height: | Size: 934 B After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 155 B After Width: | Height: | Size: 155 B |
|
Before Width: | Height: | Size: 899 B After Width: | Height: | Size: 899 B |
|
Before Width: | Height: | Size: 884 B After Width: | Height: | Size: 884 B |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 903 B |
|
Before Width: | Height: | Size: 933 B After Width: | Height: | Size: 933 B |
|
Before Width: | Height: | Size: 936 B After Width: | Height: | Size: 936 B |
|
Before Width: | Height: | Size: 937 B After Width: | Height: | Size: 937 B |
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 621 B |
|
Before Width: | Height: | Size: 641 B After Width: | Height: | Size: 641 B |
@@ -166,6 +166,23 @@
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode" : "darken",
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"extended-srgb:0.00000,0.53333,1.00000,1.00000",
|
||||
"display-p3:0.38403,0.64839,1.04685,1.00000"
|
||||
],
|
||||
"orientation" : {
|
||||
"start" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0
|
||||
},
|
||||
"stop" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0.7
|
||||
}
|
||||
}
|
||||
},
|
||||
"glass" : false,
|
||||
"image-name" : "Line 7.svg",
|
||||
"name" : "Line 7",
|
||||
@@ -186,7 +203,7 @@
|
||||
"image-name" : "Line 8.svg",
|
||||
"name" : "Line 8",
|
||||
"position" : {
|
||||
"scale" : 1.21,
|
||||
"scale" : 1.26,
|
||||
"translation-in-points" : [
|
||||
-0.1999973177900074,
|
||||
-82.12440000000004
|
||||
@@ -231,7 +248,7 @@
|
||||
"image-name" : "Ellipse 11.svg",
|
||||
"name" : "Ellipse 11",
|
||||
"position" : {
|
||||
"scale" : 0.99,
|
||||
"scale" : 0.83,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
@@ -50,6 +50,9 @@ 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),
|
||||
@@ -62,7 +65,8 @@ struct AppSettings: Codable, Equatable, Hashable {
|
||||
subtleReminderSize: ReminderSize = .medium,
|
||||
hasCompletedOnboarding: Bool = false,
|
||||
launchAtLogin: Bool = false,
|
||||
playSounds: Bool = true
|
||||
playSounds: Bool = true,
|
||||
isAppStoreVersion: Bool = false
|
||||
) {
|
||||
self.lookAwayTimer = lookAwayTimer
|
||||
self.lookAwayCountdownSeconds = lookAwayCountdownSeconds
|
||||
@@ -73,6 +77,7 @@ struct AppSettings: Codable, Equatable, Hashable {
|
||||
self.hasCompletedOnboarding = hasCompletedOnboarding
|
||||
self.launchAtLogin = launchAtLogin
|
||||
self.playSounds = playSounds
|
||||
self.isAppStoreVersion = isAppStoreVersion
|
||||
}
|
||||
|
||||
static var defaults: AppSettings {
|
||||
@@ -85,7 +90,8 @@ struct AppSettings: Codable, Equatable, Hashable {
|
||||
subtleReminderSize: .medium,
|
||||
hasCompletedOnboarding: false,
|
||||
launchAtLogin: false,
|
||||
playSounds: true
|
||||
playSounds: true,
|
||||
isAppStoreVersion: false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,5 +103,50 @@ struct AppSettings: Codable, Equatable, Hashable {
|
||||
&& 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,47 +6,63 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
enum AppStoreDetector {
|
||||
/// Returns true if the app was downloaded from the Mac App Store
|
||||
/// Returns true if the app was downloaded from the Mac App Store.
|
||||
///
|
||||
/// Uses a heuristic approach that checks for the presence of a valid App Store receipt.
|
||||
/// This is sufficient for distinguishing App Store builds from direct distribution.
|
||||
/// 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.
|
||||
///
|
||||
/// Note: This does not perform full cryptographic validation of the receipt signature, its
|
||||
/// only used to determine if we should show the 'buy me a coffee' link.
|
||||
static var isAppStoreVersion: Bool {
|
||||
/// This method is asynchronous due to the use of StoreKit's async API.
|
||||
static func isAppStoreVersion() async -> Bool {
|
||||
#if DEBUG
|
||||
return false
|
||||
#else
|
||||
guard let receiptURL = Bundle.main.appStoreReceiptURL else {
|
||||
return false
|
||||
if #available(macOS 15.0, *) {
|
||||
do {
|
||||
_ = 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
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: receiptURL.path) else {
|
||||
return false
|
||||
}
|
||||
guard let receiptData = try? Data(contentsOf: receiptURL),
|
||||
receiptData.count > 2
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let bytes = [UInt8](receiptData.prefix(2))
|
||||
return bytes[0] == 0x30 && bytes[1] == 0x82
|
||||
}
|
||||
|
||||
guard FileManager.default.fileExists(atPath: receiptURL.path) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let receiptData = try? Data(contentsOf: receiptURL),
|
||||
receiptData.count > 2
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify receipt has PKCS#7 signature format (starts with ASN.1 SEQUENCE tag)
|
||||
let bytes = [UInt8](receiptData.prefix(2))
|
||||
return bytes[0] == 0x30 && bytes[1] == 0x82
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Checks if the app is running in TestFlight
|
||||
/// Checks if the app is running in TestFlight.
|
||||
///
|
||||
/// TestFlight builds have a receipt named "sandboxReceipt" instead of "receipt"
|
||||
static var isTestFlight: Bool {
|
||||
/// 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
|
||||
return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ class Version101Migration: Migration {
|
||||
var targetVersion: String = "1.0.1"
|
||||
|
||||
func migrate(_ data: [String: Any]) throws -> [String: Any] {
|
||||
var migratedData = data
|
||||
let migratedData = data
|
||||
|
||||
// Example migration logic:
|
||||
// Add any new fields with default values if they don't exist
|
||||
|
||||
@@ -75,4 +75,13 @@ class SettingsManager: ObservableObject {
|
||||
settings.postureTimer = configuration
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ struct OnboardingContainerView: View {
|
||||
SettingsOnboardingView(
|
||||
launchAtLogin: $launchAtLogin,
|
||||
subtleReminderSize: $subtleReminderSize,
|
||||
isAppStoreVersion: Binding(
|
||||
get: { settingsManager.settings.isAppStoreVersion },
|
||||
set: { _ in }
|
||||
),
|
||||
isOnboarding: true
|
||||
)
|
||||
.tag(4)
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
struct SettingsOnboardingView: View {
|
||||
@Binding var launchAtLogin: Bool
|
||||
@Binding var subtleReminderSize: ReminderSize
|
||||
@Binding var isAppStoreVersion: Bool
|
||||
var isOnboarding: Bool = true
|
||||
|
||||
var body: some View {
|
||||
@@ -131,7 +132,7 @@ struct SettingsOnboardingView: View {
|
||||
.buttonStyle(.plain)
|
||||
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10))
|
||||
|
||||
if !AppStoreDetector.isAppStoreVersion {
|
||||
if !isAppStoreVersion {
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://buymeacoffee.com/mikefreno") {
|
||||
NSWorkspace.shared.open(url)
|
||||
@@ -198,6 +199,7 @@ struct SettingsOnboardingView: View {
|
||||
SettingsOnboardingView(
|
||||
launchAtLogin: .constant(false),
|
||||
subtleReminderSize: .constant(.medium),
|
||||
isAppStoreVersion: .constant(false),
|
||||
isOnboarding: true
|
||||
)
|
||||
}
|
||||
@@ -206,6 +208,7 @@ struct SettingsOnboardingView: View {
|
||||
SettingsOnboardingView(
|
||||
launchAtLogin: .constant(true),
|
||||
subtleReminderSize: .constant(.medium),
|
||||
isAppStoreVersion: .constant(false),
|
||||
isOnboarding: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ struct SettingsWindowView: View {
|
||||
SettingsOnboardingView(
|
||||
launchAtLogin: $launchAtLogin,
|
||||
subtleReminderSize: $subtleReminderSize,
|
||||
isAppStoreVersion: Binding(
|
||||
get: { settingsManager.settings.isAppStoreVersion },
|
||||
set: { _ in }
|
||||
),
|
||||
isOnboarding: false
|
||||
)
|
||||
.tag(4)
|
||||
@@ -140,7 +144,8 @@ struct SettingsWindowView: View {
|
||||
subtleReminderSize: subtleReminderSize,
|
||||
hasCompletedOnboarding: settingsManager.settings.hasCompletedOnboarding,
|
||||
launchAtLogin: launchAtLogin,
|
||||
playSounds: settingsManager.settings.playSounds
|
||||
playSounds: settingsManager.settings.playSounds,
|
||||
isAppStoreVersion: settingsManager.settings.isAppStoreVersion
|
||||
)
|
||||
|
||||
// Assign the entire settings object to trigger didSet and observers
|
||||
|
||||
@@ -10,20 +10,20 @@ import Testing
|
||||
|
||||
struct AppStoreDetectorTests {
|
||||
|
||||
@Test func isAppStoreVersionReturnsFalseInDebug() {
|
||||
@Test func isAppStoreVersionReturnsFalseInDebug() async {
|
||||
// In test/debug builds, should always return false
|
||||
#expect(AppStoreDetector.isAppStoreVersion == false)
|
||||
#expect(await AppStoreDetector.isAppStoreVersion() == false)
|
||||
}
|
||||
|
||||
@Test func isTestFlightReturnsFalseInDebug() {
|
||||
@Test func isTestFlightReturnsFalseInDebug() async {
|
||||
// In test/debug builds, should always return false
|
||||
#expect(AppStoreDetector.isTestFlight == false)
|
||||
#expect(await AppStoreDetector.isTestFlight() == false)
|
||||
}
|
||||
|
||||
@Test func receiptValidationHandlesMissingReceipt() {
|
||||
@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(AppStoreDetector.isAppStoreVersion == false)
|
||||
#expect(await AppStoreDetector.isAppStoreVersion() == false)
|
||||
}
|
||||
}
|
||||
|
||||