diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 0000000..db7ea4c
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -0,0 +1,296 @@
+# Gaze Release Deployment Process
+
+## Overview
+
+This document describes the complete process for building and deploying new releases of Gaze with Sparkle auto-update support.
+
+## Prerequisites
+
+- Xcode with Gaze project configured
+- `create-dmg` tool installed (`brew install create-dmg`)
+- Sparkle EdDSA signing keys in macOS Keychain (see Key Management below)
+- AWS credentials configured in `.env` file for S3 upload
+- Access to freno.me hosting infrastructure
+
+## Version Management
+
+Version numbers are managed in Xcode project settings:
+- **Marketing Version** (`MARKETING_VERSION`): User-facing version (e.g., "0.1.1")
+- **Build Number** (`CURRENT_PROJECT_VERSION`): Internal build number (e.g., "1")
+
+These must be incremented before each release and kept in sync with `build_dmg` script.
+
+## Release Checklist
+
+### 1. Prepare Release
+
+- [ ] Update version numbers in Xcode:
+ - Project Settings → General → Identity
+ - Set Marketing Version (e.g., "0.1.2")
+ - Increment Build Number (e.g., "2")
+- [ ] Update `VERSION` and `BUILD_NUMBER` in `build_dmg` script
+- [ ] Update CHANGELOG or release notes
+- [ ] Commit version changes: `git commit -am "Bump version to X.Y.Z"`
+- [ ] Create git tag: `git tag v0.1.2`
+
+### 2. Build Application
+
+```bash
+# Build the app in Xcode (Product → Archive → Export)
+# Or use the run script
+./run build
+
+# Verify the app runs correctly
+open Gaze.app
+```
+
+### 3. Create DMG and Appcast
+
+```bash
+# Run the build_dmg script
+./build_dmg
+
+# This will:
+# - Create versioned DMG file
+# - Generate appcast.xml with EdDSA signature
+# - Upload to S3 if AWS credentials are configured
+# - Display next steps
+```
+
+### 4. Verify Artifacts
+
+Check that the following files were created in `./releases/`:
+- `Gaze-X.Y.Z.dmg` - Installable disk image
+- `appcast.xml` - Update feed with signature
+- `Gaze-X.Y.Z.delta` (optional) - Delta update from previous version
+
+### 5. Upload to Hosting (if not using S3 auto-upload)
+
+**DMG File:**
+```bash
+# Upload to: https://freno.me/downloads/
+scp ./releases/Gaze-X.Y.Z.dmg your-server:/path/to/downloads/
+```
+
+**Appcast File:**
+```bash
+# Upload to: https://freno.me/api/Gaze/
+scp ./releases/appcast.xml your-server:/path/to/api/Gaze/
+```
+
+### 6. Verify Deployment
+
+Test that files are accessible via HTTPS:
+
+```bash
+# Test appcast accessibility
+curl -I https://freno.me/api/Gaze/appcast.xml
+# Should return: HTTP/2 200, content-type: application/xml
+
+# Test DMG accessibility
+curl -I https://freno.me/downloads/Gaze-X.Y.Z.dmg
+# Should return: HTTP/2 200, content-type: application/octet-stream
+
+# Validate appcast XML structure
+curl https://freno.me/api/Gaze/appcast.xml | xmllint --format -
+```
+
+### 7. Test Update Flow
+
+**Manual Testing:**
+1. Install previous version of Gaze
+2. Launch app and check Settings → General → Software Updates
+3. Click "Check for Updates Now"
+4. Verify update notification appears
+5. Complete update installation
+6. Verify new version launches correctly
+
+**Automatic Update Testing:**
+1. Set `SUScheduledCheckInterval` to a low value (e.g., 60 seconds) for testing
+2. Install previous version
+3. Wait for automatic check
+4. Verify update notification appears
+
+### 8. Finalize Release
+
+- [ ] Push git tag: `git push origin v0.1.2`
+- [ ] Create GitHub release (optional)
+- [ ] Announce release to users
+- [ ] Monitor for update errors in the first 24 hours
+
+## Hosting Configuration
+
+### Current Setup
+
+- **Appcast URL:** `https://freno.me/api/Gaze/appcast.xml`
+- **Download URL:** `https://freno.me/downloads/Gaze-{VERSION}.dmg`
+- **Hosting:** AWS S3 with freno.me domain
+- **SSL:** HTTPS enabled (required by App Transport Security)
+
+### AWS S3 Configuration
+
+Create a `.env` file in the project root with:
+
+```bash
+AWS_ACCESS_KEY_ID=your_access_key
+AWS_SECRET_ACCESS_KEY=your_secret_key
+AWS_BUCKET_NAME=your_bucket_name
+AWS_REGION=us-east-1
+```
+
+**Note:** `.env` is gitignored to protect credentials.
+
+### S3 Bucket Structure
+
+```
+your-bucket/
+├── downloads/
+│ ├── Gaze-0.1.1.dmg
+│ ├── Gaze-0.1.2.dmg
+│ └── ...
+└── api/
+ └── Gaze/
+ └── appcast.xml
+```
+
+## Key Management
+
+### EdDSA Signing Keys
+
+**Location:**
+- **Public Key:** In `Gaze/Info.plist` as `SUPublicEDKey`
+- **Private Key:** In macOS Keychain as "Sparkle EdDSA Private Key"
+- **Backup:** `~/sparkle_private_key_backup.pem` (keep secure!)
+
+**Current Public Key:**
+```
+Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM=
+```
+
+**Security:**
+- Never commit private key to version control
+- Keep backup in secure location (password manager, encrypted drive)
+- Private key is required to sign all future updates
+
+### Regenerating Keys (Emergency Only)
+
+If private key is lost, you must:
+1. Generate new key pair: `./generate_keys`
+2. Update `SUPublicEDKey` in Info.plist
+3. Release new version with new public key
+4. Previous versions won't be able to update (users must manually install)
+
+## Troubleshooting
+
+### Appcast Generation Fails
+
+**Problem:** `generate_appcast` tool not found
+
+**Solution:**
+```bash
+# Build the app first to generate Sparkle tools
+xcodebuild -project Gaze.xcodeproj -scheme Gaze -configuration Release
+
+# Find Sparkle tools
+find ~/Library/Developer/Xcode/DerivedData/Gaze-* -name "generate_appcast"
+```
+
+### Update Check Fails in App
+
+**Problem:** No updates found or connection error
+
+**Diagnostics:**
+```bash
+# Check Console.app for Sparkle logs
+# Filter by process: Gaze
+# Look for:
+# - "Downloading appcast..."
+# - "Appcast downloaded successfully"
+# - Connection errors
+# - Signature verification errors
+```
+
+**Common Issues:**
+- Appcast URL not accessible (check HTTPS)
+- Signature mismatch (wrong private key used)
+- XML malformed (validate with xmllint)
+- Version number not higher than current version
+
+### DMG Not Downloading
+
+**Problem:** Update found but download fails
+
+**Check:**
+- DMG URL is correct in appcast
+- DMG file is accessible via HTTPS
+- File size in appcast matches actual DMG size
+- No CORS issues (check browser console)
+
+## Delta Updates
+
+Sparkle automatically generates delta updates when multiple versions exist in `./releases/`:
+
+```bash
+# Keep previous versions for delta generation
+releases/
+├── Gaze-0.1.1.dmg
+├── Gaze-0.1.2.dmg
+├── Gaze-0.1.1-to-0.1.2.delta # Generated automatically
+└── appcast.xml
+```
+
+**Benefits:**
+- Much smaller downloads (MB vs GB)
+- Faster updates for users
+- Generated automatically by `generate_appcast`
+
+**Note:** First-time users still download full DMG.
+
+## Testing with Local Appcast
+
+For testing without deploying:
+
+1. Modify Info.plist temporarily:
+```xml
+SUFeedURL
+file:///Users/mike/Code/Gaze/releases/appcast.xml
+```
+
+2. Build and run app
+3. Check for updates
+4. Revert Info.plist before committing
+
+## Release Notes
+
+Release notes are embedded in appcast XML as CDATA:
+
+```xml
+What's New in Version X.Y.Z
+
+ - Feature 1
+ - Bug fix 2
+
+]]>
+```
+
+**Tips:**
+- Use simple HTML (h2, ul, li, p, strong, em)
+- No external resources (images, CSS, JS)
+- Keep concise and user-focused
+- Highlight breaking changes
+
+## References
+
+- [Sparkle Documentation](https://sparkle-project.org/documentation/)
+- [Publishing Updates](https://sparkle-project.org/documentation/publishing/)
+- [Sandboxed Apps](https://sparkle-project.org/documentation/sandboxing/)
+- [Gaze Repository](https://github.com/YOUR_USERNAME/Gaze)
+
+## Support
+
+For issues with deployment:
+1. Check Console.app for Sparkle errors
+2. Verify appcast validation with xmllint
+3. Test with file:// URL first
+4. Check AWS S3 permissions and CORS
diff --git a/Gaze.xcodeproj/project.pbxproj b/Gaze.xcodeproj/project.pbxproj
index 2c0b1ff..8fecc1e 100644
--- a/Gaze.xcodeproj/project.pbxproj
+++ b/Gaze.xcodeproj/project.pbxproj
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
+ 275915902F132B0000D0E60D /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B22F10B20000E00DBC /* Sparkle */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -57,6 +58,7 @@
buildActionMask = 2147483647;
files = (
275915892F132A9200D0E60D /* Lottie in Frameworks */,
+ 275915902F132B0000D0E60D /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -118,6 +120,7 @@
name = Gaze;
packageProductDependencies = (
27AE10B12F10B1FC00E00DBC /* Lottie */,
+ 27AE10B22F10B20000E00DBC /* Sparkle */,
);
productName = Gaze;
productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */;
@@ -203,6 +206,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */,
+ 27AE10B32F10B21000E00DBC /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */;
@@ -404,6 +408,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
+ CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -411,8 +416,8 @@
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
- ENABLE_USER_SELECTED_FILES = readonly;
- GENERATE_INFOPLIST_FILE = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Gaze/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
@@ -439,6 +444,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
+ CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -446,8 +452,8 @@
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
- ENABLE_USER_SELECTED_FILES = readonly;
- GENERATE_INFOPLIST_FILE = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Gaze/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
@@ -598,6 +604,14 @@
minimumVersion = 4.6.0;
};
};
+ 27AE10B32F10B21000E00DBC /* XCRemoteSwiftPackageReference "Sparkle" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/sparkle-project/Sparkle";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.0.0;
+ };
+ };
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -606,6 +620,11 @@
package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */;
productName = Lottie;
};
+ 27AE10B22F10B20000E00DBC /* Sparkle */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 27AE10B32F10B21000E00DBC /* XCRemoteSwiftPackageReference "Sparkle" */;
+ productName = Sparkle;
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */;
diff --git a/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index de23afb..b3cf5c5 100644
--- a/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Gaze.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "6b3386dc9ff1f3a74f1534de9c41d47137eae0901cfe819ed442f1b241549359",
+ "originHash" : "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560",
"pins" : [
{
"identity" : "lottie-spm",
@@ -9,6 +9,15 @@
"revision" : "69faaefa7721fba9e434a52c16adf4329c9084db",
"version" : "4.6.0"
}
+ },
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
+ "version" : "2.8.1"
+ }
}
],
"version" : 3
diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift
index 5060844..cc0390c 100644
--- a/Gaze/AppDelegate.swift
+++ b/Gaze/AppDelegate.swift
@@ -13,6 +13,7 @@ import Combine
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@Published var timerEngine: TimerEngine?
private var settingsManager: SettingsManager?
+ private var updateManager: UpdateManager?
private var reminderWindowController: NSWindowController?
private var settingsWindowController: NSWindowController?
private var cancellables = Set()
@@ -26,6 +27,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
settingsManager = SettingsManager.shared
timerEngine = TimerEngine(settingsManager: settingsManager!)
+ // Initialize update manager after onboarding is complete
+ if settingsManager!.settings.hasCompletedOnboarding {
+ updateManager = UpdateManager.shared
+ }
+
// Detect App Store version asynchronously at launch
Task {
await settingsManager?.detectAppStoreVersion()
@@ -42,6 +48,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func onboardingCompleted() {
startTimers()
+
+ // Start update checks after onboarding
+ if updateManager == nil {
+ updateManager = UpdateManager.shared
+ }
}
private func startTimers() {
diff --git a/Gaze/Gaze.entitlements b/Gaze/Gaze.entitlements
new file mode 100644
index 0000000..4a6c209
--- /dev/null
+++ b/Gaze/Gaze.entitlements
@@ -0,0 +1,15 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ com.apple.security.temporary-exception.mach-lookup.global-name
+
+ $(PRODUCT_BUNDLE_IDENTIFIER)-spks
+ $(PRODUCT_BUNDLE_IDENTIFIER)-spki
+
+
+
diff --git a/Gaze/Info.plist b/Gaze/Info.plist
index b2c1534..1bda10b 100644
--- a/Gaze/Info.plist
+++ b/Gaze/Info.plist
@@ -16,5 +16,15 @@
$(MARKETING_VERSION)
NSHumanReadableCopyright
Copyright © 2026 Mike Freno. All rights reserved.
+ SUPublicEDKey
+ Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM=
+ SUFeedURL
+ https://freno.me/api/Gaze/appcast.xml
+ SUEnableAutomaticChecks
+
+ SUScheduledCheckInterval
+ 86400
+ SUEnableInstallerLauncherService
+
diff --git a/Gaze/Services/AnimationService.swift b/Gaze/Services/AnimationService.swift
deleted file mode 100644
index 8bc8fbb..0000000
--- a/Gaze/Services/AnimationService.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-//
-// AnimationService.swift
-// Gaze
-//
-// Created by Mike Freno on 1/9/26.
-//
-
-import Foundation
-
-@MainActor
-class AnimationService {
- static let shared = AnimationService()
-
- private init() {}
-
- struct RemoteAnimation: Codable {
- let name: String
- let version: String
- let date: String // ISO 8601 formatted date string
-
- enum CodingKeys: String, CodingKey {
- case name, version, date
- }
- }
-
- struct RemoteAnimationsResponse: Codable {
- let animations: [RemoteAnimation]
- }
-
- // MARK: - Public Methods
-
- func fetchRemoteAnimations() async throws -> [RemoteAnimation] {
- guard let url = URL(string: "https://freno.me/api/Gaze/animations") else {
- throw URLError(.badURL)
- }
-
- let (data, response) = try await URLSession.shared.data(from: url)
-
- guard let httpResponse = response as? HTTPURLResponse else {
- throw URLError(.badServerResponse)
- }
-
- guard 200...299 ~= httpResponse.statusCode else {
- throw URLError(.badServerResponse)
- }
-
- do {
- let decoder = JSONDecoder()
- let remoteAnimations = try decoder.decode(RemoteAnimationsResponse.self, from: data)
- return remoteAnimations.animations
- } catch {
- throw error
- }
- }
-
- func updateLocalAnimationsIfNeeded(remoteAnimations: [RemoteAnimation]) async throws {
- // For now, just validate the API response structure.
- // In a real implementation, this would:
- // 1. Compare dates of local vs remote animations
- // 2. Update local files if newer versions exist
- // 3. Tag local files with date fields in ISO 8601 format
-
- for animation in remoteAnimations {
- print("Remote animation: \(animation.name) - \(animation.version) - \(animation.date)")
- }
- }
-}
-
diff --git a/Gaze/Services/UpdateManager.swift b/Gaze/Services/UpdateManager.swift
new file mode 100644
index 0000000..1d2d55f
--- /dev/null
+++ b/Gaze/Services/UpdateManager.swift
@@ -0,0 +1,73 @@
+//
+// UpdateManager.swift
+// Gaze
+//
+// Created by Mike Freno on 1/11/26.
+//
+
+import Combine
+import Foundation
+import Sparkle
+
+@MainActor
+class UpdateManager: NSObject, ObservableObject {
+ static let shared = UpdateManager()
+
+ private var updaterController: SPUStandardUpdaterController?
+ private var automaticallyChecksObservation: NSKeyValueObservation?
+ private var lastCheckDateObservation: NSKeyValueObservation?
+
+ @Published var automaticallyChecksForUpdates = false
+ @Published var lastUpdateCheckDate: Date?
+
+ private override init() {
+ super.init()
+ setupUpdater()
+ }
+
+ private func setupUpdater() {
+ updaterController = SPUStandardUpdaterController(
+ startingUpdater: true,
+ updaterDelegate: nil,
+ userDriverDelegate: nil
+ )
+
+ guard let updater = updaterController?.updater else {
+ print("Failed to initialize Sparkle updater")
+ return
+ }
+
+ automaticallyChecksObservation = updater.observe(
+ \.automaticallyChecksForUpdates,
+ options: [.new, .initial]
+ ) { [weak self] _, change in
+ guard let self = self, let newValue = change.newValue else { return }
+ Task { @MainActor in
+ self.automaticallyChecksForUpdates = newValue
+ }
+ }
+
+ lastCheckDateObservation = updater.observe(
+ \.lastUpdateCheckDate,
+ options: [.new, .initial]
+ ) { [weak self] _, change in
+ guard let self = self else { return }
+ Task { @MainActor in
+ self.lastUpdateCheckDate = change.newValue ?? nil
+ }
+ }
+ }
+
+ func checkForUpdates() {
+ guard let updater = updaterController?.updater else {
+ print("Updater not initialized")
+ return
+ }
+ updater.checkForUpdates()
+ }
+
+ deinit {
+ automaticallyChecksObservation?.invalidate()
+ lastCheckDateObservation?.invalidate()
+ }
+}
diff --git a/Gaze/Views/Setup/GeneralSetupView.swift b/Gaze/Views/Setup/GeneralSetupView.swift
index 44de1d9..0e5ef19 100644
--- a/Gaze/Views/Setup/GeneralSetupView.swift
+++ b/Gaze/Views/Setup/GeneralSetupView.swift
@@ -11,6 +11,7 @@ struct GeneralSetupView: View {
@Binding var launchAtLogin: Bool
@Binding var subtleReminderSize: ReminderSize
@Binding var isAppStoreVersion: Bool
+ @ObservedObject var updateManager = UpdateManager.shared
var isOnboarding: Bool = true
var body: some View {
@@ -52,6 +53,34 @@ struct GeneralSetupView: View {
.padding()
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
+ // Software Updates Section
+ if !isAppStoreVersion {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Software Updates")
+ .font(.headline)
+
+ Toggle("Automatically check for updates", isOn: $updateManager.automaticallyChecksForUpdates)
+ .help("Check for new versions of Gaze in the background")
+
+ if let lastCheck = updateManager.lastUpdateCheckDate {
+ Text("Last checked: \(lastCheck, style: .relative)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ } else {
+ Text("Never checked for updates")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Button("Check for Updates Now") {
+ updateManager.checkForUpdates()
+ }
+ .buttonStyle(.bordered)
+ }
+ .padding()
+ .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
+ }
+
VStack(alignment: .leading, spacing: 12) {
Text("Subtle Reminder Size")
.font(.headline)
diff --git a/GazeTests/Services/AnimationServiceTests.swift b/GazeTests/Services/AnimationServiceTests.swift
deleted file mode 100644
index 872f882..0000000
--- a/GazeTests/Services/AnimationServiceTests.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-// AnimationServiceTests.swift
-// GazeTests
-//
-// Created by Mike Freno on 1/9/26.
-//
-
-@testable import Gaze
-
-final class AnimationServiceTests {
- // Test cases can be added here as needed
-
- func testRemoteAnimationDecoding() {
- // This will be implemented when we have a testable implementation
- }
-}
-
diff --git a/GazeTests/Services/UpdateManagerTests.swift b/GazeTests/Services/UpdateManagerTests.swift
new file mode 100644
index 0000000..2a6effb
--- /dev/null
+++ b/GazeTests/Services/UpdateManagerTests.swift
@@ -0,0 +1,148 @@
+//
+// UpdateManagerTests.swift
+// GazeTests
+//
+// Created by Mike Freno on 1/11/26.
+//
+
+import XCTest
+import Combine
+@testable import Gaze
+
+@MainActor
+final class UpdateManagerTests: XCTestCase {
+
+ var sut: UpdateManager!
+ var cancellables: Set!
+
+ override func setUp() async throws {
+ try await super.setUp()
+ sut = UpdateManager.shared
+ cancellables = []
+ }
+
+ override func tearDown() async throws {
+ cancellables = nil
+ sut = nil
+ try await super.tearDown()
+ }
+
+ // MARK: - Singleton Tests
+
+ func testSingletonInstance() {
+ // Arrange & Act
+ let instance1 = UpdateManager.shared
+ let instance2 = UpdateManager.shared
+
+ // Assert
+ XCTAssertTrue(instance1 === instance2, "UpdateManager should be a singleton")
+ }
+
+ // MARK: - Initialization Tests
+
+ func testInitialization() {
+ // Assert
+ XCTAssertNotNil(sut, "UpdateManager should initialize")
+ }
+
+ func testInitialObservableProperties() {
+ // Assert - Check that properties are initialized (values may vary)
+ // automaticallyChecksForUpdates could be true or false based on Info.plist
+ // Just verify it's a valid boolean
+ XCTAssertTrue(
+ sut.automaticallyChecksForUpdates == true || sut.automaticallyChecksForUpdates == false,
+ "automaticallyChecksForUpdates should be a valid boolean"
+ )
+ }
+
+ // MARK: - Observable Property Tests
+
+ func testAutomaticallyChecksForUpdatesIsPublished() async throws {
+ // Arrange
+ let expectation = expectation(description: "automaticallyChecksForUpdates property change observed")
+ var observedValue: Bool?
+
+ // Act - Subscribe to published property
+ sut.$automaticallyChecksForUpdates
+ .dropFirst() // Skip initial value
+ .sink { newValue in
+ observedValue = newValue
+ expectation.fulfill()
+ }
+ .store(in: &cancellables)
+
+ // Toggle the value (toggle to ensure change regardless of initial value)
+ let originalValue = sut.automaticallyChecksForUpdates
+ sut.automaticallyChecksForUpdates = !originalValue
+
+ // Assert
+ await fulfillment(of: [expectation], timeout: 2.0)
+ XCTAssertNotNil(observedValue, "Should observe a value change")
+ XCTAssertEqual(observedValue, !originalValue, "Observed value should match the new value")
+ }
+
+ func testLastUpdateCheckDateIsPublished() async throws {
+ // Arrange
+ let expectation = expectation(description: "lastUpdateCheckDate property change observed")
+ var observedValue: Date?
+ var changeDetected = false
+
+ // Act - Subscribe to published property
+ sut.$lastUpdateCheckDate
+ .dropFirst() // Skip initial value
+ .sink { newValue in
+ observedValue = newValue
+ changeDetected = true
+ expectation.fulfill()
+ }
+ .store(in: &cancellables)
+
+ // Set a new date
+ let testDate = Date(timeIntervalSince1970: 1000000)
+ sut.lastUpdateCheckDate = testDate
+
+ // Assert
+ await fulfillment(of: [expectation], timeout: 2.0)
+ XCTAssertTrue(changeDetected, "Should detect property change")
+ XCTAssertEqual(observedValue, testDate, "Observed date should match the set date")
+ }
+
+ // MARK: - Update Check Tests
+
+ func testCheckForUpdatesDoesNotCrash() {
+ // Arrange - method should be callable without crash
+
+ // Act & Assert
+ XCTAssertNoThrow(
+ sut.checkForUpdates(),
+ "checkForUpdates should not throw or crash"
+ )
+ }
+
+ func testCheckForUpdatesIsCallable() {
+ // Arrange
+ var didComplete = false
+
+ // Act
+ sut.checkForUpdates()
+ didComplete = true
+
+ // Assert
+ XCTAssertTrue(didComplete, "checkForUpdates should complete synchronously")
+ }
+
+ // MARK: - Integration Tests
+
+ func testCheckForUpdatesIsAvailableAfterInitialization() {
+ // Arrange & Act
+ // checkForUpdates should be available immediately after initialization
+ var didExecute = false
+
+ // Act - Call the method
+ sut.checkForUpdates()
+ didExecute = true
+
+ // Assert
+ XCTAssertTrue(didExecute, "checkForUpdates should be callable after initialization")
+ }
+}
diff --git a/build_dmg b/build_dmg
index c53733b..97441bd 100755
--- a/build_dmg
+++ b/build_dmg
@@ -6,8 +6,28 @@ if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
-# Create the DMG
-rm -f Gaze.dmg
+# Configuration
+VERSION="0.1.1" # Should match MARKETING_VERSION in project
+BUILD_NUMBER="1" # Should match CURRENT_PROJECT_VERSION in project
+RELEASES_DIR="./releases"
+APPCAST_OUTPUT="${RELEASES_DIR}/appcast.xml"
+FEED_URL="https://freno.me/api/Gaze/appcast.xml"
+DMG_NAME="Gaze-${VERSION}.dmg"
+
+# Find Sparkle generate_appcast tool
+SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData/Gaze-* -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
+if [ -z "$SPARKLE_BIN" ]; then
+ echo "⚠️ Warning: Sparkle bin directory not found"
+ echo "Appcast generation will be skipped"
+ SPARKLE_BIN=""
+fi
+
+# Create releases directory
+mkdir -p "$RELEASES_DIR"
+
+# Remove old DMG if exists
+rm -f "$DMG_NAME"
+
echo "Creating DMG..."
create-dmg \
--volname "Gaze Installer" \
@@ -18,22 +38,82 @@ create-dmg \
--background "./dmg_background.png" \
--icon "Gaze.app" 160 200 \
--app-drop-link 440 200 \
- "Gaze.dmg" \
+ "$DMG_NAME" \
"./Gaze.app"
+# Copy DMG to releases directory
+echo "Copying DMG to releases directory..."
+cp "$DMG_NAME" "$RELEASES_DIR/"
+
+# Generate appcast if Sparkle tools are available
+if [ -n "$SPARKLE_BIN" ] && [ -d "$SPARKLE_BIN" ]; then
+ echo ""
+ echo "Generating appcast..."
+
+ # Generate appcast with download URL prefix
+ "$SPARKLE_BIN/generate_appcast" \
+ --download-url-prefix "https://freno.me/downloads/" \
+ "$RELEASES_DIR"
+
+ # Verify appcast was generated
+ if [ -f "$APPCAST_OUTPUT" ]; then
+ echo "✅ Appcast generated successfully"
+ echo "📋 Appcast location: $APPCAST_OUTPUT"
+
+ # Show signature verification
+ if grep -q "edSignature" "$APPCAST_OUTPUT"; then
+ echo "✅ EdDSA signature verified in appcast"
+ else
+ echo "⚠️ Warning: No EdDSA signature found in appcast"
+ fi
+ else
+ echo "❌ Failed to generate appcast"
+ fi
+else
+ echo ""
+ echo "⚠️ Skipping appcast generation (Sparkle tools not found)"
+ echo "To generate appcast manually, run:"
+ echo " ./generate_appcast --download-url-prefix 'https://freno.me/downloads/' '$RELEASES_DIR'"
+fi
+
# Upload to AWS S3 if environment variables are set
if [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ] && [ -n "$AWS_BUCKET_NAME" ] && [ -n "$AWS_REGION" ]; then
- echo "Uploading Gaze.dmg to S3 bucket: $AWS_BUCKET_NAME..."
+ echo ""
+ echo "Uploading to S3 bucket: $AWS_BUCKET_NAME..."
# Export AWS credentials for aws-cli
export AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="$AWS_REGION"
- # Upload to S3
- aws s3 cp Gaze.dmg "s3://$AWS_BUCKET_NAME/Gaze.dmg" --region "$AWS_REGION"
+ # Upload DMG to S3
+ aws s3 cp "$RELEASES_DIR/$DMG_NAME" "s3://$AWS_BUCKET_NAME/downloads/$DMG_NAME" --region "$AWS_REGION"
- echo "Upload complete! DMG available at: s3://$AWS_BUCKET_NAME/Gaze.dmg"
+ # Upload appcast if it exists
+ if [ -f "$APPCAST_OUTPUT" ]; then
+ aws s3 cp "$APPCAST_OUTPUT" "s3://$AWS_BUCKET_NAME/api/Gaze/appcast.xml" --region "$AWS_REGION"
+ echo "✅ Appcast uploaded to S3"
+ fi
+
+ echo "✅ Upload complete!"
+ echo " DMG: s3://$AWS_BUCKET_NAME/downloads/$DMG_NAME"
+ echo " Appcast: s3://$AWS_BUCKET_NAME/api/Gaze/appcast.xml"
else
- echo "Skipping S3 upload - AWS credentials not found in .env"
+ echo ""
+ echo "⚠️ Skipping S3 upload - AWS credentials not found in .env"
fi
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "Release artifacts created:"
+echo " 📦 DMG: $RELEASES_DIR/$DMG_NAME"
+if [ -f "$APPCAST_OUTPUT" ]; then
+ echo " 📋 Appcast: $APPCAST_OUTPUT"
+fi
+echo ""
+echo "Next steps:"
+echo " 1. Upload DMG to: https://freno.me/downloads/$DMG_NAME"
+echo " 2. Upload appcast to: $FEED_URL"
+echo " 3. Verify appcast is accessible and valid"
+echo " 4. Test update from previous version"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
diff --git a/freno-dev b/freno-dev
new file mode 120000
index 0000000..4bbf958
--- /dev/null
+++ b/freno-dev
@@ -0,0 +1 @@
+/Users/mike/Code/freno-dev
\ No newline at end of file
diff --git a/releases/appcast-template.xml b/releases/appcast-template.xml
new file mode 100644
index 0000000..d3f1d8a
--- /dev/null
+++ b/releases/appcast-template.xml
@@ -0,0 +1,30 @@
+
+
+
+ Gaze Updates
+ Most recent updates to Gaze
+ en
+ -
+ Version 0.1.1
+ What's New in Gaze 0.1.1
+
+ - Initial release with auto-update support
+ - Blink reminders to reduce eye strain
+ - 20-20-20 rule break reminders
+ - Customizable reminder intervals
+ - Posture check reminders
+
+ ]]>
+ Sat, 11 Jan 2026 12:00:00 +0000
+ 1
+ 0.1.1
+ 14.6
+
+
+
+
diff --git a/releases/validate_hosting.sh b/releases/validate_hosting.sh
new file mode 100755
index 0000000..2f7caa3
--- /dev/null
+++ b/releases/validate_hosting.sh
@@ -0,0 +1,216 @@
+#!/bin/bash
+
+# Gaze Appcast Hosting Validation Script
+# Tests that all hosting infrastructure is properly configured
+
+set -e
+
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "Gaze Appcast Hosting Validation"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+# Configuration
+APPCAST_URL="https://freno.me/api/Gaze/appcast.xml"
+DMG_URL="https://freno.me/downloads/Gaze-0.1.1.dmg"
+
+# Test 1: Appcast Accessibility
+echo "📋 Test 1: Appcast Accessibility"
+echo "Testing: $APPCAST_URL"
+APPCAST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$APPCAST_URL")
+
+if [ "$APPCAST_STATUS" = "200" ]; then
+ echo "✅ Appcast is accessible (HTTP 200)"
+
+ # Test content type
+ CONTENT_TYPE=$(curl -s -I "$APPCAST_URL" | grep -i "content-type" | awk '{print $2}' | tr -d '\r')
+ if [[ "$CONTENT_TYPE" == *"xml"* ]] || [[ "$CONTENT_TYPE" == *"text"* ]]; then
+ echo "✅ Content-Type is correct: $CONTENT_TYPE"
+ else
+ echo "⚠️ Warning: Content-Type might be incorrect: $CONTENT_TYPE"
+ echo " Expected: application/xml or text/xml"
+ fi
+
+ # Test HTTPS
+ if [[ "$APPCAST_URL" == https://* ]]; then
+ echo "✅ Using HTTPS (required by App Transport Security)"
+ else
+ echo "❌ NOT using HTTPS - this will fail on macOS!"
+ fi
+
+ # Validate XML structure
+ echo ""
+ echo "Validating XML structure..."
+ APPCAST_CONTENT=$(curl -s "$APPCAST_URL")
+
+ if echo "$APPCAST_CONTENT" | xmllint --noout - 2>/dev/null; then
+ echo "✅ XML is well-formed"
+ else
+ echo "❌ XML is malformed"
+ exit 1
+ fi
+
+ # Check for required Sparkle elements
+ if echo "$APPCAST_CONTENT" | grep -q "sparkle:version"; then
+ echo "✅ Contains sparkle:version"
+ else
+ echo "❌ Missing sparkle:version"
+ fi
+
+ if echo "$APPCAST_CONTENT" | grep -q "sparkle:shortVersionString"; then
+ echo "✅ Contains sparkle:shortVersionString"
+ else
+ echo "❌ Missing sparkle:shortVersionString"
+ fi
+
+ if echo "$APPCAST_CONTENT" | grep -q "sparkle:edSignature"; then
+ echo "✅ Contains sparkle:edSignature"
+ else
+ echo "⚠️ Warning: Missing sparkle:edSignature (required for updates)"
+ fi
+
+elif [ "$APPCAST_STATUS" = "404" ]; then
+ echo "⚠️ Appcast not found (HTTP 404)"
+ echo " This is expected before first deployment"
+ echo " Run ./build_dmg and upload appcast.xml to proceed"
+else
+ echo "❌ Unexpected status: HTTP $APPCAST_STATUS"
+ exit 1
+fi
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+# Test 2: DMG Accessibility
+echo "📦 Test 2: DMG Accessibility"
+echo "Testing: $DMG_URL"
+DMG_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$DMG_URL")
+
+if [ "$DMG_STATUS" = "200" ]; then
+ echo "✅ DMG is accessible (HTTP 200)"
+
+ # Get file size
+ DMG_SIZE=$(curl -s -I "$DMG_URL" | grep -i "content-length" | awk '{print $2}' | tr -d '\r')
+ if [ -n "$DMG_SIZE" ]; then
+ DMG_SIZE_MB=$(echo "scale=2; $DMG_SIZE / 1024 / 1024" | bc)
+ echo "✅ DMG size: ${DMG_SIZE_MB} MB (${DMG_SIZE} bytes)"
+ fi
+
+ # Test HTTPS
+ if [[ "$DMG_URL" == https://* ]]; then
+ echo "✅ Using HTTPS"
+ else
+ echo "⚠️ Not using HTTPS"
+ fi
+
+elif [ "$DMG_STATUS" = "404" ]; then
+ echo "⚠️ DMG not found (HTTP 404)"
+ echo " This is expected before first release"
+ echo " Run ./build_dmg and upload DMG to proceed"
+else
+ echo "❌ Unexpected status: HTTP $DMG_STATUS"
+fi
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+# Test 3: Local Infrastructure
+echo "🔧 Test 3: Local Infrastructure"
+
+# Check releases directory
+if [ -d "./releases" ]; then
+ echo "✅ Releases directory exists"
+
+ if [ -f "./releases/appcast-template.xml" ]; then
+ echo "✅ Appcast template exists"
+ else
+ echo "⚠️ Appcast template not found"
+ fi
+else
+ echo "❌ Releases directory not found"
+ exit 1
+fi
+
+# Check build_dmg script
+if [ -f "./build_dmg" ]; then
+ echo "✅ build_dmg script exists"
+
+ if [ -x "./build_dmg" ]; then
+ echo "✅ build_dmg is executable"
+ else
+ echo "⚠️ build_dmg is not executable (run: chmod +x ./build_dmg)"
+ fi
+else
+ echo "❌ build_dmg script not found"
+ exit 1
+fi
+
+# Check for Sparkle keys (Keychain or backup file)
+KEY_IN_KEYCHAIN=false
+KEY_IN_FILE=false
+
+if security find-generic-password -l "Sparkle EdDSA Private Key" >/dev/null 2>&1; then
+ KEY_IN_KEYCHAIN=true
+fi
+
+if [ -f "$HOME/sparkle_private_key_backup.pem" ]; then
+ KEY_IN_FILE=true
+fi
+
+if [ "$KEY_IN_KEYCHAIN" = true ]; then
+ echo "✅ Sparkle EdDSA private key found in Keychain"
+elif [ "$KEY_IN_FILE" = true ]; then
+ echo "✅ Sparkle EdDSA private key found in backup file"
+ echo " (~/sparkle_private_key_backup.pem)"
+else
+ echo "❌ Sparkle EdDSA private key not found"
+ echo " Run: ./generate_keys (from Sparkle tools)"
+fi
+
+# Check Info.plist configuration
+if [ -f "./Gaze/Info.plist" ]; then
+ echo "✅ Info.plist exists"
+
+ if grep -q "SUFeedURL" "./Gaze/Info.plist"; then
+ FEED_URL=$(grep -A 1 "SUFeedURL" "./Gaze/Info.plist" | tail -1 | sed 's/.*\(.*\)<\/string>.*/\1/' | tr -d '\t ')
+ echo "✅ SUFeedURL configured: $FEED_URL"
+ else
+ echo "❌ SUFeedURL not found in Info.plist"
+ fi
+
+ if grep -q "SUPublicEDKey" "./Gaze/Info.plist"; then
+ echo "✅ SUPublicEDKey configured"
+ else
+ echo "❌ SUPublicEDKey not found in Info.plist"
+ fi
+else
+ echo "❌ Info.plist not found"
+fi
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+# Summary
+echo "📊 Summary"
+echo ""
+
+if [ "$APPCAST_STATUS" = "200" ] && [ "$DMG_STATUS" = "200" ]; then
+ echo "✅ Hosting is fully operational"
+ echo " Ready for production updates"
+elif [ "$APPCAST_STATUS" = "404" ] || [ "$DMG_STATUS" = "404" ]; then
+ echo "⚠️ Hosting partially configured"
+ echo " Next steps:"
+ echo " 1. Build the app (./run build)"
+ echo " 2. Create DMG and appcast (./build_dmg)"
+ echo " 3. Upload files to hosting"
+ echo " 4. Run this script again to verify"
+else
+ echo "❌ Hosting has issues - see errors above"
+ exit 1
+fi
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"