From 0f9ee7a58aaed2b1de3ef490d178de7969c05e01 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 11 Jan 2026 17:45:32 -0500 Subject: [PATCH] feat: auto-update feature --- DEPLOYMENT.md | 296 ++++++++++++++++++ Gaze.xcodeproj/project.pbxproj | 27 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- Gaze/AppDelegate.swift | 11 + Gaze/Gaze.entitlements | 15 + Gaze/Info.plist | 10 + Gaze/Services/AnimationService.swift | 68 ---- Gaze/Services/UpdateManager.swift | 73 +++++ Gaze/Views/Setup/GeneralSetupView.swift | 29 ++ .../Services/AnimationServiceTests.swift | 17 - GazeTests/Services/UpdateManagerTests.swift | 148 +++++++++ build_dmg | 96 +++++- freno-dev | 1 + releases/appcast-template.xml | 30 ++ releases/validate_hosting.sh | 216 +++++++++++++ 15 files changed, 950 insertions(+), 98 deletions(-) create mode 100644 DEPLOYMENT.md create mode 100644 Gaze/Gaze.entitlements delete mode 100644 Gaze/Services/AnimationService.swift create mode 100644 Gaze/Services/UpdateManager.swift delete mode 100644 GazeTests/Services/AnimationServiceTests.swift create mode 100644 GazeTests/Services/UpdateManagerTests.swift create mode 120000 freno-dev create mode 100644 releases/appcast-template.xml create mode 100755 releases/validate_hosting.sh 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 + +]]> +``` + +**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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"