Files

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-*.yml metadata 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-image reshuffles 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 Range requests (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.
  • .deb and .rpm never carry a block map — electron-builder publishes none for them, at any compression level — so they are always downloaded whole, regardless of compressionLevel.
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

  1. Check — Detects current OS/arch, fetches the appropriate latest-*.yml from the provider, parses it, and compares versions
  2. Select — Picks the right binary from the file list based on OS, architecture, and installer format
  3. Download — Assembles the binary from the previous artifact plus ranged requests for the changed blocks when possible, otherwise streams it whole, emitting DownloadProgress via Flow
  4. Verify — Computes SHA-512 of the result and compares against the expected hash
  5. Cache — Keeps the artifact and its block map so the next update only fetches what changed
  6. 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'