Sparkle Auto-Updates and the Art of Shipping
There is a particular kind of satisfaction in running fastlane server_all and walking away. The machine archives the project, notarizes it with Apple, uploads the signed binary to the web server, generates a cryptographically signed appcast, publishes it, and purges the CDN cache. When it finishes, every running copy of ShowShark Server will discover the new version within 24 hours and offer a one-click update. The entire pipeline, from source code to running software on someone else's machine, is a single command.
Getting here took longer than writing the update UI itself. This post covers the full integration: Sparkle in a sandboxed macOS app, the UpdateManager that handles graceful shutdown mid-stream, the fastlane automation that makes deployment a single command, and the appcast generation that ties it all together.
Why Sparkle
ShowShark Server is distributed outside the Mac App Store. It links against GStreamer, runs a WebSocket server, and manages its own TLS certificates. The App Store sandbox would require removing most of what makes it useful. Distribution via Developer ID means handling updates ourselves.
Sparkle is the standard answer for this. It has been around since 2006, it handles the entire update lifecycle (check, download, verify, install, relaunch), and it supports EdDSA signature verification so users are not trusting plain HTTP downloads. The Sparkle 2.x rewrite uses XPC services to isolate the download and installation steps from the host app, which matters for sandboxed apps.
The alternative is telling users to check a website and manually drag a new .app into their Applications folder. That is not a serious option for a media server that people leave running in the background for weeks at a time.
The Moving Parts
Before diving into code, here is how the pieces fit together:
┌──────────────────────────────────────────────────────────────────┐
│ Developer Machine │
│ │
│ fastlane server_all │
│ │ │
│ ├── 1. xcodebuild archive + export (Developer ID) │
│ │ │
│ ├── 2. notarytool submit --wait │
│ │ └── stapler staple (with retry) │
│ │ │
│ ├── 3. ditto -c -k (create versioned ZIP) │
│ │ └── scp to web server │
│ │ └── update "latest" symlink │
│ │ │
│ ├── 4. sign_update (EdDSA signature) │
│ │ └── generate appcast.xml │
│ │ └── scp appcast to web server │
│ │ │
│ └── 5. Cloudflare cache purge (ZIP + appcast URLs) │
│ │
└──────────────────────────────────────────────────────────────────┘
│
HTTPS (Cloudflare CDN)
│
┌───────────────────────────┼───────────────────────────────────────┐
│ User's Mac │
│ │ │
│ ShowShark Server ▼ │
│ │ ┌─────────────┐ │
│ │ │ appcast.xml │ Sparkle checks every 24h │
│ │ └──────┬──────┘ │
│ │ │ │
│ │ Newer version? │
│ │ │ yes │
│ │ ▼ │
│ │ Download ZIP, verify EdDSA signature │
│ │ │ │
│ │ ┌──────┴──────┐ │
│ │ │ UpdateMgr │ Check active connections │
│ │ │ warns user │ Graceful server shutdown │
│ │ └──────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ [old app quits] [new app launches] │
│ │
└───────────────────────────────────────────────────────────────────┘
Five stages on the developer side; four on the user side. Let's start with the app itself.
Integrating Sparkle into the App
Info.plist: Three Keys
Sparkle needs three things in Info.plist to function:
<key>SUFeedURL</key>
<string>https://example.com/downloads/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>YqAq7ZMkEzzOERQFLAyJv+X/LTlnsNtJ5NZjseM6W+o=</string>
<key>SUEnableInstallerLauncherService</key>
<true/>
SUFeedURL is the URL Sparkle polls for update metadata. SUPublicEDKey is the Ed25519 public key used to verify the signature on downloaded archives. If the signature does not match, Sparkle refuses to install the update. SUEnableInstallerLauncherService enables the XPC-based installer, which is required for sandboxed apps.
The Ed25519 keypair is generated once with Sparkle's generate_keys tool and stored in the macOS Keychain. The public key goes into Info.plist; the private key stays in the Keychain and is used by sign_update during appcast generation. Lose the private key and you cannot publish updates that existing installations will accept. Keep a backup.
Entitlements: The Sandbox Gotcha
ShowShark Server runs in the App Sandbox. Sparkle 2.x uses two embedded XPC services for its work: one for downloading (Downloader.xpc) and one for installing (Installer.xpc). These services need to communicate with the host app through Mach ports, and the sandbox blocks that by default.
The fix is a pair of mach-lookup exceptions in the entitlements file:
<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>
The -spks suffix is the Sparkle downloader service; -spki is the installer service. The $(PRODUCT_BUNDLE_IDENTIFIER) variable expands at build time (in this case, to com.acgao.ShowShark). Without these entries, the download phase succeeds but the install phase fails with a generic "An error occurred while running the updater" message. No useful error in Console.app, no crash log, just a dead-end dialog. This is the kind of bug that costs you an afternoon the first time.
UpdateManager: The Sparkle Wrapper
The UpdateManager class owns the SPUUpdater instance and implements SPUUpdaterDelegate to intercept lifecycle events. Initialization is straightforward:
@MainActor @Observable
final class UpdateManager: NSObject {
private var updater: SPUUpdater?
private let logger = AppLogger.forCategory()
init(
coordinator: NetworkingCoordinator,
transcodingSessionManager: TranscodingSessionManager,
clientSessionManager: ClientSessionManager,
upgradeManager: UpgradeManager
) {
self.coordinator = coordinator
self.transcodingSessionManager = transcodingSessionManager
self.clientSessionManager = clientSessionManager
self.upgradeManager = upgradeManager
super.init()
do {
let userDriver = SPUStandardUserDriver(
hostBundle: Bundle.main, delegate: nil)
let sparkleUpdater = SPUUpdater(
hostBundle: Bundle.main,
applicationBundle: Bundle.main,
userDriver: userDriver,
delegate: self)
sparkleUpdater.updateCheckInterval = 86_400 // 24 hours
try sparkleUpdater.start()
self.updater = sparkleUpdater
logger.info("Sparkle updater started successfully")
} catch {
logger.error("Failed to start Sparkle updater: \(error)")
}
UpdateManager.shared = self
}
}
The SPUStandardUserDriver handles all the UI: the "update available" dialog, the progress bar during download, the "restart to update" prompt. You get a fully functional update experience without writing a single view. The delegate: self on the SPUUpdater is where things get interesting.
The Pre-Relaunch Problem
ShowShark Server is a media server. At any given moment, people might be watching movies. The worst possible update experience is: someone is halfway through a film, the server silently restarts, and their stream drops. Sparkle handles relaunch automatically by default, but it has no idea that active connections exist.
The SPUUpdaterDelegate protocol includes shouldPostponeRelaunchForUpdate, which lets the app intercept the moment between "update downloaded and verified" and "quit and install." This is where the server gets to be a good citizen:
nonisolated func updater(
_ updater: SPUUpdater,
shouldPostponeRelaunchForUpdate item: SUAppcastItem,
untilInvoking installHandler: @escaping () -> Void
) -> Bool {
nonisolated(unsafe) let handler = installHandler
Task { @MainActor in
await self.handlePreRelaunch(installHandler: handler)
}
return true // Yes, we are postponing. We will call installHandler ourselves.
}
Returning true tells Sparkle "do not relaunch yet; I will call installHandler when I am ready." The nonisolated(unsafe) dance is a Swift 6 concurrency requirement; Sparkle's delegate methods are nonisolated, but we need to bounce to the main actor for UI work and async operations.
The actual pre-relaunch handler checks for active connections:
private func handlePreRelaunch(installHandler: @escaping () -> Void) async {
let count = await activeSessionCount()
if count > 0 {
let shouldContinue = showActiveConnectionAlert(count: count)
guard shouldContinue else {
// User declined. Sparkle will re-offer on next check cycle.
logger.info("User cancelled update due to \(count) active connection(s)")
return
}
}
await coordinator.shutdownServer()
logger.info("Graceful shutdown completed before update installation")
installHandler()
}
activeSessionCount() queries the transcoding session manager across all session types: video, audio, BDMV, and HLS. If anyone is connected, we show an alert:
private func showActiveConnectionAlert(count: Int) -> Bool {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Active Connections"
let connectionWord = count == 1 ? "connection" : "connections"
alert.informativeText = """
There are \(count) active client \(connectionWord). \
Installing this update will disconnect all clients and \
stop all playback. Continue?
"""
alert.addButton(withTitle: "Continue")
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn
}
If the user cancels, we simply do not call installHandler(). Sparkle handles this gracefully; it will re-offer the update on the next check cycle or app restart. If the user confirms, we call coordinator.shutdownServer() to gracefully tear down all connections and stop the WebSocket listener before calling installHandler() to proceed with the install and relaunch.
Update Discovery Notifications
The other interesting delegate method is didFindValidUpdate, which fires when Sparkle discovers a new version in the appcast:
nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
let version = item.displayVersionString
Task { @MainActor in
self.isUpdateAvailable = true
self.latestVersion = version
self.logger.info("Sparkle found valid update: v\(version)")
await SystemNotificationManager.shared.postUpdateAvailable(version: version)
}
}
Since ShowShark Server is often a menu bar app running in the background, the system notification ensures the user actually sees that an update is available, even if the server window is hidden or on another desktop.
The Automation Pipeline
The app-side integration is arguably the easy part. The real value is in the deployment automation. Running fastlane server_all executes four lanes in sequence, each building on the output of the previous one.
Lane 1: server_macos (Archive, Export, Notarize)
lane :server_macos do
archive_path = File.join(ARCHIVE_ROOT, "ShowShark-Server-macOS.xcarchive")
# Clean export directory
FileUtils.rm_rf(SERVER_EXPORT_DIR)
FileUtils.mkdir_p(SERVER_EXPORT_DIR)
plist_path = write_export_options_plist!(method: "developer-id")
xcodebuild_archive!(
project: PROJECT_PATH,
scheme: SERVER_SCHEME,
destination: "generic/platform=macOS",
archive_path: archive_path
)
xcodebuild_export!(
archive_path: archive_path,
export_path: SERVER_EXPORT_DIR,
plist_path: plist_path
)
app_path = find_server_app!
# notarytool requires a zip, not a bare .app
zip_path = File.join(SERVER_EXPORT_DIR, "ShowShark-Server-notarize.zip")
sh("ditto -c -k --keepParent #{app_path} #{zip_path}")
notarize_with_notarytool!(zip_path, staple_path: app_path)
end
The export uses developer-id method, which signs for distribution outside the App Store. After export, we zip the .app for notarization (Apple's notarytool does not accept bare .app directories), submit it, wait for Apple's approval, then staple the notarization ticket to the .app.
The notarization function handles three different credential strategies (Keychain profile, App Store Connect API key, or Apple ID) and retries stapling up to five times with increasing delays. Apple's CDN sometimes takes a few seconds to propagate the notarization ticket, and stapling will fail until it does. The retry loop with 15-second increments handles this reliably:
max_attempts = 5
max_attempts.times do |attempt|
begin
sh("xcrun stapler staple #{target}")
break
rescue StandardError => e
remaining = max_attempts - attempt - 1
if remaining > 0
delay = 15 * (attempt + 1)
sleep(delay)
else
UI.user_error!("Stapling failed after #{max_attempts} attempts")
end
end
end
Lane 2: server_deploy (Upload to Web Server)
lane :server_deploy do
app_path = find_server_app!
# Read version from the notarized app
short_version = sh("PlistBuddy -c 'Print CFBundleShortVersionString' #{plist_path}").strip
bundle_version = sh("PlistBuddy -c 'Print CFBundleVersion' #{plist_path}").strip
# Create versioned ZIP: ShowShark-Server-20260323-55.zip
zip_name = "ShowShark-Server-#{short_version.tr('.', '')}-#{bundle_version}.zip"
zip_path = File.join(SERVER_EXPORT_DIR, zip_name)
sh("ditto -c -k --sequesterRsrc --keepParent #{app_path} #{zip_path}")
sh("scp #{zip_path} ghost:#{DEPLOY_DIR}/")
# Update the "latest" symlink so the appcast URL never changes
sh("ssh ghost 'cd #{DEPLOY_DIR} && rm -f ShowShark-Server-latest.zip && " \
"ln -s #{zip_name} ShowShark-Server-latest.zip'")
purge_cloudflare_cache_by_url!(latest_download_url)
end
The versioned filename means every release is preserved on the server. The ShowShark-Server-latest.zip symlink always points to the current version, and the appcast always references the symlink URL. This means we never need to update the download URL in the appcast when the version changes; the symlink redirection handles it.
Lane 3: appcast_update (Generate Signed Appcast)
This lane calls the generate-appcast.sh script, which does the cryptographic signing:
# Sign the ZIP with EdDSA (key lives in macOS Keychain)
SIGN_OUTPUT=$("$SIGN_UPDATE" "$ZIP_PATH")
# Parse: sparkle:edSignature="..." length="..."
ED_SIGNATURE=$(echo "$SIGN_OUTPUT" | sed -nE 's/.*sparkle:edSignature="([^"]+)".*/\1/p')
The sign_update tool is part of Sparkle itself. It reads the EdDSA private key from the Keychain, signs the ZIP file, and outputs the signature as an XML attribute string. The script parses this and generates the complete appcast XML:
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>ShowShark Server Updates</title>
<item>
<title>Version 2026.03.23</title>
<pubDate>Tue, 24 Mar 2026 04:09:42 -0400</pubDate>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<enclosure
url=".../ShowShark-Server-latest.zip"
sparkle:version="55"
sparkle:shortVersionString="2026.03.23"
sparkle:edSignature="mzYAvcoMrM1Y6sex...ZACw=="
length="168644868"
type="application/octet-stream"
/>
</item>
</channel>
</rss>
The sparkle:version is the build number (monotonically increasing integer). The sparkle:shortVersionString is the marketing version. Sparkle uses the build number for comparison; if the user's build number is lower than the one in the appcast, an update is available.
Lane 4: appcast_deploy (Publish and Purge Cache)
lane :appcast_deploy do
sh("scp #{APPCAST_LOCAL_PATH} ghost:#{APPCAST_REMOTE_DIR}/appcast.xml")
purge_cloudflare_cache_by_url!(APPCAST_URL)
end
The Cloudflare cache purge is critical. Without it, Sparkle clients would fetch the cached (old) appcast for up to however long Cloudflare decides to cache it. The purge is surgical; it targets the specific URLs for the appcast XML and the latest ZIP symlink rather than flushing the entire zone:
def purge_cloudflare_cache_by_url!(url)
endpoint = "https://api.cloudflare.com/client/v4/zones/#{zone_id}/purge_cache"
payload = { files: [url] }.to_json
Open3.capture3(
"curl", "-sS", "-X", "POST", endpoint,
"-H", "Authorization: Bearer #{api_token}",
"-H", "Content-Type: application/json",
"--data", payload
)
end
The Full Sequence
Here is what happens when I run fastlane server_all, condensed into a timeline:
Time Action Where
────── ────────────────────────────────────── ──────────────
0:00 xcodebuild clean archive Local
1:30 xcodebuild -exportArchive Local
2:00 ditto (zip for notarization) Local
2:05 notarytool submit --wait Apple servers
4:00 stapler staple Local
4:10 ditto (versioned distribution zip) Local
4:15 scp ShowShark-Server-20260323-55.zip ghost (web server)
4:25 ln -s ... ShowShark-Server-latest.zip ghost
4:30 sign_update (EdDSA sign ZIP) Local (Keychain)
4:32 generate appcast.xml Local
4:33 scp appcast.xml ghost
4:35 Cloudflare cache purge (2 URLs) Cloudflare API
4:36 Done.
Under five minutes from source code to live update. Most of the time is Apple's notarization service (it varies from 30 seconds to several minutes depending on their load). Everything else is fast because everything else is just copying files.
What Happens on the User's Machine
The user sees none of the above. From their perspective:
- ShowShark Server checks the appcast URL once every 24 hours (or when they manually click "Check for Updates").
- If a new version exists, Sparkle shows a dialog with the version number and release notes.
- The user clicks "Install Update." Sparkle downloads the ZIP, verifies the EdDSA signature, and extracts it.
- Before relaunching,
UpdateManagerchecks for active streaming sessions. - If clients are connected, an alert explains the situation and asks whether to proceed.
- If the user confirms (or no one is connected), the server shuts down gracefully and Sparkle replaces the app and relaunches it.
The graceful shutdown is the part I am most glad we built. A media server that reboots out from under active viewers would be genuinely rude. Giving the user the choice to wait until everyone is done watching is a small thing, but it is the difference between software that respects its operator and software that does not.
Lessons Learned
Sign your builds. EdDSA verification is not optional decoration. Without it, anyone who can intercept the download (coffee shop WiFi, compromised DNS, a CDN misconfiguration) can replace the update with whatever they want. The Sparkle team made this the default for good reason.
Notarization retry is not paranoia. Apple's stapling CDN has propagation delays. The first staple attempt after notarization fails about 20% of the time in my experience. A retry loop with backoff is load-bearing infrastructure, not defensive programming.
Purge your CDN cache. If you serve the appcast or the ZIP through any kind of CDN or caching proxy, you need to invalidate the cache on deploy. Otherwise, clients will download the old version even after you have published a new one. I learned this the boring way, by publishing an update and then wondering why no one was getting it.
Test the sandbox entitlements. Sparkle's XPC services require mach-lookup exceptions in the host app's entitlements. If your app is sandboxed and you forget them, everything works right up until the install phase, where it fails with a generic error that tells you nothing about what went wrong. Add the entitlements, test with a real update (not just a build), and verify the full cycle works before shipping.
Keep the private key backed up. The EdDSA private key in the Keychain is the root of trust for your entire update chain. If you lose it, existing installations will reject all future updates because the signatures will not match the public key baked into their Info.plist. Store a backup somewhere safe. Sparkle's generate_keys -f can export it.
The Result
The deployment pipeline took about two days to build, including the Sparkle integration, the fastlane lanes, the appcast generation script, and the Cloudflare cache purging. Since then, every release has been the same command: fastlane server_all. No manual steps, no forgetting to notarize, no stale CDN caches, no unsigned updates.
The best infrastructure is the kind you build once and then forget about. Every time I push an update and it arrives on the other side without incident, I appreciate those two days a little more.