In-app update checking and silent update application for Java desktop apps.
Compatible with the Sparkle appcast format — the same XML feed works for macOS Sparkle, WinSparkle, and sparkle4j.
- Background update checking with configurable intervals
- Native-feeling Swing dialog with release notes and download progress
- Silent update application on Windows (Inno Setup / NSIS), macOS (.app bundle), and Linux (dpkg / rpm)
- Ed25519 signature verification
- ETag / Last-Modified caching
- Zero runtime dependencies beyond JDK 17+
sparkle4j is published on GitHub Packages.
Maven — add to your pom.xml:
<repositories>
<repository>
<id>github-sparkle4j</id>
<url>https://maven.pkg.github.com/rygel/sparkle4j</url>
</repository>
</repositories>Gradle — add to your build.gradle or settings.gradle:
repositories {
maven {
url = uri("https://maven.pkg.github.com/rygel/sparkle4j")
credentials {
username = project.findProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR")
password = project.findProperty("gpr.token") ?: System.getenv("GITHUB_TOKEN")
}
}
}GitHub Packages requires authentication even for public packages. You need a GitHub personal access token with
read:packagesscope. See GitHub docs for setup details.
Maven:
<dependency>
<groupId>io.github.rygel</groupId>
<artifactId>sparkle4j</artifactId>
<version>0.5.6</version>
</dependency>Gradle:
implementation 'io.github.rygel:sparkle4j:0.5.6'import io.github.rygel.sparkle4j.Sparkle4j;
import javax.swing.SwingUtilities;
// In your app's startup, after the main window is visible:
SwingUtilities.invokeLater(() -> {
Sparkle4j.builder()
.appcastUrl("https://example.com/appcast.xml")
.currentVersion("1.0.0")
.parentComponent(mainWindow)
.build()
.checkInBackground();
});That's it. The library fetches the appcast feed in the background, compares versions, and shows a dialog if an update is available. The user can view release notes, download the update with a progress bar, and install it — all without leaving your app.
- Check — fetches the appcast XML over HTTPS (with ETag caching to avoid redundant downloads)
- Compare — finds the highest version for the current platform and compares it to the running version
- Dialog — shows a Swing dialog with release notes and three buttons:
┌──────────────────────────────────────────────────┐
│ My App 1.1.0 is available │
│ You have 1.0.0. Would you like to update? │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Release notes (scrollable) │ │
│ │ Rendered from Markdown or HTML │ │
│ └──────────────────────────────────────────┘ │
│ │
│ [Skip This Version] [Remind Me Later] [Install] │
└──────────────────────────────────────────────────┘
- Download — streams the installer to a temp file with a progress bar and cancel button
- Verify — checks the Ed25519 signature (if configured) before applying
- Apply — launches the platform installer and exits the app
All options with their defaults:
import io.github.rygel.sparkle4j.Sparkle4j;
import io.github.rygel.sparkle4j.Sparkle4jInstance;
Sparkle4jInstance updater = Sparkle4j.builder()
// Required — URL of the appcast XML feed (must be HTTPS)
.appcastUrl("https://example.com/appcast.xml")
// Required — the running version of your app (SemVer)
.currentVersion("1.0.0")
// Recommended — Base64-encoded Ed25519 public key for signature verification.
// If null, verification is skipped (not recommended for production).
.publicKey("MCowBQYDK2VwAyEA...")
// Optional — name shown in the update dialog (default: JAR manifest Implementation-Title)
.appName("My App")
// Optional — Swing parent component for the modal dialog (default: centered on screen)
.parentComponent(mainWindow)
// Optional — minimum hours between checks, 0 = every launch (default: 24)
.checkIntervalHours(24)
// Optional — macOS only: path to the .app bundle (default: auto-detected)
.macosAppPath(Path.of("/Applications/MyApp.app"))
// Optional — called on the EDT when an update is found.
// Return true to show the built-in dialog, false to suppress it and handle UI yourself.
.onUpdateFound(item -> {
System.out.println("Update available: " + item.version());
return true; // show built-in dialog
})
.build();
// Fire-and-forget (shows dialog on EDT if update found)
updater.checkInBackground();Use checkNow() for full control. Run it on a background thread, not the EDT:
Sparkle4jInstance updater = Sparkle4j.builder()
.appcastUrl("https://example.com/appcast.xml")
.currentVersion("1.0.0")
.build();
UpdateItem update = updater.checkNow();
if (update != null) {
System.out.println("New version: " + update.shortVersionString());
System.out.println("Download URL: " + update.url());
System.out.println("Release notes: " + update.releaseNotesUrl());
System.out.println("Critical: " + update.isCriticalUpdate());
// Download and apply directly (skips the built-in dialog)
updater.applyUpdate(update);
}Users can skip versions via the dialog button. You can also do it programmatically:
updater.skipVersion("1.1.0"); // persisted across app restarts via java.util.prefsReturn false from onUpdateFound to handle the update entirely in your own UI:
Sparkle4j.builder()
.appcastUrl("https://example.com/appcast.xml")
.currentVersion("1.0.0")
.onUpdateFound(item -> {
// Show your own notification, toast, menu badge, etc.
showCustomUpdateBanner(item.shortVersionString(), item.releaseNotesUrl());
return false; // suppress the built-in Swing dialog
})
.build()
.checkInBackground();| Method | Description |
|---|---|
static Sparkle4jBuilder builder() |
Creates a new builder |
| Method | Returns | Description |
|---|---|---|
checkInBackground() |
void |
Fire-and-forget async check. Shows dialog on the EDT if an update is found. |
checkNow() |
UpdateItem |
Blocking check. Returns the newest available update, or null if up to date. |
applyUpdate(UpdateItem) |
void |
Download and apply immediately (skips the dialog). |
skipVersion(String) |
void |
Suppress future checks for this version (persisted across launches). |
| Accessor | Type | Description |
|---|---|---|
title() |
String |
Item title from the feed |
version() |
String |
Machine-readable version for comparison |
shortVersionString() |
String |
Human-readable display version |
url() |
String |
Direct download URL (HTTPS) |
length() |
long |
File size in bytes |
edSignature() |
String |
Base64 Ed25519 signature (nullable) |
releaseNotesUrl() |
String |
URL to Markdown/HTML release notes (nullable) |
inlineDescription() |
String |
Inline release notes from <description> (nullable) |
minimumJavaVersion() |
Integer |
Minimum Java major version (nullable) |
pubDate() |
String |
Publication date string (nullable) |
os() |
String |
Platform: "windows", "macos", or "linux" |
isCriticalUpdate() |
boolean |
If true, user cannot skip this version |
installerType() |
String |
Windows installer type: "inno" or "nsis" |
The feed is standard RSS 2.0 with Sparkle namespace extensions. One <enclosure> per platform:
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>My App</title>
<item>
<title>My App 1.1.0</title>
<pubDate>Fri, 10 Apr 2026 12:00:00 +0000</pubDate>
<sparkle:version>1.1.0</sparkle:version>
<sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
<sparkle:releaseNotesLink>https://example.com/changelog.md</sparkle:releaseNotesLink>
<!-- Optional: mark as critical (disables "Skip This Version") -->
<!-- <sparkle:criticalUpdate/> -->
<!-- Windows -->
<enclosure
url="https://example.com/releases/myapp-1.1.0-windows.exe"
sparkle:os="windows"
length="185000000"
type="application/octet-stream"
sparkle:edSignature="BASE64_ED25519_SIGNATURE"
sparkle:installerType="inno"/>
<!-- macOS -->
<enclosure
url="https://example.com/releases/myapp-1.1.0-macos.zip"
sparkle:os="macos"
length="170000000"
type="application/zip"
sparkle:edSignature="BASE64_ED25519_SIGNATURE"/>
<!-- Linux -->
<enclosure
url="https://example.com/releases/myapp-1.1.0.deb"
sparkle:os="linux"
length="160000000"
type="application/vnd.debian.binary-package"
sparkle:edSignature="BASE64_ED25519_SIGNATURE"/>
</item>
<!-- Older versions can remain in the feed; sparkle4j picks the highest -->
</channel>
</rss>| Field | Required | Description |
|---|---|---|
sparkle:version |
Yes | Machine-readable version for comparison (SemVer) |
sparkle:os on <enclosure> |
Yes | Platform: windows, macos, linux |
enclosure url |
Yes | Direct download URL (must be HTTPS) |
enclosure length |
Yes | File size in bytes (used for progress bar) |
sparkle:edSignature |
No | Base64 Ed25519 signature of the file |
sparkle:shortVersionString |
No | Human-readable display version |
sparkle:releaseNotesLink |
No | URL to Markdown or HTML release notes |
sparkle:minimumJavaVersion |
No | Minimum Java major version (e.g. 17) |
sparkle:installerType |
No | Windows only: inno (default) or nsis |
sparkle:criticalUpdate |
No | Presence disables "Skip This Version" |
pubDate |
No | Displayed in the update dialog |
sparkle4j includes a CLI tool to generate or update the appcast XML on each release:
java -cp sparkle4j.jar io.github.rygel.sparkle4j.tools.AppcastGenerator <version> <appcast-file>| Variable | Required | Description |
|---|---|---|
SPARKLE_PRIVATE_KEY |
Yes | Base64-encoded PKCS#8 Ed25519 private key |
APPCAST_BASE_URL |
Yes | Base download URL (e.g. https://example.com/releases) |
INSTALLER_WINDOWS |
No | Path to the Windows .exe installer |
INSTALLER_MACOS |
No | Path to the macOS .zip or .dmg installer |
INSTALLER_LINUX |
No | Path to the Linux .deb or .rpm package |
RELEASE_NOTES_URL |
No | URL to the release notes page |
APP_NAME |
No | Application name (default: "Application") |
export SPARKLE_PRIVATE_KEY="$SPARKLE_SIGNING_KEY"
export APPCAST_BASE_URL="https://github.com/myorg/myapp/releases/download/v1.1.0"
export INSTALLER_WINDOWS="target/myapp-1.1.0-windows.exe"
export INSTALLER_MACOS="target/myapp-1.1.0-macos.zip"
export INSTALLER_LINUX="target/myapp-1.1.0.deb"
export RELEASE_NOTES_URL="https://example.com/changelog.md"
export APP_NAME="My App"
java -cp sparkle4j.jar io.github.rygel.sparkle4j.tools.AppcastGenerator 1.1.0 appcast.xmlThe tool signs each installer with Ed25519, generates SHA-256 hashes, creates a new <item> block,
and prepends it to the existing appcast XML (or creates a new one if the file doesn't exist).
sparkle4j uses Ed25519 for verifying downloads. Generate a keypair once:
# Generate private key
openssl genpkey -algorithm ed25519 -out private.pem
# Export public key as base64 (embed this in your app)
openssl pkey -in private.pem -pubout -outform DER | base64 -w 0
# Export private key as base64 PKCS#8 (store in CI secrets for AppcastGenerator)
openssl pkcs8 -topk8 -nocrypt -in private.pem -outform DER | base64 -w 0
# Sign a release file manually
openssl pkeyutl -sign -inkey private.pem -in myapp-1.1.0-windows.exe | base64 -w 0Pass the public key to sparkle4j:
Sparkle4j.builder()
.appcastUrl("https://example.com/appcast.xml")
.currentVersion("1.0.0")
.publicKey("MCowBQYDK2VwAyEA...") // base64 from the export step above
.build()
.checkInBackground();Both raw 32-byte keys (Sparkle format) and DER-encoded X.509 keys (Java standard, 44 bytes) are accepted.
If publicKey is null, signature verification is skipped with a warning log.
| Platform | What happens after "Install Now" |
|---|---|
| Windows | Renames download to .exe, launches installer with /PASSIVE /NORESTART (Inno) or /S (NSIS), exits the app |
| macOS | Launches a bundled helper script that waits for the JVM to exit, replaces the .app bundle, and relaunches. The script must be bundled at Contents/MacOS/sparkle4j-updater inside your .app. Falls back to opening the browser if the script is missing. |
| Linux | Runs pkexec dpkg -i (.deb) or pkexec rpm -U (.rpm) with a polkit password prompt, then exits. Falls back to opening the browser for unknown formats (e.g. AppImage). |
The update dialog renders release notes from sparkle:releaseNotesLink:
- HTML — rendered directly in a
JEditorPane - Markdown (
.mdextension or content-sniffed) — converted to HTML. Uses flexmark-java if on the classpath; otherwise falls back to a built-in converter supporting headings, bold, lists, code blocks, and links - Inline — if no URL is provided, the
<description>element content is used as a fallback - Errors — network failures show "Could not load release notes." without blocking the dialog
The library never throws exceptions to the caller or crashes the host app.
| Error | Behavior |
|---|---|
| Network failure fetching appcast | Logs WARN, silently aborts. Retries next launch. |
| Malformed appcast XML | Logs ERROR, silently aborts. |
| No enclosure for current platform | No update available (not an error). |
| HTTP failure during download | Shows error dialog: "Download failed. Try again later." |
| Signature mismatch | Logs ERROR, deletes temp file, shows error dialog. Never applies. |
| Helper script not found (macOS) | Logs ERROR, falls back to opening the browser. |
- Java 17+ (uses
java.net.http.HttpClient,ProcessHandle, EdDSA, records) - Swing for the built-in update dialog
- No runtime dependencies — just the JDK