diff --git a/.distribution_configs/appstore/Gaze.entitlements b/.distribution_configs/appstore/Gaze.entitlements
new file mode 100644
index 0000000..ee95ab7
--- /dev/null
+++ b/.distribution_configs/appstore/Gaze.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+
+
diff --git a/.distribution_configs/appstore/Info.plist b/.distribution_configs/appstore/Info.plist
new file mode 100644
index 0000000..4b71fcf
--- /dev/null
+++ b/.distribution_configs/appstore/Info.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundlePackageType
+ APPL
+ LSUIElement
+
+ LSApplicationCategoryType
+ public.app-category.productivity
+ CFBundleName
+ Gaze
+ CFBundleDisplayName
+ Gaze
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ NSHumanReadableCopyright
+ Copyright © 2026 Mike Freno. All rights reserved.
+
+
diff --git a/.distribution_configs/appstore/project_release.txt b/.distribution_configs/appstore/project_release.txt
new file mode 100644
index 0000000..7091c01
--- /dev/null
+++ b/.distribution_configs/appstore/project_release.txt
@@ -0,0 +1,36 @@
+ 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_ENTITLEMENTS = Gaze/Gaze.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 6;
+ DEVELOPMENT_TEAM = 6GK4F9L62V;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Gaze/Info.plist;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ 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;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
diff --git a/.distribution_configs/self/Gaze.entitlements b/.distribution_configs/self/Gaze.entitlements
new file mode 100644
index 0000000..4a6c209
--- /dev/null
+++ b/.distribution_configs/self/Gaze.entitlements
@@ -0,0 +1,15 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ com.apple.security.temporary-exception.mach-lookup.global-name
+
+ $(PRODUCT_BUNDLE_IDENTIFIER)-spks
+ $(PRODUCT_BUNDLE_IDENTIFIER)-spki
+
+
+
diff --git a/.distribution_configs/self/Info.plist b/.distribution_configs/self/Info.plist
new file mode 100644
index 0000000..3851b18
--- /dev/null
+++ b/.distribution_configs/self/Info.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundlePackageType
+ APPL
+ LSUIElement
+
+ LSApplicationCategoryType
+ public.app-category.productivity
+ CFBundleName
+ Gaze
+ CFBundleDisplayName
+ Gaze
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ NSHumanReadableCopyright
+ Copyright © 2026 Mike Freno. All rights reserved.
+ SUPublicEDKey
+ Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM=
+ SUFeedURL
+ https://freno.me/api/Gaze/appcast.xml
+ SUEnableAutomaticChecks
+
+ SUScheduledCheckInterval
+ 86400
+ SUEnableInstallerLauncherService
+
+
+
diff --git a/.distribution_configs/self/project_release.txt b/.distribution_configs/self/project_release.txt
new file mode 100644
index 0000000..d3b7842
--- /dev/null
+++ b/.distribution_configs/self/project_release.txt
@@ -0,0 +1,35 @@
+ 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_ENTITLEMENTS = Gaze/Gaze.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 6;
+ DEVELOPMENT_TEAM = 6GK4F9L62V;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Gaze/Info.plist;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MARKETING_VERSION = 0.4.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.Gaze;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md
new file mode 100644
index 0000000..08bbc69
--- /dev/null
+++ b/DISTRIBUTION.md
@@ -0,0 +1,214 @@
+# Gaze Distribution Guide
+
+This guide explains how to build and distribute Gaze for both the Mac App Store and direct distribution with auto-updates.
+
+## Distribution Methods
+
+Gaze supports two distribution methods:
+
+1. **Self-Distribution** (Direct Download) - Includes Sparkle for automatic updates
+2. **Mac App Store** - Uses Apple's update mechanism, no Sparkle
+
+## Quick Start
+
+### Switching Between Distributions
+
+Use the `switch_to` script to configure the project for each distribution method:
+
+```bash
+# For self-distribution with Sparkle auto-updates
+./switch_to self
+
+# For Mac App Store submission
+./switch_to appstore
+
+# Check current configuration
+./switch_to status
+```
+
+### What Gets Changed
+
+The `switch_to` script automatically manages:
+
+**Self-Distribution Mode:**
+- ✅ Adds Sparkle keys to `Info.plist` (SUPublicEDKey, SUFeedURL, etc.)
+- ✅ Adds Sparkle entitlements for XPC services
+- ✅ Removes `APPSTORE` compiler flag
+- ✅ Enables UpdateManager with Sparkle framework
+
+**App Store Mode:**
+- ✅ Removes all Sparkle keys from `Info.plist`
+- ✅ Removes Sparkle entitlements
+- ✅ Adds `-D APPSTORE` compiler flag
+- ✅ Disables Sparkle code at compile time
+
+## Building for Self-Distribution
+
+```bash
+# 1. Switch to self-distribution mode
+./switch_to self
+```
+
+The script will:
+- Prompt for version bump (major/minor/patch)
+- Build and code sign with Developer ID
+- Notarize the app with Apple
+- Create a signed DMG
+- Generate Sparkle appcast with EdDSA signature
+- (Optional) Upload to S3 if credentials are configured
+
+## Building for Mac App Store
+
+```bash
+# 1. Switch to App Store mode
+./switch_to appstore
+
+# 2. Add Run Script Phase in Xcode (one-time setup)
+# See section below
+
+# 3. Archive and distribute via Xcode
+# Product → Archive
+# Window → Organizer → Distribute App → App Store Connect
+```
+
+### Required: Run Script Phase
+
+For App Store builds, you **must** add this Run Script phase in Xcode:
+
+1. Open Gaze.xcodeproj in Xcode
+2. Select the Gaze target → Build Phases
+3. Click + → New Run Script Phase
+4. Name it: "Remove Sparkle for App Store"
+5. Place it **after** "Embed Frameworks"
+6. Add this script:
+
+```bash
+#!/bin/bash
+if [[ "${OTHER_SWIFT_FLAGS}" == *"APPSTORE"* ]]; then
+ echo "Removing Sparkle framework for App Store build..."
+ rm -rf "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework"
+ echo "Sparkle framework removed successfully"
+fi
+```
+
+This ensures Sparkle.framework is removed from the app bundle before submission.
+
+## Configuration Files
+
+Configuration backups are stored in `.distribution_configs/`:
+- `.distribution_configs/appstore/` - App Store configuration
+- `.distribution_configs/self/` - Self-distribution configuration
+
+These backups are created automatically and used when switching between modes.
+
+## Validation
+
+Before submitting to App Store Connect, verify your configuration:
+
+```bash
+./switch_to status
+```
+
+Expected output for App Store:
+```
+Info.plist: App Store (no Sparkle keys)
+Entitlements: App Store (no Sparkle exceptions)
+Build Settings: App Store (has APPSTORE flag)
+```
+
+Expected output for Self-Distribution:
+```
+Info.plist: Self-Distribution (has Sparkle keys)
+Entitlements: Self-Distribution (has Sparkle exceptions)
+Build Settings: Self-Distribution (no APPSTORE flag)
+```
+
+## Troubleshooting
+
+### App Store Validation Fails
+
+**Error: "App sandbox not enabled" with Sparkle executables**
+- Solution: Make sure you ran `./switch_to appstore` and added the Run Script phase
+
+**Error: "Bad Bundle Executable" or "CFBundlePackageType"**
+- Solution: These are now fixed in the Info.plist
+
+**Error: Still seeing Sparkle in the build**
+- Solution: Clean build folder (⌘⇧K) and rebuild
+
+### Self-Distribution Issues
+
+**Sparkle updates not working**
+- Verify: `./switch_to status` shows "Self-Distribution" mode
+- Check: Info.plist contains SUPublicEDKey and SUFeedURL
+- Verify: Appcast is accessible at the SUFeedURL
+
+**Code signing issues**
+- Check `.env` file has correct credentials
+- Verify Developer ID certificate: `security find-identity -v -p codesigning`
+
+## Environment Variables
+
+For self-distribution, create a `.env` file with:
+
+```bash
+# Required for code signing
+DEVELOPER_ID_APPLICATION="Developer ID Application: Your Name (TEAM_ID)"
+APPLE_TEAM_ID="XXXXXXXXXX"
+
+# Required for notarization
+NOTARY_KEYCHAIN_PROFILE="notary-profile"
+
+# Optional for S3 upload
+AWS_ACCESS_KEY_ID="your-key"
+AWS_SECRET_ACCESS_KEY="your-secret"
+AWS_BUCKET_NAME="your-bucket"
+AWS_REGION="us-east-1"
+```
+
+Setup notarization profile (one-time):
+```bash
+xcrun notarytool store-credentials "notary-profile" \
+ --apple-id "your@email.com" \
+ --team-id "TEAM_ID"
+```
+
+## Version Management
+
+The `self_distribute` script handles version bumping:
+- **Major** (X.0.0) - Breaking changes
+- **Minor** (x.X.0) - New features
+- **Patch** (x.x.X) - Bug fixes
+- **Custom** - Any version string
+- **Keep** - Increment build number only
+
+Git tags are created automatically for new versions.
+
+## Testing
+
+### Test Self-Distribution Build
+```bash
+./switch_to self
+# Test the DMG on a clean macOS system
+```
+
+### Test App Store Build
+```bash
+./switch_to appstore
+# Archive in Xcode
+# Use TestFlight for testing before release
+```
+
+## Best Practices
+
+1. **Always use `switch_to`** - Don't manually edit configuration files
+2. **Check status before building** - Use `./switch_to status`
+3. **Clean builds** - Run Clean Build Folder when switching modes
+4. **Test thoroughly** - Test both distribution methods separately
+5. **Commit before switching** - Use git to track configuration changes
+
+## Support
+
+For issues or questions:
+- GitHub Issues: https://github.com/mikefreno/Gaze/issues
+- Check AGENTS.md for development guidelines
diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj
index 8b5e2a9..e56439c 100644
--- a/Gaze.xcodeproj/project.pbxproj
+++ b/Gaze.xcodeproj/project.pbxproj
@@ -121,6 +121,7 @@
buildPhases = (
27A21B382F0F69DC0018C4F3 /* Sources */,
27A21B392F0F69DC0018C4F3 /* Frameworks */,
+ 27D081082F16AA7100FF3A31 /* Run Script */,
27A21B3A2F0F69DC0018C4F3 /* Resources */,
);
buildRules = (
@@ -257,6 +258,27 @@
};
/* End PBXResourcesBuildPhase section */
+/* Begin PBXShellScriptBuildPhase section */
+ 27D081082F16AA7100FF3A31 /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = " #!/bin/bash\n if [[ \"${OTHER_SWIFT_FLAGS}\" == *\"APPSTORE\"* ]]; then\n echo \"Removing Sparkle framework for App Store build...\"\n rm -rf \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework\"\n echo \"Sparkle framework removed successfully\"\n fi\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
/* Begin PBXSourcesBuildPhase section */
27A21B382F0F69DC0018C4F3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -475,6 +497,7 @@
);
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;
diff --git a/Gaze.xcodeproj/project.pbxproj.tmp b/Gaze.xcodeproj/project.pbxproj.tmp
new file mode 100644
index 0000000..e69de29
diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift
index c121cc3..21e1f14 100644
--- a/Gaze/AppDelegate.swift
+++ b/Gaze/AppDelegate.swift
@@ -193,8 +193,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
window.backgroundColor = .clear
window.contentView = NSHostingView(rootView: content)
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
- window.acceptsMouseMovedEvents = !requiresFocus
- window.ignoresMouseEvents = !requiresFocus
+
+ // Allow mouse events for all reminders (needed for dismiss button)
+ window.acceptsMouseMovedEvents = true
+ window.ignoresMouseEvents = false
let windowController = NSWindowController(window: window)
windowController.showWindow(nil)
diff --git a/Gaze/Gaze.entitlements b/Gaze/Gaze.entitlements
index 4a6c209..ee95ab7 100644
--- a/Gaze/Gaze.entitlements
+++ b/Gaze/Gaze.entitlements
@@ -6,10 +6,5 @@
com.apple.security.network.client
- com.apple.security.temporary-exception.mach-lookup.global-name
-
- $(PRODUCT_BUNDLE_IDENTIFIER)-spks
- $(PRODUCT_BUNDLE_IDENTIFIER)-spki
-
diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift
index baf01c3..d8f7e37 100644
--- a/Gaze/GazeApp.swift
+++ b/Gaze/GazeApp.swift
@@ -12,6 +12,15 @@ struct GazeApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var settingsManager = SettingsManager.shared
+ init() {
+ // Handle test launch arguments
+ if TestingEnvironment.shouldSkipOnboarding {
+ SettingsManager.shared.settings.hasCompletedOnboarding = true
+ } else if TestingEnvironment.shouldResetOnboarding {
+ SettingsManager.shared.settings.hasCompletedOnboarding = false
+ }
+ }
+
var body: some Scene {
// Onboarding window (only shown when not completed)
WindowGroup {
diff --git a/Gaze/Info.plist b/Gaze/Info.plist
index 94300be..4b71fcf 100644
--- a/Gaze/Info.plist
+++ b/Gaze/Info.plist
@@ -2,6 +2,10 @@
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundlePackageType
+ APPL
LSUIElement
LSApplicationCategoryType
@@ -18,15 +22,5 @@
$(MARKETING_VERSION)
NSHumanReadableCopyright
Copyright © 2026 Mike Freno. All rights reserved.
- SUPublicEDKey
- Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM=
- SUFeedURL
- https://freno.me/api/Gaze/appcast.xml
- SUEnableAutomaticChecks
-
- SUScheduledCheckInterval
- 86400
- SUEnableInstallerLauncherService
-
diff --git a/Gaze/Services/UpdateManager.swift b/Gaze/Services/UpdateManager.swift
index 1d2d55f..0e8b351 100644
--- a/Gaze/Services/UpdateManager.swift
+++ b/Gaze/Services/UpdateManager.swift
@@ -7,24 +7,31 @@
import Combine
import Foundation
+#if !APPSTORE
import Sparkle
+#endif
@MainActor
class UpdateManager: NSObject, ObservableObject {
static let shared = UpdateManager()
+ #if !APPSTORE
private var updaterController: SPUStandardUpdaterController?
private var automaticallyChecksObservation: NSKeyValueObservation?
private var lastCheckDateObservation: NSKeyValueObservation?
+ #endif
@Published var automaticallyChecksForUpdates = false
@Published var lastUpdateCheckDate: Date?
private override init() {
super.init()
+ #if !APPSTORE
setupUpdater()
+ #endif
}
+ #if !APPSTORE
private func setupUpdater() {
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
@@ -57,17 +64,24 @@ class UpdateManager: NSObject, ObservableObject {
}
}
}
+ #endif
func checkForUpdates() {
+ #if !APPSTORE
guard let updater = updaterController?.updater else {
print("Updater not initialized")
return
}
updater.checkForUpdates()
+ #else
+ print("Updates are managed by the App Store")
+ #endif
}
deinit {
+ #if !APPSTORE
automaticallyChecksObservation?.invalidate()
lastCheckDateObservation?.invalidate()
+ #endif
}
}
diff --git a/Gaze/Views/Reminders/LookAwayReminderView.swift b/Gaze/Views/Reminders/LookAwayReminderView.swift
index 7603faf..b599e3b 100644
--- a/Gaze/Views/Reminders/LookAwayReminderView.swift
+++ b/Gaze/Views/Reminders/LookAwayReminderView.swift
@@ -101,13 +101,15 @@ struct LookAwayReminderView: View {
}
private func startCountdown() {
- timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ let timer = Timer(timeInterval: 1.0, repeats: true) { [self] _ in
if remainingSeconds > 0 {
remainingSeconds -= 1
} else {
dismiss()
}
}
+ RunLoop.current.add(timer, forMode: .common)
+ self.timer = timer
}
private func dismiss() {
diff --git a/Gaze/Views/Reminders/UserTimerOverlayReminderView.swift b/Gaze/Views/Reminders/UserTimerOverlayReminderView.swift
index cea88af..f868b63 100644
--- a/Gaze/Views/Reminders/UserTimerOverlayReminderView.swift
+++ b/Gaze/Views/Reminders/UserTimerOverlayReminderView.swift
@@ -101,13 +101,15 @@ struct UserTimerOverlayReminderView: View {
}
private func startCountdown() {
- countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ let timer = Timer(timeInterval: 1.0, repeats: true) { [self] _ in
if remainingSeconds > 0 {
remainingSeconds -= 1
} else {
dismiss()
}
}
+ RunLoop.current.add(timer, forMode: .common)
+ countdownTimer = timer
}
private func dismiss() {
diff --git a/self_distribute b/self_distribute
index ebce7c1..ddc7372 100755
--- a/self_distribute
+++ b/self_distribute
@@ -1,6 +1,10 @@
#!/bin/bash
set -e
+# Ensure we're using self-distribution configuration
+echo "🔄 Switching to self-distribution configuration..."
+./switch_to self
+
# Load environment variables from .env file
if [ -f .env ]; then
set -a
diff --git a/switch_to b/switch_to
new file mode 100755
index 0000000..87e831a
--- /dev/null
+++ b/switch_to
@@ -0,0 +1,348 @@
+#!/bin/bash
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Configuration paths
+INFO_PLIST="Gaze/Info.plist"
+ENTITLEMENTS="Gaze/Gaze.entitlements"
+PROJECT_FILE="Gaze.xcodeproj/project.pbxproj"
+BACKUP_DIR=".distribution_configs"
+
+# Distribution configurations
+APPSTORE_CONFIG="${BACKUP_DIR}/appstore"
+SELF_CONFIG="${BACKUP_DIR}/self"
+
+# Function to print colored messages
+print_info() {
+ echo -e "${BLUE}ℹ${NC} $1"
+}
+
+print_success() {
+ echo -e "${GREEN}✓${NC} $1"
+}
+
+print_warning() {
+ echo -e "${YELLOW}⚠${NC} $1"
+}
+
+print_error() {
+ echo -e "${RED}✗${NC} $1"
+}
+
+# Function to create backup directories
+create_backup_dirs() {
+ mkdir -p "${APPSTORE_CONFIG}"
+ mkdir -p "${SELF_CONFIG}"
+}
+
+# Function to backup current configuration
+backup_current_config() {
+ local config_name=$1
+ local config_dir=$2
+
+ print_info "Backing up ${config_name} configuration..."
+
+ # Backup Info.plist
+ if [ -f "${INFO_PLIST}" ]; then
+ cp "${INFO_PLIST}" "${config_dir}/Info.plist"
+ fi
+
+ # Backup entitlements
+ if [ -f "${ENTITLEMENTS}" ]; then
+ cp "${ENTITLEMENTS}" "${config_dir}/Gaze.entitlements"
+ fi
+
+ # Backup relevant parts of project.pbxproj (just the Release config)
+ if [ -f "${PROJECT_FILE}" ]; then
+ # Extract the Release configuration section
+ awk '/27A21B5F2F0F69DD0018C4F3 \/\* Release \*\/ = {/,/name = Release;/ {print}' "${PROJECT_FILE}" > "${config_dir}/project_release.txt"
+ fi
+
+ print_success "Backed up ${config_name} configuration"
+}
+
+# Function to restore configuration
+restore_config() {
+ local config_name=$1
+ local config_dir=$2
+
+ print_info "Restoring ${config_name} configuration..."
+
+ # Restore Info.plist
+ if [ -f "${config_dir}/Info.plist" ]; then
+ cp "${config_dir}/Info.plist" "${INFO_PLIST}"
+ print_success "Restored Info.plist"
+ else
+ print_warning "No Info.plist backup found for ${config_name}"
+ fi
+
+ # Restore entitlements
+ if [ -f "${config_dir}/Gaze.entitlements" ]; then
+ cp "${config_dir}/Gaze.entitlements" "${ENTITLEMENTS}"
+ print_success "Restored entitlements"
+ else
+ print_warning "No entitlements backup found for ${config_name}"
+ fi
+
+ # Restore project.pbxproj Release configuration
+ if [ -f "${config_dir}/project_release.txt" ]; then
+ # Check if we're restoring to appstore or self mode based on file content
+ if grep -q "OTHER_SWIFT_FLAGS.*APPSTORE" "${config_dir}/project_release.txt"; then
+ # Add APPSTORE flag
+ if ! grep -q "OTHER_SWIFT_FLAGS.*APPSTORE" "${PROJECT_FILE}"; then
+ # Find the Release config section and add the flag
+ sed -i.backup '/27A21B5F2F0F69DD0018C4F3 \/\* Release \*\/ = {/,/name = Release;/{
+ /MARKETING_VERSION = /a\
+ OTHER_SWIFT_FLAGS = "-D APPSTORE";
+ }' "${PROJECT_FILE}"
+ rm -f "${PROJECT_FILE}.backup"
+ print_success "Added APPSTORE compiler flag"
+ fi
+ else
+ # Remove APPSTORE flag
+ if grep -q "OTHER_SWIFT_FLAGS.*APPSTORE" "${PROJECT_FILE}"; then
+ sed -i.backup '/OTHER_SWIFT_FLAGS = "-D APPSTORE";/d' "${PROJECT_FILE}"
+ rm -f "${PROJECT_FILE}.backup"
+ print_success "Removed APPSTORE compiler flag"
+ fi
+ fi
+ else
+ print_warning "No project.pbxproj backup found for ${config_name}"
+ fi
+}
+
+# Function to initialize configurations if they don't exist
+initialize_configs() {
+ create_backup_dirs
+
+ # Check if we have existing backups
+ if [ ! -f "${SELF_CONFIG}/Info.plist" ]; then
+ print_info "No self-distribution config found. Creating from current state..."
+
+ # The current state should be self-distribution (before my changes)
+ # Let's create the self-distribution version with Sparkle keys
+ cat > "${SELF_CONFIG}/Info.plist" <<'EOF'
+
+
+
+
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundlePackageType
+ APPL
+ LSUIElement
+
+ LSApplicationCategoryType
+ public.app-category.productivity
+ CFBundleName
+ Gaze
+ CFBundleDisplayName
+ Gaze
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ NSHumanReadableCopyright
+ Copyright © 2026 Mike Freno. All rights reserved.
+ SUPublicEDKey
+ Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM=
+ SUFeedURL
+ https://freno.me/api/Gaze/appcast.xml
+ SUEnableAutomaticChecks
+
+ SUScheduledCheckInterval
+ 86400
+ SUEnableInstallerLauncherService
+
+
+
+EOF
+
+ cat > "${SELF_CONFIG}/Gaze.entitlements" <<'EOF'
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ com.apple.security.temporary-exception.mach-lookup.global-name
+
+ $(PRODUCT_BUNDLE_IDENTIFIER)-spks
+ $(PRODUCT_BUNDLE_IDENTIFIER)-spki
+
+
+
+EOF
+
+ # Extract current Release config WITHOUT APPSTORE flag
+ awk '/27A21B5F2F0F69DD0018C4F3 \/\* Release \*\/ = {/,/name = Release;/ {
+ if ($0 !~ /OTHER_SWIFT_FLAGS/) {
+ print
+ } else {
+ # Skip the APPSTORE flag line
+ if ($0 !~ /APPSTORE/) {
+ print
+ }
+ }
+ }' "${PROJECT_FILE}" > "${SELF_CONFIG}/project_release.txt"
+
+ print_success "Created self-distribution config"
+ fi
+
+ if [ ! -f "${APPSTORE_CONFIG}/Info.plist" ]; then
+ print_info "Creating App Store config..."
+
+ # Backup current state as App Store config (after my changes)
+ backup_current_config "App Store" "${APPSTORE_CONFIG}"
+ fi
+}
+
+# Function to show current distribution mode
+show_current_mode() {
+ print_info "Current configuration:"
+ echo ""
+
+ # Check for Sparkle keys in Info.plist
+ if grep -q "SUPublicEDKey" "${INFO_PLIST}" 2>/dev/null; then
+ echo " Info.plist: ${GREEN}Self-Distribution${NC} (has Sparkle keys)"
+ else
+ echo " Info.plist: ${BLUE}App Store${NC} (no Sparkle keys)"
+ fi
+
+ # Check for Sparkle entitlements
+ if grep -q "mach-lookup.global-name" "${ENTITLEMENTS}" 2>/dev/null; then
+ echo " Entitlements: ${GREEN}Self-Distribution${NC} (has Sparkle exceptions)"
+ else
+ echo " Entitlements: ${BLUE}App Store${NC} (no Sparkle exceptions)"
+ fi
+
+ # Check for APPSTORE flag
+ if grep -q "OTHER_SWIFT_FLAGS.*APPSTORE" "${PROJECT_FILE}" 2>/dev/null; then
+ echo " Build Settings: ${BLUE}App Store${NC} (has APPSTORE flag)"
+ else
+ echo " Build Settings: ${GREEN}Self-Distribution${NC} (no APPSTORE flag)"
+ fi
+
+ echo ""
+}
+
+# Function to switch to App Store configuration
+switch_to_appstore() {
+ print_info "Switching to App Store distribution configuration..."
+ echo ""
+
+ # Backup current state if it's self-distribution
+ if grep -q "SUPublicEDKey" "${INFO_PLIST}" 2>/dev/null; then
+ backup_current_config "self-distribution" "${SELF_CONFIG}"
+ fi
+
+ # Restore App Store config
+ restore_config "App Store" "${APPSTORE_CONFIG}"
+
+ echo ""
+ print_success "Switched to App Store distribution mode"
+ print_warning "Remember to add the Run Script phase to remove Sparkle framework!"
+ echo ""
+ echo "Run Script to add in Xcode Build Phases:"
+ echo '#!/bin/bash'
+ echo 'if [[ "${OTHER_SWIFT_FLAGS}" == *"APPSTORE"* ]]; then'
+ echo ' rm -rf "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework"'
+ echo 'fi'
+}
+
+# Function to switch to self-distribution configuration
+switch_to_self() {
+ print_info "Switching to self-distribution configuration..."
+ echo ""
+
+ # Backup current state if it's App Store
+ if ! grep -q "SUPublicEDKey" "${INFO_PLIST}" 2>/dev/null; then
+ backup_current_config "App Store" "${APPSTORE_CONFIG}"
+ fi
+
+ # Restore self-distribution config
+ restore_config "self-distribution" "${SELF_CONFIG}"
+
+ echo ""
+ print_success "Switched to self-distribution mode"
+ print_info "Sparkle auto-updates enabled"
+}
+
+# Function to show usage
+show_usage() {
+ cat << EOF
+${BLUE}Gaze Distribution Configuration Switcher${NC}
+
+${GREEN}Usage:${NC}
+ ./switch_to [command]
+
+${GREEN}Commands:${NC}
+ appstore Switch to App Store distribution configuration
+ - Removes Sparkle keys from Info.plist
+ - Removes Sparkle entitlements
+ - Adds APPSTORE compiler flag
+
+ self Switch to self-distribution configuration
+ - Adds Sparkle keys to Info.plist
+ - Adds Sparkle entitlements for XPC services
+ - Removes APPSTORE compiler flag
+
+ status Show current distribution configuration
+
+ help Show this help message
+
+${GREEN}Examples:${NC}
+ ./switch_to appstore # Prepare for App Store submission
+ ./switch_to self # Prepare for direct distribution with auto-updates
+ ./switch_to status # Check current configuration
+
+${YELLOW}Note:${NC} Configuration backups are stored in ${BACKUP_DIR}/
+EOF
+}
+
+# Main script logic
+main() {
+ # Check if we're in the right directory
+ if [ ! -f "${PROJECT_FILE}" ]; then
+ print_error "Not in Gaze project directory. Please run from project root."
+ exit 1
+ fi
+
+ # Initialize configurations if needed
+ initialize_configs
+
+ # Parse command
+ case "${1:-}" in
+ appstore)
+ switch_to_appstore
+ ;;
+ self)
+ switch_to_self
+ ;;
+ status)
+ show_current_mode
+ ;;
+ help|--help|-h)
+ show_usage
+ ;;
+ *)
+ print_error "Unknown command: ${1:-}"
+ echo ""
+ show_usage
+ exit 1
+ ;;
+ esac
+}
+
+# Run main function
+main "$@"