diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index b5e0e8d..c121cc3 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -76,7 +76,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } func applicationWillTerminate(_ notification: Notification) { - settingsManager.save() + settingsManager.saveImmediately() timerEngine?.stop() } @@ -98,7 +98,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @objc private func systemWillSleep() { timerEngine?.handleSystemSleep() - settingsManager.save() + settingsManager.saveImmediately() } @objc private func systemDidWake() { diff --git a/Gaze/Services/SettingsManager.swift b/Gaze/Services/SettingsManager.swift index c98e471..15f4d32 100644 --- a/Gaze/Services/SettingsManager.swift +++ b/Gaze/Services/SettingsManager.swift @@ -26,10 +26,6 @@ class SettingsManager: ObservableObject { ] private init() { - #if DEBUG - // Clear settings on every development build - UserDefaults.standard.removeObject(forKey: "gazeAppSettings") - #endif self.settings = Self.loadSettings() #if DEBUG validateTimerConfigMappings() @@ -39,7 +35,7 @@ class SettingsManager: ObservableObject { deinit { saveCancellable?.cancel() - // Final save will be called by AppDelegate.applicationWillTerminate + // Final save is called by AppDelegate.applicationWillTerminate } private func setupDebouncedSave() { @@ -52,23 +48,72 @@ class SettingsManager: ObservableObject { } private static func loadSettings() -> AppSettings { - guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings"), - let settings = try? JSONDecoder().decode(AppSettings.self, from: data) - else { + guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else { + #if DEBUG + print("ℹ️ No saved settings found, using defaults") + #endif + return .defaults + } + + do { + let settings = try JSONDecoder().decode(AppSettings.self, from: data) + #if DEBUG + print("✅ Settings loaded successfully (\(data.count) bytes)") + #endif + return settings + } catch { + print("⚠️ Failed to decode settings, using defaults: \(error.localizedDescription)") + if let decodingError = error as? DecodingError { + switch decodingError { + case .keyNotFound(let key, let context): + print(" Missing key: \(key.stringValue) at path: \(context.codingPath)") + case .typeMismatch(let type, let context): + print(" Type mismatch for type: \(type) at path: \(context.codingPath)") + case .valueNotFound(let type, let context): + print(" Value not found for type: \(type) at path: \(context.codingPath)") + case .dataCorrupted(let context): + print(" Data corrupted at path: \(context.codingPath)") + @unknown default: + print(" Unknown decoding error: \(decodingError)") + } + } return .defaults } - return settings } /// Saves settings to UserDefaults. /// Note: Settings are automatically saved via debouncing (500ms delay) when the `settings` property changes. /// This method is also called explicitly during app termination to ensure final state is persisted. func save() { - guard let data = try? JSONEncoder().encode(settings) else { - print("Failed to encode settings") - return + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(settings) + userDefaults.set(data, forKey: settingsKey) + + #if DEBUG + print("✅ Settings saved successfully (\(data.count) bytes)") + #endif + } catch { + print("❌ Failed to encode settings: \(error.localizedDescription)") + if let encodingError = error as? EncodingError { + switch encodingError { + case .invalidValue(let value, let context): + print(" Invalid value: \(value) at path: \(context.codingPath)") + default: + print(" Encoding error: \(encodingError)") + } + } } - userDefaults.set(data, forKey: settingsKey) + } + + /// Forces immediate save and ensures UserDefaults are persisted to disk. + /// Use this for critical save points like app termination or system sleep. + func saveImmediately() { + save() + // Cancel any pending debounced saves + saveCancellable?.cancel() + setupDebouncedSave() } func load() { diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index eea4f2d..fca49c7 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -59,6 +59,13 @@ struct SettingsWindowView: View { Divider() HStack { + #if DEBUG + Button("Retrigger Onboarding") { + retriggerOnboarding() + } + .buttonStyle(.bordered) + #endif + Spacer() Button("Close") { @@ -87,6 +94,22 @@ struct SettingsWindowView: View { window.close() } } + + #if DEBUG + private func retriggerOnboarding() { + // Close settings window first + closeWindow() + + // Get AppDelegate and open onboarding + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + // Reset onboarding state so it shows as fresh + settingsManager.settings.hasCompletedOnboarding = false + + // Open onboarding window + appDelegate.openOnboarding() + } + } + #endif } #Preview { diff --git a/GazeTests/IntegrationTests.swift b/GazeTests/IntegrationTests.swift index f944144..4db54ec 100644 --- a/GazeTests/IntegrationTests.swift +++ b/GazeTests/IntegrationTests.swift @@ -31,7 +31,7 @@ final class IntegrationTests: XCTestCase { func testSettingsChangePropagateToTimerEngine() { timerEngine.start() - let originalInterval = timerEngine.timerStates[.lookAway]?.remainingSeconds + let originalInterval = timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds XCTAssertEqual(originalInterval, 20 * 60) let newConfig = TimerConfiguration(enabled: true, intervalSeconds: 10 * 60) @@ -39,31 +39,31 @@ final class IntegrationTests: XCTestCase { timerEngine.start() - let newInterval = timerEngine.timerStates[.lookAway]?.remainingSeconds + let newInterval = timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds XCTAssertEqual(newInterval, 10 * 60) } func testDisablingTimerRemovesFromEngine() { timerEngine.start() - XCTAssertNotNil(timerEngine.timerStates[.blink]) + XCTAssertNotNil(timerEngine.timerStates[.builtIn(.blink)]) var config = TimerConfiguration(enabled: false, intervalSeconds: 5 * 60) settingsManager.updateTimerConfiguration(for: .blink, configuration: config) timerEngine.start() - XCTAssertNil(timerEngine.timerStates[.blink]) + XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)]) } func testEnablingTimerAddsToEngine() { settingsManager.settings.postureTimer.enabled = false timerEngine.start() - XCTAssertNil(timerEngine.timerStates[.posture]) + XCTAssertNil(timerEngine.timerStates[.builtIn(.posture)]) let config = TimerConfiguration(enabled: true, intervalSeconds: 30 * 60) settingsManager.updateTimerConfiguration(for: .posture, configuration: config) timerEngine.start() - XCTAssertNotNil(timerEngine.timerStates[.posture]) + XCTAssertNotNil(timerEngine.timerStates[.builtIn(.posture)]) } func testSettingsPersistAcrossEngineLifecycle() { @@ -76,7 +76,7 @@ final class IntegrationTests: XCTestCase { let newEngine = TimerEngine(settingsManager: settingsManager) newEngine.start() - XCTAssertNil(newEngine.timerStates[.lookAway]) + XCTAssertNil(newEngine.timerStates[.builtIn(.lookAway)]) } func testMultipleTimerConfigurationUpdates() { @@ -94,9 +94,9 @@ final class IntegrationTests: XCTestCase { timerEngine.start() - XCTAssertEqual(timerEngine.timerStates[.lookAway]?.remainingSeconds, 600) - XCTAssertEqual(timerEngine.timerStates[.blink]?.remainingSeconds, 300) - XCTAssertEqual(timerEngine.timerStates[.posture]?.remainingSeconds, 1800) + XCTAssertEqual(timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds, 600) + XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 300) + XCTAssertEqual(timerEngine.timerStates[.builtIn(.posture)]?.remainingSeconds, 1800) } func testResetToDefaultsAffectsTimerEngine() { @@ -104,13 +104,13 @@ final class IntegrationTests: XCTestCase { settingsManager.updateTimerConfiguration(for: .blink, configuration: config) timerEngine.start() - XCTAssertNil(timerEngine.timerStates[.blink]) + XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)]) settingsManager.resetToDefaults() timerEngine.start() - XCTAssertNotNil(timerEngine.timerStates[.blink]) - XCTAssertEqual(timerEngine.timerStates[.blink]?.remainingSeconds, 5 * 60) + XCTAssertNotNil(timerEngine.timerStates[.builtIn(.blink)]) + XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 5 * 60) } func testTimerEngineRespectsDisabledTimers() { @@ -138,8 +138,8 @@ final class IntegrationTests: XCTestCase { XCTAssertFalse(state.isPaused) } - timerEngine.skipNext(type: .lookAway) - XCTAssertEqual(timerEngine.timerStates[.lookAway]?.remainingSeconds, 20 * 60) + timerEngine.skipNext(identifier: .builtIn(.lookAway)) + XCTAssertEqual(timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds, 20 * 60) timerEngine.stop() XCTAssertTrue(timerEngine.timerStates.isEmpty) @@ -148,7 +148,7 @@ final class IntegrationTests: XCTestCase { func testReminderWorkflow() { timerEngine.start() - timerEngine.triggerReminder(for: .lookAway) + timerEngine.triggerReminder(for: .builtIn(.lookAway)) XCTAssertNotNil(timerEngine.activeReminder) for (_, state) in timerEngine.timerStates { diff --git a/GazeTests/Models/ReminderEventTests.swift b/GazeTests/Models/ReminderEventTests.swift index ce4613f..69809ac 100644 --- a/GazeTests/Models/ReminderEventTests.swift +++ b/GazeTests/Models/ReminderEventTests.swift @@ -40,19 +40,19 @@ final class ReminderEventTests: XCTestCase { } } - func testTypePropertyForLookAway() { + func testIdentifierPropertyForLookAway() { let event = ReminderEvent.lookAwayTriggered(countdownSeconds: 20) - XCTAssertEqual(event.type, .lookAway) + XCTAssertEqual(event.identifier, .builtIn(.lookAway)) } - func testTypePropertyForBlink() { + func testIdentifierPropertyForBlink() { let event = ReminderEvent.blinkTriggered - XCTAssertEqual(event.type, .blink) + XCTAssertEqual(event.identifier, .builtIn(.blink)) } - func testTypePropertyForPosture() { + func testIdentifierPropertyForPosture() { let event = ReminderEvent.postureTriggered - XCTAssertEqual(event.type, .posture) + XCTAssertEqual(event.identifier, .builtIn(.posture)) } func testEquality() { @@ -79,9 +79,9 @@ final class ReminderEventTests: XCTestCase { XCTAssertNotEqual(event2, event3) XCTAssertNotEqual(event1, event3) - XCTAssertEqual(event1.type, .lookAway) - XCTAssertEqual(event2.type, .lookAway) - XCTAssertEqual(event3.type, .lookAway) + XCTAssertEqual(event1.identifier, .builtIn(.lookAway)) + XCTAssertEqual(event2.identifier, .builtIn(.lookAway)) + XCTAssertEqual(event3.identifier, .builtIn(.lookAway)) } func testNegativeCountdown() { @@ -104,11 +104,13 @@ final class ReminderEventTests: XCTestCase { for event in events { switch event { case .lookAwayTriggered: - XCTAssertEqual(event.type, .lookAway) + XCTAssertEqual(event.identifier, .builtIn(.lookAway)) case .blinkTriggered: - XCTAssertEqual(event.type, .blink) + XCTAssertEqual(event.identifier, .builtIn(.blink)) case .postureTriggered: - XCTAssertEqual(event.type, .posture) + XCTAssertEqual(event.identifier, .builtIn(.posture)) + case .userTimerTriggered: + XCTFail("Unexpected user timer in this test") } } } diff --git a/GazeTests/Models/TimerStateTests.swift b/GazeTests/Models/TimerStateTests.swift index bd54090..972e5ab 100644 --- a/GazeTests/Models/TimerStateTests.swift +++ b/GazeTests/Models/TimerStateTests.swift @@ -11,34 +11,34 @@ import XCTest final class TimerStateTests: XCTestCase { func testInitialization() { - let state = TimerState(type: .lookAway, intervalSeconds: 1200) + let state = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200) - XCTAssertEqual(state.type, .lookAway) + XCTAssertEqual(state.identifier, .builtIn(.lookAway)) XCTAssertEqual(state.remainingSeconds, 1200) XCTAssertFalse(state.isPaused) XCTAssertTrue(state.isActive) } func testInitializationWithPausedState() { - let state = TimerState(type: .blink, intervalSeconds: 300, isPaused: true) + let state = TimerState(identifier: .builtIn(.blink), intervalSeconds: 300, isPaused: true) - XCTAssertEqual(state.type, .blink) + XCTAssertEqual(state.identifier, .builtIn(.blink)) XCTAssertEqual(state.remainingSeconds, 300) XCTAssertTrue(state.isPaused) XCTAssertTrue(state.isActive) } func testInitializationWithInactiveState() { - let state = TimerState(type: .posture, intervalSeconds: 1800, isPaused: false, isActive: false) + let state = TimerState(identifier: .builtIn(.posture), intervalSeconds: 1800, isPaused: false, isActive: false) - XCTAssertEqual(state.type, .posture) + XCTAssertEqual(state.identifier, .builtIn(.posture)) XCTAssertEqual(state.remainingSeconds, 1800) XCTAssertFalse(state.isPaused) XCTAssertFalse(state.isActive) } func testMutability() { - var state = TimerState(type: .lookAway, intervalSeconds: 1200) + var state = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200) state.remainingSeconds = 600 XCTAssertEqual(state.remainingSeconds, 600) @@ -51,10 +51,10 @@ final class TimerStateTests: XCTestCase { } func testEquality() { - let state1 = TimerState(type: .lookAway, intervalSeconds: 1200) - let state2 = TimerState(type: .lookAway, intervalSeconds: 1200) - let state3 = TimerState(type: .blink, intervalSeconds: 1200) - let state4 = TimerState(type: .lookAway, intervalSeconds: 600) + let state1 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200) + let state2 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200) + let state3 = TimerState(identifier: .builtIn(.blink), intervalSeconds: 1200) + let state4 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 600) XCTAssertEqual(state1, state2) XCTAssertNotEqual(state1, state3) @@ -62,32 +62,32 @@ final class TimerStateTests: XCTestCase { } func testEqualityWithDifferentPausedState() { - let state1 = TimerState(type: .lookAway, intervalSeconds: 1200, isPaused: false) - let state2 = TimerState(type: .lookAway, intervalSeconds: 1200, isPaused: true) + let state1 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200, isPaused: false) + let state2 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200, isPaused: true) XCTAssertNotEqual(state1, state2) } func testEqualityWithDifferentActiveState() { - let state1 = TimerState(type: .lookAway, intervalSeconds: 1200, isActive: true) - let state2 = TimerState(type: .lookAway, intervalSeconds: 1200, isActive: false) + let state1 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200, isActive: true) + let state2 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200, isActive: false) XCTAssertNotEqual(state1, state2) } func testZeroRemainingSeconds() { - let state = TimerState(type: .lookAway, intervalSeconds: 0) + let state = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 0) XCTAssertEqual(state.remainingSeconds, 0) } func testNegativeRemainingSeconds() { - var state = TimerState(type: .lookAway, intervalSeconds: 10) + var state = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 10) state.remainingSeconds = -5 XCTAssertEqual(state.remainingSeconds, -5) } func testLargeIntervalSeconds() { - let state = TimerState(type: .posture, intervalSeconds: 86400) + let state = TimerState(identifier: .builtIn(.posture), intervalSeconds: 86400) XCTAssertEqual(state.remainingSeconds, 86400) } } diff --git a/build_dmg b/build_dmg index 6edfa69..a0b61e3 100755 --- a/build_dmg +++ b/build_dmg @@ -8,6 +8,238 @@ if [ -f .env ]; then set +a fi +# Function to ask for version bump confirmation +ask_version_bump() { + echo "" + echo "═══════════════════════════════════════" + echo " Gaze Version Bump & Build Tool " + echo "═══════════════════════════════════════" + echo "" + + # Check if we're in a git repository + if [ ! -d .git ] && [ ! -f .git ]; then + echo "Error: Not in a git repository" + exit 1 + fi + + # Check for uncommitted changes + if ! git diff-index --quiet HEAD --; then + echo "Warning: You have uncommitted changes" + git status --short + echo "" + read -p "Continue anyway? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted" + exit 1 + fi + echo "" + fi + + # Get current version from git tag or fallback to project file + CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') + if [ -z "$CURRENT_VERSION" ]; then + echo "Warning: No existing git tags found" + echo "Attempting to read version from Xcode project..." + PROJECT_FILE="Gaze.xcodeproj/project.pbxproj" + CURRENT_VERSION=$(grep -A 1 "MARKETING_VERSION" "$PROJECT_FILE" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1) + if [ -z "$CURRENT_VERSION" ]; then + echo "Error: Could not extract version from git tags or project file" + exit 1 + fi + echo "Using version from project file as fallback" + fi + + echo "Current version: v${CURRENT_VERSION}" + + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + + MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//') + MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//') + PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//') + + echo "" + echo "Select version bump type:" + echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH} → $((MAJOR+1)).0.0" + echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.$((MINOR+1)).0" + echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH} → ${MAJOR}.${MINOR}.$((PATCH+1))" + echo " 4) Custom version" + echo " 5) Keep current version" + echo "" + read -p "Enter choice (1-5): " -n 1 -r CHOICE + echo "" + echo "" + + case $CHOICE in + 1) + NEW_MAJOR=$((MAJOR+1)) + NEW_MINOR=0 + NEW_PATCH=0 + BUMP_TYPE="major" + ;; + 2) + NEW_MAJOR=$MAJOR + NEW_MINOR=$((MINOR+1)) + NEW_PATCH=0 + BUMP_TYPE="minor" + ;; + 3) + NEW_MAJOR=$MAJOR + NEW_MINOR=$MINOR + NEW_PATCH=$((PATCH+1)) + BUMP_TYPE="patch" + ;; + 4) + read -p "Enter custom version (e.g., 1.0.0-beta): " CUSTOM_VERSION + NEW_VERSION="$CUSTOM_VERSION" + BUMP_TYPE="custom" + ;; + 5) + echo "Keeping current version v${CURRENT_VERSION}" + NEW_VERSION="$CURRENT_VERSION" + ;; + *) + echo "Invalid choice" + exit 1 + ;; + esac + + # Construct new version (unless custom) + if [ "$BUMP_TYPE" != "custom" ] && [ "$BUMP_TYPE" != "keep" ]; then + NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}" + fi + + echo "New version: v${NEW_VERSION}" + + # Ask for confirmation to proceed with version bumping + echo "" + echo "This will:" + echo " 1. Update project.pbxproj → MARKETING_VERSION = ${NEW_VERSION}" + echo " 2. Update project.pbxproj → CURRENT_PROJECT_VERSION = $(($(date +%Y%m%d)))" + echo " 3. Create git tag v${NEW_VERSION}" + echo "" + read -p "Proceed with version bump? (y/n) " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted" + exit 0 + fi + + # Update project files + echo "[1/3] Updating project version..." + + # Update MARKETING_VERSION + sed -i.bak "s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${NEW_VERSION};/" Gaze.xcodeproj/project.pbxproj + rm -f Gaze.xcodeproj/project.pbxproj.bak + + # Update CURRENT_PROJECT_VERSION (build number) + BUILD_NUMBER=$(date +%Y%m%d) + sed -i.bak "s/CURRENT_PROJECT_VERSION = [0-9]*;/CURRENT_PROJECT_VERSION = ${BUILD_NUMBER};/" Gaze.xcodeproj/project.pbxproj + rm -f Gaze.xcodeproj/project.pbxproj.bak + + echo "✓ Project version updated" + + # Stage changes and commit + echo "[2/3] Committing changes..." + git add Gaze.xcodeproj/project.pbxproj + git commit -m "Version bump to v${NEW_VERSION}" + + # Create tag + echo "[3/3] Creating tag..." + git tag -a "v${NEW_VERSION}" -m "Release version ${NEW_VERSION}" + echo "✓ Version bumped to v${NEW_VERSION}" +} + +# Ask whether to bump version before continuing +read -p "Do you want to bump the version before building? (y/n) " -n 1 -r +echo "" +if [[ $REPLY =~ ^[Yy]$ ]]; then + ask_version_bump +fi + +# Validate required code signing and notarization credentials +echo "🔐 Validating credentials..." +MISSING_CREDS=() + +if [ -z "$DEVELOPER_ID_APPLICATION" ]; then + MISSING_CREDS+=("DEVELOPER_ID_APPLICATION") +fi + +if [ -z "$NOTARY_KEYCHAIN_PROFILE" ]; then + MISSING_CREDS+=("NOTARY_KEYCHAIN_PROFILE") +fi + +if [ -z "$APPLE_TEAM_ID" ]; then + MISSING_CREDS+=("APPLE_TEAM_ID") +fi + +if [ ${#MISSING_CREDS[@]} -gt 0 ]; then + echo "❌ ERROR: Missing required credentials in .env file:" + for cred in "${MISSING_CREDS[@]}"; do + echo " - $cred" + done + echo "" + echo "Required environment variables:" + echo " DEVELOPER_ID_APPLICATION='Developer ID Application: Your Name (TEAM_ID)'" + echo " APPLE_TEAM_ID='XXXXXXXXXX'" + echo " NOTARY_KEYCHAIN_PROFILE='notary-profile'" + echo "" + echo "Setup instructions:" + echo " 1. Find your Developer ID certificate:" + echo " security find-identity -v -p codesigning" + echo "" + echo " 2. Store notarization credentials in keychain (one-time setup):" + echo " xcrun notarytool store-credentials \"notary-profile\" \\" + echo " --apple-id \"your@email.com\" \\" + echo " --team-id \"TEAM_ID\"" + echo "" + echo " You'll be prompted for an app-specific password." + echo " Generate one at: https://appleid.apple.com/account/manage" + echo "" + echo " 3. Set NOTARY_KEYCHAIN_PROFILE='notary-profile' in .env" + exit 1 +fi + +# Verify keychain profile exists +echo "🔍 Verifying keychain profile..." +if ! xcrun notarytool history --keychain-profile "$NOTARY_KEYCHAIN_PROFILE" &>/dev/null; then + echo "❌ ERROR: Keychain profile '$NOTARY_KEYCHAIN_PROFILE' not found or invalid" + echo "" + echo "Create the profile with:" + echo " xcrun notarytool store-credentials \"$NOTARY_KEYCHAIN_PROFILE\" \\" + echo " --apple-id \"your@email.com\" \\" + echo " --team-id \"$APPLE_TEAM_ID\"" + exit 1 +fi + +echo "✅ All credentials validated" + +# Extract version from Xcode project (Release configuration) +PROJECT_FILE="Gaze.xcodeproj/project.pbxproj" +VERSION=$(grep -A 1 "MARKETING_VERSION" "$PROJECT_FILE" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1) +BUILD_NUMBER=$(grep -A 1 "CURRENT_PROJECT_VERSION" "$PROJECT_FILE" | grep -o '[0-9]\+' | head -1) + +# Fallback to manual values if extraction fails +if [ -z "$VERSION" ]; then + echo "⚠️ Could not extract MARKETING_VERSION from project, using fallback" + VERSION="0.2.0" +fi + +if [ -z "$BUILD_NUMBER" ]; then + echo "⚠️ Could not extract CURRENT_PROJECT_VERSION from project, using fallback" + BUILD_NUMBER="1" +fi + +echo "📦 Building Gaze v${VERSION} (build ${BUILD_NUMBER}) for distribution" + +RELEASES_DIR="./releases" +ARCHIVE_PATH="./build/Gaze.xcarchive" +EXPORT_PATH="./build/export" +APPCAST_OUTPUT="${RELEASES_DIR}/appcast.xml" +FEED_URL="https://freno.me/api/Gaze/appcast.xml" +DOWNLOAD_URL_PREFIX="https://freno.me/api/downloads/" +DMG_NAME="Gaze-${VERSION}.dmg" + # Validate required code signing and notarization credentials echo "🔐 Validating credentials..." MISSING_CREDS=()