feat: auto-update feature
This commit is contained in:
296
DEPLOYMENT.md
Normal file
296
DEPLOYMENT.md
Normal 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
|
||||
@@ -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 */;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
15
Gaze/Gaze.entitlements
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
Gaze/Services/UpdateManager.swift
Normal file
73
Gaze/Services/UpdateManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
148
GazeTests/Services/UpdateManagerTests.swift
Normal file
148
GazeTests/Services/UpdateManagerTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
96
build_dmg
96
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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
30
releases/appcast-template.xml
Normal file
30
releases/appcast-template.xml
Normal 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
216
releases/validate_hosting.sh
Executable 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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Reference in New Issue
Block a user