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 */
|
/* Begin PBXBuildFile section */
|
||||||
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
|
275915892F132A9200D0E60D /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B12F10B1FC00E00DBC /* Lottie */; };
|
||||||
|
275915902F132B0000D0E60D /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 27AE10B22F10B20000E00DBC /* Sparkle */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
275915892F132A9200D0E60D /* Lottie in Frameworks */,
|
275915892F132A9200D0E60D /* Lottie in Frameworks */,
|
||||||
|
275915902F132B0000D0E60D /* Sparkle in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -118,6 +120,7 @@
|
|||||||
name = Gaze;
|
name = Gaze;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
27AE10B12F10B1FC00E00DBC /* Lottie */,
|
27AE10B12F10B1FC00E00DBC /* Lottie */,
|
||||||
|
27AE10B22F10B20000E00DBC /* Sparkle */,
|
||||||
);
|
);
|
||||||
productName = Gaze;
|
productName = Gaze;
|
||||||
productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */;
|
productReference = 27A21B3C2F0F69DC0018C4F3 /* Gaze.app */;
|
||||||
@@ -203,6 +206,7 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */,
|
27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */,
|
||||||
|
27AE10B32F10B21000E00DBC /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */;
|
productRefGroup = 27A21B3D2F0F69DC0018C4F3 /* Products */;
|
||||||
@@ -404,6 +408,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -411,8 +416,8 @@
|
|||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
INFOPLIST_FILE = Gaze/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -439,6 +444,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Gaze;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Gaze/Gaze.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -446,8 +452,8 @@
|
|||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
INFOPLIST_FILE = Gaze/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -598,6 +604,14 @@
|
|||||||
minimumVersion = 4.6.0;
|
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 */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@@ -606,6 +620,11 @@
|
|||||||
package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */;
|
package = 27AE10B02F10B1FC00E00DBC /* XCRemoteSwiftPackageReference "lottie-spm" */;
|
||||||
productName = Lottie;
|
productName = Lottie;
|
||||||
};
|
};
|
||||||
|
27AE10B22F10B20000E00DBC /* Sparkle */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 27AE10B32F10B21000E00DBC /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||||
|
productName = Sparkle;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */;
|
rootObject = 27A21B342F0F69DC0018C4F3 /* Project object */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "6b3386dc9ff1f3a74f1534de9c41d47137eae0901cfe819ed442f1b241549359",
|
"originHash" : "513d974fbede884a919977d3446360023f6e3239ac314f4fbd9657e80aca7560",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "lottie-spm",
|
"identity" : "lottie-spm",
|
||||||
@@ -9,6 +9,15 @@
|
|||||||
"revision" : "69faaefa7721fba9e434a52c16adf4329c9084db",
|
"revision" : "69faaefa7721fba9e434a52c16adf4329c9084db",
|
||||||
"version" : "4.6.0"
|
"version" : "4.6.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sparkle",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
|
||||||
|
"version" : "2.8.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Combine
|
|||||||
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||||
@Published var timerEngine: TimerEngine?
|
@Published var timerEngine: TimerEngine?
|
||||||
private var settingsManager: SettingsManager?
|
private var settingsManager: SettingsManager?
|
||||||
|
private var updateManager: UpdateManager?
|
||||||
private var reminderWindowController: NSWindowController?
|
private var reminderWindowController: NSWindowController?
|
||||||
private var settingsWindowController: NSWindowController?
|
private var settingsWindowController: NSWindowController?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -26,6 +27,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
settingsManager = SettingsManager.shared
|
settingsManager = SettingsManager.shared
|
||||||
timerEngine = TimerEngine(settingsManager: settingsManager!)
|
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
|
// Detect App Store version asynchronously at launch
|
||||||
Task {
|
Task {
|
||||||
await settingsManager?.detectAppStoreVersion()
|
await settingsManager?.detectAppStoreVersion()
|
||||||
@@ -42,6 +48,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|||||||
|
|
||||||
func onboardingCompleted() {
|
func onboardingCompleted() {
|
||||||
startTimers()
|
startTimers()
|
||||||
|
|
||||||
|
// Start update checks after onboarding
|
||||||
|
if updateManager == nil {
|
||||||
|
updateManager = UpdateManager.shared
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTimers() {
|
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>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>Copyright © 2026 Mike Freno. All rights reserved.</string>
|
<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>
|
</dict>
|
||||||
</plist>
|
</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 launchAtLogin: Bool
|
||||||
@Binding var subtleReminderSize: ReminderSize
|
@Binding var subtleReminderSize: ReminderSize
|
||||||
@Binding var isAppStoreVersion: Bool
|
@Binding var isAppStoreVersion: Bool
|
||||||
|
@ObservedObject var updateManager = UpdateManager.shared
|
||||||
var isOnboarding: Bool = true
|
var isOnboarding: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -52,6 +53,34 @@ struct GeneralSetupView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12))
|
.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) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("Subtle Reminder Size")
|
Text("Subtle Reminder Size")
|
||||||
.font(.headline)
|
.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)
|
export $(grep -v '^#' .env | xargs)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the DMG
|
# Configuration
|
||||||
rm -f Gaze.dmg
|
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..."
|
echo "Creating DMG..."
|
||||||
create-dmg \
|
create-dmg \
|
||||||
--volname "Gaze Installer" \
|
--volname "Gaze Installer" \
|
||||||
@@ -18,22 +38,82 @@ create-dmg \
|
|||||||
--background "./dmg_background.png" \
|
--background "./dmg_background.png" \
|
||||||
--icon "Gaze.app" 160 200 \
|
--icon "Gaze.app" 160 200 \
|
||||||
--app-drop-link 440 200 \
|
--app-drop-link 440 200 \
|
||||||
"Gaze.dmg" \
|
"$DMG_NAME" \
|
||||||
"./Gaze.app"
|
"./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
|
# 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
|
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 credentials for aws-cli
|
||||||
export AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID"
|
export AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID"
|
||||||
export AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY"
|
export AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY"
|
||||||
export AWS_DEFAULT_REGION="$AWS_REGION"
|
export AWS_DEFAULT_REGION="$AWS_REGION"
|
||||||
|
|
||||||
# Upload to S3
|
# Upload DMG to S3
|
||||||
aws s3 cp Gaze.dmg "s3://$AWS_BUCKET_NAME/Gaze.dmg" --region "$AWS_REGION"
|
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
|
else
|
||||||
echo "Skipping S3 upload - AWS credentials not found in .env"
|
echo ""
|
||||||
|
echo "⚠️ Skipping S3 upload - AWS credentials not found in .env"
|
||||||
fi
|
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