feat: persistence

This commit is contained in:
Michael Freno
2026-01-13 10:16:33 -05:00
parent f74390e4c3
commit 338e79c6c6
7 changed files with 363 additions and 61 deletions

View File

@@ -76,7 +76,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
settingsManager.save() settingsManager.saveImmediately()
timerEngine?.stop() timerEngine?.stop()
} }
@@ -98,7 +98,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@objc private func systemWillSleep() { @objc private func systemWillSleep() {
timerEngine?.handleSystemSleep() timerEngine?.handleSystemSleep()
settingsManager.save() settingsManager.saveImmediately()
} }
@objc private func systemDidWake() { @objc private func systemDidWake() {

View File

@@ -26,10 +26,6 @@ class SettingsManager: ObservableObject {
] ]
private init() { private init() {
#if DEBUG
// Clear settings on every development build
UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
#endif
self.settings = Self.loadSettings() self.settings = Self.loadSettings()
#if DEBUG #if DEBUG
validateTimerConfigMappings() validateTimerConfigMappings()
@@ -39,7 +35,7 @@ class SettingsManager: ObservableObject {
deinit { deinit {
saveCancellable?.cancel() saveCancellable?.cancel()
// Final save will be called by AppDelegate.applicationWillTerminate // Final save is called by AppDelegate.applicationWillTerminate
} }
private func setupDebouncedSave() { private func setupDebouncedSave() {
@@ -52,23 +48,72 @@ class SettingsManager: ObservableObject {
} }
private static func loadSettings() -> AppSettings { private static func loadSettings() -> AppSettings {
guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings"), guard let data = UserDefaults.standard.data(forKey: "gazeAppSettings") else {
let settings = try? JSONDecoder().decode(AppSettings.self, from: data) #if DEBUG
else { 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 .defaults
} }
return settings
} }
/// Saves settings to UserDefaults. /// Saves settings to UserDefaults.
/// Note: Settings are automatically saved via debouncing (500ms delay) when the `settings` property changes. /// 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. /// This method is also called explicitly during app termination to ensure final state is persisted.
func save() { func save() {
guard let data = try? JSONEncoder().encode(settings) else { do {
print("Failed to encode settings") let encoder = JSONEncoder()
return 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() { func load() {

View File

@@ -59,6 +59,13 @@ struct SettingsWindowView: View {
Divider() Divider()
HStack { HStack {
#if DEBUG
Button("Retrigger Onboarding") {
retriggerOnboarding()
}
.buttonStyle(.bordered)
#endif
Spacer() Spacer()
Button("Close") { Button("Close") {
@@ -87,6 +94,22 @@ struct SettingsWindowView: View {
window.close() 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 { #Preview {

View File

@@ -31,7 +31,7 @@ final class IntegrationTests: XCTestCase {
func testSettingsChangePropagateToTimerEngine() { func testSettingsChangePropagateToTimerEngine() {
timerEngine.start() timerEngine.start()
let originalInterval = timerEngine.timerStates[.lookAway]?.remainingSeconds let originalInterval = timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds
XCTAssertEqual(originalInterval, 20 * 60) XCTAssertEqual(originalInterval, 20 * 60)
let newConfig = TimerConfiguration(enabled: true, intervalSeconds: 10 * 60) let newConfig = TimerConfiguration(enabled: true, intervalSeconds: 10 * 60)
@@ -39,31 +39,31 @@ final class IntegrationTests: XCTestCase {
timerEngine.start() timerEngine.start()
let newInterval = timerEngine.timerStates[.lookAway]?.remainingSeconds let newInterval = timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds
XCTAssertEqual(newInterval, 10 * 60) XCTAssertEqual(newInterval, 10 * 60)
} }
func testDisablingTimerRemovesFromEngine() { func testDisablingTimerRemovesFromEngine() {
timerEngine.start() timerEngine.start()
XCTAssertNotNil(timerEngine.timerStates[.blink]) XCTAssertNotNil(timerEngine.timerStates[.builtIn(.blink)])
var config = TimerConfiguration(enabled: false, intervalSeconds: 5 * 60) var config = TimerConfiguration(enabled: false, intervalSeconds: 5 * 60)
settingsManager.updateTimerConfiguration(for: .blink, configuration: config) settingsManager.updateTimerConfiguration(for: .blink, configuration: config)
timerEngine.start() timerEngine.start()
XCTAssertNil(timerEngine.timerStates[.blink]) XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)])
} }
func testEnablingTimerAddsToEngine() { func testEnablingTimerAddsToEngine() {
settingsManager.settings.postureTimer.enabled = false settingsManager.settings.postureTimer.enabled = false
timerEngine.start() timerEngine.start()
XCTAssertNil(timerEngine.timerStates[.posture]) XCTAssertNil(timerEngine.timerStates[.builtIn(.posture)])
let config = TimerConfiguration(enabled: true, intervalSeconds: 30 * 60) let config = TimerConfiguration(enabled: true, intervalSeconds: 30 * 60)
settingsManager.updateTimerConfiguration(for: .posture, configuration: config) settingsManager.updateTimerConfiguration(for: .posture, configuration: config)
timerEngine.start() timerEngine.start()
XCTAssertNotNil(timerEngine.timerStates[.posture]) XCTAssertNotNil(timerEngine.timerStates[.builtIn(.posture)])
} }
func testSettingsPersistAcrossEngineLifecycle() { func testSettingsPersistAcrossEngineLifecycle() {
@@ -76,7 +76,7 @@ final class IntegrationTests: XCTestCase {
let newEngine = TimerEngine(settingsManager: settingsManager) let newEngine = TimerEngine(settingsManager: settingsManager)
newEngine.start() newEngine.start()
XCTAssertNil(newEngine.timerStates[.lookAway]) XCTAssertNil(newEngine.timerStates[.builtIn(.lookAway)])
} }
func testMultipleTimerConfigurationUpdates() { func testMultipleTimerConfigurationUpdates() {
@@ -94,9 +94,9 @@ final class IntegrationTests: XCTestCase {
timerEngine.start() timerEngine.start()
XCTAssertEqual(timerEngine.timerStates[.lookAway]?.remainingSeconds, 600) XCTAssertEqual(timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds, 600)
XCTAssertEqual(timerEngine.timerStates[.blink]?.remainingSeconds, 300) XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 300)
XCTAssertEqual(timerEngine.timerStates[.posture]?.remainingSeconds, 1800) XCTAssertEqual(timerEngine.timerStates[.builtIn(.posture)]?.remainingSeconds, 1800)
} }
func testResetToDefaultsAffectsTimerEngine() { func testResetToDefaultsAffectsTimerEngine() {
@@ -104,13 +104,13 @@ final class IntegrationTests: XCTestCase {
settingsManager.updateTimerConfiguration(for: .blink, configuration: config) settingsManager.updateTimerConfiguration(for: .blink, configuration: config)
timerEngine.start() timerEngine.start()
XCTAssertNil(timerEngine.timerStates[.blink]) XCTAssertNil(timerEngine.timerStates[.builtIn(.blink)])
settingsManager.resetToDefaults() settingsManager.resetToDefaults()
timerEngine.start() timerEngine.start()
XCTAssertNotNil(timerEngine.timerStates[.blink]) XCTAssertNotNil(timerEngine.timerStates[.builtIn(.blink)])
XCTAssertEqual(timerEngine.timerStates[.blink]?.remainingSeconds, 5 * 60) XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 5 * 60)
} }
func testTimerEngineRespectsDisabledTimers() { func testTimerEngineRespectsDisabledTimers() {
@@ -138,8 +138,8 @@ final class IntegrationTests: XCTestCase {
XCTAssertFalse(state.isPaused) XCTAssertFalse(state.isPaused)
} }
timerEngine.skipNext(type: .lookAway) timerEngine.skipNext(identifier: .builtIn(.lookAway))
XCTAssertEqual(timerEngine.timerStates[.lookAway]?.remainingSeconds, 20 * 60) XCTAssertEqual(timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds, 20 * 60)
timerEngine.stop() timerEngine.stop()
XCTAssertTrue(timerEngine.timerStates.isEmpty) XCTAssertTrue(timerEngine.timerStates.isEmpty)
@@ -148,7 +148,7 @@ final class IntegrationTests: XCTestCase {
func testReminderWorkflow() { func testReminderWorkflow() {
timerEngine.start() timerEngine.start()
timerEngine.triggerReminder(for: .lookAway) timerEngine.triggerReminder(for: .builtIn(.lookAway))
XCTAssertNotNil(timerEngine.activeReminder) XCTAssertNotNil(timerEngine.activeReminder)
for (_, state) in timerEngine.timerStates { for (_, state) in timerEngine.timerStates {

View File

@@ -40,19 +40,19 @@ final class ReminderEventTests: XCTestCase {
} }
} }
func testTypePropertyForLookAway() { func testIdentifierPropertyForLookAway() {
let event = ReminderEvent.lookAwayTriggered(countdownSeconds: 20) let event = ReminderEvent.lookAwayTriggered(countdownSeconds: 20)
XCTAssertEqual(event.type, .lookAway) XCTAssertEqual(event.identifier, .builtIn(.lookAway))
} }
func testTypePropertyForBlink() { func testIdentifierPropertyForBlink() {
let event = ReminderEvent.blinkTriggered let event = ReminderEvent.blinkTriggered
XCTAssertEqual(event.type, .blink) XCTAssertEqual(event.identifier, .builtIn(.blink))
} }
func testTypePropertyForPosture() { func testIdentifierPropertyForPosture() {
let event = ReminderEvent.postureTriggered let event = ReminderEvent.postureTriggered
XCTAssertEqual(event.type, .posture) XCTAssertEqual(event.identifier, .builtIn(.posture))
} }
func testEquality() { func testEquality() {
@@ -79,9 +79,9 @@ final class ReminderEventTests: XCTestCase {
XCTAssertNotEqual(event2, event3) XCTAssertNotEqual(event2, event3)
XCTAssertNotEqual(event1, event3) XCTAssertNotEqual(event1, event3)
XCTAssertEqual(event1.type, .lookAway) XCTAssertEqual(event1.identifier, .builtIn(.lookAway))
XCTAssertEqual(event2.type, .lookAway) XCTAssertEqual(event2.identifier, .builtIn(.lookAway))
XCTAssertEqual(event3.type, .lookAway) XCTAssertEqual(event3.identifier, .builtIn(.lookAway))
} }
func testNegativeCountdown() { func testNegativeCountdown() {
@@ -104,11 +104,13 @@ final class ReminderEventTests: XCTestCase {
for event in events { for event in events {
switch event { switch event {
case .lookAwayTriggered: case .lookAwayTriggered:
XCTAssertEqual(event.type, .lookAway) XCTAssertEqual(event.identifier, .builtIn(.lookAway))
case .blinkTriggered: case .blinkTriggered:
XCTAssertEqual(event.type, .blink) XCTAssertEqual(event.identifier, .builtIn(.blink))
case .postureTriggered: case .postureTriggered:
XCTAssertEqual(event.type, .posture) XCTAssertEqual(event.identifier, .builtIn(.posture))
case .userTimerTriggered:
XCTFail("Unexpected user timer in this test")
} }
} }
} }

View File

@@ -11,34 +11,34 @@ import XCTest
final class TimerStateTests: XCTestCase { final class TimerStateTests: XCTestCase {
func testInitialization() { 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) XCTAssertEqual(state.remainingSeconds, 1200)
XCTAssertFalse(state.isPaused) XCTAssertFalse(state.isPaused)
XCTAssertTrue(state.isActive) XCTAssertTrue(state.isActive)
} }
func testInitializationWithPausedState() { 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) XCTAssertEqual(state.remainingSeconds, 300)
XCTAssertTrue(state.isPaused) XCTAssertTrue(state.isPaused)
XCTAssertTrue(state.isActive) XCTAssertTrue(state.isActive)
} }
func testInitializationWithInactiveState() { 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) XCTAssertEqual(state.remainingSeconds, 1800)
XCTAssertFalse(state.isPaused) XCTAssertFalse(state.isPaused)
XCTAssertFalse(state.isActive) XCTAssertFalse(state.isActive)
} }
func testMutability() { func testMutability() {
var state = TimerState(type: .lookAway, intervalSeconds: 1200) var state = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200)
state.remainingSeconds = 600 state.remainingSeconds = 600
XCTAssertEqual(state.remainingSeconds, 600) XCTAssertEqual(state.remainingSeconds, 600)
@@ -51,10 +51,10 @@ final class TimerStateTests: XCTestCase {
} }
func testEquality() { func testEquality() {
let state1 = TimerState(type: .lookAway, intervalSeconds: 1200) let state1 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200)
let state2 = TimerState(type: .lookAway, intervalSeconds: 1200) let state2 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200)
let state3 = TimerState(type: .blink, intervalSeconds: 1200) let state3 = TimerState(identifier: .builtIn(.blink), intervalSeconds: 1200)
let state4 = TimerState(type: .lookAway, intervalSeconds: 600) let state4 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 600)
XCTAssertEqual(state1, state2) XCTAssertEqual(state1, state2)
XCTAssertNotEqual(state1, state3) XCTAssertNotEqual(state1, state3)
@@ -62,32 +62,32 @@ final class TimerStateTests: XCTestCase {
} }
func testEqualityWithDifferentPausedState() { func testEqualityWithDifferentPausedState() {
let state1 = TimerState(type: .lookAway, intervalSeconds: 1200, isPaused: false) let state1 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200, isPaused: false)
let state2 = TimerState(type: .lookAway, intervalSeconds: 1200, isPaused: true) let state2 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200, isPaused: true)
XCTAssertNotEqual(state1, state2) XCTAssertNotEqual(state1, state2)
} }
func testEqualityWithDifferentActiveState() { func testEqualityWithDifferentActiveState() {
let state1 = TimerState(type: .lookAway, intervalSeconds: 1200, isActive: true) let state1 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200, isActive: true)
let state2 = TimerState(type: .lookAway, intervalSeconds: 1200, isActive: false) let state2 = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 1200, isActive: false)
XCTAssertNotEqual(state1, state2) XCTAssertNotEqual(state1, state2)
} }
func testZeroRemainingSeconds() { func testZeroRemainingSeconds() {
let state = TimerState(type: .lookAway, intervalSeconds: 0) let state = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 0)
XCTAssertEqual(state.remainingSeconds, 0) XCTAssertEqual(state.remainingSeconds, 0)
} }
func testNegativeRemainingSeconds() { func testNegativeRemainingSeconds() {
var state = TimerState(type: .lookAway, intervalSeconds: 10) var state = TimerState(identifier: .builtIn(.lookAway), intervalSeconds: 10)
state.remainingSeconds = -5 state.remainingSeconds = -5
XCTAssertEqual(state.remainingSeconds, -5) XCTAssertEqual(state.remainingSeconds, -5)
} }
func testLargeIntervalSeconds() { func testLargeIntervalSeconds() {
let state = TimerState(type: .posture, intervalSeconds: 86400) let state = TimerState(identifier: .builtIn(.posture), intervalSeconds: 86400)
XCTAssertEqual(state.remainingSeconds, 86400) XCTAssertEqual(state.remainingSeconds, 86400)
} }
} }

232
build_dmg
View File

@@ -8,6 +8,238 @@ if [ -f .env ]; then
set +a set +a
fi 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 # Validate required code signing and notarization credentials
echo "🔐 Validating credentials..." echo "🔐 Validating credentials..."
MISSING_CREDS=() MISSING_CREDS=()