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