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 "$@"