Sparkle-Style Auto-Updates for PyQt6 Desktop Apps
I built a cross-platform auto-update system for PyQt6 that mirrors Sparkle's appcast pattern. Ed25519-signed XML feeds, QThread-based background checks, and the same installer plumbing on Windows, macOS, and Linux.
Sparkle-Style Auto-Updates for PyQt6 Desktop Apps
Desktop software has a distribution problem that web apps solved years ago: how do users get updates? On mobile, the App Store and Play Store handle it transparently. On desktop, every platform does it differently, and most of them badly.
The gold standard is Sparkle -- the open-source update framework used by Panic, Sublime Text, and practically every serious macOS indie app. It works: the app periodically fetches an RSS/XML feed (called an appcast), compares the latest version against the installed version, and if newer, downloads and applies the update with proper signature verification.
But Sparkle is macOS-only. There is no equivalent for cross-platform PyQt6 apps that ship on Windows, macOS, and Linux from the same Python codebase.
I needed this for OCR Receipt, a desktop app I am building that processes receipt PDFs through Tesseract and structured extraction. The app targets freelancers and small businesses on all three platforms. Manually emailing users "download v1.1 from the website" was not an option.
So I built it.
The Architecture
The update system has three layers, mirroring Sparkle's design:
Appcast XML (hosted on CDN)
|
v
Updater (sync logic: fetch, parse, compare, download)
|
v
UpdateChecker (QThread wrapper: runs in background, emits Qt signals)
|
v
Main Window (connects signals, shows notification)
Layer 1: The Appcast
The appcast is a static RSS 2.0 XML file hosted on the app's CDN. Each release is an <item> element inside an RSS <channel>:
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<item>
<title>Version 1.0.0</title>
<sparkle:version>1.0.0</sparkle:version>
<enclosure
url="https://ocrreceipt.com/downloads/OCR-Receipt-1.0.0.exe"
sparkle:edSignature="BASE64_ED25519_SIG"
length="12345678"
type="application/octet-stream"/>
</item>
</channel>
</rss>
The critical part is sparkle:edSignature -- an Ed25519 signature of the installer archive. The client verifies this signature before launching the downloaded installer. Without it, an attacker who compromises the CDN can serve a malicious binary and the user accepts it automatically.
Layer 2: The Updater (Synchronous Core)
The Updater class handles the sync workflow: fetch the appcast, parse the XML, compare SemVer strings, and download the archive. Pure Python, no Qt dependency, testable in isolation.
class Updater:
def __init__(self, appcast_url, current_version, timeout=15):
self.appcast_url = appcast_url
self.current_version = current_version
def check(self) -> Optional[UpdateInfo]:
raw = self._fetch_url(self.appcast_url)
return self._parse_appcast(raw)
Version comparison is strict SemVer. If 1.0.0 is installed and the appcast advertises 1.0.0, no update. If it advertises 1.0.1, the user gets notified. The _compare_versions method zero-pads segments so 1.0 and 1.0.0 compare correctly -- a surprising number of naive implementations get this wrong.
The download method streams the archive in 64 KB chunks with a progress callback, so the UI can display a download progress bar without loading the entire file into memory:
def _download_to(self, url, target, progress_callback=None, chunk_size=64*1024):
req = urllib.request.Request(url)
with urllib.request.urlopen(req) as resp:
total = int(resp.headers.get("Content-Length", "0"))
downloaded = 0
with open(target, "wb") as f:
while True:
chunk = resp.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if progress_callback and total:
progress_callback(downloaded, total)
Layer 3: UpdateChecker (QThread)
Qt has a hard rule: never block the main thread. Network requests during an update check would freeze the entire UI for multiple seconds. The UpdateChecker subclass of QThread handles this cleanly:
class UpdateChecker(QThread):
update_available = pyqtSignal(str, str, str)
up_to_date = pyqtSignal(str)
check_error = pyqtSignal(str)
def run(self):
try:
updater = Updater(appcast_url=self._appcast_url, ...)
info = updater.check()
if info is not None:
self.update_available.emit(info.version, info.download_url, ...)
else:
self.up_to_date.emit(self._current_version)
except URLError as e:
self.check_error.emit(f"Network error: {e.reason}")
The main window connects to these signals at startup. When update_available fires, a small notification appears in the corner of the window -- unobtrusive, non-blocking. Clicking it triggers the download and opens the installer.
Why Sparkle's Pattern Still Matters
Sparkle was designed in 2006, before Sparkle 2 added Ed25519 signatures and phased out DSA. The pattern has survived because it is simple and secure:
Simple. A static XML file on a CDN. No database, no API, no authentication. The app uses HTTP caching so a 304 response costs pennies per user.
Secure. Ed25519 signatures prove the installer came from the developer. An attacker who compromises the CDN or MITMs the download cannot replace the binary. The public key ships with the app binary and is never transmitted.
Cross-platform from one codebase. The same Updater and UpdateChecker classes work on Windows (downloading an NSIS .exe), macOS (downloading a .dmg), and Linux (downloading an .AppImage). The only platform-specific code is what happens after the download completes -- launching the installer.
The Installer Plumbing
On each platform, the "apply update" step is different:
- Windows: Download the new NSIS
.exe, launch it silently with/Sfor unattended upgrade, then quit the app. The installer handles replacing the old files. - macOS: Download the new
.dmg, attach it, copy the.appto/Applications, detach it. No notarization headache for updates since Sparkle-style apps on macOS typically request the user to drag the new app manually (or use a helper tool). - Linux: Download the new
.AppImage, replace the old one. AppImages are single files, so this is literallymv new.AppImage old.AppImage. No dependency resolution, no packaging hell.
What Surprised Me
I expected the cross-platform differences to be the hard part. They were not. What was genuinely hard was the version comparison edge cases.
SemVer looks simple until you parse it from an XML element and realize the RSS spec technically only supports CDATA sections, not typed attributes. Sparkle's namespace extension (sparkle:version) is not part of any standard. The xml.etree.ElementTree namespace handling required explicit namespace registration, which I had to work around with a custom _s() helper that prefixes the Sparkle namespace URL directly.
Another surprise: PyInstaller bundles xml.etree.ElementTree differently on each platform. On macOS, the module is bundled in the .app but the namespace constants resolve slightly differently than on Linux. The fix was to declare the namespace map as a module-level constant and always pass it explicitly to find() and findall().
The Result
The whole system is 418 lines, self-contained in a single module. Zero external dependencies beyond Python's standard library, PyQt6 (which the app already uses), and an Ed25519 signing tool run once per release.
For a comparison, Sparkle's Objective-C implementation is roughly 6,000 lines across 15 files. The cross-platform equivalent in Python compresses to one file because Python's standard library provides HTTP, XML parsing, and JSON serialization -- things Sparkle has to vendor or link against.
When You Should Build This
If you ship a desktop app built with PyQt6, PySide6, or any Python GUI framework:
- Use this pattern if your app is installed locally (not a portable binary).
- Use this pattern if you want users to get updates without visiting a website.
- Do not build your own if your app is a web app wrapped in Electron/Tauri -- those ecosystems already have update solutions (electron-updater, Tauri updater).
But if you are shipping native PyQt6 to real users on all three platforms, this pattern is the right level of effort. 418 lines, no cloud dependencies, Ed25519 security, and a user experience that matches what desktop users expect from serious software.