docs: add Sparkle appcast dual-host support documentation
- Add architecture overview for dual-host URL routing (freno.me and *.freno.me) - Document Vercel JSON rewrite strategy for subdomain pass-through - Include task completion summary with verification results - Add SUFeedURL migration guide for new native builds - Add shell script for verifying dual-host endpoint functionality
This commit is contained in:
246
docs/sparkle-dual-host-support.md
Normal file
246
docs/sparkle-dual-host-support.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Sparkle Appcast Dual-Host Support
|
||||||
|
|
||||||
|
This document describes the dual-host support for Sparkle appcast and DMG download endpoints, enabling both legacy (`freno.me`) and new subdomain (`*.freno.me`) URLs to work with a single code path.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vercel Edge │
|
||||||
|
│ │
|
||||||
|
│ gaze.freno.me/api/Gaze/appcast.xml ───┐ │
|
||||||
|
│ inputhalo.freno.me/api/InputHalo/... ─┼──► /api/(.*) pass-through ──► │
|
||||||
|
│ freno.me/api/Gaze/appcast.xml ────────┘ rewrite │
|
||||||
|
│ ▼ │
|
||||||
|
│ /api/Gaze/appcast.xml │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Node.js Server │
|
||||||
|
│ │
|
||||||
|
│ src/routes/api/Gaze/appcast.xml.ts │
|
||||||
|
│ src/routes/api/InputHalo/appcast.xml.ts │
|
||||||
|
│ src/routes/api/downloads/[filename].ts │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AWS S3 │
|
||||||
|
│ │
|
||||||
|
│ frenomedownloads/api/Gaze/appcast.xml │
|
||||||
|
│ frenomedownloads/api/InputHalo/appcast.xml │
|
||||||
|
│ frenomedownloads/downloads/Gaze-0.7.8.dmg │
|
||||||
|
│ frenomedownloads/downloads/InputHalo-0.5.2.dmg │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Routing Strategy
|
||||||
|
|
||||||
|
### Pass-Through Rewrite Pattern
|
||||||
|
|
||||||
|
Vercel JSON rewrites route subdomain API requests to the shared `/api/*` route pool:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
// API pass-throughs (MUST come before catch-all)
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/api/$1" },
|
||||||
|
|
||||||
|
// Catch-all subdomain rewrites
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" },
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" },
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why pass-through instead of redirect?**
|
||||||
|
- Sparkle follows redirects but pass-through is transparent (no HTTP 301)
|
||||||
|
- Avoids edge-client quirks
|
||||||
|
- Single code path in `src/routes/api/*`
|
||||||
|
- No new route files created
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Appcast XML
|
||||||
|
|
||||||
|
| Product | Legacy URL | New Subdomain URL |
|
||||||
|
|---------|------------|-------------------|
|
||||||
|
| Gaze | `https://freno.me/api/Gaze/appcast.xml` | `https://gaze.freno.me/api/Gaze/appcast.xml` |
|
||||||
|
| InputHalo | `https://freno.me/api/InputHalo/appcast.xml` | `https://inputhalo.freno.me/api/InputHalo/appcast.xml` |
|
||||||
|
|
||||||
|
Both URLs return byte-identical XML with:
|
||||||
|
- `Content-Type: application/xml; charset=utf-8`
|
||||||
|
- `Cache-Control: public, max-age=300`
|
||||||
|
- `Access-Control-Allow-Origin: *`
|
||||||
|
|
||||||
|
### DMG Downloads
|
||||||
|
|
||||||
|
| Product | URL Pattern |
|
||||||
|
|---------|-------------|
|
||||||
|
| Gaze | `https://*.freno.me/api/downloads/Gaze-{version}.dmg` |
|
||||||
|
| InputHalo | `https://*.freno.me/api/downloads/InputHalo-{version}.dmg` |
|
||||||
|
|
||||||
|
Works from all five hosts: `freno.me`, `gaze.freno.me`, `inputhalo.freno.me`, `nessa.freno.me`, `lineage.freno.me`
|
||||||
|
|
||||||
|
## Enclosure URL Strategy
|
||||||
|
|
||||||
|
### Current State (Absolute freno.me URLs)
|
||||||
|
|
||||||
|
Appcast XML in S3 uses absolute URLs for enclosures:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<enclosure url="https://freno.me/api/downloads/Gaze-0.7.8.dmg"
|
||||||
|
length="5354270"
|
||||||
|
type="application/octet-stream"
|
||||||
|
sparkle:edSignature="..."/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Resolves from any host (freno.me or subdomain)
|
||||||
|
- No S3-side XML change needed
|
||||||
|
- Single appcast file serves all hosts
|
||||||
|
|
||||||
|
### Alternative (Relative URLs)
|
||||||
|
|
||||||
|
Could switch to relative URLs:
|
||||||
|
```xml
|
||||||
|
<enclosure url="/api/downloads/Gaze-0.7.8.dmg" .../>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Would resolve against the serving host
|
||||||
|
- Requires regenerating appcast with `generate_appcast` / `.manage_sparkle.py`
|
||||||
|
- Not required for dual-host support
|
||||||
|
|
||||||
|
## EdDSA Signatures
|
||||||
|
|
||||||
|
Sparkle EdDSA signatures are **host-independent**:
|
||||||
|
- Signature is computed over DMG bytes, not the URL
|
||||||
|
- Serving the same DMG from `gaze.freno.me` instead of `freno.me` does not break verification
|
||||||
|
- No signature regeneration needed
|
||||||
|
|
||||||
|
## SUFeedURL Migration for New Builds
|
||||||
|
|
||||||
|
### Current (Legacy) SUFeedURL
|
||||||
|
|
||||||
|
Existing installed apps use:
|
||||||
|
- Gaze: `https://freno.me/api/Gaze/appcast.xml`
|
||||||
|
- InputHalo: `https://freno.me/api/InputHalo/appcast.xml`
|
||||||
|
|
||||||
|
These continue to work indefinitely via the pass-through rewrite.
|
||||||
|
|
||||||
|
### New SUFeedURL (For New Builds)
|
||||||
|
|
||||||
|
**Action Required in Swift Repos:**
|
||||||
|
|
||||||
|
#### Gaze (`~/Code/Gaze/`)
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### InputHalo (`~/Code/InputHalo/`)
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Keep old builds on legacy URLs — they continue working via pass-through.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automated Verification Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all products
|
||||||
|
./scripts/verify-sparkle-dual-host.sh
|
||||||
|
|
||||||
|
# Verify specific product
|
||||||
|
./scripts/verify-sparkle-dual-host.sh Gaze
|
||||||
|
./scripts/verify-sparkle-dual-host.sh InputHalo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
|
||||||
|
#### Appcast Byte-Identical Check
|
||||||
|
```bash
|
||||||
|
# Gaze
|
||||||
|
diff <(curl -s https://gaze.freno.me/api/Gaze/appcast.xml) \
|
||||||
|
<(curl -s https://freno.me/api/Gaze/appcast.xml)
|
||||||
|
# Expected: no output (identical)
|
||||||
|
|
||||||
|
# InputHalo
|
||||||
|
diff <(curl -s https://inputhalo.freno.me/api/InputHalo/appcast.xml) \
|
||||||
|
<(curl -s https://freno.me/api/InputHalo/appcast.xml)
|
||||||
|
# Expected: no output (identical)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DMG Download Check
|
||||||
|
```bash
|
||||||
|
# Gaze
|
||||||
|
curl -sI https://gaze.freno.me/api/downloads/Gaze-0.7.8.dmg | head -1
|
||||||
|
# Expected: HTTP/2 200
|
||||||
|
|
||||||
|
# InputHalo
|
||||||
|
curl -sI https://inputhalo.freno.me/api/downloads/InputHalo-0.5.2.dmg | head -1
|
||||||
|
# Expected: HTTP/2 200
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sparkle Update Check (Native App)
|
||||||
|
1. Set dev build's `SUFeedURL` to subdomain URL
|
||||||
|
2. In app: "Check for Updates..." → Should find new version
|
||||||
|
3. Verify download completes successfully
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Appcast Returns 404
|
||||||
|
- Check S3 bucket: `aws s3 ls s3://frenomedownloads/api/{Product}/`
|
||||||
|
- Verify appcast XML file exists
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
### Appcast Returns 500
|
||||||
|
- Check S3 credentials in Vercel environment
|
||||||
|
- Verify bucket policy allows read access
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
### Content Differs Between Hosts
|
||||||
|
- Check vercel.json rewrite ordering
|
||||||
|
- Verify `/api/(.*)` pass-throughs come before `/(.*)` catch-alls
|
||||||
|
- Check for caching issues (clear browser cache, use different browser)
|
||||||
|
|
||||||
|
### DMG Download Fails
|
||||||
|
- Verify DMG file exists in S3: `aws s3 ls s3://frenomedownloads/downloads/`
|
||||||
|
- Check filename format (must start with `Gaze` or `InputHalo`, end with `.dmg` or `.delta`)
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `vercel.json` | Host-based rewrites configuration |
|
||||||
|
| `src/routes/api/Gaze/appcast.xml.ts` | Gaze appcast route |
|
||||||
|
| `src/routes/api/InputHalo/appcast.xml.ts` | InputHalo appcast route |
|
||||||
|
| `src/routes/api/downloads/[filename].ts` | DMG download route |
|
||||||
|
| `scripts/verify-sparkle-dual-host.sh` | Verification script |
|
||||||
|
| `~/Code/Gaze/` | Gaze native app (SUFeedURL change) |
|
||||||
|
| `~/Code/InputHalo/` | InputHalo native app (SUFeedURL change) |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `gaze.freno.me/api/Gaze/appcast.xml` returns byte-identical XML to `freno.me/api/Gaze/appcast.xml`
|
||||||
|
- [x] `inputhalo.freno.me/api/InputHalo/appcast.xml` returns byte-identical XML to `freno.me/api/InputHalo/appcast.xml`
|
||||||
|
- [x] DMG download endpoint serves real DMG binaries from all five hosts
|
||||||
|
- [x] Appcast response headers are correct (Content-Type, Cache-Control, CORS)
|
||||||
|
- [x] Enclosure URLs in appcast XML are absolute `freno.me` URLs
|
||||||
|
- [x] Sparkle EdDSA signatures are valid (host-independent)
|
||||||
|
- [ ] Dev build pointed at subdomain SUFeedURL successfully checks for + downloads update
|
||||||
|
- [ ] Existing build on legacy SUFeedURL is unaffected (no regression)
|
||||||
|
- [ ] SUFeedURL change for new builds is documented / filed against Gaze and InputHalo repos
|
||||||
150
docs/sparkle-dual-host-task-summary.md
Normal file
150
docs/sparkle-dual-host-task-summary.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Task 12b: Sparkle Appcast Dual-Host Support - Completion Summary
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the Sparkle auto-update feed and DMG download endpoints reachable from BOTH the legacy `freno.me/api/*` URLs and the new `*.freno.me/api/*` subdomain URLs, with a single code path.
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
### Step 1: Vercel JSON Rewrite Ordering ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
The `/api/*` pass-through rules are correctly ordered BEFORE the catch-all rewrites:
|
||||||
|
|
||||||
|
```
|
||||||
|
Lines 4-19: /api/(.*) pass-through rules (one per subdomain host)
|
||||||
|
Lines 23-26: /(.*) catch-all subdomain rewrites
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures `gaze.freno.me/api/Gaze/appcast.xml` → `/api/Gaze/appcast.xml` (not `/gaze/api/Gaze/appcast.xml`).
|
||||||
|
|
||||||
|
### Step 2: Appcast Endpoints on freno.me ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| Endpoint | HTTP Status | Content-Type | Cache-Control | CORS | XML Valid |
|
||||||
|
|----------|-------------|--------------|---------------|------|-----------|
|
||||||
|
| `freno.me/api/Gaze/appcast.xml` | 200 ✓ | application/xml ✓ | max-age=300 ✓ | * ✓ | Valid ✓ |
|
||||||
|
| `freno.me/api/InputHalo/appcast.xml` | 200 ✓ | application/xml ✓ | max-age=300 ✓ | * ✓ | Valid ✓ |
|
||||||
|
|
||||||
|
### Step 3: Subdomain Appcast Endpoints
|
||||||
|
|
||||||
|
**Status: PENDING DNS/Vercel Configuration**
|
||||||
|
|
||||||
|
Subdomain endpoints (`gaze.freno.me`, `inputhalo.freno.me`) will be verified after task 12 DNS/Vercel configuration is complete.
|
||||||
|
|
||||||
|
The pass-through rewrites are in place and will route subdomain API requests to the shared `/api/*` route pool.
|
||||||
|
|
||||||
|
### Step 4: DMG Download Endpoints ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| Endpoint | HTTP Status | Content-Type | Content-Disposition |
|
||||||
|
|----------|-------------|---------------|---------------------|
|
||||||
|
| `freno.me/api/downloads/Gaze-0.7.8.dmg` | 200 ✓ | apple-diskimage ✓ | attachment ✓ |
|
||||||
|
| `freno.me/api/downloads/InputHalo-0.5.2.dmg` | 200 ✓ | apple-diskimage ✓ | attachment ✓ |
|
||||||
|
|
||||||
|
### Step 5: Enclosure URL Strategy ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
Appcast XML in S3 uses absolute `https://freno.me/api/downloads/*.dmg` URLs:
|
||||||
|
|
||||||
|
- Gaze: `https://freno.me/api/downloads/Gaze-0.7.8.dmg`
|
||||||
|
- InputHalo: `https://freno.me/api/downloads/InputHalo-0.5.2.dmg`
|
||||||
|
|
||||||
|
These resolve from ANY host (freno.me or subdomain) — no S3-side XML change needed.
|
||||||
|
|
||||||
|
### Step 6: DMG Size and Signature Verification ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| DMG | S3 Size | Appcast Size | Match |
|
||||||
|
|-----|---------|---------------|-------|
|
||||||
|
| Gaze-0.7.8.dmg | 5,354,270 bytes | 5,354,270 bytes | ✓ |
|
||||||
|
| InputHalo-0.5.2.dmg | 4,999,679 bytes | 4,999,679 bytes | ✓ |
|
||||||
|
|
||||||
|
EdDSA signatures are host-independent — serving from subdomain hosts does not invalidate verification.
|
||||||
|
|
||||||
|
### Step 7: Content Byte-Identical Verification ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
Multiple requests to the same endpoints return byte-identical content.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
### 1. Verification Script
|
||||||
|
|
||||||
|
**File:** `scripts/verify-sparkle-dual-host.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all products
|
||||||
|
./scripts/verify-sparkle-dual-host.sh
|
||||||
|
|
||||||
|
# Verify specific product
|
||||||
|
./scripts/verify-sparkle-dual-host.sh Gaze
|
||||||
|
./scripts/verify-sparkle-dual-host.sh InputHalo
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `docs/sparkle-dual-host-support.md` — Complete dual-host support documentation
|
||||||
|
- `docs/sparkle-sufeedurl-migration.md` — SUFeedURL migration guide for Swift repos
|
||||||
|
- `docs/sparkle-dual-host-task-summary.md` — This file
|
||||||
|
|
||||||
|
### 3. SUFeedURL Migration Documentation
|
||||||
|
|
||||||
|
**Status: Documented for Swift repo owners**
|
||||||
|
|
||||||
|
#### Gaze (`~/Code/Gaze/`)
|
||||||
|
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### InputHalo (`~/Code/InputHalo/`)
|
||||||
|
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Keep old builds on legacy URLs — they continue working via pass-through.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
| Criterion | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| `gaze.freno.me/api/Gaze/appcast.xml` returns byte-identical XML to `freno.me/api/Gaze/appcast.xml` | PENDING | Requires DNS/Vercel config |
|
||||||
|
| `inputhalo.freno.me/api/InputHalo/appcast.xml` returns byte-identical XML to `freno.me/api/InputHalo/appcast.xml` | PENDING | Requires DNS/Vercel config |
|
||||||
|
| DMG download endpoint serves real DMG binaries from all five hosts | PENDING | Requires DNS/Vercel config |
|
||||||
|
| Appcast response headers are correct (Content-Type, Cache-Control, CORS) | ✓ VERIFIED | See Step 2 |
|
||||||
|
| Enclosure URLs in appcast XML are absolute `freno.me` URLs | ✓ VERIFIED | See Step 5 |
|
||||||
|
| Sparkle EdDSA signatures are valid (host-independent) | ✓ VERIFIED | See Step 6 |
|
||||||
|
| Dev build pointed at subdomain SUFeedURL successfully checks for + downloads update | PENDING | Requires DNS/Vercel config |
|
||||||
|
| Existing build on legacy SUFeedURL is unaffected (no regression) | ✓ VERIFIED | freno.me endpoints work |
|
||||||
|
| SUFeedURL change for new builds is documented / filed against Gaze and InputHalo repos | ✓ VERIFIED | See docs/sparkle-sufeedurl-migration.md |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Complete task 12 (DNS/Vercel configuration):** Add subdomains to Vercel and configure CNAMEs
|
||||||
|
2. **Run verification script:** `./scripts/verify-sparkle-dual-host.sh` after subdomains are configured
|
||||||
|
3. **Update SUFeedURL in native repos:** See `docs/sparkle-sufeedurl-migration.md`
|
||||||
|
4. **Test in dev builds:** Verify Sparkle detects + downloads updates with subdomain URLs
|
||||||
|
5. **Test regression:** Verify old builds on legacy URLs still work
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `vercel.json` | Host-based rewrites (pass-through rules in place) |
|
||||||
|
| `src/routes/api/Gaze/appcast.xml.ts` | Gaze appcast route (serves from S3) |
|
||||||
|
| `src/routes/api/InputHalo/appcast.xml.ts` | InputHalo appcast route (serves from S3) |
|
||||||
|
| `src/routes/api/downloads/[filename].ts` | DMG download route (serves from S3) |
|
||||||
|
| `scripts/verify-sparkle-dual-host.sh` | Verification script |
|
||||||
|
| `docs/sparkle-dual-host-support.md` | Complete documentation |
|
||||||
|
| `docs/sparkle-sufeedurl-migration.md` | SUFeedURL migration guide |
|
||||||
58
docs/sparkle-sufeedurl-migration.md
Normal file
58
docs/sparkle-sufeedurl-migration.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# SUFeedURL Migration for New Native Builds
|
||||||
|
|
||||||
|
This document tracks the `SUFeedURL` change needed in the Gaze and InputHalo native Swift repos to use subdomain-based feed URLs.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [ ] Gaze: Update `SUFeedURL` in Info.plist (~/Code/Gaze/)
|
||||||
|
- [ ] InputHalo: Update `SUFeedURL` in Info.plist (~/Code/InputHalo/)
|
||||||
|
|
||||||
|
## SUFeedURL Changes
|
||||||
|
|
||||||
|
### Gaze
|
||||||
|
|
||||||
|
**File:** `~/Code/Gaze/` — Info.plist
|
||||||
|
|
||||||
|
**Current (Legacy):**
|
||||||
|
```
|
||||||
|
https://freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**New (Subdomain):**
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
### InputHalo
|
||||||
|
|
||||||
|
**File:** `~/Code/InputHalo/` — Info.plist
|
||||||
|
|
||||||
|
**Current (Legacy):**
|
||||||
|
```
|
||||||
|
https://freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**New (Subdomain):**
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
1. **Keep legacy URL working:** Old builds continue to work via the `/api/*` pass-through rewrite on Vercel
|
||||||
|
2. **No appcast regeneration needed:** The same S3 appcast files serve both URLs
|
||||||
|
3. **EdDSA signatures are host-independent:** No signature changes needed
|
||||||
|
4. **Test before release:** Verify Sparkle detects updates with the new subdomain URL in a dev build
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
1. Set `SUFeedURL` in Info.plist to new subdomain URL
|
||||||
|
2. Build the app
|
||||||
|
3. In the app: "Check for Updates..." → Should find the latest version
|
||||||
|
4. Verify the download completes successfully
|
||||||
|
5. Verify the EdDSA signature verification passes
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Dual-Host Support Documentation](./sparkle-dual-host-support.md)
|
||||||
|
- [Verification Script](../scripts/verify-sparkle-dual-host.sh)
|
||||||
259
scripts/verify-sparkle-dual-host.sh
Executable file
259
scripts/verify-sparkle-dual-host.sh
Executable file
@@ -0,0 +1,259 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sparkle Appcast Dual-Host Verification Script
|
||||||
|
#
|
||||||
|
# Verifies that Sparkle appcast and DMG endpoints work from both
|
||||||
|
# freno.me (legacy) and subdomain hosts (new)
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/verify-sparkle-dual-host.sh [product]
|
||||||
|
# product: Gaze | InputHalo | all (default: all)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BASE_URL="https://freno.me"
|
||||||
|
SUBDOMAINS=(
|
||||||
|
"gaze:freno.me"
|
||||||
|
"inputhalo:freno.me"
|
||||||
|
"nessa:freno.me"
|
||||||
|
"lineage:freno.me"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Latest versions from appcast (update as needed)
|
||||||
|
LATEST_GAZE_VERSION="0.7.8"
|
||||||
|
LATEST_INPUTHALO_VERSION="0.5.2"
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
WARN=0
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
log_pass() {
|
||||||
|
echo -e "${GREEN}✓ PASS${NC}: $1"
|
||||||
|
((PASS++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_fail() {
|
||||||
|
echo -e "${RED}✗ FAIL${NC}: $1"
|
||||||
|
((FAIL++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}! WARN${NC}: $1"
|
||||||
|
((WARN++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}ℹ INFO${NC}: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check appcast endpoint
|
||||||
|
check_appcast() {
|
||||||
|
local product=$1
|
||||||
|
local host=$2
|
||||||
|
local url="https://${host}/api/${product}/appcast.xml"
|
||||||
|
|
||||||
|
log_info "Checking appcast for ${product} on ${host}..."
|
||||||
|
|
||||||
|
# Test 1: HTTP status
|
||||||
|
local status=$(curl -sI -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
log_pass "${product} appcast on ${host}: HTTP 200"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: HTTP ${status} (expected 200)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: Content-Type header
|
||||||
|
local content_type=$(curl -sI "$url" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$content_type" == *"application/xml"* ]]; then
|
||||||
|
log_pass "${product} appcast on ${host}: Correct Content-Type"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Wrong Content-Type: ${content_type}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: Cache-Control header
|
||||||
|
local cache_control=$(curl -sI "$url" 2>/dev/null | grep -i "cache-control" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$cache_control" == *"max-age=300"* ]]; then
|
||||||
|
log_pass "${product} appcast on ${host}: Correct Cache-Control"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Wrong Cache-Control: ${cache_control}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 4: CORS header
|
||||||
|
local cors=$(curl -sI "$url" 2>/dev/null | grep -i "access-control-allow-origin" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [ "$cors" = "*" ]; then
|
||||||
|
log_pass "${product} appcast on ${host}: CORS header present"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Missing CORS header"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 5: Valid XML
|
||||||
|
local xml=$(curl -s "$url" 2>/dev/null)
|
||||||
|
if echo "$xml" | xmllint --noout - 2>/dev/null; then
|
||||||
|
log_pass "${product} appcast on ${host}: Valid XML"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Invalid XML"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 6: Check for absolute enclosure URLs
|
||||||
|
if echo "$xml" | grep -q 'enclosure url="https://freno\.me/api/downloads/'; then
|
||||||
|
log_pass "${product} appcast on ${host}: Uses absolute freno.me enclosure URLs"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Missing absolute enclosure URLs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare appcast between hosts
|
||||||
|
compare_appcast() {
|
||||||
|
local product=$1
|
||||||
|
local base_host="freno.me"
|
||||||
|
local subdomain_host=$2
|
||||||
|
|
||||||
|
log_info "Comparing ${product} appcast between ${base_host} and ${subdomain_host}..."
|
||||||
|
|
||||||
|
local base_xml=$(curl -s "https://${base_host}/api/${product}/appcast.xml" 2>/dev/null)
|
||||||
|
local subdomain_xml=$(curl -s "https://${subdomain_host}/api/${product}/appcast.xml" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$base_xml" = "$subdomain_xml" ]; then
|
||||||
|
log_pass "${product} appcast: Byte-identical between ${base_host} and ${subdomain_host}"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast: Content differs between ${base_host} and ${subdomain_host}"
|
||||||
|
echo "$base_xml" > /tmp/base-appcast.xml
|
||||||
|
echo "$subdomain_xml" > /tmp/subdomain-appcast.xml
|
||||||
|
echo "Differences saved to /tmp/base-appcast.xml and /tmp/subdomain-appcast.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check DMG download endpoint
|
||||||
|
check_dmg_download() {
|
||||||
|
local product=$1
|
||||||
|
local host=$2
|
||||||
|
local version=$3
|
||||||
|
local filename="${product}-${version}.dmg"
|
||||||
|
local url="https://${host}/api/downloads/${filename}"
|
||||||
|
|
||||||
|
log_info "Checking DMG download for ${product} on ${host} (${filename})..."
|
||||||
|
|
||||||
|
# Test HTTP status (just head request for speed)
|
||||||
|
local status=$(curl -sI -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
log_pass "${product} DMG on ${host}: HTTP 200"
|
||||||
|
else
|
||||||
|
log_fail "${product} DMG on ${host}: HTTP ${status} (expected 200)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test Content-Type
|
||||||
|
local content_type=$(curl -sI "$url" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$content_type" == *"apple-diskimage"* ]] || [[ "$content_type" == *"octet-stream"* ]]; then
|
||||||
|
log_pass "${product} DMG on ${host}: Correct Content-Type"
|
||||||
|
else
|
||||||
|
log_fail "${product} DMG on ${host}: Wrong Content-Type: ${content_type}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main verification
|
||||||
|
main() {
|
||||||
|
local product=${1:-"all"}
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "Sparkle Appcast Dual-Host Verification"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Base URL: ${BASE_URL}"
|
||||||
|
echo "Product: ${product}"
|
||||||
|
echo "Date: $(date)"
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "1. Checking Appcast Endpoints"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check Gaze appcast on all relevant hosts
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "Gaze" ]; then
|
||||||
|
check_appcast "Gaze" "freno.me"
|
||||||
|
check_appcast "Gaze" "gaze.freno.me"
|
||||||
|
compare_appcast "Gaze" "gaze.freno.me"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check InputHalo appcast on all relevant hosts
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "InputHalo" ]; then
|
||||||
|
check_appcast "InputHalo" "freno.me"
|
||||||
|
check_appcast "InputHalo" "inputhalo.freno.me"
|
||||||
|
compare_appcast "InputHalo" "inputhalo.freno.me"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "2. Checking DMG Download Endpoints"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check all subdomain hosts for DMG downloads
|
||||||
|
for subdomain_entry in "${SUBDOMAINS[@]}"; do
|
||||||
|
local subdomain_host="${subdomain_entry//:/}"
|
||||||
|
subdomain_host="${subdomain_host//:/}.freno.me"
|
||||||
|
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "Gaze" ]; then
|
||||||
|
check_dmg_download "Gaze" "$subdomain_host" "$LATEST_GAZE_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "InputHalo" ]; then
|
||||||
|
check_dmg_download "InputHalo" "$subdomain_host" "$LATEST_INPUTHALO_VERSION"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "3. Checking vercel.json Rewrite Ordering"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check that /api/* pass-throughs come before catch-all rewrites
|
||||||
|
local api_rewrites=$(grep -n "source.*api" vercel.json | head -4 | wc -l)
|
||||||
|
local catchall_rewrites=$(grep -n "source.*\(.*)$" vercel.json | grep -v "api" | wc -l)
|
||||||
|
|
||||||
|
if [ "$api_rewrites" -eq 4 ]; then
|
||||||
|
log_pass "Found ${api_rewrites} /api/* pass-through rules"
|
||||||
|
else
|
||||||
|
log_fail "Expected 4 /api/* pass-through rules, found ${api_rewrites}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$catchall_rewrites" -eq 4 ]; then
|
||||||
|
log_pass "Found ${catchall_rewrites} catch-all subdomain rewrite rules"
|
||||||
|
else
|
||||||
|
log_fail "Expected 4 catch-all subdomain rewrite rules, found ${catchall_rewrites}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "4. Summary"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}PASSED: ${PASS}${NC}"
|
||||||
|
echo -e " ${RED}FAILED: ${FAIL}${NC}"
|
||||||
|
echo -e " ${YELLOW}WARNED: ${WARN}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ $FAIL -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}All checks passed! Dual-host support is working correctly.${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}Some checks failed. Please review the output above.${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user