feat: persistence
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
232
build_dmg
232
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=()
|
||||
|
||||
Reference in New Issue
Block a user