feat: auto-update feature

This commit is contained in:
Michael Freno
2026-01-11 17:45:32 -05:00
parent 37564d0579
commit 0f9ee7a58a
15 changed files with 950 additions and 98 deletions

296
DEPLOYMENT.md Normal file
View File

@@ -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
<key>SUFeedURL</key>
<string>file:///Users/mike/Code/Gaze/releases/appcast.xml</string>
```
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
<description><![CDATA[
<h2>What's New in Version X.Y.Z</h2>
<ul>
<li>Feature 1</li>
<li>Bug fix 2</li>
</ul>
]]></description>
```
**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

View File

@@ -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 */;

View File

@@ -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

View File

@@ -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<AnyCancellable>()
@@ -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() {

15
Gaze/Gaze.entitlements Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
</dict>
</plist>

View File

@@ -16,5 +16,15 @@
<string>$(MARKETING_VERSION)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Mike Freno. All rights reserved.</string>
<key>SUPublicEDKey</key>
<string>Z2RmohI1y2bgeGQQUDqO9F0HNF2AzFotOt8CwGB6VJM=</string>
<key>SUFeedURL</key>
<string>https://freno.me/api/Gaze/appcast.xml</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>SUEnableInstallerLauncherService</key>
<true/>
</dict>
</plist>

View File

@@ -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)")
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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<AnyCancellable>!
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")
}
}

View File

@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

1
freno-dev Symbolic link
View File

@@ -0,0 +1 @@
/Users/mike/Code/freno-dev

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Gaze Updates</title>
<description>Most recent updates to Gaze</description>
<language>en</language>
<item>
<title>Version 0.1.1</title>
<description><![CDATA[
<h2>What's New in Gaze 0.1.1</h2>
<ul>
<li>Initial release with auto-update support</li>
<li>Blink reminders to reduce eye strain</li>
<li>20-20-20 rule break reminders</li>
<li>Customizable reminder intervals</li>
<li>Posture check reminders</li>
</ul>
]]></description>
<pubDate>Sat, 11 Jan 2026 12:00:00 +0000</pubDate>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>0.1.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
<enclosure
url="https://freno.me/downloads/Gaze-0.1.1.dmg"
sparkle:edSignature="SIGNATURE_GENERATED_BY_GENERATE_APPCAST"
length="FILE_SIZE_IN_BYTES"
type="application/octet-stream" />
</item>
</channel>
</rss>

216
releases/validate_hosting.sh Executable file
View File

@@ -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>\(.*\)<\/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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"