Skip to content

Commit 8579ac5

Browse files
committed
feat(updater): add installAndQuit() for silent background updates
Allows applying updates without relaunching the app — the update takes effect on next manual start. Transparent on macOS (DMG/ZIP) and Windows (NSIS/MSI in user mode); Linux DEB/RPM always requires elevation. Also fixes README references to the non-existent quitAndInstall().
1 parent 8cf5ba1 commit 8579ac5

4 files changed

Lines changed: 73 additions & 26 deletions

File tree

docs/auto-update.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ https://updates.example.com/MyApp-1.2.3-macos-arm64.dmg
185185
| `isUpdateSupported(): Boolean` | Check if the current executable type supports auto-update |
186186
| `suspend checkForUpdates(): UpdateResult` | Check for a newer version |
187187
| `downloadUpdate(info: UpdateInfo): Flow<DownloadProgress>` | Download the installer with progress |
188-
| `installAndRestart(installerFile: File)` | Launch the installer and exit the current process |
188+
| `installAndRestart(installerFile: File)` | Launch the installer, exit the current process, and relaunch after install |
189+
| `installAndQuit(installerFile: File)` | Launch the installer and exit without relaunching — the update is applied on next manual start |
189190

190191
#### DownloadProgress
191192

@@ -256,7 +257,7 @@ fun UpdateBanner() {
256257

257258
### Installer Behavior
258259

259-
The `installAndRestart()` method launches the platform-specific installer and exits the current process:
260+
The `installAndRestart()` method launches the platform-specific installer, exits the current process, and relaunches the app after installation:
260261

261262
| Platform | Format | Command |
262263
|----------|--------|---------|
@@ -266,6 +267,31 @@ The `installAndRestart()` method launches the platform-specific installer and ex
266267
| Windows | EXE/NSIS | `<file> /S` (silent) |
267268
| Windows | MSI | `msiexec /i <file> /passive` |
268269

270+
### Silent Update with `installAndQuit()`
271+
272+
The `installAndQuit()` method works like `installAndRestart()` but does **not** relaunch the application after installation. The update is applied silently in the background and takes effect the next time the user opens the app. This is useful for applying updates transparently (e.g. when the user closes the app).
273+
274+
```kotlin
275+
// Example: apply update silently on app close
276+
updater.downloadUpdate(result.info).collect { progress ->
277+
if (progress.file != null) {
278+
updater.installAndQuit(progress.file!!)
279+
}
280+
}
281+
```
282+
283+
#### Platform considerations
284+
285+
| Platform | Format | Silent? | Notes |
286+
|----------|--------|---------|-------|
287+
| macOS | DMG | Yes | Installed via `open`, no elevation needed |
288+
| macOS | ZIP | Yes | Extracted silently, no elevation needed |
289+
| Windows | NSIS/EXE | Depends | Silent if installed in **user mode**; requires UAC elevation if installed system-wide |
290+
| Windows | MSI | Depends | Silent if installed in **user mode**; requires UAC elevation if installed system-wide |
291+
| Linux | AppImage | Yes | Replaces the file in place, no elevation needed |
292+
| Linux | DEB | No | Always requires elevation (`pkexec`) |
293+
| Linux | RPM | No | Always requires elevation (`pkexec`) |
294+
269295
### Security
270296

271297
- All downloads are verified with **SHA-512** checksums (base64-encoded)

updater-runtime/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ when (val result = updater.checkForUpdates()) {
3838
println("${progress.percent.toInt()}%")
3939
if (progress.file != null) {
4040
// Download complete, install
41-
updater.quitAndInstall(progress.file!!)
41+
updater.installAndRestart(progress.file!!)
4242
}
4343
}
4444
}
@@ -94,7 +94,8 @@ Download URL: `{baseUrl}/{fileName}`
9494
|--------|-------------|
9595
| `suspend checkForUpdates(): UpdateResult` | Checks for a newer version |
9696
| `downloadUpdate(info): Flow<DownloadProgress>` | Downloads the binary, emits progress |
97-
| `quitAndInstall(file: File)` | Launches the installer and exits the process |
97+
| `installAndRestart(file: File)` | Launches the installer, exits the process, and relaunches after install |
98+
| `installAndQuit(file: File)` | Launches the installer and exits without relaunching — update takes effect on next start |
9899

99100
### UpdateResult
100101

@@ -156,7 +157,7 @@ fun UpdateBanner() {
156157
LinearProgressIndicator(progress = { (progress / 100.0).toFloat() })
157158
}
158159
downloadedFile?.let { file ->
159-
Button(onClick = { updater.quitAndInstall(file) }) {
160+
Button(onClick = { updater.installAndRestart(file) }) {
160161
Text("Install & Restart")
161162
}
162163
}

updater-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/updater/NucleusUpdater.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@ class NucleusUpdater(
116116

117117
fun installAndRestart(installerFile: File) {
118118
val platform = PlatformInfo.currentPlatform()
119-
PlatformInstaller.install(installerFile, platform)
119+
PlatformInstaller.install(installerFile, platform, restart = true)
120+
}
121+
122+
fun installAndQuit(installerFile: File) {
123+
val platform = PlatformInfo.currentPlatform()
124+
PlatformInstaller.install(installerFile, platform, restart = false)
120125
}
121126

122127
private fun doCheckForUpdates(): UpdateResult {

updater-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/updater/internal/PlatformInstaller.kt

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ internal object PlatformInstaller {
88
fun install(
99
file: File,
1010
platform: Platform,
11+
restart: Boolean = true,
1112
) {
1213
val extension = file.name.substringAfterLast('.').lowercase()
1314

1415
when {
15-
platform == Platform.MACOS && extension == "zip" -> installMacZip(file)
16-
platform == Platform.WINDOWS -> installWindows(file, extension)
17-
platform == Platform.LINUX && extension == "appimage" -> installLinuxAppImage(file)
18-
platform == Platform.LINUX && (extension == "deb" || extension == "rpm") -> installLinuxPackage(file, extension)
16+
platform == Platform.MACOS && extension == "zip" -> installMacZip(file, restart)
17+
platform == Platform.WINDOWS -> installWindows(file, extension, restart)
18+
platform == Platform.LINUX && extension == "appimage" -> installLinuxAppImage(file, restart)
19+
platform == Platform.LINUX && (extension == "deb" || extension == "rpm") -> installLinuxPackage(file, extension, restart)
1920
else -> buildProcessForInstaller(file, platform, extension).start()
2021
}
2122
exitProcess(0)
@@ -42,12 +43,19 @@ internal object PlatformInstaller {
4243
else -> ProcessBuilder("xdg-open", file.absolutePath)
4344
}
4445

45-
private fun installLinuxAppImage(newAppImage: File) {
46+
private fun installLinuxAppImage(newAppImage: File, restart: Boolean) {
4647
val pid = ProcessHandle.current().pid()
4748
val currentAppImage =
4849
System.getenv("APPIMAGE")
4950
?: error("APPIMAGE environment variable not set — update is only supported from a packaged AppImage")
5051

52+
val relaunchCmd =
53+
if (restart) {
54+
"\n# Relaunch in a fully detached process\nnohup \"\$OLD_FILE\" > /dev/null 2>&1 &\n"
55+
} else {
56+
""
57+
}
58+
5159
val script = File(System.getProperty("java.io.tmpdir"), "nucleus-update.sh")
5260
script.writeText(
5361
"""
@@ -72,10 +80,7 @@ internal object PlatformInstaller {
7280
|# Replace the old AppImage with the new one
7381
|mv -f "${'$'}NEW_FILE" "${'$'}OLD_FILE"
7482
|chmod +x "${'$'}OLD_FILE"
75-
|
76-
|# Relaunch in a fully detached process
77-
|nohup "${'$'}OLD_FILE" > /dev/null 2>&1 &
78-
|
83+
|$relaunchCmd
7984
|# Clean up this script
8085
|rm -f "${'$'}{0}"
8186
""".trimMargin(),
@@ -93,6 +98,7 @@ internal object PlatformInstaller {
9398
private fun installLinuxPackage(
9499
packageFile: File,
95100
extension: String,
101+
restart: Boolean,
96102
) {
97103
val pid = ProcessHandle.current().pid()
98104
val launcher =
@@ -106,6 +112,13 @@ internal object PlatformInstaller {
106112
else -> error("Unsupported package format: $extension")
107113
}
108114

115+
val relaunchCmd =
116+
if (restart) {
117+
"\n# Relaunch the application\nnohup \"\$APP_LAUNCHER\" > /dev/null 2>&1 &\n"
118+
} else {
119+
""
120+
}
121+
109122
val script = File(System.getProperty("java.io.tmpdir"), "nucleus-update.sh")
110123
script.writeText(
111124
"""
@@ -131,10 +144,7 @@ internal object PlatformInstaller {
131144
|
132145
|# Clean up the package file
133146
|rm -f "${'$'}PKG_FILE"
134-
|
135-
|# Relaunch the application
136-
|nohup "${'$'}APP_LAUNCHER" > /dev/null 2>&1 &
137-
|
147+
|$relaunchCmd
138148
|# Clean up this script
139149
|rm -f "${'$'}{0}"
140150
""".trimMargin(),
@@ -162,7 +172,7 @@ internal object PlatformInstaller {
162172

163173
private fun buildMacInstaller(file: File): ProcessBuilder = ProcessBuilder("open", file.absolutePath)
164174

165-
private fun installMacZip(zipFile: File) {
175+
private fun installMacZip(zipFile: File, restart: Boolean) {
166176
val appBundle =
167177
resolveCurrentAppBundle()
168178
?: error("Cannot resolve current .app bundle from java.home")
@@ -171,10 +181,17 @@ internal object PlatformInstaller {
171181
val appPath = File(installDir, appName).absolutePath
172182
val pid = ProcessHandle.current().pid()
173183

184+
val relaunchCmd =
185+
if (restart) {
186+
"\n# Relaunch the app\nopen \"\$APP_PATH\"\n"
187+
} else {
188+
""
189+
}
190+
174191
// Write a shell script that will:
175192
// 1. Wait for our process to actually die
176193
// 2. Replace the app bundle
177-
// 3. Remove quarantine and relaunch
194+
// 3. Remove quarantine and optionally relaunch
178195
val script = File(System.getProperty("java.io.tmpdir"), "nucleus-update.sh")
179196
script.writeText(
180197
"""
@@ -201,10 +218,7 @@ internal object PlatformInstaller {
201218
|
202219
|# Remove quarantine attribute
203220
|xattr -r -d com.apple.quarantine "${'$'}APP_PATH" 2>/dev/null || true
204-
|
205-
|# Relaunch the app
206-
|open "${'$'}APP_PATH"
207-
|
221+
|$relaunchCmd
208222
|# Clean up
209223
|rm -f "${'$'}ZIP_FILE"
210224
|rm -f "${'$'}{0}"
@@ -234,6 +248,7 @@ internal object PlatformInstaller {
234248
private fun installWindows(
235249
file: File,
236250
extension: String,
251+
restart: Boolean,
237252
) {
238253
val pid = ProcessHandle.current().pid()
239254
val launcher = resolveWindowsLauncher()
@@ -244,7 +259,7 @@ internal object PlatformInstaller {
244259
}
245260

246261
val relaunchCmd =
247-
if (launcher != null) {
262+
if (restart && launcher != null) {
248263
"\n|# Relaunch the application\n|Start-Process '${launcher.absolutePath}'"
249264
} else {
250265
""

0 commit comments

Comments
 (0)