Nucleus
Packaging & distribution

Auto-update

Check for, download, verify, and install application updates from Kotlin, without a third-party service.

The updater-runtime module checks for, downloads, verifies, and installs application updates from Kotlin. It has a build-time half — latest-*.yml metadata generated next to your installers — and a runtime half, the NucleusUpdater class. The metadata format is compatible with electron-builder's update format.

Add the dependency

build.gradle.kts
dependencies {
    implementation("dev.nucleusframework:nucleus.updater-runtime:2.5.15")
    // Optional, for corporate networks with a private root CA:
    implementation("dev.nucleusframework:nucleus.native-http:2.5.15")
}

Check and install an update

Create a NucleusUpdater, check the configured provider, then download and install:

import dev.nucleusframework.updater.NucleusUpdater
import dev.nucleusframework.updater.UpdateResult
import dev.nucleusframework.updater.provider.GitHubProvider
import java.io.File

val updater = NucleusUpdater {
    provider = GitHubProvider(owner = "myorg", repo = "myapp")
}

when (val result = updater.checkForUpdates()) {
    is UpdateResult.Available -> {
        var installer: File? = null
        updater.downloadUpdate(result.info).collect { progress ->
            println("${progress.percent.toInt()}%")
            progress.file?.let { installer = it }
        }
        // installAndRestart launches the installer, exits, and relaunches
        installer?.let(updater::installAndRestart)
    }
    UpdateResult.NotAvailable -> println("Up to date")
    is UpdateResult.Error -> println("Error: ${result.exception.message}")
}

checkForUpdates() is a suspend function; call it from a coroutine.

Supported formats

The runtime updates in-place formats and leaves store-managed formats to their store.

PlatformUpdatableManaged by the store
macOSDMG, ZIPPKG
WindowsNSIS, NSIS Web, MSIAppX/MSIX
LinuxDEB, RPM, AppImageSnap, Flatpak

macOS needs a ZIP alongside the DMG

The updater replaces the .app silently from the ZIP; the DMG is only for the first install. Add TargetFormat.Zip next to TargetFormat.Dmg so both ship in the same release — latest-mac.yml references both.

How it works

Metadata

The plugin writes one YAML file per platform, listing every installer with its SHA-512 and size. Multi-arch or multi-platform releases need a single YAML per platform that lists every architecture; CI merges them in the release job. A latest-mac.yml looks like this:

version: 1.2.3
files:
  - url: MyApp-1.2.3-macos-arm64.dmg
    sha512: VkJl1gDqcBHYbYhMb0HRI...
    size: 102400000
  - url: MyApp-1.2.3-macos-arm64.zip
    sha512: qJ8a5gFDCwv0R2rW6lM3k...
    size: 98000000
releaseDate: '2026-03-01T12:00:00.000Z'

Channels

The version tag drives the channel: v1.0.0 maps to latest, v1.0.0-beta.1 to beta, and v1.0.0-alpha.1 to alpha. Each channel has its own *-mac.yml, *.yml, and *-linux.yml. Users on beta see latest and beta; users on alpha see all three.

Hosting

Hosting is configured in nativeDistributions.publish { } (see publishing):

  • GitHub Releases — the release workflow handles it end to end.
  • S3 — set credentials via environment variables; the plugin uploads alongside the YAML.
  • Generic HTTPS — upload to any static host yourself; the updater fetches <baseUrl>/latest-*.yml. The base URL must be https (see Providers).

The publish { } block only generates electron-builder config — it does not upload. CI does the upload.

Differential downloads

From 2.3, downloadUpdate tries a differential download before a full fetch. It matches electron-builder block maps of the new artifact against a cached copy of the previous one, copies unchanged blocks from disk, and fetches the rest with HTTP range requests. Blocks are matched by content, so jumps such as 1.0 → 1.7 are differential too — no pairwise delta files are ever produced.

The Nucleus Gradle plugin already publishes the maps the runtime needs:

FormatBlock map
NSIS, macOS ZIP / DMGStandalone gzipped <artifact>.blockmap next to the installer
AppImageMap appended in the AppImage trailer (blockMapSize in latest-linux.yml)

Behaviour:

  • AppImage — the running executable is the previous artifact, so the first update can already be differential.
  • Other formats — the updater caches each successful download (and its map) under cacheDir (default %LOCALAPPDATA%/nucleus/updates/<appId> on Windows, ~/.cache/nucleus/updates/<appId> elsewhere). The second update on is differential.
  • Any missing map, range-request failure, or digest mismatch silently falls back to a full download.
val updater = NucleusUpdater {
    provider = GitHubProvider(owner = "myorg", repo = "myapp")
    differentialDownload = true   // default
    // cacheDir = File("/var/cache/myapp/updates")  // optional override
}

updater.downloadUpdate(info).collect { progress ->
    val kind = if (progress.isDifferential) "differential" else "full"
    println(
        "${progress.percent.toInt()}% " +
            "(${progress.bytesDownloaded}/${progress.totalBytes}, $kind)",
    )
    progress.file?.let { installer = it }
}

DownloadProgress.bytesDownloaded / totalBytes count bytes transferred, so for a differential download totalBytes is smaller than the artifact size and percent tracks wait time faithfully. isDifferential is true when the differential path completed.

Set differentialDownload = false to force full downloads (for example when a CDN does not support HTTP ranges).

Post-update detection

After installAndRestart or installAndQuit, the updater writes a marker file in the platform's app-data directory (resolved from NucleusApp.appId). On the next launch, wasJustUpdated() returns true and consumeUpdateEvent() yields an UpdateEvent(previousVersion, newVersion, updateLevel) you can use for a "What's new" banner.

Configure enterprise networks

If your users sit behind a corporate proxy with a private root CA, the default java.net.http.HttpClient fails the TLS handshake. Inject a NativeHttpClient that reads the OS trust store:

import dev.nucleusframework.nativehttp.NativeHttpClient

val updater = NucleusUpdater {
    provider = GitHubProvider(owner = "myorg", repo = "myapp")
    httpClient = NativeHttpClient.create()
}

See native-ssl for the trust-manager details.

Set up passwordless updates on Linux

By default, applying a DEB or RPM update runs through pkexec, which shows a PolicyKit password dialog. You can let the app install verified updates without a password, gated on a signature check.

This builds on Linux code signing. Enable it on the signing block:

build.gradle.kts
linux {
    signing {
        enabled.set(true)
        keyId.set("AB12CD34EF56")
        silentUpdate.set(true)   // requires enabled = true
    }
}

The package payload and post-install scripts set up passwordless updates as follows:

  1. A root-owned update helper shipped inside the package at /opt/<App>/nucleus-update-helper (so dpkg -S / rpm -qf attribute it to the package). afterInstall only hardens permissions and installs a polkit policy scoped to that exact binary (allow_active=yes); afterRemove removes the policy.
  2. Your signing public key, bundled at /opt/<App>/resources/nucleus-update.pub.asc.
  3. At update time the runtime downloads the package and its detached <file>.asc signature (CI uploads *.asc next to the packages when Linux GPG secrets are present), resolves the helper by walking up from bin/, then runs pkexec <helper> <file>.
  4. The helper verifies the signature against the bundled key, checks that the package name matches the installed app, and only then runs dpkg -i or rpm -U.

AppImage updates replace the binary in place while the previous FUSE mount still holds the old inode, wait for the old process to exit (single-instance lock), and relaunch with a cleaned environment (APPDIR, APPIMAGE, LD_LIBRARY_PATH, … unset) so the new image is not bound to the unmounted squashfs.

The polkit allow_active=yes rule is scoped to the single helper binary, and the helper refuses any package whose signature does not verify against the bundled key, so a passwordless install cannot install an arbitrary package — an attacker would need your private signing key. If the helper or the <file>.asc is missing, the runtime falls back to the password-prompting pkexec install and logs the reason. Passwordless install requires an active local session.

API reference

Runtime methods

NucleusUpdater exposes:

  • suspend fun checkForUpdates(): UpdateResult — returns Available(info, level), NotAvailable, or Error(exception).
  • fun downloadUpdate(info: UpdateInfo): Flow<DownloadProgress> — emits progress; the final emission has file != null. Preferential differential path when maps and cache allow; see Differential downloads.
  • fun installAndRestart(file: File) — launches the installer, exits the process, relaunches after install (AppImage: replace-in-place + clean relaunch).
  • fun installAndQuit(file: File) — silent install, no relaunch. The update applies on the next manual launch.
  • fun isUpdateSupported(): Boolean — whether the current executable type can auto-update.
  • fun wasJustUpdated(): Boolean and fun consumeUpdateEvent(): UpdateEvent? — post-update detection.

Result and level types

  • UpdateResult.Available(info: UpdateInfo, level: UpdateLevel)
  • UpdateResult.NotAvailable
  • UpdateResult.Error(exception: UpdateException)

UpdateLevel reports how large the jump is: MAJOR, MINOR, PATCH, or PRE_RELEASE. Branch your UI on it — force a dialog for major bumps, install patches silently.

Builder fields

NucleusUpdater { } configures a UpdaterConfig:

FieldDefaultNotes
currentVersionfrom NucleusApp / jpackage / markerUsed for channel and compare
providerrequiredGitHubProvider, GenericProvider, …
channel"latest"Release channel name
allowDowngradefalse
allowPrereleasefalseAlso true when currentVersion contains -
executableTypeautoOverride detected package type
httpClientJDK client with redirectsUse NativeHttpClient for private CAs
differentialDownloadtrueAssemble from cache + range requests; safe to leave on
cacheDirplatform default under nucleus/updates/<appId>Previous artifact + block map

Providers

  • GitHubProvider(owner, repo, token? = null)
  • GenericProvider(baseUrl) — the baseUrl must use https. A non-https origin throws IllegalArgumentException at construction. http is accepted only for loopback hosts (localhost, 127.0.0.0/8, ::1) so integration tests can serve fixtures without TLS.

Custom auth headers come from the provider's authHeaders(), not from the config.

The manifest, the checksums, and the artifact all come from that one origin, so plain http would let an on-path attacker rewrite the three together — the SHA-512 in the manifest protects nothing if the attacker also writes the manifest.

Installer commands

OSFormatCommand
LinuxDEBpkexec dpkg -i <file>
LinuxRPMpkexec rpm -U <file>
LinuxAppImagereplace in place
macOSDMG/ZIPopen <file> / extract
WindowsNSIS/EXE<file> /S
WindowsMSImsiexec /i <file> /passive

Exceptions

UpdateException is the base type. Subtypes: NetworkException, ChecksumException, NoMatchingFileException, ParseException.

Notes

  • SHA-512 checksums are verified after download; a failed check deletes the file and surfaces an error.
  • Passwordless Linux updates (signing.silentUpdate) add a second, stronger check: a GPG signature verified against the key bundled in the app.
  • GitHub tokens are sent in the Authorization header, never in URL parameters.
  • The detached installer script runs from a fresh owner-only directory (POSIX 0700) created per update, not from a fixed path in the shared temp directory.
  • For Mac App Store, Microsoft Store, Snapcraft, and Flathub, the store handles updates; skip updater-runtime entirely.

What's next

  • Publishing — configure providers in nativeDistributions.publish { }.
  • CI/CD — the release workflow that generates and uploads metadata.
  • Code signing — GPG signing for DEB/RPM and passwordless updates.