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
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.
| Platform | Updatable | Managed by the store |
|---|---|---|
| macOS | DMG, ZIP | PKG |
| Windows | NSIS, NSIS Web, MSI | AppX/MSIX |
| Linux | DEB, RPM, AppImage | Snap, 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 behttps(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:
| Format | Block map |
|---|---|
| NSIS, macOS ZIP / DMG | Standalone gzipped <artifact>.blockmap next to the installer |
| AppImage | Map 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:
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:
- A root-owned update helper shipped inside the package at
/opt/<App>/nucleus-update-helper(sodpkg -S/rpm -qfattribute 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. - Your signing public key, bundled at
/opt/<App>/resources/nucleus-update.pub.asc. - At update time the runtime downloads the package and its detached
<file>.ascsignature (CI uploads*.ascnext to the packages when Linux GPG secrets are present), resolves the helper by walking up frombin/, then runspkexec <helper> <file>. - The helper verifies the signature against the bundled key, checks that the package name matches
the installed app, and only then runs
dpkg -iorrpm -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— returnsAvailable(info, level),NotAvailable, orError(exception).fun downloadUpdate(info: UpdateInfo): Flow<DownloadProgress>— emits progress; the final emission hasfile != 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(): Booleanandfun consumeUpdateEvent(): UpdateEvent?— post-update detection.
Result and level types
UpdateResult.Available(info: UpdateInfo, level: UpdateLevel)UpdateResult.NotAvailableUpdateResult.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:
| Field | Default | Notes |
|---|---|---|
currentVersion | from NucleusApp / jpackage / marker | Used for channel and compare |
provider | required | GitHubProvider, GenericProvider, … |
channel | "latest" | Release channel name |
allowDowngrade | false | |
allowPrerelease | false | Also true when currentVersion contains - |
executableType | auto | Override detected package type |
httpClient | JDK client with redirects | Use NativeHttpClient for private CAs |
differentialDownload | true | Assemble from cache + range requests; safe to leave on |
cacheDir | platform default under nucleus/updates/<appId> | Previous artifact + block map |
Providers
GitHubProvider(owner, repo, token? = null)GenericProvider(baseUrl)— thebaseUrlmust usehttps. A non-httpsorigin throwsIllegalArgumentExceptionat construction.httpis 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
| OS | Format | Command |
|---|---|---|
| Linux | DEB | pkexec dpkg -i <file> |
| Linux | RPM | pkexec rpm -U <file> |
| Linux | AppImage | replace in place |
| macOS | DMG/ZIP | open <file> / extract |
| Windows | NSIS/EXE | <file> /S |
| Windows | MSI | msiexec /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
Authorizationheader, 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-runtimeentirely.
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.