updater-runtime
Auto-update library for JVM desktop applications packaged with the Nucleus plugin (electron-builder). This module reproduces the electron-updater flow adapted to the JVM/Kotlin ecosystem.
Features
- Fetches
latest-*.ymlmetadata from GitHub Releases or any HTTP server - Semver comparison with pre-release support (
1.0.0-beta.1 < 1.0.0) - Automatic file selection based on current OS, architecture, and installer format
- Streaming download with progress reporting via Kotlin
Flow - SHA-512 integrity verification (base64-encoded, matching electron-builder format)
- Platform-specific installer launch (DEB, RPM, DMG, PKG, EXE/NSIS, MSI)
- Private repository support via GitHub token
Setup
// build.gradle.kts
dependencies {
implementation(project(":updater-runtime"))
}
Quick start
val updater = NucleusUpdater {
provider = GitHubProvider(owner = "myorg", repo = "myapp")
}
// Check
when (val result = updater.checkForUpdates()) {
is UpdateResult.Available -> {
println("New version: ${result.info.version}")
// Download with progress
updater.downloadUpdate(result.info).collect { progress ->
println("${progress.percent.toInt()}%")
if (progress.file != null) {
// Download complete, install
updater.installAndRestart(progress.file!!)
}
}
}
is UpdateResult.NotAvailable -> println("Up to date")
is UpdateResult.Error -> println("Error: ${result.exception.message}")
}
Configuration
NucleusUpdater {
currentVersion = "1.0.0" // Defaults to System.getProperty("jpackage.app-version") or "0.1.0"
provider = GitHubProvider(...) // Required — update source
channel = "latest" // "latest", "beta", or "alpha"
allowDowngrade = false // Allow installing older versions
allowPrerelease = false // Auto-set to true if currentVersion contains "-"
executableType = null // Force format (deb, rpm, dmg...), auto-detected if null
}
Providers
GitHub Releases
provider = GitHubProvider(
owner = "myorg",
repo = "myapp",
token = "ghp_..." // Optional, for private repos
)
Metadata URL: https://github.com/{owner}/{repo}/releases/latest/download/latest-{suffix}.yml
Download URL: https://github.com/{owner}/{repo}/releases/download/v{version}/{fileName}
Generic HTTP server
provider = GenericProvider(
baseUrl = "https://updates.example.com"
)
Metadata URL: {baseUrl}/latest-{suffix}.yml
Download URL: {baseUrl}/{fileName}
API reference
NucleusUpdater
| Method | Description |
|---|---|
suspend checkForUpdates(): UpdateResult |
Checks for a newer version |
downloadUpdate(info): Flow<DownloadProgress> |
Downloads the binary, emits progress |
installAndRestart(file: File) |
Launches the installer, exits the process, and relaunches after install |
installAndQuit(file: File) |
Launches the installer and exits without relaunching — update takes effect on next start |
UpdateResult
sealed class UpdateResult {
data class Available(val info: UpdateInfo)
data object NotAvailable
data class Error(val exception: UpdateException)
}
DownloadProgress
data class DownloadProgress(
val bytesDownloaded: Long,
val totalBytes: Long,
val percent: Double, // 0.0 .. 100.0
val file: File? = null, // Non-null on the final emission (download complete)
val isDifferential: Boolean = false, // Only the changed blocks are being fetched
)
bytesDownloaded and totalBytes count the bytes actually transferred, so for a differential
download totalBytes is the size of the delta rather than of the artifact — percent stays a
faithful measure of the wait.
Differential updates
An update is normally assembled from the copy already on the machine, downloading only the blocks that changed — typically a few percent of the artifact. A Compose Desktop app is an excellent candidate: the bundled Java runtime and the dependency jars are byte-identical between two releases, so only the application jar actually moves.
This uses the block maps the Nucleus Gradle plugin already publishes — a standalone
<artifact>.blockmap next to NSIS installers and macOS ZIPs, and the one electron-builder appends to
an AppImage's own tail — and the same algorithm as electron-updater, so no per-version delta files
are produced at build time and nothing has to be kept in sync on the server. Blocks are matched by
content, which means an update from 1.0 straight to 1.7 is differential too.
| Format | Differential | Notes |
|---|---|---|
| AppImage | ✅ from the first update | The running executable is the previous artifact, block map included |
| macOS ZIP | ✅ from the second update | Per-entry deflate preserves block locality |
NSIS (.exe) |
✅ from the second update | electron-builder builds the payload non-solid with a 1 MB dictionary for exactly this |
| DMG | ⚠️ poor yield | The macOS updater uses the ZIP anyway |
| DEB / RPM / MSI | ❌ | A single solid compressed stream; the updater downloads them whole |
Measured yield
Real NSIS installers of the sample apps, two builds apart with a one-line source change between
them (scripts/measure-delta.sh produces the pair, RealArtifactDeltaTest measures it):
| Build | Installer | Transferred per update | Ratio |
|---|---|---|---|
| JVM, no ProGuard | 101.3 MB | 1.4 MB | 1.4 % |
JVM, ProGuard optimize + obfuscate |
47.4 MB | 13.8 MB | 29.1 % |
GraalVM native image, -O3 |
46.2 MB | 32.5 MB | 70.3 % |
The bundled Java runtime deltas perfectly — the block map shows the jlink image reused in full. What ruins the ratio is code that gets rewritten wholesale:
- ProGuard obfuscation reassigns names across every minified jar, so a single changed string invalidates ~21 MB of compressed payload. It halves the first download and multiplies every later one by ten; past roughly the fifth update, shipping without obfuscation has transferred less in total, and each individual update is 10× cheaper.
- A native image is a single blob whose layout
native-imagereshuffles globally: 100 % of the executable's blocks change, and only the DLLs shipped beside it are reused. Deltas are close to pointless there.
Requirements and behaviour:
- The release host must honour HTTP
Rangerequests (GitHub Releases, S3 and any static host do). - The previous artifact must be on disk. Every download leaves it — hard-linked, so it costs no
extra space — in
%LOCALAPPDATA%/nucleus/updates/<appId>on Windows and~/.cache/nucleus/updates/<appId>elsewhere. A fresh install therefore downloads its first update in full, except for AppImages. - Anything missing or inconsistent — no block map published, a host that ignores
Range, a stale cache, a digest mismatch on the assembled file — silently falls back to a full download. The assembled file is validated against the manifest SHA-512 before it is ever handed to the installer, exactly like a full download. .deband.rpmnever carry a block map — electron-builder publishes none for them, at any compression level — so they are always downloaded whole, regardless ofcompressionLevel.
NucleusUpdater {
differentialDownload = false // opt out (default: true)
cacheDir = File("/custom/path") // relocate the basis cache
}
Compose Desktop integration
@Composable
fun UpdateBanner() {
val updater = remember {
NucleusUpdater {
currentVersion = "1.0.0"
provider = GitHubProvider(owner = "myorg", repo = "myapp")
}
}
var status by remember { mutableStateOf("Checking...") }
var progress by remember { mutableStateOf(-1.0) }
var downloadedFile by remember { mutableStateOf<File?>(null) }
LaunchedEffect(Unit) {
when (val result = updater.checkForUpdates()) {
is UpdateResult.Available -> {
status = "Downloading v${result.info.version}..."
updater.downloadUpdate(result.info).collect {
progress = it.percent
if (it.file != null) {
downloadedFile = it.file
status = "Ready to install v${result.info.version}"
}
}
}
is UpdateResult.NotAvailable -> status = "Up to date"
is UpdateResult.Error -> status = "Error: ${result.exception.message}"
}
}
Column {
Text(status)
if (progress in 0.0..99.9) {
LinearProgressIndicator(progress = { (progress / 100.0).toFloat() })
}
downloadedFile?.let { file ->
Button(onClick = { updater.installAndRestart(file) }) {
Text("Install & Restart")
}
}
}
}
How it works
- Check — Detects current OS/arch, fetches the appropriate
latest-*.ymlfrom the provider, parses it, and compares versions - Select — Picks the right binary from the file list based on OS, architecture, and installer format
- Download — Assembles the binary from the previous artifact plus ranged requests for the
changed blocks when possible, otherwise streams it whole, emitting
DownloadProgressvia Flow - Verify — Computes SHA-512 of the result and compares against the expected hash
- Cache — Keeps the artifact and its block map so the next update only fetches what changed
- Install — Launches the platform-specific installer and exits the process
Installer behavior per platform
| Platform | Format | Command |
|---|---|---|
| Linux | DEB | sudo dpkg -i <file> |
| Linux | RPM | sudo rpm -U <file> |
| macOS | DMG/PKG | open <file> |
| Windows | EXE/NSIS | <file> /S (silent) |
| Windows | MSI | msiexec /i <file> /passive |
YML format
The module parses the latest-*.yml format produced by electron-builder:
version: 1.2.3
files:
- url: MyApp-1.2.3-linux-amd64.deb
sha512: VkJl1gDqcBHYbYhMb0HRI...
size: 68461240
blockMapSize: 71732
- url: MyApp-1.2.3-linux-arm64.deb
sha512: qJ8a5gFDCwv0R2rW6lM3k...
size: 65432100
path: MyApp-1.2.3-linux-amd64.deb
sha512: VkJl1gDqcBHYbYhMb0HRI...
releaseDate: '2025-01-15T10:30:00.000Z'