diff --git a/ARCHIVE_POST_ACTION_SETUP.md b/ARCHIVE_POST_ACTION_SETUP.md
new file mode 100644
index 0000000..4cd6e62
--- /dev/null
+++ b/ARCHIVE_POST_ACTION_SETUP.md
@@ -0,0 +1,201 @@
+# Archive Post-Action Setup (REQUIRED for App Store)
+
+## The Problem
+
+App Store validation fails with:
+```
+App sandbox not enabled. The following executables must include the
+"com.apple.security.app-sandbox" entitlement...
+Sparkle.framework/Versions/B/Autoupdate
+Sparkle.framework/Versions/B/Updater.app
+...
+```
+
+Sparkle.framework **must be physically removed** from the app bundle for App Store distribution.
+
+## Why Build Phase Scripts Don't Work
+
+Build Phase scripts run during compilation when files are code-signed and locked by macOS. Even with `chmod` and `chflags`, you get "Operation not permitted" due to System Integrity Protection.
+
+## The Solution: Archive Post-Action
+
+Archive Post-Actions run **after** the archive completes, when files are no longer locked. This is the correct place to remove Sparkle.
+
+---
+
+## Setup Instructions (2 minutes)
+
+### 1. Open Scheme Editor
+In Xcode: **Product → Scheme → Edit Scheme...** (or press **⌘<**)
+
+### 2. Select Archive
+Click **Archive** in the left sidebar
+
+### 3. Add Post-Action Script
+- At the bottom, under "Post-actions", click the **+** button
+- Select **New Run Script Action**
+
+### 4. Configure the Action
+
+**Provide build settings from:** Select **Gaze** from the dropdown (IMPORTANT!)
+
+**Shell:** Leave as `/bin/bash`
+
+**Script:** Copy and paste this entire script:
+
+```bash
+if [[ "${OTHER_SWIFT_FLAGS}" == *"APPSTORE"* ]]; then
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "🗑️ Removing Sparkle from archived app..."
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+
+ SPARKLE_PATH="${ARCHIVE_PATH}/Products/Applications/Gaze.app/Contents/Frameworks/Sparkle.framework"
+
+ if [ -d "$SPARKLE_PATH" ]; then
+ echo "📂 Found Sparkle at: $SPARKLE_PATH"
+
+ # Make writable and remove
+ chmod -R u+w "$SPARKLE_PATH" 2>/dev/null || true
+ chflags -R nouchg "$SPARKLE_PATH" 2>/dev/null || true
+ rm -rf "$SPARKLE_PATH"
+
+ if [ ! -d "$SPARKLE_PATH" ]; then
+ echo "✅ Sparkle framework removed successfully!"
+ else
+ echo "❌ ERROR: Could not remove Sparkle framework"
+ echo " This will cause App Store validation to fail"
+ exit 1
+ fi
+ else
+ echo "ℹ️ Sparkle framework not found (already removed)"
+ fi
+
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "✅ Archive ready for App Store distribution"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+else
+ echo "✓ Self-distribution archive - Sparkle retained"
+fi
+```
+
+### 5. Save
+Click **Close** to save the scheme changes
+
+---
+
+## Verification Steps
+
+### Test the Archive
+
+1. **Switch to App Store mode:**
+ ```bash
+ ./switch_to appstore
+ ```
+
+2. **Archive in Xcode:**
+ - **Product → Archive** (or **⌘⇧B** then Archive)
+ - Watch the build log - you should see the post-action output
+
+3. **Check the archive contents:**
+ - **Window → Organizer**
+ - Select your latest Gaze archive
+ - Right-click → **Show in Finder**
+ - Right-click the `.xcarchive` file → **Show Package Contents**
+ - Navigate to: `Products/Applications/Gaze.app/Contents/Frameworks/`
+ - **Verify:** Only `Lottie.framework` should be present ✅
+
+4. **Distribute to App Store:**
+ - In Organizer, click **Distribute App**
+ - Choose **App Store Connect**
+ - Complete the distribution wizard
+ - **Validation should now pass!** ✅
+
+---
+
+## What You Should See
+
+### In the Build Log (after archiving):
+```
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+🗑️ Removing Sparkle from archived app...
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+📂 Found Sparkle at: [path]
+✅ Sparkle framework removed successfully!
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+✅ Archive ready for App Store distribution
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+```
+
+### In the Frameworks folder:
+```
+Gaze.app/Contents/Frameworks/
+└── Lottie.framework/
+```
+
+No Sparkle.framework! ✅
+
+---
+
+## Troubleshooting
+
+### "I don't see the post-action output"
+- Make sure you selected **Gaze** in "Provide build settings from"
+- Check View → Navigators → Show Report Navigator (⌘9)
+- Select the Archive action to see full logs
+
+### "Sparkle is still in the archive"
+- Verify `./switch_to status` shows "App Store" for all items
+- Check the script exactly matches (copy/paste the entire script)
+- Try cleaning: Product → Clean Build Folder (⌘⇧K)
+
+### "Script says 'Sparkle framework not found'"
+- This means Sparkle wasn't embedded (good!)
+- Continue with distribution - validation should pass
+
+### "Archive Post-Action section doesn't exist"
+- Make sure you're editing the **Archive** section, not Run or Test
+- Click the triangle next to "Archive" to expand it
+
+---
+
+## Optional: Remove Old Build Phase Script
+
+If you previously added a Build Phase script (which doesn't work due to file locking), you can remove it:
+
+1. Gaze target → Build Phases
+2. Find "Remove Sparkle for App Store" or "Run Script"
+3. Click the **X** to delete it
+
+The Archive Post-Action is the correct and only solution needed.
+
+---
+
+## Why This Is Required
+
+Even though:
+- ✅ Sparkle code is disabled via `#if !APPSTORE`
+- ✅ Info.plist has no Sparkle keys
+- ✅ Entitlements have no Sparkle exceptions
+
+App Store validation **still checks** for the physical presence of unsandboxed executables in frameworks. Sparkle contains XPC services that aren't App Store compatible, so the entire framework must be removed.
+
+---
+
+## For Self-Distribution
+
+When building for self-distribution (`./switch_to self`), the script detects the absence of the APPSTORE flag and leaves Sparkle intact. You don't need to change anything!
+
+```bash
+./switch_to self
+./self_distribute # Sparkle is retained and works normally
+```
+
+---
+
+## Summary
+
+✅ **One-time setup:** Add Archive Post-Action script
+✅ **Works automatically:** Removes Sparkle only for App Store builds
+✅ **Zero maintenance:** Once configured, runs automatically forever
+
+This is the **correct and only working solution** for removing Sparkle from App Store builds!
diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj
index 01cecfc..2ba1ab2 100644
--- a/Gaze.xcodeproj/project.pbxproj
+++ b/Gaze.xcodeproj/project.pbxproj
@@ -8,7 +8,7 @@
/* Begin PBXBuildFile section */
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
- 275915902F132B0000D0E60D /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B22F10B20000E00DBC /* Sparkle */; };
+ 275915902F132B0000D0E60D /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B22F10B20000E00DBC /* Sparkle */; settings = {ATTRIBUTES = (Weak, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
diff --git a/Gaze.xcodeproj/xcshareddata/xcschemes/Gaze.xcscheme b/Gaze.xcodeproj/xcshareddata/xcschemes/Gaze.xcscheme
new file mode 100644
index 0000000..e7dc879
--- /dev/null
+++ b/Gaze.xcodeproj/xcshareddata/xcschemes/Gaze.xcscheme
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Gaze.xcodeproj/xcuserdata/mike.xcuserdatad/xcschemes/xcschememanagement.plist b/Gaze.xcodeproj/xcuserdata/mike.xcuserdatad/xcschemes/xcschememanagement.plist
index e4347a5..236396b 100644
--- a/Gaze.xcodeproj/xcuserdata/mike.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Gaze.xcodeproj/xcuserdata/mike.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -10,5 +10,23 @@
0
+ SuppressBuildableAutocreation
+
+ 27A21B3B2F0F69DC0018C4F3
+
+ primary
+
+
+ 27A21B482F0F69DD0018C4F3
+
+ primary
+
+
+ 27A21B522F0F69DD0018C4F3
+
+ primary
+
+
+
diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift
index 21e1f14..b2ef003 100644
--- a/Gaze/AppDelegate.swift
+++ b/Gaze/AppDelegate.swift
@@ -20,28 +20,49 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private var hasStartedTimers = false
func applicationDidFinishLaunching(_ notification: Notification) {
+ print("🚀 Gaze: applicationDidFinishLaunching")
+
// Set activation policy to hide dock icon
NSApplication.shared.setActivationPolicy(.accessory)
+ print("✓ Activation policy set to accessory")
timerEngine = TimerEngine(settingsManager: settingsManager)
+ print("✓ TimerEngine initialized")
// Initialize update manager after onboarding is complete
if settingsManager.settings.hasCompletedOnboarding {
+ print("✓ Onboarding completed, initializing UpdateManager")
updateManager = UpdateManager.shared
+ } else {
+ print("ℹ️ Onboarding not completed, skipping UpdateManager")
}
// Detect App Store version asynchronously at launch
Task {
- await settingsManager.detectAppStoreVersion()
+ do {
+ print("🔍 Detecting App Store version...")
+ await settingsManager.detectAppStoreVersion()
+ print("✓ App Store detection complete: \(settingsManager.settings.isAppStoreVersion)")
+ } catch {
+ print("⚠️ Failed to detect App Store version: \(error)")
+ }
}
setupLifecycleObservers()
+ print("✓ Lifecycle observers set up")
+
observeSettingsChanges()
+ print("✓ Settings change observer set up")
// Start timers if onboarding is complete
if settingsManager.settings.hasCompletedOnboarding {
+ print("▶️ Starting timers (onboarding complete)")
startTimers()
+ } else {
+ print("⏸️ Timers not started (onboarding incomplete)")
}
+
+ print("✅ Gaze: Launch complete")
}
func onboardingCompleted() {
diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift
index d8f7e37..d89c064 100644
--- a/Gaze/GazeApp.swift
+++ b/Gaze/GazeApp.swift
@@ -13,12 +13,18 @@ struct GazeApp: App {
@StateObject private var settingsManager = SettingsManager.shared
init() {
+ print("🚀 GazeApp: init")
+
// Handle test launch arguments
if TestingEnvironment.shouldSkipOnboarding {
+ print("ℹ️ Test mode: Skipping onboarding")
SettingsManager.shared.settings.hasCompletedOnboarding = true
} else if TestingEnvironment.shouldResetOnboarding {
+ print("ℹ️ Test mode: Resetting onboarding")
SettingsManager.shared.settings.hasCompletedOnboarding = false
}
+
+ print("✓ GazeApp initialized")
}
var body: some Scene {
diff --git a/Gaze/Services/AppStoreDetector.swift b/Gaze/Services/AppStoreDetector.swift
index 711c9ec..a6ced7c 100644
--- a/Gaze/Services/AppStoreDetector.swift
+++ b/Gaze/Services/AppStoreDetector.swift
@@ -17,30 +17,55 @@ enum AppStoreDetector {
/// This method is asynchronous due to the use of StoreKit's async API.
static func isAppStoreVersion() async -> Bool {
#if DEBUG
+ print("🔍 AppStoreDetector: DEBUG build, returning false")
return false
#else
+ print("🔍 AppStoreDetector: Checking App Store status...")
if #available(macOS 15.0, *) {
+ print(" ℹ️ Using macOS 15+ AppTransaction API")
do {
- _ = try await AppTransaction.shared
+ let transaction = try await AppTransaction.shared
+ print(" ✅ AppTransaction found: This is an App Store version")
return true
} catch {
+ print(" ⚠️ AppTransaction error: \(error.localizedDescription)")
+ print(" → Assuming NOT an App Store version")
return false
}
} else {
// Fallback for older macOS: use legacy receipt check
+ print(" ℹ️ Using legacy receipt check (macOS <15)")
+
guard let receiptURL = Bundle.main.appStoreReceiptURL else {
+ print(" ⚠️ No receipt URL available")
return false
}
- guard FileManager.default.fileExists(atPath: receiptURL.path) else {
+ print(" 📄 Receipt URL: \(receiptURL.path)")
+
+ do {
+ let fileExists = FileManager.default.fileExists(atPath: receiptURL.path)
+ guard fileExists else {
+ print(" ⚠️ Receipt file does not exist")
+ return false
+ }
+ print(" ✓ Receipt file exists")
+
+ guard let receiptData = try? Data(contentsOf: receiptURL),
+ receiptData.count > 2
+ else {
+ print(" ⚠️ Receipt file is empty or unreadable")
+ return false
+ }
+ print(" ✓ Receipt data loaded (\(receiptData.count) bytes)")
+
+ let bytes = [UInt8](receiptData.prefix(2))
+ let isValid = bytes[0] == 0x30 && bytes[1] == 0x82
+ print(" \(isValid ? "✅" : "⚠️") Receipt validation: \(isValid)")
+ return isValid
+ } catch {
+ print(" ❌ Receipt check error: \(error.localizedDescription)")
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
}
#endif
}