fix: build conformed with corrected app store detector

This commit is contained in:
Michael Freno
2026-01-10 20:42:21 -05:00
parent 3f9bb250f4
commit 96398bdbbf
22 changed files with 156 additions and 44 deletions

View File

@@ -8,7 +8,6 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; }; 275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
2759160C2F132C7A00D0E60D /* Gaze.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2759160B2F132C7A00D0E60D /* Gaze.icon */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -29,7 +28,6 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference 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; }; 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; }; 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; }; 27A21B532F0F69DD0018C4F3 /* GazeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GazeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -82,7 +80,6 @@
27A21B332F0F69DC0018C4F3 = { 27A21B332F0F69DC0018C4F3 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2759160B2F132C7A00D0E60D /* Gaze.icon */,
27A21B3E2F0F69DC0018C4F3 /* Gaze */, 27A21B3E2F0F69DC0018C4F3 /* Gaze */,
27A21B4C2F0F69DD0018C4F3 /* GazeTests */, 27A21B4C2F0F69DD0018C4F3 /* GazeTests */,
27A21B562F0F69DD0018C4F3 /* GazeUITests */, 27A21B562F0F69DD0018C4F3 /* GazeUITests */,
@@ -224,7 +221,6 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2759160C2F132C7A00D0E60D /* Gaze.icon in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -405,7 +401,9 @@
27A21B5E2F0F69DD0018C4F3 /* Debug */ = { 27A21B5E2F0F69DD0018C4F3 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -415,6 +413,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -436,7 +435,9 @@
27A21B5F2F0F69DD0018C4F3 /* Release */ = { 27A21B5F2F0F69DD0018C4F3 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -446,6 +447,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View File

@@ -26,6 +26,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
settingsManager = SettingsManager.shared settingsManager = SettingsManager.shared
timerEngine = TimerEngine(settingsManager: settingsManager!) timerEngine = TimerEngine(settingsManager: settingsManager!)
// Detect App Store version asynchronously at launch
Task {
await settingsManager?.detectAppStoreVersion()
}
setupLifecycleObservers() setupLifecycleObservers()
observeSettingsChanges() observeSettingsChanges()

View File

Before

Width:  |  Height:  |  Size: 178 B

After

Width:  |  Height:  |  Size: 178 B

View File

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 934 B

View File

Before

Width:  |  Height:  |  Size: 155 B

After

Width:  |  Height:  |  Size: 155 B

View File

Before

Width:  |  Height:  |  Size: 899 B

After

Width:  |  Height:  |  Size: 899 B

View File

Before

Width:  |  Height:  |  Size: 884 B

After

Width:  |  Height:  |  Size: 884 B

View File

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 903 B

View File

Before

Width:  |  Height:  |  Size: 933 B

After

Width:  |  Height:  |  Size: 933 B

View File

Before

Width:  |  Height:  |  Size: 936 B

After

Width:  |  Height:  |  Size: 936 B

View File

Before

Width:  |  Height:  |  Size: 937 B

After

Width:  |  Height:  |  Size: 937 B

View File

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 621 B

View File

Before

Width:  |  Height:  |  Size: 641 B

After

Width:  |  Height:  |  Size: 641 B

View File

@@ -166,6 +166,23 @@
"hidden" : false, "hidden" : false,
"layers" : [ "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, "glass" : false,
"image-name" : "Line 7.svg", "image-name" : "Line 7.svg",
"name" : "Line 7", "name" : "Line 7",
@@ -186,7 +203,7 @@
"image-name" : "Line 8.svg", "image-name" : "Line 8.svg",
"name" : "Line 8", "name" : "Line 8",
"position" : { "position" : {
"scale" : 1.21, "scale" : 1.26,
"translation-in-points" : [ "translation-in-points" : [
-0.1999973177900074, -0.1999973177900074,
-82.12440000000004 -82.12440000000004
@@ -231,7 +248,7 @@
"image-name" : "Ellipse 11.svg", "image-name" : "Ellipse 11.svg",
"name" : "Ellipse 11", "name" : "Ellipse 11",
"position" : { "position" : {
"scale" : 0.99, "scale" : 0.83,
"translation-in-points" : [ "translation-in-points" : [
0, 0,
0 0

View File

@@ -49,6 +49,9 @@ struct AppSettings: Codable, Equatable, Hashable {
var hasCompletedOnboarding: Bool var hasCompletedOnboarding: Bool
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(
@@ -62,7 +65,8 @@ 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 = false
) { ) {
self.lookAwayTimer = lookAwayTimer self.lookAwayTimer = lookAwayTimer
self.lookAwayCountdownSeconds = lookAwayCountdownSeconds self.lookAwayCountdownSeconds = lookAwayCountdownSeconds
@@ -73,6 +77,7 @@ 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 {
@@ -85,7 +90,8 @@ struct AppSettings: Codable, Equatable, Hashable {
subtleReminderSize: .medium, subtleReminderSize: .medium,
hasCompletedOnboarding: false, hasCompletedOnboarding: false,
launchAtLogin: false, launchAtLogin: false,
playSounds: true playSounds: true,
isAppStoreVersion: false
) )
} }
@@ -97,5 +103,50 @@ struct AppSettings: Codable, Equatable, Hashable {
&& lhs.subtleReminderSize == rhs.subtleReminderSize && lhs.subtleReminderSize == rhs.subtleReminderSize
&& lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding && lhs.hasCompletedOnboarding == rhs.hasCompletedOnboarding
&& lhs.launchAtLogin == rhs.launchAtLogin && lhs.playSounds == rhs.playSounds && 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

@@ -6,47 +6,63 @@
// //
import Foundation import Foundation
import StoreKit
enum AppStoreDetector { 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. /// Uses StoreKit's AppTransaction API on macOS 15+ to verify if the app is an App Store version.
/// This is sufficient for distinguishing App Store builds from direct distribution. /// 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 /// This method is asynchronous due to the use of StoreKit's async API.
/// only used to determine if we should show the 'buy me a coffee' link. static func isAppStoreVersion() async -> Bool {
static var isAppStoreVersion: Bool {
#if DEBUG #if DEBUG
return false return false
#else #else
guard let receiptURL = Bundle.main.appStoreReceiptURL else { if #available(macOS 15.0, *) {
return false 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 #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" /// On macOS 15+, StoreKit does not expose a TestFlight receipt type.
static var isTestFlight: Bool { /// 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 #if DEBUG
return false return false
#else #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 #endif
} }
} }

View File

@@ -160,7 +160,7 @@ class Version101Migration: Migration {
var targetVersion: String = "1.0.1" var targetVersion: String = "1.0.1"
func migrate(_ data: [String: Any]) throws -> [String: Any] { func migrate(_ data: [String: Any]) throws -> [String: Any] {
var migratedData = data let migratedData = data
// Example migration logic: // Example migration logic:
// Add any new fields with default values if they don't exist // Add any new fields with default values if they don't exist
@@ -197,4 +197,4 @@ class Version102Migration: Migration {
return migratedData return migratedData
} }
} }

View File

@@ -75,4 +75,13 @@ class SettingsManager: ObservableObject {
settings.postureTimer = configuration 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
}
}
} }

View File

@@ -77,6 +77,10 @@ struct OnboardingContainerView: View {
SettingsOnboardingView( SettingsOnboardingView(
launchAtLogin: $launchAtLogin, launchAtLogin: $launchAtLogin,
subtleReminderSize: $subtleReminderSize, subtleReminderSize: $subtleReminderSize,
isAppStoreVersion: Binding(
get: { settingsManager.settings.isAppStoreVersion },
set: { _ in }
),
isOnboarding: true isOnboarding: true
) )
.tag(4) .tag(4)

View File

@@ -10,6 +10,7 @@ import SwiftUI
struct SettingsOnboardingView: View { struct SettingsOnboardingView: View {
@Binding var launchAtLogin: Bool @Binding var launchAtLogin: Bool
@Binding var subtleReminderSize: ReminderSize @Binding var subtleReminderSize: ReminderSize
@Binding var isAppStoreVersion: Bool
var isOnboarding: Bool = true var isOnboarding: Bool = true
var body: some View { var body: some View {
@@ -131,7 +132,7 @@ struct SettingsOnboardingView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10)) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 10))
if !AppStoreDetector.isAppStoreVersion { if !isAppStoreVersion {
Button(action: { Button(action: {
if let url = URL(string: "https://buymeacoffee.com/mikefreno") { if let url = URL(string: "https://buymeacoffee.com/mikefreno") {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
@@ -198,6 +199,7 @@ struct SettingsOnboardingView: View {
SettingsOnboardingView( SettingsOnboardingView(
launchAtLogin: .constant(false), launchAtLogin: .constant(false),
subtleReminderSize: .constant(.medium), subtleReminderSize: .constant(.medium),
isAppStoreVersion: .constant(false),
isOnboarding: true isOnboarding: true
) )
} }
@@ -206,6 +208,7 @@ struct SettingsOnboardingView: View {
SettingsOnboardingView( SettingsOnboardingView(
launchAtLogin: .constant(true), launchAtLogin: .constant(true),
subtleReminderSize: .constant(.medium), subtleReminderSize: .constant(.medium),
isAppStoreVersion: .constant(false),
isOnboarding: true isOnboarding: true
) )
} }

View File

@@ -82,6 +82,10 @@ struct SettingsWindowView: View {
SettingsOnboardingView( SettingsOnboardingView(
launchAtLogin: $launchAtLogin, launchAtLogin: $launchAtLogin,
subtleReminderSize: $subtleReminderSize, subtleReminderSize: $subtleReminderSize,
isAppStoreVersion: Binding(
get: { settingsManager.settings.isAppStoreVersion },
set: { _ in }
),
isOnboarding: false isOnboarding: false
) )
.tag(4) .tag(4)
@@ -140,7 +144,8 @@ struct SettingsWindowView: View {
subtleReminderSize: subtleReminderSize, subtleReminderSize: subtleReminderSize,
hasCompletedOnboarding: settingsManager.settings.hasCompletedOnboarding, hasCompletedOnboarding: settingsManager.settings.hasCompletedOnboarding,
launchAtLogin: launchAtLogin, launchAtLogin: launchAtLogin,
playSounds: settingsManager.settings.playSounds playSounds: settingsManager.settings.playSounds,
isAppStoreVersion: settingsManager.settings.isAppStoreVersion
) )
// Assign the entire settings object to trigger didSet and observers // Assign the entire settings object to trigger didSet and observers

View File

@@ -10,20 +10,20 @@ import Testing
struct AppStoreDetectorTests { struct AppStoreDetectorTests {
@Test func isAppStoreVersionReturnsFalseInDebug() { @Test func isAppStoreVersionReturnsFalseInDebug() async {
// In test/debug builds, should always return false // 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 // 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 // When there's no receipt (development build), should return false
// This is implicitly tested by isAppStoreVersionReturnsFalseInDebug // This is implicitly tested by isAppStoreVersionReturnsFalseInDebug
// but we're documenting the expected behavior // but we're documenting the expected behavior
#expect(AppStoreDetector.isAppStoreVersion == false) #expect(await AppStoreDetector.isAppStoreVersion() == false)
} }
} }