Files
freno-dev/docs/sparkle-dual-host-support.md
Michael Freno 37ed5170ed 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
2026-07-23 10:55:35 -04:00

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

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

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

  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
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.xml returns byte-identical XML to freno.me/api/Gaze/appcast.xml
  • inputhalo.freno.me/api/InputHalo/appcast.xml returns byte-identical XML to freno.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.me URLs
  • 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