- 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
10 KiB
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:
{
"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-8Cache-Control: public, max-age=300Access-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:
<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:
<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.meinstead offreno.medoes 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
# 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
# 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
# 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)
- Set dev build's
SUFeedURLto subdomain URL - In app: "Check for Updates..." → Should find new version
- 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
GazeorInputHalo, end with.dmgor.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
gaze.freno.me/api/Gaze/appcast.xmlreturns byte-identical XML tofreno.me/api/Gaze/appcast.xmlinputhalo.freno.me/api/InputHalo/appcast.xmlreturns byte-identical XML tofreno.me/api/InputHalo/appcast.xml- DMG download endpoint serves real DMG binaries from all five hosts
- Appcast response headers are correct (Content-Type, Cache-Control, CORS)
- Enclosure URLs in appcast XML are absolute
freno.meURLs - 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