diff --git a/CLAUDE.md b/CLAUDE.md index 7ad0d762e..eb560a4a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Published releases are `2.5.x` (latest tag `v2.5.0`). Do not treat `IDEAL_API.md - `nucleus-application` - `nucleusApplication`, `DecoratedWindow` / `HostedWindow`, `onDeepLink`, `aotTraining` - `core-runtime` - Executable type detection, single instance, deep links, platform detection, app metadata (`NucleusApp`) - `aot-runtime` - AOT cache mode detection for JDK 25+ (Project Leyden) -- `updater-runtime` - Auto-update engine (GitHub/S3), SHA-512, delta/blockmap, progress, update level, post-update events +- `updater-runtime` - Auto-update engine (GitHub/S3), SHA-512, delta/blockmap, progress, update level, post-update events, Windows NSIS hot update (see Development Notes) - `freedesktop-icons` - Type-safe freedesktop Icon Naming Specification constants (shared by notification-linux and launcher-linux) - `sf-symbols` - Type-safe SF Symbols catalog - `notification-common` - Cross-platform notification DSL with per-platform option blocks @@ -78,6 +78,7 @@ Published releases are `2.5.x` (latest tag `v2.5.0`). Do not treat `IDEAL_API.md - **Event-loop watchdog** (#643): a stalled loop produces no exception — to the JVM the thread is a healthy `RUNNABLE` / `_thread_in_native` — and `TaoApplication.rethrowPendingFatal` sits *after* `nativeRunBlocking`, which a deadlocked loop never leaves, so #640 froze silently. `TaoEventLoopWatchdog` is a min-priority daemon thread that polls `IsHungAppWindow` (`NativeTaoBridge.nativeIsWindowHung`) every 2 s and logs `SEVERE` + a full thread dump once a window has been hung past the grace period on top of Windows' own ~5 s threshold. The probe is a pure OS-state query — it sends nothing to the loop, unlike a `SendMessageTimeout(WM_NULL)` probe, whose inline sent message is exactly the re-entrancy that deadlocked #640. HWNDs are cached on `WINDOW_READY` from the event-loop thread: resolving one later goes through the native `WINDOWS` map, whose lock a stalled loop may hold. **The app-facing shape is Electron's**: the framework logs and raises `onUnresponsive` / `onResponsive` (`NucleusApplicationScope`, `TaoApplication` — `webContents`' `unresponsive` / `responsive`), and ships **no UI of its own**; the "wait or quit" prompt is the app's to build, as it is in Electron, Chromium's HangWatcher, IntelliJ's PerformanceWatcher and Unreal's `FThreadHeartBeat`. Both callbacks run on their own `nucleus-tao-watchdog-events` thread — not the UI thread (the stuck one, so anything posted to `Dispatchers.Main` would only run once the stall ends) and not the sampling thread, so a listener that blocks in a "wait or quit" prompt delays the next callback, never the detection. Off by default under a debug agent (a breakpoint on the UI thread is indistinguishable from a stall — the reason Unreal ships `HangDuration=0`); a poll that overslept by >10 s is read as a system suspend, which drops the episode and ignores the next 30 s (Electron #53529's `base::PowerMonitor` rule). `expectUnresponsive { }` (`NucleusApplicationScope`, `TaoApplication`) declares a long synchronous operation so it is not reported — Chromium's `InvalidateActiveExpectations()`, and the reason the global switch is not the only recourse. The watchdog thread parks while no window is registered (HangWatcher does the same with an empty watch list). `-Dnucleus.tao.watchdog=false` disables it (`=true` forces it on under a debugger), `-Dnucleus.tao.watchdogGraceMs=` retunes it, `-Dnucleus.tao.watchdogDialog=true` also pops the native dialog (from the watchdog thread — the loop thread is the stuck one, #622's constraint; `nucleus.tao.fatalErrorDialog=false` suppresses it too, as it does every native modal). Windows only: macOS has no public "not responding" query and X11's `_NET_WM_PING` perturbs the loop it observes. E2E: `EventLoopWatchdogHeadfulCases` (real window, real freeze), black-box switch smoke `./gradlew :decorated-window-tao:taoWatchdogSmoke` (prints `severe=N unresponsive=N responsive=N`; `-Dnucleus.tao.watchdogDialog=true -Dnucleus.tao.watchdog.smoke.holdMs=20000` to look at the dialog) - **macOS trackpad on Tao** (#652–#654, #660): scroll deltas are AWT-shaped (`preciseWheelRotation`, no display scale). Trackpad two-finger swipe reaches Compose as `PanStart` / `PanMove` / `PanEnd` (`panOffset` = AWT delta × 10 dp), wheel notches as `Scroll`; foundation's `Modifier.scrollable` handles both. Custom handlers that only listen for `PointerEventType.Scroll` must also handle Pan, or the app can set `-Dnucleus.tao.trackpadPanEvents=false` to get AWT-style `Scroll` for everything. Everything scroll-related enters the scene through `TaoSceneScrollRouter` (window + NSPanel popups); the phase wire (Rust `SCROLL_GESTURE_*`, `popup_panel.m`, `TaoScrollGesturePhase`) is guarded by `TaoScrollWireDriftTest`. Platform-recognized pinch is `ScaleStart` / `ScaleChange` / `ScaleEnd` (`scaleFactor` = per-event ratio) via `dispatchTrackpadScale` — not two synthetic Touch contacts; `Modifier.transformable` and MapLibre consume that path, while `detectTransformGestures` still only sees two-finger rotate (two synthetic Touch contacts). Magnify and rotate interleave on a real trackpad and the two models cannot overlap (a Scale event without the contacts reads as their release → a touch tap per step; one carrying them stamps the factor on every pointer and foundation multiplies it per pointer), so the gesture that begins first owns it: during a pinch rotate steps are dropped, during a rotation magnify widens the contacts (spacing clamped to 0.05–20×, past which `detectZoom` handed the app `Infinity` / `NaN`). The contacts never coexist with **any** mouse-only event: a rotation does not start while a pan is open (`TaoSceneScrollRouter.panOpen`), drops trackpad scroll and smart-magnify while it owns the fingers, and a real cursor move / click / exit / focus loss interrupts it (cancelled, not a tap; the rest of it is ignored until it ends). Headful coverage: `MacOsTrackpadGestureMonkeyHeadfulCases` (trackpad / chaos / burst profiles × seeds against an exact oracle of the host rules, plus degenerate cases: collapsing / exploding contacts, gestures far off-window, a window closed with 200 gestures queued) and `MacOsTrackpadScaleHeadfulCases` (gesture NSEvents via `nativeDiagInjectTrackpadGesture` — a type-29 CGEvent, window set through field 51 + the private `CGEventSetWindowLocation`, **posted** with `postEvent:atStart:`: a synchronous `sendEvent:` from the test body re-enters tao's event callback and deadlocks). Linux/Windows pinch (GDK / Ctrl+wheel) uses the same Scale events. **GDK differs**: it reports pinch and rotation as *one* gesture (every `GdkEventTouchpadPinch` carries a scale and an angle, `touch.rs` forwards a magnify then a rotate step for each), so first-come would make rotation unreachable — a pinch opens as Scale and only accumulates its angle, and the rotation takes over (Scale closes, contacts pressed already turned by that angle) once it has turned 10° while the zoom stays within ±10 %. GDK's `angle_delta` is clockwise-positive on screen, i.e. Compose's sense (no flip, unlike AppKit). The contacts carry `TaoTrackpadRotationContacts` ids, which is how `TitleBar` keeps them from arming a window drag on every platform (a Linux rotation over the bar started a compositor move). An interrupted rotation calls `cancelPointerInput()` **before** sending the contacts' Release — the other order delivers an unconsumed touch-up, i.e. a tap. Linux headful coverage: `LinuxTrackpadPinchHeadfulCases` (synthetic `GdkEventTouchpadPinch` through the GtkWindow's `event` signal via `nativeLinuxInjectGdkTouchpadPinch`; coordinates are toplevel-relative, so add `nativeLinuxContentOrigin`) and `TrackpadScaleHeadfulCases` (real Ctrl+wheel through the AWT Robot — X11 leg only, the Robot cannot inject on Wayland). - macOS Liquid Glass enabled by default via `macOsSdkVersion = "26.0"` (vtool SDK patching) +- **Windows NSIS hot update** (every NSIS installer of a JVM app, no DSL switch; per-user installs only — a non-writable `Program Files` install falls back to the classic update): the app never leaves the screen while it updates. `WindowsHotUpdateLayout` lays the jpackage image out as `.exe` + `app\.cfg` at the root and `versions\\{app,runtime}` — the `.cfg` names the runtime with `app.runtime=$ROOTDIR\versions\\runtime` and every `$APPDIR` becomes `$ROOTDIR\versions\\app` (the jpackage launcher reads nothing else, from JDK 21 at least). `installAndRestart` (`WindowsHotUpdate`) then returns immediately: it renames the running launcher(s) to `*.nucleus-old` (a running exe can be renamed, not overwritten) and copies each back, writable (jpackage ships it read-only) — the copy is mapped by nobody, so the installer can replace it while shortcuts, the Run key and protocol handlers keep working — then runs the installer **while the app runs** with `NUCLEUS_HOT_UPDATE=1` — `WindowsHotUpdateNsis`'s `customCheckAppRunning` skips electron-builder's kill and the old version's `customRemoveFiles` keeps its files (both reproduce the 26.x template bodies otherwise; the env reaches the old uninstaller because the installer's `ExecWait` inherits it) — reads the installed version back from `app.runtime`, releases the single-instance lock (`SingleInstanceManager.releaseForHandoff`), launches the new version with `NUCLEUS_UPDATE_READY_FILE` / `NUCLEUS_UPDATE_PREVIOUS_PID`, and exits once the file appears. `UpdateHandoff.signalReady()` writes it from `TaoWindow`'s first presented frame after `show()`, then deletes retired versions (rename-then-delete: a version still in use cannot be renamed) and launchers — **jpackage ships the launcher read-only**, clear the flag before deleting. The previous PIDs include the launcher parent: jpackage's Windows launcher restarts itself as a child (skipped when inherited env says it already did). If the hot path cannot start it falls back to the classic update; if the **installer** fails the app just keeps running (the classic path would rerun the same failing installer and close/reopen the app at every check). **Multi-instance (Chromium's model)**: installs are serialized by an exclusive lock on `versions\.nucleus-install.lock` (Chromium's single machine-wide updater); an instance that waited, or finds the `.cfg` already starting a newer version, only hands off. Other instances learn about it locally — `NucleusUpdater.pendingRestartVersion` (`InstalledVersionWatcher`: `WatchService` on `app\` + 30 min poll, read under the **shared** lock because the `.cfg` is written before its version finishes extracting; Chromium's `InstalledVersionMonitor` + `InstalledVersionPoller`) — and `checkForUpdates` returns `NotAvailable` for a version already on disk, so nothing is downloaded twice. Nothing restarts on its own (unsaved work): the app offers it and calls `restartToInstalledVersion(relaunchArguments)`. `relaunchArguments` (`installAndRestart(file, args)`, Windows only) is explicit and empty by default — replaying the original command line would resend the autostart marker (the new version would think it started at login) or a deep link; Chromium drops positional args too. A same-JVM lock through another channel is an `OverlappingFileLockException`, not a wait: `withInstallLock` retries it. A PowerShell guard relaunches the app if a non-hot installer closed it anyway — unless the user quit it (a shutdown hook drops `app-exited`; a killed process runs none). PowerShell scripts are written with a UTF-8 **BOM** (`writePowerShellScript`): Windows PowerShell 5.1 reads BOM-less scripts as ANSI, which broke every update — classic included — for accented profile paths (`C:\Users\Hélène\…`). The "just updated" marker is written before the install, so `consumeUpdateEvent` / `wasJustUpdated` only report it when its target is the running version (a failed install used to announce an update that never happened). `-Dnucleus.updater.hotUpdate.disabled=true` forces classic. GraalVM native images have no `.cfg` indirection and stay classic (would need a stub launcher). E2E: `scripts/windows-hot-update-e2e.ps1` + `examples/hot-update-demo` (samples visible windows and the screen pixel every ~18 ms; measured 0 ms gap hot vs ~13-15 s classic). `-Scenario` covers `update`, `relaunch-during-install`, `close-during-install`, `failing-installer`, `stale-target-dir`, `two-instances`, `notify-other-instance`; `-NewVersion a,b` chains updates; `-InstallDir` with spaces/apostrophe/accents; a flat (pre-hot) old installer checks the migration (first hop classic, then hot). The window manager cross-fades windows, so blends of the two versions' colours are not gaps. The screen check is meaningless while the display is off — the capture freezes and nothing composes - **PKG has two channels**, chosen by `macOS { pkg { appStore } }` (default `true`); whether `TargetFormat.Pkg` is a store format is `JvmApplicationDistributions.isSandboxed(format)`, not an enum property. App Store PKG = sandboxed pipeline, "3rd Party Mac Developer" certificates, `productsign` after the build, never notarized (Transporter upload). `appStore = false` = Developer ID PKG on the DMG pipeline (#249): electron-builder signs the installer itself from `pkg.identity` = the **bare** `NAME (TEAMID)` (it prepends "Developer ID Installer" and rejects a prefixed qualifier; `CSC_IDENTITY_AUTO_DISCOVERY=false` means no identity ⇒ silently unsigned, which the task catches with `pkgutil --check-signature`), a DSL keychain travels as `CSC_KEYCHAIN`, and `notarizePkg` notarizes the `.pkg`. `pkg { preInstall / postInstall }` are staged in `/build/pkg-scripts` for `pkgbuild --scripts` (shebang required); the App Store rejects install scripts (error 90254), so they require `appStore = false`. **The staged `preinstall` / `postinstall` are Nucleus shims, not the app's script**: electron-builder sets `BundlePre/PostInstallScriptPath` *and* passes `--scripts`, so `PackageInfo` declares each script twice and Installer runs it twice (confirmed on a real install). The shim skips the per-bundle pass (`$2` is the `.app`) and execs the app's copy, staged as `nucleus-app-pre` / `nucleus-app-post` — names electron-builder's `name.includes("preinstall")` scan must not match. Runtime: gate sandbox-sensitive features on `ExecutableRuntime.isSandboxed()` (`APP_SANDBOX_CONTAINER_ID`), never on `isPkg()` — that is also what makes a Developer ID PKG self-updatable (`NucleusUpdater.isUpdateSupported`) while the App Store build stays excluded - **Node.js is provisioned, not required**: every format except `TargetFormat.RawAppImage` is built by electron-builder, which the plugin installs with `npm ci --ignore-scripts` against an embedded lock file — so packaging needs a Node.js. It downloads one from `nodejs.org` (verified against the release's `SHASUMS256.txt`) into `/nucleus/nodejs`, exactly like the GraalVM and packaging JDK toolchains, and the three share `ToolchainDownloads`. Configure with `nativeDistributions { nodejs { autoDownload / version / installDir } }`; `version` is a major line (`"22"`, the default), `"lts"`, or a pinned release, and a floating line is sticky once downloaded. Precedence: the `compose.electronBuilder.nodePath` Gradle property, then `NUCLEUS_NODE_HOME`, then the provisioned install, then `PATH` (also the fallback when the download fails). CI therefore runs **no** `actions/setup-node` — only a cache of `~/.gradle/nucleus/nodejs`. `release-desktop`'s `universal-macos` job runs electron-builder outside the plugin, so `build-macos-universal/provision-electron-builder.sh` mirrors it: same Node resolution rule, same install layout and marker (the cache entry is shared), and `npm ci --ignore-scripts` against the plugin's embedded lock file — never `npx --yes` - The HotSpot GC is selected type-safely with `application { garbageCollector = GarbageCollector.Z }` (unset = JVM ergonomics). The flags are prepended to the launcher `.cfg` java-options and to the `run` task — before `jvmArgs`, so an explicit `-XX:+Use…GC` there still wins — and the AOT training run inherits them from the `.cfg` diff --git a/core-runtime/api/core-runtime.api b/core-runtime/api/core-runtime.api index 798509f4c..14f8c4780 100644 --- a/core-runtime/api/core-runtime.api +++ b/core-runtime/api/core-runtime.api @@ -148,6 +148,7 @@ public final class dev/nucleusframework/core/runtime/SingleInstanceManager { public final fun getConfiguration ()Ldev/nucleusframework/core/runtime/SingleInstanceManager$Configuration; public final fun isSingleInstance (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Z public static synthetic fun isSingleInstance$default (Ldev/nucleusframework/core/runtime/SingleInstanceManager;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Z + public final fun releaseForHandoff ()V public final fun setConfiguration (Ldev/nucleusframework/core/runtime/SingleInstanceManager$Configuration;)V } @@ -170,6 +171,27 @@ public final class dev/nucleusframework/core/runtime/SingleInstanceManager$Confi public fun toString ()Ljava/lang/String; } +public final class dev/nucleusframework/core/runtime/UpdateHandoff { + public static final field ENV_HOT_INSTALL Ljava/lang/String; + public static final field ENV_PREVIOUS_PID Ljava/lang/String; + public static final field ENV_READY_FILE Ljava/lang/String; + public static final field INSTANCE Ldev/nucleusframework/core/runtime/UpdateHandoff; + public static final field RETIRED_LAUNCHER_SUFFIX Ljava/lang/String; + public static final field VERSIONS_DIR_NAME Ljava/lang/String; + public static final fun cleanupRetiredVersions ()V + public static final fun getVersionedInstall ()Ldev/nucleusframework/core/runtime/VersionedInstall; + public static final fun isHandoffLaunch ()Z + public static final fun signalReady ()V +} + +public final class dev/nucleusframework/core/runtime/VersionedInstall { + public fun (Ljava/io/File;Ljava/io/File;Ljava/io/File;)V + public final fun getLauncher ()Ljava/io/File; + public final fun getRoot ()Ljava/io/File; + public final fun getVersionDir ()Ljava/io/File; + public final fun getVersionsDir ()Ljava/io/File; +} + public final class dev/nucleusframework/core/runtime/WindowBackend : java/lang/Enum { public static final field Awt Ldev/nucleusframework/core/runtime/WindowBackend; public static final field Companion Ldev/nucleusframework/core/runtime/WindowBackend$Companion; diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/SingleInstanceManager.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/SingleInstanceManager.kt index 302e0db33..abe6f71c2 100644 --- a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/SingleInstanceManager.kt +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/SingleInstanceManager.kt @@ -59,6 +59,9 @@ public object SingleInstanceManager { private var fileLock: FileLock? = null private var isWatching = false + @Volatile + private var handedOff = false + /** * Checks if the current process is the single running instance. * @@ -112,6 +115,8 @@ public object SingleInstanceManager { } Runtime.getRuntime().addShutdownHook( Thread { + // After a handoff the lock file belongs to the new instance. + if (handedOff) return@Thread releaseLock() lockFile.delete() deleteRestoreRequestFile() @@ -175,7 +180,7 @@ public object SingleInstanceManager { continue } val filename = event.context() as Path - if (filename.toString() == configuration.restoreRequestFileName) { + if (!handedOff && filename.toString() == configuration.restoreRequestFileName) { debugLog { "Restore request file detected" } configuration.restoreRequestFilePath.onRestoreRequest() // Remove the request file after processing @@ -225,6 +230,21 @@ public object SingleInstanceManager { } } + /** + * Gives up the lock while this process keeps running, so the instance it is about to launch + * becomes the single instance — the seamless restart after a hot update, where the old version + * stays on screen until the new one is. From then on this process ignores restore requests and + * leaves the lock file to its successor. No-op when the lock is not held. + */ + public fun releaseForHandoff() { + if (fileLock == null) return + handedOff = true + releaseLock() + fileLock = null + fileChannel = null + debugLog { "Lock released for an update handoff" } + } + private fun releaseLock() { try { fileLock?.release() diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/UpdateHandoff.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/UpdateHandoff.kt new file mode 100644 index 000000000..a3b4f72fb --- /dev/null +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/UpdateHandoff.kt @@ -0,0 +1,227 @@ +package dev.nucleusframework.core.runtime + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Level +import java.util.logging.Logger + +/** + * A Windows NSIS install laid out for hot updates: the launcher and its `app\.cfg` stay at + * [root], while the Java runtime and the application live in `versions\\` ([versionDir]). + * + * A new version is installed into a sibling `versions\\` directory while this one keeps + * running — nothing this process holds open is overwritten — and the rewritten `.cfg` makes the + * next launch of [launcher] start the new version. + */ +public class VersionedInstall( + /** Installation directory: holds the launcher, `app\*.cfg` and `versions\`. */ + public val root: File, + /** The `versions\` directory this process runs from. */ + public val versionDir: File, + /** The application launcher (`jpackage.app-path`), directly under [root]. */ + public val launcher: File, +) { + /** Directory holding every installed version. */ + public val versionsDir: File get() = versionDir.parentFile +} + +/** + * The seamless restart that follows a hot update: the running (old) version launches the new one, + * which calls [signalReady] once its first window is on screen; only then does the old version + * exit, so the application never disappears from the screen while it updates. + * + * Nucleus windows signal readiness on their first presented frame, so applications built on + * `nucleusApplication` need nothing. An application that shows no Nucleus window (tray-only, or + * its own window toolkit) calls [signalReady] itself once it is usable; otherwise the old version + * gives up waiting after a timeout and exits anyway. + */ +public object UpdateHandoff { + /** + * Set to `1` in the environment of an installer run as a hot update. The installer then leaves + * the running application alone instead of closing it, and the old version's uninstaller keeps + * its files in place. + */ + public const val ENV_HOT_INSTALL: String = "NUCLEUS_HOT_UPDATE" + + /** File the new version creates once it is on screen. Set by the old version on the new one. */ + public const val ENV_READY_FILE: String = "NUCLEUS_UPDATE_READY_FILE" + + /** + * Comma-separated process ids of the old version (its JVM and the launcher it runs under), + * whose files the new version deletes once they have all exited. + */ + public const val ENV_PREVIOUS_PID: String = "NUCLEUS_UPDATE_PREVIOUS_PID" + + /** Name of the directory holding the installed versions, under [VersionedInstall.root]. */ + public const val VERSIONS_DIR_NAME: String = "versions" + + /** + * Suffix of a launcher moved aside during a hot update: a running executable can be renamed but + * not overwritten, so the old launcher is renamed before the installer writes the new one. + */ + public const val RETIRED_LAUNCHER_SUFFIX: String = ".nucleus-old" + + private const val RUNTIME_DIR_NAME = "runtime" + private const val TRASH_PREFIX = ".trash-" + private const val PREVIOUS_EXIT_TIMEOUT_SECONDS = 120L + private const val CLEANUP_ATTEMPTS = 10 + private const val CLEANUP_RETRY_DELAY_MS = 300L + + private val logger: Logger = Logger.getLogger(UpdateHandoff::class.java.name) + private val signaled = AtomicBoolean(false) + + /** The versioned install this process runs from, or `null` for any other layout or platform. */ + @JvmStatic + public val versionedInstall: VersionedInstall? by lazy { + detectVersionedInstall( + javaHome = System.getProperty("java.home"), + launcherPath = System.getProperty("jpackage.app-path"), + isWindows = Platform.Current == Platform.Windows, + ) + } + + /** Whether this process was launched by an older version handing over to it after a hot update. */ + @JvmStatic + public val isHandoffLaunch: Boolean get() = System.getenv(ENV_READY_FILE) != null + + /** + * Tells the version that launched this one that it is on screen, so it can exit, then deletes + * the versions left behind by earlier updates once that version is gone. Idempotent and cheap: + * the work runs on a background thread. + */ + @JvmStatic + public fun signalReady() { + if (!signaled.compareAndSet(false, true)) return + val readyFile = System.getenv(ENV_READY_FILE) + if (readyFile == null && Platform.Current != Platform.Windows) return + Thread({ + readyFile?.let(::writeReadyFile) + awaitPreviousInstance() + cleanupRetiredVersions() + }, "nucleus-update-handoff").apply { + isDaemon = true + priority = Thread.MIN_PRIORITY + start() + } + } + + /** + * Deletes the versions and launchers left behind by earlier hot updates. A version still in use + * (another instance running it) cannot be renamed, which is how it is detected and kept. + */ + @JvmStatic + public fun cleanupRetiredVersions() { + val install = versionedInstall ?: return + cleanupRetiredVersions(install) + } + + internal fun cleanupRetiredVersions(install: VersionedInstall) { + // A process is reported gone slightly before Windows releases its image and mapped + // DLLs, so what the previous version held may need a few more attempts. + repeat(CLEANUP_ATTEMPTS) { attempt -> + if (cleanupPass(install)) return + if (attempt < CLEANUP_ATTEMPTS - 1) Thread.sleep(CLEANUP_RETRY_DELAY_MS) + } + logger.fine { "Retired versions still in use; the next start will retry" } + } + + /** One cleanup pass; returns `true` when nothing retired is left. */ + private fun cleanupPass(install: VersionedInstall): Boolean { + var clean = true + val current = install.versionDir.canonicalFile + install.versionsDir.listFiles()?.forEach { dir -> + if (!dir.isDirectory || dir.canonicalFile == current) return@forEach + if (dir.name.startsWith(TRASH_PREFIX)) { + if (!dir.deleteClearingReadOnly()) clean = false + return@forEach + } + // Renaming first makes the deletion all-or-nothing: Windows refuses to rename a + // directory with open files, so a version another instance still runs is left intact + // instead of losing the files it has not opened yet. + val trash = File(dir.parentFile, "$TRASH_PREFIX${dir.name}-${System.nanoTime()}") + if (!dir.renameTo(trash) || !trash.deleteClearingReadOnly()) { + logger.fine { "Could not delete retired version ${dir.name} yet" } + clean = false + } + } + install.root + .listFiles { file -> file.isFile && file.name.endsWith(RETIRED_LAUNCHER_SUFFIX) } + ?.forEach { + // jpackage ships the launcher read-only, which Windows refuses to delete. + it.setWritable(true) + if (!it.delete()) { + logger.fine { "Could not delete retired launcher ${it.name} yet" } + clean = false + } + } + return clean + } + + /** [File.deleteRecursively] that first clears the read-only flag Windows refuses to delete. */ + private fun File.deleteClearingReadOnly(): Boolean { + walkBottomUp().filter { !it.canWrite() }.forEach { it.setWritable(true) } + return deleteRecursively() + } + + private fun writeReadyFile(path: String) { + val target = File(path) + // The variable is inherited by whatever this instance starts later (a restart, say); by then + // the version that waited for it is gone along with its directory, and nobody is listening. + if (target.parentFile?.isDirectory != true) { + logger.fine { "No update handoff waiting on $path" } + return + } + try { + val temp = File(target.parentFile, "${target.name}.tmp") + temp.writeText(ProcessHandle.current().pid().toString()) + Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.WARNING, "Could not signal the update handoff through $path", e) + } + } + + private fun awaitPreviousInstance() { + val pids = System.getenv(ENV_PREVIOUS_PID)?.split(',')?.mapNotNull { it.trim().toLongOrNull() } ?: return + logger.fine { "Waiting for the previous version to exit: $pids" } + pids.forEach { pid -> awaitExit(pid) } + } + + private fun awaitExit(pid: Long) { + ProcessHandle.of(pid).ifPresent { previous -> + try { + previous.onExit().get(PREVIOUS_EXIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.FINE, "Previous version $pid still running; cleanup may skip it", e) + } + } + } + + /** + * Recognizes the versioned layout from the running JVM: `java.home` is + * `\versions\\runtime` and the launcher sits directly in ``. + */ + internal fun detectVersionedInstall( + javaHome: String?, + launcherPath: String?, + isWindows: Boolean, + ): VersionedInstall? { + if (!isWindows || javaHome == null || launcherPath == null) return null + val runtime = File(javaHome).absoluteFile + val versionDir = runtime.parentFile ?: return null + val versionsDir = versionDir.parentFile ?: return null + val root = versionsDir.parentFile ?: return null + val launcher = File(launcherPath).absoluteFile + val matches = + runtime.name.equals(RUNTIME_DIR_NAME, ignoreCase = true) && + versionsDir.name.equals(VERSIONS_DIR_NAME, ignoreCase = true) && + launcher.parentFile == root + return if (matches) VersionedInstall(root, versionDir, launcher) else null + } +} diff --git a/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/UpdateHandoffTest.kt b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/UpdateHandoffTest.kt new file mode 100644 index 000000000..9fc4db760 --- /dev/null +++ b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/UpdateHandoffTest.kt @@ -0,0 +1,97 @@ +package dev.nucleusframework.core.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class UpdateHandoffTest { + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `versioned layout is recognized from java home and launcher`() { + val root = tmp.newFolder("App") + val install = + UpdateHandoff.detectVersionedInstall( + javaHome = File(root, "versions/1.2.0/runtime").path, + launcherPath = File(root, "App.exe").path, + isWindows = true, + ) + + assertNotNull(install) + assertEquals(root.absoluteFile, install!!.root) + assertEquals("1.2.0", install.versionDir.name) + assertEquals(File(root, "versions").absoluteFile, install.versionsDir) + } + + @Test + fun `flat jpackage layout is not versioned`() { + val root = tmp.newFolder("App") + + val install = + UpdateHandoff.detectVersionedInstall( + javaHome = File(root, "runtime").path, + launcherPath = File(root, "App.exe").path, + isWindows = true, + ) + + assertNull(install) + } + + @Test + fun `launcher outside the install root is not versioned`() { + val root = tmp.newFolder("App") + + val install = + UpdateHandoff.detectVersionedInstall( + javaHome = File(root, "versions/1.2.0/runtime").path, + launcherPath = File(tmp.root, "elsewhere/App.exe").path, + isWindows = true, + ) + + assertNull(install) + } + + @Test + fun `versioned layout is Windows only`() { + val root = tmp.newFolder("App") + + val install = + UpdateHandoff.detectVersionedInstall( + javaHome = File(root, "versions/1.2.0/runtime").path, + launcherPath = File(root, "App.exe").path, + isWindows = false, + ) + + assertNull(install) + } + + @Test + fun `cleanup deletes retired versions and launchers but keeps the running one`() { + val root = tmp.newFolder("App") + val current = File(root, "versions/1.2.0").apply { File(this, "runtime").mkdirs() } + val retiredVersion = File(root, "versions/1.1.0").apply { File(this, "app").mkdirs() } + File(retiredVersion, "app/lib.jar").writeText("jar") + val trash = File(root, "versions/.trash-1.0.0-42").apply { mkdirs() } + val launcher = File(root, "App.exe").apply { writeText("new") } + // jpackage ships its launcher read-only; the retired copy keeps the attribute. + val retiredLauncher = File(root, "App.exe.123.nucleus-old").apply { writeText("old") } + retiredLauncher.setWritable(false) + val install = VersionedInstall(root, current, launcher) + + UpdateHandoff.cleanupRetiredVersions(install) + + assertTrue(current.isDirectory) + assertTrue(launcher.isFile) + assertFalse(retiredVersion.exists()) + assertFalse(trash.exists()) + assertFalse(retiredLauncher.exists()) + assertEquals(listOf("1.2.0"), File(root, "versions").list()!!.toList()) + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index 16bd33468..6f1594384 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.IntRect import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.core.runtime.UpdateHandoff import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxTouchBridge @@ -1544,6 +1545,9 @@ public class TaoWindow internal constructor( if (startupEraseActive) { startupEraseActive = false setStartupBackgroundEraseEnabled(false) + // A window is on screen with content: after a hot update, the + // version that launched this one may now exit (no-op otherwise). + UpdateHandoff.signalReady() } } TaoEventCode.FOCUSED -> { diff --git a/examples/hot-update-demo/build.gradle.kts b/examples/hot-update-demo/build.gradle.kts new file mode 100644 index 000000000..7537f0d80 --- /dev/null +++ b/examples/hot-update-demo/build.gradle.kts @@ -0,0 +1,57 @@ +import dev.nucleusframework.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// Fixture for the Windows hot-update E2E (scripts/e2e/windows-hot-update.ps1): the app shows its +// version on a version-coloured background and, when HOT_UPDATE_DEMO_FEED points at a loopback +// update feed, downloads the update and calls installAndRestart on its own. +// +// Build two versions with: ./gradlew :examples:hot-update-demo:packageNsis -PhotUpdateDemoVersion=1.1.0 + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlinComposePlugin) + alias(libs.plugins.jetbrainsCompose) + id("dev.nucleusframework") +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(project(":core-runtime")) + implementation(project(":updater-runtime")) + implementation(project(":decorated-window-tao")) + implementation(project(":nucleus-application")) + implementation(libs.coroutines.core) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +val demoVersion = providers.gradleProperty("hotUpdateDemoVersion").getOrElse("1.0.0") + +nucleus.application { + mainClass = "hotupdatedemo.MainKt" + + nativeDistributions { + packageName = "HotUpdateDemo" + packageVersion = demoVersion + targetFormats(TargetFormat.Nsis) + + windows { + nsis { + oneClick = true + perMachine = false + createDesktopShortcut = false + createStartMenuShortcut = false + runAfterFinish = false + } + } + } +} diff --git a/examples/hot-update-demo/src/main/kotlin/hotupdatedemo/Main.kt b/examples/hot-update-demo/src/main/kotlin/hotupdatedemo/Main.kt new file mode 100644 index 000000000..7def2c7b7 --- /dev/null +++ b/examples/hot-update-demo/src/main/kotlin/hotupdatedemo/Main.kt @@ -0,0 +1,119 @@ +package hotupdatedemo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.updater.NucleusUpdater +import dev.nucleusframework.updater.UpdateResult +import dev.nucleusframework.updater.provider.GenericProvider +import dev.nucleusframework.window.NucleusDecoratedWindowTheme +import dev.nucleusframework.window.TitleBar +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.last +import java.io.File +import java.time.LocalTime +import kotlin.time.Duration.Companion.seconds + +private val feed: String? = System.getenv("HOT_UPDATE_DEMO_FEED") + +// The E2E samples the screen at the window: keep it above whatever else is open there. +private val topmost = System.getenv("HOT_UPDATE_DEMO_TOPMOST") == "1" + +// Multi-instance E2E: every launch is its own instance, holding the "document" passed as argument. +private val multiInstance = System.getenv("HOT_UPDATE_DEMO_MULTI") == "1" + +// An instance that never checks the feed learns about an update another one installed. +private val checksForUpdates = System.getenv("HOT_UPDATE_DEMO_CHECK") != "0" +private val logFile = File(System.getProperty("java.io.tmpdir"), "hot-update-demo.log") + +private fun log(message: String) { + val line = "${LocalTime.now()} pid=${ProcessHandle.current().pid()} $message" + runCatching { logFile.appendText("$line\n") } +} + +fun main(args: Array) = + nucleusApplication(args, enableSingleInstance = !multiInstance) { + val updater = remember { feed?.let { url -> NucleusUpdater { provider = GenericProvider(url) } } } + val version = updater?.currentVersion ?: "dev" + var status by remember { mutableStateOf(if (updater == null) "No update feed" else "Checking…") } + + LaunchedEffect(Unit) { + val command = + ProcessHandle + .current() + .info() + .command() + .orElse("?") + log( + "started version=$version args=${args.toList()} command=$command " + + "java.home=${System.getProperty("java.home")}", + ) + updater?.consumeUpdateEvent()?.let { log("updated from ${it.previousVersion} to ${it.newVersion}") } + if (updater == null || !checksForUpdates) return@LaunchedEffect + // Poll, so that a chained E2E can publish the next version once this one is running. + var result = updater.checkForUpdates() + while (result !is UpdateResult.Available) { + status = "Up to date" + log("no update ($result)") + delay(3.seconds) + result = updater.checkForUpdates() + } + status = "Downloading ${result.info.version}…" + val file = updater.downloadUpdate(result.info).last().file ?: return@LaunchedEffect + status = "Installing ${result.info.version}…" + log("installAndRestart ${file.name}") + updater.installAndRestart(file, relaunchArguments = args.toList()) + } + + // Another instance installed an update: restart onto it, keeping this instance's document. + // A real app would offer "Restart to update" instead of restarting on its own. + LaunchedEffect(Unit) { + val pending = updater?.pendingRestartVersion?.first { it != null } ?: return@LaunchedEffect + log("pendingRestart $pending") + status = "Restarting to $pending…" + updater.restartToInstalledVersion(relaunchArguments = args.toList()) + } + + NucleusDecoratedWindowTheme(isDark = true) { + DecoratedWindow( + onCloseRequest = ::exitApplication, + title = "Hot Update Demo $version", + alwaysOnTop = topmost, + state = rememberWindowState(size = DpSize(640.dp, 400.dp), position = WindowPosition(200.dp, 200.dp)), + ) { + TitleBar { BasicText("Hot Update Demo $version", style = TextStyle(color = Color.White)) } + Column( + modifier = Modifier.fillMaxSize().background(versionColor(version)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BasicText(version, style = TextStyle(color = Color.White, fontSize = 72.sp)) + BasicText(status, style = TextStyle(color = Color.White, fontSize = 20.sp)) + } + } + } + } + +private fun versionColor(version: String): Color = + listOf(Color(0xFF1565C0), Color(0xFF2E7D32), Color(0xFF6A1B9A), Color(0xFFC62828))[ + Math.floorMod(version.hashCode(), 4), + ] diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayout.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayout.kt new file mode 100644 index 000000000..b609a8f88 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayout.kt @@ -0,0 +1,96 @@ +package dev.nucleusframework.desktop.application.internal + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * Lays a Windows jpackage app image out for hot updates (every NSIS installer): + * + * ``` + * .exe launcher, stays at the root + * app\.cfg rewritten to point into the version directory + * versions\\runtime\ was runtime\ + * versions\\app\ was app\ (everything but the .cfg files) + * ``` + * + * The jpackage launcher reads `app\.cfg` next to itself at every start and nothing more, so + * a new version can be installed next to a running one — nothing the running JVM holds open is + * overwritten — and the rewritten `.cfg` makes the next start pick it up. The `.cfg` names the + * runtime with `app.runtime` and every `$APPDIR` reference becomes `$ROOTDIR\versions\\app`. + * + * Must match `UpdateHandoff` / `WindowsHotUpdate` in the runtime, which recognize the layout from + * `java.home` and read the installed version back from `app.runtime`. + */ +internal object WindowsHotUpdateLayout { + internal const val VERSIONS_DIR_NAME = "versions" + private const val APP_DIR_NAME = "app" + private const val RUNTIME_DIR_NAME = "runtime" + private const val APPLICATION_SECTION = "[Application]" + private const val RUNTIME_KEY = "app.runtime" + private const val APPDIR_MACRO = "\$APPDIR" + private const val ROOTDIR_MACRO = "\$ROOTDIR" + + /** + * Rewrites [appImageDir] in place. Returns `false`, leaving it untouched, when it is not a + * jpackage image (a GraalVM native image has no `.cfg` nor `runtime\`) or is already versioned. + */ + fun apply( + appImageDir: File, + version: String, + ): Boolean { + val appDir = File(appImageDir, APP_DIR_NAME) + val runtimeDir = File(appImageDir, RUNTIME_DIR_NAME) + val cfgFiles = appDir.listFiles { file -> file.isFile && file.extension.equals("cfg", ignoreCase = true) } + if (cfgFiles.isNullOrEmpty() || !runtimeDir.isDirectory) return false + if (File(appImageDir, VERSIONS_DIR_NAME).exists()) return false + + val versionName = versionDirName(version) + val versionDir = File(appImageDir, "$VERSIONS_DIR_NAME/$versionName") + val versionAppDir = File(versionDir, APP_DIR_NAME).apply { mkdirs() } + move(runtimeDir, File(versionDir, RUNTIME_DIR_NAME)) + appDir.listFiles()?.filter { it !in cfgFiles }?.forEach { move(it, File(versionAppDir, it.name)) } + + val versionRoot = "$ROOTDIR_MACRO\\$VERSIONS_DIR_NAME\\$versionName" + cfgFiles.forEach { cfg -> cfg.writeText(rewriteCfg(cfg.readText(), versionRoot)) } + return true + } + + /** A version string made safe as a directory name (it names `versions\`). */ + internal fun versionDirName(version: String): String = + version + .trim() + .replace(Regex("[^A-Za-z0-9._+-]"), "_") + .trimEnd('.') + .ifEmpty { "current" } + + /** Points a launcher `.cfg` at `\app` and `\runtime`. */ + internal fun rewriteCfg( + cfg: String, + versionRoot: String, + ): String { + val lineSeparator = if (cfg.contains("\r\n")) "\r\n" else "\n" + val lines = + cfg + .lines() + .filterNot { it.trim().startsWith("$RUNTIME_KEY=") } + .map { it.replace(APPDIR_MACRO, "$versionRoot\\$APP_DIR_NAME") } + .toMutableList() + val runtimeLine = "$RUNTIME_KEY=$versionRoot\\$RUNTIME_DIR_NAME" + val section = lines.indexOfFirst { it.trim() == APPLICATION_SECTION } + if (section >= 0) { + lines.add(section + 1, runtimeLine) + } else { + lines.addAll(0, listOf(APPLICATION_SECTION, runtimeLine, "")) + } + return lines.joinToString(lineSeparator) + } + + private fun move( + source: File, + target: File, + ) { + target.parentFile.mkdirs() + Files.move(source.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE) + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateNsis.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateNsis.kt new file mode 100644 index 000000000..2f7953bd5 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateNsis.kt @@ -0,0 +1,93 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.logging.Logger +import java.io.File + +/** + * NSIS hooks that let electron-builder's installer run as a hot update, next to a running app laid + * out by [WindowsHotUpdateLayout]. + * + * The updater runs the installer with `NUCLEUS_HOT_UPDATE=1` in its environment (inherited by the + * old version's uninstaller, which the installer runs first). In that mode: + * - `customCheckAppRunning` does not close the running app — by default electron-builder kills + * every process started from the install directory; + * - `customRemoveFiles` (uninstaller) keeps the old version's files — they are in use, and the new + * version deletes the retired `versions\` once the old process has exited. + * + * Without the variable (a manual install, an uninstall, a classic update) both reproduce + * electron-builder's default bodies, copied from the pinned 26.x templates + * (`allowOnlyOneInstallerInstance.nsh` / `uninstaller.nsh`). Defining `customCheckAppRunning` + * makes the template skip `getProcessInfo.nsh` and `Var pid`, which the default body needs, so + * they are declared here. + * + * Both macros are guarded with `!ifmacrondef`: a user include script defining its own wins (and + * [warnOnConflicts] says hot updates are then up to it). + */ +internal object WindowsHotUpdateNsis { + private val HOOKS = listOf("customCheckAppRunning", "customRemoveFiles") + + val MACROS: String = + """ + |; --- Nucleus hot update (see WindowsHotUpdateNsis) --- + |!ifmacrondef customCheckAppRunning + | !include "getProcessInfo.nsh" + | Var pid + | + | !macro customCheckAppRunning + | ReadEnvStr ${'$'}R0 NUCLEUS_HOT_UPDATE + | ${'$'}{if} ${'$'}R0 != "1" + | !insertmacro IS_POWERSHELL_AVAILABLE + | !insertmacro _CHECK_APP_RUNNING + | ${'$'}{endIf} + | !macroend + |!endif + | + |!ifmacrondef customRemoveFiles + | !macro customRemoveFiles + | ReadEnvStr ${'$'}R0 NUCLEUS_HOT_UPDATE + | ${'$'}{if} ${'$'}R0 == "1" + | ${'$'}{andIf} ${'$'}{isUpdated} + | DetailPrint "Hot update: the running version keeps its files" + | ${'$'}{else} + | ${'$'}{if} ${'$'}{isUpdated} + | CreateDirectory "${'$'}PLUGINSDIR\old-install" + | + | Push "" + | Call un.atomicRMDir + | Pop ${'$'}R0 + | + | ${'$'}{if} ${'$'}R0 != 0 + | DetailPrint "File is busy, aborting: ${'$'}R0" + | + | Push "" + | Call un.restoreFiles + | Pop ${'$'}R0 + | + | Abort `Can't rename "${'$'}INSTDIR" to "${'$'}PLUGINSDIR\old-install".` + | ${'$'}{endif} + | ${'$'}{endif} + | + | SetOutPath ${'$'}TEMP + | RMDir /r ${'$'}INSTDIR + | ${'$'}{endIf} + | !macroend + |!endif + | + """.trimMargin() + + /** Warns when [userInclude] defines a hook the hot update needs, since it then takes over. */ + fun warnOnConflicts( + userInclude: File, + logger: Logger, + ) { + val text = runCatching { userInclude.readText() }.getOrDefault("") + val overridden = HOOKS.filter { Regex("""!macro\s+$it\b""").containsMatchIn(text) } + if (overridden.isNotEmpty()) { + logger.warn( + "nsis.includeScript defines ${overridden.joinToString()}; Nucleus keeps yours, so hot " + + "updates only work if it honours NUCLEUS_HOT_UPDATE=1 (leave the running app and " + + "its files alone); otherwise the app updates the classic way, closing during the install.", + ) + } + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderConfigGenerator.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderConfigGenerator.kt index 33b084c2e..36b6ff866 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderConfigGenerator.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderConfigGenerator.kt @@ -72,7 +72,7 @@ internal class ElectronBuilderConfigGenerator { executableName: String? = null, dmgBackgroundOverride: File? = null, dmgWindowOverride: DmgWindowOverride? = null, - nsisProtocolInclude: File? = null, + nsisInclude: File? = null, macBundleName: String? = null, ): String { val yaml = StringBuilder() @@ -142,7 +142,7 @@ internal class ElectronBuilderConfigGenerator { targetArch, windowsIconOverride, executableName, - nsisProtocolInclude, + nsisInclude, ) OS.Linux -> generateLinuxConfig( @@ -306,7 +306,7 @@ internal class ElectronBuilderConfigGenerator { targetArch: Arch, windowsIconOverride: File?, executableName: String?, - nsisProtocolInclude: File?, + nsisInclude: File?, ) { yaml.appendLine("win:") yaml.appendLine(" target:") @@ -331,7 +331,7 @@ internal class ElectronBuilderConfigGenerator { yaml, distributions.windows.nsis, " ", - nsisProtocolInclude, + nsisInclude, menuCategoryDefault = distributions.windows.menuGroup, ) } @@ -341,7 +341,7 @@ internal class ElectronBuilderConfigGenerator { yaml, distributions.windows.nsis, " ", - nsisProtocolInclude, + nsisInclude, menuCategoryDefault = distributions.windows.menuGroup, ) } @@ -463,7 +463,7 @@ internal class ElectronBuilderConfigGenerator { yaml: StringBuilder, nsis: NsisSettings, indent: String, - protocolInclude: File? = null, + nsisInclude: File? = null, menuCategoryDefault: String? = null, ) { yaml.appendLine("${indent}oneClick: ${nsis.oneClick}") @@ -480,7 +480,7 @@ internal class ElectronBuilderConfigGenerator { yaml.appendLine("${indent}deleteAppDataOnUninstall: ${nsis.deleteAppDataOnUninstall}") yaml.appendLine("${indent}warningsAsErrors: false") - appendNsisFileSettings(yaml, nsis, indent, protocolInclude) + appendNsisFileSettings(yaml, nsis, indent, nsisInclude) if (nsis.multiLanguageInstaller) { yaml.appendLine("${indent}multiLanguageInstaller: true") @@ -497,7 +497,7 @@ internal class ElectronBuilderConfigGenerator { yaml: StringBuilder, nsis: NsisSettings, indent: String, - protocolInclude: File? = null, + nsisInclude: File? = null, ) { appendIfNotNull( yaml, @@ -523,10 +523,11 @@ internal class ElectronBuilderConfigGenerator { appendIfNotNull( yaml, "${indent}include", - nsis.includeScript.orNull - ?.asFile - ?.absolutePath - ?: protocolInclude?.absolutePath, + // The generated include chains the user's own script, so it wins when present. + nsisInclude?.absolutePath + ?: nsis.includeScript.orNull + ?.asFile + ?.absolutePath, ) appendIfNotNull( yaml, diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt index bfeee096a..f3a44dfaa 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt @@ -34,6 +34,8 @@ import dev.nucleusframework.desktop.application.internal.files.isDylibPath import dev.nucleusframework.desktop.application.internal.MACOS_DMG_TITLE_BAR_HEIGHT import dev.nucleusframework.desktop.application.internal.padDmgBackgroundForTitleBar import dev.nucleusframework.desktop.application.internal.readImageDimensions +import dev.nucleusframework.desktop.application.internal.WindowsHotUpdateLayout +import dev.nucleusframework.desktop.application.internal.WindowsHotUpdateNsis import dev.nucleusframework.desktop.application.internal.sanitizeFileName import dev.nucleusframework.desktop.application.internal.updateExecutableTypeInAppImage import dev.nucleusframework.desktop.application.internal.validation.ValidatedMacOSSigningSettings @@ -110,6 +112,8 @@ abstract class AbstractElectronBuilderPackageTask private const val APPX_SQUARE150_LOGO_SIZE = 150 private const val APPX_WIDE_LOGO_WIDTH = 310 private const val APPX_WIDE_LOGO_HEIGHT = 150 + private const val DEFAULT_PACKAGE_VERSION = "1.0.0" + private val NSIS_FORMATS = setOf(TargetFormat.Nsis, TargetFormat.NsisWeb, TargetFormat.Exe) } @get:InputDirectory @@ -326,6 +330,7 @@ abstract class AbstractElectronBuilderPackageTask bundleSilentUpdateArtifacts(workingAppDir, dist) ensureLinuxExecutableAlias(workingAppDir) updateExecutableTypeInAppImage(workingAppDir, targetFormat, logger, packageVersion.orNull) + val hotUpdateLayout = applyWindowsHotUpdateLayout(workingAppDir, dist) ensureMacAdHocSigning(workingAppDir, targetFormat) val (node, npm) = resolveNodeJs() @@ -358,6 +363,7 @@ abstract class AbstractElectronBuilderPackageTask windowsIconOverride = windowsIconOverride, linuxAfterInstallTemplate = linuxAfterInstallTemplate, linuxAfterRemoveTemplate = linuxAfterRemoveTemplate, + hotUpdateLayout = hotUpdateLayout, ) ensureProjectPackageMetadata(outputDir, dist) @@ -519,6 +525,7 @@ abstract class AbstractElectronBuilderPackageTask windowsIconOverride: File?, linuxAfterInstallTemplate: File?, linuxAfterRemoveTemplate: File?, + hotUpdateLayout: Boolean, ): File { val configGenerator = ElectronBuilderConfigGenerator() val resolvedArch = Arch.entries.first { it.id == targetArch.get() } @@ -552,7 +559,7 @@ abstract class AbstractElectronBuilderPackageTask ) } - val nsisProtocolInclude = generateNucleusNsisInclude(distributions, outputDir) + val nsisInclude = generateNsisInclude(distributions, outputDir, hotUpdateLayout) val configContent = configGenerator.generateConfig( @@ -568,7 +575,7 @@ abstract class AbstractElectronBuilderPackageTask executableName = resolveExecutableName(), dmgBackgroundOverride = dmgBackgroundOverride, dmgWindowOverride = dmgWindowOverride, - nsisProtocolInclude = nsisProtocolInclude, + nsisInclude = nsisInclude, macBundleName = macBundleName.orNull, ) val configFile = File(outputDir, "electron-builder.yml") @@ -578,35 +585,94 @@ abstract class AbstractElectronBuilderPackageTask } /** - * Generates an NSIS include script that registers the declared URL protocol handlers - * (deep linking) in the Windows registry at install time. + * Lays the Windows app image out for hot updates (`versions\\`, see + * [WindowsHotUpdateLayout]) when the target is an NSIS installer. + * Returns whether the layout was applied, which is what the NSIS include keys its hot + * update support on. + */ + private fun applyWindowsHotUpdateLayout( + appDir: File, + distributions: JvmApplicationDistributions, + ): Boolean { + if (currentOS != OS.Windows || targetFormat !in NSIS_FORMATS) return false + val version = packageVersion.orNull?.takeIf { it.isNotBlank() } ?: DEFAULT_PACKAGE_VERSION + val applied = WindowsHotUpdateLayout.apply(appDir, version) + if (applied) { + logger.info( + "Laid the app image out for hot updates " + + "(versions\\${WindowsHotUpdateLayout.versionDirName(version)})", + ) + } else { + logger.info("Hot update layout skipped: not a jpackage app image") + } + return applied + } + + /** + * Generates the NSIS include script passed to electron-builder, or null when nothing needs + * one. It chains, in order: the user's `nsis.includeScript`, the URL protocol registration and + * app data removal (only without a user script, see [nucleusNsisMacros]) and the hot update hooks + * ([WindowsHotUpdateNsis]) when [hotUpdateLayout] applies. + */ + private fun generateNsisInclude( + distributions: JvmApplicationDistributions, + outputDir: File, + hotUpdateLayout: Boolean, + ): File? { + if (currentOS != OS.Windows || targetFormat !in NSIS_FORMATS) return null + val userInclude = + distributions.windows.nsis.includeScript.orNull + ?.asFile + val nucleusMacros = nucleusNsisMacros(distributions, hasUserInclude = userInclude != null) + if (nucleusMacros == null && !hotUpdateLayout) return null + + val script = + buildString { + if (userInclude != null) { + if (hotUpdateLayout) WindowsHotUpdateNsis.warnOnConflicts(userInclude, logger) + appendLine("!include \"${userInclude.absolutePath}\"") + appendLine() + } + nucleusMacros?.let { appendLine(it) } + if (hotUpdateLayout) append(WindowsHotUpdateNsis.MACROS) + } + + val nshFile = File(outputDir, "nucleus-installer.nsh") + nshFile.parentFile.mkdirs() + // Write with a UTF-8 BOM so makensis detects the encoding and keeps non-ASCII + // protocol names (e.g. Hebrew) intact. NSIS treats '#' as a comment, so a + // "#pragma" directive would be inert — the BOM is the supported mechanism. + nshFile.writeText("$script", Charsets.UTF_8) + logger.info("Generated NSIS include script at ${nshFile.absolutePath}") + return nshFile + } + + /** + * Builds the NSIS macros that register the declared URL protocol handlers (deep linking) + * in the Windows registry at install time. * * electron-builder's `protocols` field only registers schemes on macOS (Info.plist) and * Linux (.desktop `x-scheme-handler`); the NSIS target ignores it. Windows therefore needs * explicit registry writes, which we emit via the `customInstall`/`customUnInstall` hooks. * * With `deleteAppDataOnUninstall`, the same `customUnInstall` also removes - * `%APPDATA%\` (see [appendAppDataRemoval]). Both live in one file because - * NSIS allows a single `customUnInstall` macro. + * `%APPDATA%\` (see [appendAppDataRemoval]). Both live in one macro because + * NSIS allows a single `customUnInstall`. * - * Returns null when the current OS is not Windows, the target is not an NSIS-family - * installer, there is nothing to emit, or the user already supplied a custom NSIS include - * script (which must not be overridden). + * Returns the macros, or null when there is nothing to emit or the user already supplied a + * custom NSIS include script (whose own macros must not be overridden). */ - private fun generateNucleusNsisInclude( + private fun nucleusNsisMacros( distributions: JvmApplicationDistributions, - outputDir: File, - ): File? { - if (currentOS != OS.Windows) return null - if (targetFormat !in setOf(TargetFormat.Nsis, TargetFormat.NsisWeb, TargetFormat.Exe)) return null - + hasUserInclude: Boolean, + ): String? { val appDataDir = runtimeAppId.orNull ?.takeIf { distributions.windows.nsis.deleteAppDataOnUninstall } ?.let { appDataDirNameOrNull(it) } if (distributions.protocols.isEmpty() && appDataDir == null) return null - if (distributions.windows.nsis.includeScript.orNull != null) { + if (hasUserInclude) { if (distributions.protocols.isNotEmpty()) { logger.warn( "URL protocol handlers are declared but a custom nsis.includeScript is set; " + @@ -681,17 +747,10 @@ abstract class AbstractElectronBuilderPackageTask appendLine("!macroend") } - val nshFile = File(outputDir, "nucleus-installer.nsh") - nshFile.parentFile.mkdirs() - // Write with a UTF-8 BOM so makensis detects the encoding and keeps non-ASCII - // protocol names (e.g. Hebrew) intact. NSIS treats '#' as a comment, so a - // "#pragma" directive would be inert — the BOM is the supported mechanism. - nshFile.writeText("$script", Charsets.UTF_8) logger.info( - "Generated NSIS include at ${nshFile.absolutePath} " + - "(schemes: ${handlers.joinToString { it.first }}; app data: ${appDataDir.orEmpty()})", + "NSIS macros: schemes ${handlers.joinToString { it.first }}; app data ${appDataDir.orEmpty()}", ) - return nshFile + return script } private fun exportPackagingMetadata( diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayoutTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayoutTest.kt new file mode 100644 index 000000000..037a0ca38 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayoutTest.kt @@ -0,0 +1,92 @@ +package dev.nucleusframework.desktop.application.internal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class WindowsHotUpdateLayoutTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun jpackageImage(): File { + val image = tmp.newFolder("App") + File(image, "App.exe").writeText("launcher") + File(image, "runtime/bin").mkdirs() + File(image, "runtime/bin/jvm.dll").writeText("jvm") + File(image, "app/resources").mkdirs() + File(image, "app/app.jar").writeText("jar") + File(image, "app/nucleus_tao.dll").writeText("dll") + File(image, "app/App.cfg").writeText( + "[Application]\r\napp.classpath=\$APPDIR\\app.jar\r\napp.mainclass=MainKt\r\n\r\n" + + "[JavaOptions]\r\njava-options=-Dnucleus.native.libraryPath=\$APPDIR\r\n", + ) + return image + } + + @Test + fun `app image is moved under versions and the cfg points into it`() { + val image = jpackageImage() + + assertTrue(WindowsHotUpdateLayout.apply(image, "1.2.0")) + + assertTrue(File(image, "App.exe").isFile) + assertEquals(listOf("App.cfg"), File(image, "app").list()!!.toList()) + assertTrue(File(image, "versions/1.2.0/runtime/bin/jvm.dll").isFile) + assertTrue(File(image, "versions/1.2.0/app/app.jar").isFile) + assertTrue(File(image, "versions/1.2.0/app/nucleus_tao.dll").isFile) + assertTrue(File(image, "versions/1.2.0/app/resources").isDirectory) + assertFalse(File(image, "runtime").exists()) + assertEquals( + "[Application]\r\n" + + "app.runtime=\$ROOTDIR\\versions\\1.2.0\\runtime\r\n" + + "app.classpath=\$ROOTDIR\\versions\\1.2.0\\app\\app.jar\r\n" + + "app.mainclass=MainKt\r\n\r\n" + + "[JavaOptions]\r\n" + + "java-options=-Dnucleus.native.libraryPath=\$ROOTDIR\\versions\\1.2.0\\app\r\n", + File(image, "app/App.cfg").readText(), + ) + } + + @Test + fun `an image without cfg or runtime is left untouched`() { + val image = tmp.newFolder("Native") + File(image, "App.exe").writeText("native image") + + assertFalse(WindowsHotUpdateLayout.apply(image, "1.2.0")) + assertFalse(File(image, "versions").exists()) + } + + @Test + fun `an already versioned image is left untouched`() { + val image = jpackageImage() + WindowsHotUpdateLayout.apply(image, "1.2.0") + val cfg = File(image, "app/App.cfg").readText() + + assertFalse(WindowsHotUpdateLayout.apply(image, "1.3.0")) + assertEquals(cfg, File(image, "app/App.cfg").readText()) + } + + @Test + fun `an existing app runtime entry is replaced`() { + val cfg = "[Application]\napp.runtime=\$APPDIR\\..\\runtime\napp.mainclass=MainKt\n" + + val rewritten = WindowsHotUpdateLayout.rewriteCfg(cfg, "\$ROOTDIR\\versions\\2.0.0") + + assertEquals( + "[Application]\napp.runtime=\$ROOTDIR\\versions\\2.0.0\\runtime\napp.mainclass=MainKt\n", + rewritten, + ) + } + + @Test + fun `version directory names are sanitized`() { + assertEquals("1.2.0-beta.1", WindowsHotUpdateLayout.versionDirName("1.2.0-beta.1")) + assertEquals("1.2.0_build_7", WindowsHotUpdateLayout.versionDirName("1.2.0 build/7")) + assertEquals("1.2", WindowsHotUpdateLayout.versionDirName("1.2.")) + assertEquals("current", WindowsHotUpdateLayout.versionDirName(" ")) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderMsiConfigTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderMsiConfigTest.kt index 8e0bb0127..7dcf38b39 100644 --- a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderMsiConfigTest.kt +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderMsiConfigTest.kt @@ -30,7 +30,7 @@ class ElectronBuilderMsiConfigTest { targetArch = Arch.X64, windowsIconOverride = null, executableName = "nucleusdemo", - nsisProtocolInclude = null, + nsisInclude = null, ) return yaml.toString() } diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderNsisConfigTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderNsisConfigTest.kt index 3ab9682d6..45ffab165 100644 --- a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderNsisConfigTest.kt +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderNsisConfigTest.kt @@ -30,7 +30,7 @@ class ElectronBuilderNsisConfigTest { targetArch = Arch.X64, windowsIconOverride = null, executableName = "nucleusdemo", - nsisProtocolInclude = null, + nsisInclude = null, ) return yaml.toString() } diff --git a/scripts/windows-hot-update-e2e.ps1 b/scripts/windows-hot-update-e2e.ps1 new file mode 100644 index 000000000..17f0ec6cc --- /dev/null +++ b/scripts/windows-hot-update-e2e.ps1 @@ -0,0 +1,465 @@ +<# +.SYNOPSIS + End-to-end check of the Windows hot update: the app must never disappear from the screen while it + updates itself. + +.DESCRIPTION + Installs the old NSIS installer silently into -InstallDir, serves the new one from a loopback + update feed (jwebserver + a generated latest.yml), and launches the app with HOT_UPDATE_DEMO_FEED + pointing at it; examples/hot-update-demo then downloads the update and calls installAndRestart on + its own. + + While that runs, a sampler polls every few milliseconds: + - the visible, non-cloaked top-level windows of processes started from -InstallDir (title and pid); + - the pixel on screen at the centre of the app window, which is what the user actually sees. + A sample with no app window, or a screen pixel that is not one of the demo's background colours, + counts as a gap. The run fails if any gap is longer than -MaxGapMs after the first window appeared. + + -Mode classic sets -Dnucleus.updater.hotUpdate.disabled=true (through JAVA_TOOL_OPTIONS) to measure + the close-install-relaunch update the hot update replaces. + + -Scenario picks what happens around the install (hot mode): + update nothing: the app must never leave the screen, and the new version + must delete the retired version and launcher. + relaunch-during-install the launcher is started again mid-install, as a shortcut would: it must + exist and run (no "file not found"), and one window must be left. + close-during-install the window is closed mid-install: the app must not be relaunched, the + install must still complete, and the next start runs the new version + and deletes the retired one. + failing-installer the feed serves an installer that exits with code 3: the app must stay + on screen, on its version, with its launcher intact. + stale-target-dir versions\ already holds leftovers from an interrupted attempt. + two-instances two instances (single instance off), holding docA and docB, both start + the update at once: the install lock must serialize them, and each must + come back on the new version with its own document. + notify-other-instance as above, but only the docA instance checks the feed: the docB one must + learn about the update from pendingRestartVersion, downloading nothing, + and restart onto it with its document. + + Build the fixtures first: + ./gradlew :examples:hot-update-demo:packageNsis -PhotUpdateDemoVersion=1.0.0 (copy the .exe aside) + ./gradlew :examples:hot-update-demo:packageNsis -PhotUpdateDemoVersion=1.1.0 + +.EXAMPLE + powershell -File scripts/windows-hot-update-e2e.ps1 -OldInstaller v1\hotupdatedemo-1.0.0-win-x64-nsis.exe ` + -NewInstaller v2\hotupdatedemo-1.1.0-win-x64-nsis.exe -NewVersion 1.1.0 -JdkHome $env:JAVA_HOME +#> +param( + [Parameter(Mandatory)] [string] $OldInstaller, + # One or more newer installers, applied in turn: the feed moves to the next one as soon as the + # previous one is on screen, which chains hot updates (each started by a handed-over instance). + [Parameter(Mandatory)] [string[]] $NewInstaller, + [Parameter(Mandatory)] [string[]] $NewVersion, + [Parameter(Mandatory)] [string] $JdkHome, + [string] $InstallDir = "$env:TEMP\nucleus-hot-update-e2e\install", + [ValidateSet('hot', 'classic')] [string] $Mode = 'hot', + [ValidateSet('update', 'relaunch-during-install', 'close-during-install', 'failing-installer', 'stale-target-dir', + 'two-instances', 'notify-other-instance')] + [string] $Scenario = 'update', + [int] $Port = 8765, + [int] $MaxGapMs = 0, + [int] $TimeoutSeconds = 180, + [string] $ReportDir = "$env:TEMP\nucleus-hot-update-e2e", + # Extra JVM options for the app (e.g. a JUL config to trace the handoff). + [string] $JavaToolOptions = '' +) + +$ErrorActionPreference = 'Stop' +$exeName = 'HotUpdateDemo.exe' + +Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +public static class HotUpdateSampler { + [DllImport("user32.dll")] static extern bool SetProcessDPIAware(); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr p); + delegate bool EnumProc(IntPtr h, IntPtr p); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] static extern IntPtr GetDC(IntPtr h); + [DllImport("gdi32.dll")] static extern uint GetPixel(IntPtr dc, int x, int y); + [DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr h, int attr, out int v, int size); + [DllImport("kernel32.dll")] static extern IntPtr OpenProcess(int access, bool inherit, uint pid); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool QueryFullProcessImageName(IntPtr h, int flags, StringBuilder s, ref int n); + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; } + + static readonly Dictionary paths = new Dictionary(); + public static int LastX = -1, LastY = -1; + + public static string Pixel(int x, int y) { + SetProcessDPIAware(); + return GetPixel(GetDC(IntPtr.Zero), x, y).ToString("X6"); + } + + static string ImagePath(uint pid) { + string p; + if (paths.TryGetValue(pid, out p)) return p; + p = ""; + IntPtr h = OpenProcess(0x1000, false, pid); + if (h != IntPtr.Zero) { + var sb = new StringBuilder(1024); int n = sb.Capacity; + if (QueryFullProcessImageName(h, 0, sb, ref n)) p = sb.ToString(); + CloseHandle(h); + } + paths[pid] = p; + return p; + } + + // One line per sample: elapsedMs|pixelRGB|pid:title;pid:title... + public static List Run(string installDir, string titlePrefix, string stopFile, int timeoutMs) { + SetProcessDPIAware(); + var lines = new List(); + var sw = Stopwatch.StartNew(); + IntPtr screen = GetDC(IntPtr.Zero); + int cx = -1, cy = -1; + while (sw.ElapsedMilliseconds < timeoutMs && !System.IO.File.Exists(stopFile)) { + var found = new List(); + EnumWindows((h, _) => { + if (!IsWindowVisible(h)) return true; + int cloaked; DwmGetWindowAttribute(h, 14, out cloaked, 4); + if (cloaked != 0) return true; + var sb = new StringBuilder(256); GetWindowText(h, sb, 256); + string title = sb.ToString(); + if (!title.StartsWith(titlePrefix)) return true; + uint pid; GetWindowThreadProcessId(h, out pid); + if (!ImagePath(pid).StartsWith(installDir, StringComparison.OrdinalIgnoreCase)) return true; + RECT r; GetWindowRect(h, out r); + // Left margin of the content: the background, clear of the centred text and the title bar. + cx = r.L + 30; cy = (r.T + r.B) / 2; + found.Add(pid + ":" + title); + return true; + }, IntPtr.Zero); + string pixel = cx < 0 ? "-" : GetPixel(screen, cx, cy).ToString("X6"); + lines.Add(sw.ElapsedMilliseconds + "|" + pixel + "|" + string.Join(";", found)); + LastX = cx; LastY = cy; + System.Threading.Thread.Sleep(5); + } + return lines; + } +} +'@ + +function Get-Sha512Base64([string] $path) { + $sha = [System.Security.Cryptography.SHA512]::Create() + $stream = [System.IO.File]::OpenRead($path) + try { [Convert]::ToBase64String($sha.ComputeHash($stream)) } finally { $stream.Dispose() } +} + +function Stop-App { + Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallDir, 'OrdinalIgnoreCase') } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } +} + +New-Item -ItemType Directory -Force -Path $ReportDir | Out-Null +$feedDir = Join-Path $ReportDir 'feed' +Remove-Item $feedDir -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $feedDir | Out-Null + +# --- Update feed ----------------------------------------------------------------------------- +if ($NewInstaller.Count -ne $NewVersion.Count) { throw "-NewInstaller and -NewVersion must have the same length" } +for ($i = 0; $i -lt $NewInstaller.Count; $i++) { + $installer = $NewInstaller[$i] + $name = Split-Path $installer -Leaf + Copy-Item $installer (Join-Path $feedDir $name) + if (Test-Path "$installer.blockmap") { Copy-Item "$installer.blockmap" (Join-Path $feedDir "$name.blockmap") } + $sha = Get-Sha512Base64 $installer + $size = (Get-Item $installer).Length + @" +version: $($NewVersion[$i]) +files: + - url: $name + sha512: $sha + size: $size +path: $name +sha512: $sha +releaseDate: '$(Get-Date -Format o)' +"@ | Set-Content -Encoding ascii (Join-Path $feedDir "latest-$i.yml") +} +if ($Scenario -eq 'failing-installer') { + # A GUI-subsystem exe (no console flashes) that fails like a broken installer would. + $fake = Join-Path $feedDir "fake-$($NewVersion[0])-win-x64-nsis.exe" + Add-Type -OutputType WindowsApplication -OutputAssembly $fake -TypeDefinition @' +public static class FailingInstaller { + public static int Main() { System.Threading.Thread.Sleep(2000); return 3; } +} +'@ + $sha = Get-Sha512Base64 $fake + @" +version: $($NewVersion[0]) +files: + - url: $(Split-Path $fake -Leaf) + sha512: $sha + size: $((Get-Item $fake).Length) +"@ | Set-Content -Encoding ascii (Join-Path $feedDir 'latest-0.yml') +} +Copy-Item (Join-Path $feedDir 'latest-0.yml') (Join-Path $feedDir 'latest.yml') +$finalVersion = $NewVersion[-1] +$oldVersion = $null + +$server = Start-Process -FilePath (Join-Path $JdkHome 'bin\jwebserver.exe') ` + -ArgumentList '-b', '127.0.0.1', '-p', "$Port", '-d', $feedDir -PassThru -WindowStyle Hidden +Start-Sleep -Seconds 2 + +# --- Install the old version ----------------------------------------------------------------- +Stop-App +if (Test-Path $InstallDir) { + $uninstaller = Get-ChildItem $InstallDir -Filter 'Uninstall *.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($uninstaller) { Start-Process $uninstaller.FullName -ArgumentList '/S' -Wait } + Remove-Item $InstallDir -Recurse -Force -ErrorAction SilentlyContinue +} +Start-Process $OldInstaller -ArgumentList '/S', "/D=$InstallDir" -Wait +if (-not (Test-Path (Join-Path $InstallDir $exeName))) { throw "Old version was not installed into $InstallDir" } +$oldVersion = @(Get-ChildItem (Join-Path $InstallDir 'versions') -Directory -ErrorAction SilentlyContinue | ForEach-Object Name)[0] +$logFile = "$env:TEMP\hot-update-demo.log" +Remove-Item $logFile -ErrorAction SilentlyContinue +if ($Scenario -eq 'stale-target-dir') { + $stale = Join-Path $InstallDir "versions\$($NewVersion[0])\app" + New-Item -ItemType Directory -Force -Path $stale | Out-Null + Set-Content (Join-Path $stale 'leftover.jar') 'not a jar' +} + +# --- Run the app and sample the screen until the new version has taken over ------------------ +$stopFile = Join-Path $ReportDir 'stop' +Remove-Item $stopFile -ErrorAction SilentlyContinue +$env:HOT_UPDATE_DEMO_FEED = "http://127.0.0.1:$Port" +$env:HOT_UPDATE_DEMO_TOPMOST = '1' # launched from a background process, the window would open behind others +$toolOptions = if ($Mode -eq 'classic') { "-Dnucleus.updater.hotUpdate.disabled=true $JavaToolOptions" } else { $JavaToolOptions } +if ($toolOptions.Trim()) { $env:JAVA_TOOL_OPTIONS = $toolOptions.Trim() } else { Remove-Item Env:JAVA_TOOL_OPTIONS -ErrorAction SilentlyContinue } + +$multiInstance = $Scenario -in 'two-instances', 'notify-other-instance' +$expectedWindows = if ($multiInstance) { 2 } else { 1 } +$watcher = Start-Job -ScriptBlock { + param($stopFile, $feedDir, $versions, $timeout, $scenario, $logFile, $launcher, $reportDir, $expectedWindows) + $deadline = (Get-Date).AddSeconds($timeout) + $next = 0 + $seenAt = $null + $actedAt = $null + while ((Get-Date) -lt $deadline) { + $processes = @(Get-Process -Name 'HotUpdateDemo' -ErrorAction SilentlyContinue) + $titles = @($processes | ForEach-Object MainWindowTitle) + if ($next -lt $versions.Count -and ($titles -contains "Hot Update Demo $($versions[$next])")) { + $next++ + if ($next -lt $versions.Count) { + Copy-Item (Join-Path $feedDir "latest-$next.yml") (Join-Path $feedDir 'latest.yml') -Force + } + } + $onFinal = @($titles | Where-Object { $_ -eq "Hot Update Demo $($versions[-1])" }).Count + if (-not $seenAt -and $next -ge $versions.Count -and $onFinal -ge $expectedWindows) { $seenAt = Get-Date } + $installing = (Test-Path $logFile) -and (Select-String -Path $logFile -Pattern 'installAndRestart' -Quiet) + if ($installing -and -not $actedAt) { + $actedAt = Get-Date + Start-Sleep -Milliseconds 1500 # the installer is running by now + switch ($scenario) { + 'relaunch-during-install' { + try { Start-Process $launcher -ErrorAction Stop; 'ok' | Set-Content (Join-Path $reportDir 'relaunch.txt') } + catch { "error: $_" | Set-Content (Join-Path $reportDir 'relaunch.txt') } + } + 'close-during-install' { + $processes | Where-Object MainWindowTitle | ForEach-Object { $_.CloseMainWindow() | Out-Null } + (Get-Date).ToString('o') | Set-Content (Join-Path $reportDir 'closed.txt') + } + } + } + # Keep sampling a few seconds after the last switch to catch a late disappearance. + if ($seenAt -and ((Get-Date) - $seenAt).TotalSeconds -gt 6) { break } + # No switch expected: watch long enough for the install to finish and a relaunch to show up. + if ($actedAt -and $scenario -in 'close-during-install', 'failing-installer' -and ((Get-Date) - $actedAt).TotalSeconds -gt 25) { break } + Start-Sleep -Milliseconds 200 + } + New-Item -ItemType File -Path $stopFile -Force | Out-Null +} -ArgumentList $stopFile, $feedDir, $NewVersion, $TimeoutSeconds, $Scenario, $logFile, (Join-Path $InstallDir $exeName), $ReportDir, $expectedWindows +Remove-Item (Join-Path $ReportDir 'relaunch.txt'), (Join-Path $ReportDir 'closed.txt') -ErrorAction SilentlyContinue + +$launcherPath = Join-Path $InstallDir $exeName +if ($multiInstance) { + $env:HOT_UPDATE_DEMO_MULTI = '1' + Start-Process $launcherPath -ArgumentList 'docA' | Out-Null + if ($Scenario -eq 'notify-other-instance') { $env:HOT_UPDATE_DEMO_CHECK = '0' } + Start-Process $launcherPath -ArgumentList 'docB' | Out-Null + Remove-Item Env:HOT_UPDATE_DEMO_MULTI, Env:HOT_UPDATE_DEMO_CHECK -ErrorAction SilentlyContinue +} else { + Start-Process $launcherPath | Out-Null +} +$samples = [HotUpdateSampler]::Run($InstallDir, 'Hot Update Demo', $stopFile, $TimeoutSeconds * 1000) +Wait-Job $watcher | Out-Null +Remove-Item Env:HOT_UPDATE_DEMO_FEED, Env:HOT_UPDATE_DEMO_TOPMOST, Env:JAVA_TOOL_OPTIONS -ErrorAction SilentlyContinue +$samples | Set-Content (Join-Path $ReportDir "samples-$Mode-$Scenario.txt") +$cfgAfterRun = Get-Content (Join-Path $InstallDir "app\$([IO.Path]::GetFileNameWithoutExtension($exeName)).cfg") -Raw +$launcherExists = Test-Path (Join-Path $InstallDir $exeName) + +if ($Scenario -eq 'close-during-install') { + # The user starts the app again later: the new version must run and retire the old one. + $env:HOT_UPDATE_DEMO_FEED = "http://127.0.0.1:$Port" + Start-Process (Join-Path $InstallDir $exeName) | Out-Null + Remove-Item Env:HOT_UPDATE_DEMO_FEED -ErrorAction SilentlyContinue + $restartDeadline = (Get-Date).AddSeconds(40) + while ((Get-Date) -lt $restartDeadline -and -not (Get-Process -Name 'HotUpdateDemo' -ErrorAction SilentlyContinue | + Where-Object MainWindowTitle -eq "Hot Update Demo $finalVersion")) { Start-Sleep -Milliseconds 200 } +} + +# What the screen shows at the app's position once it is gone: the reference for a gap. +Start-Sleep -Seconds 3 # let the new version clean the retired one up +$versions = @(Get-ChildItem (Join-Path $InstallDir 'versions') -Directory -ErrorAction SilentlyContinue | ForEach-Object Name) +$retired = @(Get-ChildItem $InstallDir -Filter '*.nucleus-old' -ErrorAction SilentlyContinue | ForEach-Object Name) +$running = @(Get-Process -Name 'HotUpdateDemo' -ErrorAction SilentlyContinue | Where-Object MainWindowTitle | ForEach-Object MainWindowTitle) +$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallDir, 'OrdinalIgnoreCase') } | + ForEach-Object { "$($_.ProcessId)<-$($_.ParentProcessId):$(Split-Path $_.ExecutablePath -Leaf)" }) +Stop-App +Start-Sleep -Milliseconds 800 +$background = [HotUpdateSampler]::Pixel([HotUpdateSampler]::LastX, [HotUpdateSampler]::LastY) + +# --- Analyse --------------------------------------------------------------------------------- +$appColors = @('C06515', '327D2E', '9A1B6A', '2828C6') # demo palette, as GetPixel's 0x00BBGGRR + +function Get-Distance([string] $a, [string] $b) { + $x = [Convert]::ToInt32($a, 16); $y = [Convert]::ToInt32($b, 16) + $d = 0 + foreach ($shift in 0, 8, 16) { $d += [math]::Abs((($x -shr $shift) -band 255) - (($y -shr $shift) -band 255)) } + $d +} + +# The window manager cross-fades a window it shows or hides, so the pixel passes through blends of +# the two versions' colours: a sample is a gap only when it is closer to the background than to the app. +function Test-AppVisible([string] $pixel) { + if ($pixel -eq '-') { return $false } + if ($appColors -contains $pixel) { return $true } + $toApp = ($appColors | ForEach-Object { Get-Distance $pixel $_ } | Measure-Object -Minimum).Minimum + $toApp -lt (Get-Distance $pixel $background) +} +$firstSeen = $null; $newSeen = $null; $lastT = 0 +$gaps = @(); $gapStart = $null; $screenSeen = $false; $overlapMs = 0; $pixelGaps = @(); $pixelGapStart = $null +foreach ($line in $samples) { + $parts = $line.Split('|', 3) + $t = [long]$parts[0]; $pixel = $parts[1]; $windows = $parts[2] + $titles = @($windows.Split(';', [StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { $_.Split(':', 2)[1] }) + if ($titles.Count -gt 0 -and -not $firstSeen) { $firstSeen = $t } + if (-not $newSeen -and ($titles -contains "Hot Update Demo $finalVersion")) { $newSeen = $t } + if ($firstSeen) { + if ($titles.Count -eq 0) { if (-not $gapStart) { $gapStart = $t } } + elseif ($gapStart) { $gaps += ($t - $gapStart); $gapStart = $null } + if (($titles | Select-Object -Unique).Count -gt 1) { $overlapMs += ($t - $lastT) } + # Counted from the first frame the app is really on screen: the window is reported + # visible while the window manager is still fading it in at startup. + $onScreen = Test-AppVisible $pixel + if ($onScreen) { $screenSeen = $true } + if ($screenSeen) { + if (-not $onScreen) { if (-not $pixelGapStart) { $pixelGapStart = $t } } + elseif ($pixelGapStart) { $pixelGaps += ($t - $pixelGapStart); $pixelGapStart = $null } + } + } + $lastT = $t +} +if ($gapStart) { $gaps += ($lastT - $gapStart) } +if ($pixelGapStart) { $pixelGaps += ($lastT - $pixelGapStart) } +$maxGap = ($gaps + 0 | Measure-Object -Maximum).Maximum +$maxPixelGap = ($pixelGaps + 0 | Measure-Object -Maximum).Maximum +$intervals = for ($i = 1; $i -lt $samples.Count; $i++) { [long]$samples[$i].Split('|')[0] - [long]$samples[$i - 1].Split('|')[0] } +$avgInterval = [math]::Round(($intervals | Measure-Object -Average).Average, 1) + +Write-Host "mode=$Mode scenario=$Scenario installDir=$InstallDir samples=$($samples.Count) avgIntervalMs=$avgInterval" +Write-Host "firstWindowMs=$firstSeen newVersionWindowMs=$newSeen" +Write-Host "windowGaps=$($gaps.Count) maxWindowGapMs=$maxGap" +Write-Host "screenGaps=$($pixelGaps.Count) maxScreenGapMs=$maxPixelGap" +Write-Host "overlapMs=$overlapMs backgroundPixel=$background" +Write-Host "versionsLeft=$($versions -join ',') retiredLaunchersLeft=$($retired -join ',')" +Write-Host "runningWindows=$($running -join ',')" +Write-Host "processes=$($processes -join ' ')" +Get-Content "$env:TEMP\hot-update-demo.log" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " app: $_" } + +Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue + +$failures = @() +$finalWindows = @($running | Where-Object { $_ -like 'Hot Update Demo*' }) +$appLog = @(Get-Content $logFile -Encoding UTF8 -ErrorAction SilentlyContinue) +$expectedCommand = "command=$(Join-Path $InstallDir $exeName)" +if (-not $launcherExists) { $failures += "the launcher $exeName was missing after the run" } +if ($Scenario -ne 'failing-installer' -and ($cfgAfterRun -notmatch [regex]::Escape("versions\$finalVersion\runtime"))) { + $failures += "the launcher cfg does not start $finalVersion" +} +# Every start, the handed-over ones included, runs from the stable launcher path (autostart, +# protocol handlers and shortcuts registered by the app keep pointing at something that exists). +$badCommand = @($appLog | Where-Object { $_ -match ' started ' -and $_ -notmatch [regex]::Escape($expectedCommand) }) +if ($badCommand.Count -gt 0) { $failures += "a start did not run from $expectedCommand : $($badCommand -join ' | ')" } + +$screenVerified = @($samples | Where-Object { $appColors -contains $_.Split('|')[1] }).Count -gt 0 +function Test-NoGap { + if ($maxGap -gt $MaxGapMs) { $script:failures += "the app had no window on screen for $maxGap ms" } + # The app window is topmost: if the screen never showed it once, the desktop is not being + # composed (display off, session locked) and the screen check says nothing either way. + if (-not $screenVerified) { + Write-Host "WARNING: the screen never showed the app (display off or session locked?); screen check skipped" + } elseif ($maxPixelGap -gt $MaxGapMs) { + $script:failures += "the app was not visible at its position for $maxPixelGap ms" + } +} +function Test-CleanedUp { + if ($versions.Count -ne 1) { $script:failures += "retired versions were not cleaned up: $($versions -join ',')" } + if ($retired.Count -ne 0) { $script:failures += "retired launchers were not cleaned up: $($retired -join ',')" } +} + +switch ($Scenario) { + { $_ -in 'update', 'relaunch-during-install', 'stale-target-dir' } { + if (-not $newSeen) { $failures += "the new version never showed a window" } + if ($finalWindows.Count -ne 1) { $failures += "expected one app window at the end, got: $($finalWindows -join ',')" } + Test-NoGap + if ($Mode -eq 'hot') { Test-CleanedUp } + if ($_ -eq 'relaunch-during-install') { + $relaunch = Get-Content (Join-Path $ReportDir 'relaunch.txt') -ErrorAction SilentlyContinue + Write-Host "relaunchDuringInstall=$relaunch" + if ($relaunch -ne 'ok') { $failures += "starting the launcher during the install failed: $relaunch" } + } + } + 'close-during-install' { + # Samples after the window closed must stay empty: the app was quit, it must not come back. + $closedAt = $null; $reappeared = $null; $shown = $false + foreach ($line in $samples) { + $parts = $line.Split('|', 3); $t = [long]$parts[0] + if ($parts[2]) { $shown = $true } + if ($shown -and -not $closedAt -and -not $parts[2]) { $closedAt = $t } + if ($closedAt -and $parts[2]) { $reappeared = "$t ms: $($parts[2])"; break } + } + Write-Host "closedAtMs=$closedAt reappeared=$reappeared" + if (-not $closedAt) { $failures += "the window was never closed" } + if ($reappeared) { $failures += "the app came back after the user closed it ($reappeared)" } + if ($finalWindows -notcontains "Hot Update Demo $finalVersion") { $failures += "the next start did not run $finalVersion" } + Test-CleanedUp + } + { $_ -in 'two-instances', 'notify-other-instance' } { + if (-not $newSeen) { $failures += "the new version never showed a window" } + $onFinal = @($finalWindows | Where-Object { $_ -eq "Hot Update Demo $finalVersion" }) + if ($onFinal.Count -ne 2) { $failures += "expected two $finalVersion windows at the end, got: $($finalWindows -join ',')" } + Test-NoGap + Test-CleanedUp + foreach ($doc in 'docA', 'docB') { + if (-not ($appLog | Where-Object { $_ -match "started version=$([regex]::Escape($finalVersion)) args=\[$doc\]" })) { + $failures += "no $finalVersion instance came back with $doc" + } + } + $installs = @($appLog | Where-Object { $_ -match 'installAndRestart' }).Count + Write-Host "installAndRestartCalls=$installs pendingRestart=$(@($appLog | Where-Object { $_ -match 'pendingRestart' }).Count)" + if ($_ -eq 'notify-other-instance') { + if (-not ($appLog | Where-Object { $_ -match "pendingRestart $([regex]::Escape($finalVersion))" })) { + $failures += "the docB instance never learned about the installed update" + } + if ($installs -ne 1) { $failures += "expected one install (docA), got $installs" } + } + } + 'failing-installer' { + if ($newSeen) { $failures += "a new version showed up although the installer failed" } + if ($finalWindows -notcontains "Hot Update Demo $oldVersion") { $failures += "the app did not stay on $oldVersion" } + if (@($samples | Where-Object { $_ -match "Hot Update Demo $oldVersion" } | ForEach-Object { $_.Split('|')[2].Split(':')[0] } | Select-Object -Unique).Count -ne 1) { + $failures += "the app process changed although the installer failed" + } + if ($versions -join ',' -ne $oldVersion) { $failures += "the versions directory changed: $($versions -join ',')" } + Test-NoGap + } +} +if ($failures.Count -gt 0) { Write-Host "FAILED: $($failures -join '; ')"; exit 1 } +Write-Host "PASSED" diff --git a/settings.gradle.kts b/settings.gradle.kts index 496cb09fe..b28a4ca8b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -101,4 +101,5 @@ include(":examples:rect-stress-demo") include(":examples:watermark-demo") include(":examples:widget-demo") include(":examples:macos-appex-demo") +include(":examples:hot-update-demo") includeBuild("plugin-build") diff --git a/updater-runtime/api/updater-runtime.api b/updater-runtime/api/updater-runtime.api index 1ec8e0776..780d748a7 100644 --- a/updater-runtime/api/updater-runtime.api +++ b/updater-runtime/api/updater-runtime.api @@ -25,9 +25,13 @@ public final class dev/nucleusframework/updater/NucleusUpdater { public final fun consumeUpdateEvent ()Ldev/nucleusframework/updater/UpdateEvent; public final fun downloadUpdate (Ldev/nucleusframework/updater/UpdateInfo;)Lkotlinx/coroutines/flow/Flow; public final fun getCurrentVersion ()Ljava/lang/String; + public final fun getPendingRestartVersion ()Lkotlinx/coroutines/flow/StateFlow; public final fun installAndQuit (Ljava/io/File;)V public final fun installAndRestart (Ljava/io/File;)V + public final fun installAndRestart (Ljava/io/File;Ljava/util/List;)V public final fun isUpdateSupported ()Z + public final fun restartToInstalledVersion (Ljava/util/List;)Z + public static synthetic fun restartToInstalledVersion$default (Ldev/nucleusframework/updater/NucleusUpdater;Ljava/util/List;ILjava/lang/Object;)Z public final fun wasJustUpdated ()Z } diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt index 5b8c36178..c84139cc4 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt @@ -9,9 +9,11 @@ import dev.nucleusframework.updater.exception.NoMatchingFileException import dev.nucleusframework.updater.exception.UpdateException import dev.nucleusframework.updater.internal.ChecksumVerifier import dev.nucleusframework.updater.internal.FileSelector +import dev.nucleusframework.updater.internal.InstalledVersionWatcher import dev.nucleusframework.updater.internal.PlatformInfo import dev.nucleusframework.updater.internal.PlatformInstaller import dev.nucleusframework.updater.internal.UpdateMarker +import dev.nucleusframework.updater.internal.WindowsHotUpdate import dev.nucleusframework.updater.internal.YamlParser import dev.nucleusframework.updater.internal.delta.DeltaPlan import dev.nucleusframework.updater.internal.delta.DeltaResolver @@ -20,6 +22,9 @@ import dev.nucleusframework.updater.internal.delta.UpdateCache import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext @@ -300,10 +305,75 @@ public class NucleusUpdater( } } + /** + * Installs [installerFile] and restarts the application on the new version. + * + * On a per-user Windows NSIS install of a JVM app (the plugin lays every one out for it) this + * returns immediately: the new version is installed while the application keeps running, then + * launched, and this process exits once the new version's first window is on screen — the + * application never disappears while it updates. If that install fails, the application keeps + * running on its current version. + * Everywhere else the application exits right away, the installer runs, and the new version + * is relaunched. + */ public fun installAndRestart(installerFile: File) { + installAndRestart(installerFile, relaunchArguments = emptyList()) + } + + /** + * [installAndRestart] that starts the new version with [relaunchArguments] — for an app that + * runs one instance per document, the document this instance has open. + * + * The original command line is deliberately not replayed (Chromium does not either): it may + * hold one-shot arguments — the autostart marker, which would make the new version believe it + * was started at login, or a deep link that would fire a second time. Honoured on Windows; + * macOS and Linux relaunch without arguments. + */ + public fun installAndRestart( + installerFile: File, + relaunchArguments: List, + ) { writeUpdateMarker() val platform = PlatformInfo.currentPlatform() - PlatformInstaller.install(installerFile, platform, restart = true) + val hotInstall = WindowsHotUpdate.eligibleInstall(installerFile, platform, resolveExecutableType()) + if (hotInstall != null) { + WindowsHotUpdate.start(installerFile, hotInstall, relaunchArguments) + return + } + PlatformInstaller.install(installerFile, platform, restart = true, relaunchArguments = relaunchArguments) + } + + /** + * The version installed on disk when it is not the one running — another instance of an app + * without single instance installed an update — or `null`. Windows hot-update installs only; + * elsewhere it stays `null`. + * + * Like Chromium's upgrade detector, this is how the other instances learn about an update: + * locally, without downloading anything. Observe it to offer "Restart to update", then call + * [restartToInstalledVersion]. Nothing restarts on its own — the instance may hold unsaved + * work the user has not decided to give up. + */ + public val pendingRestartVersion: StateFlow by lazy { + val install = WindowsHotUpdate.currentInstall(PlatformInfo.currentPlatform(), resolveExecutableType()) + install?.let { InstalledVersionWatcher(it).apply { start() }.version } + ?: MutableStateFlow(null).asStateFlow() + } + + /** + * Hands over to the version another instance already installed ([pendingRestartVersion]), + * started with [relaunchArguments] (see [installAndRestart]): nothing is downloaded or + * installed, and this process exits once the new version is on screen. + * + * Returns `false`, doing nothing, when no other version is installed. + */ + public fun restartToInstalledVersion(relaunchArguments: List = emptyList()): Boolean { + val install = + WindowsHotUpdate.currentInstall(PlatformInfo.currentPlatform(), resolveExecutableType()) + ?: return false + val installed = WindowsHotUpdate.installedVersionDir(install) ?: return false + writeUpdateMarker(installed.name) + WindowsHotUpdate.startHandOff(install, relaunchArguments) + return true } public fun installAndQuit(installerFile: File) { @@ -318,7 +388,9 @@ public class NucleusUpdater( * post-update launch (e.g. to show a "What's new" dialog or run migrations). */ public fun consumeUpdateEvent(): UpdateEvent? { - val event = peekUpdateEvent() ?: return null + if (!UpdateMarker.exists()) return null + val event = peekUpdateEvent() + // Consumed either way: a marker for another version is stale and must not linger. UpdateMarker.delete() return event } @@ -327,16 +399,24 @@ public class NucleusUpdater( * Returns `true` if the application was launched after an update. * Does **not** consume the event — call [consumeUpdateEvent] to clear it. */ - public fun wasJustUpdated(): Boolean = UpdateMarker.exists() + public fun wasJustUpdated(): Boolean = peekUpdateEvent() != null + /** + * The event recorded before the last install, if that install is the version now running. The + * marker is written *before* the installer runs, so an install that failed — or was never + * completed — leaves a marker naming a version this is not; reporting it would announce an + * update that did not happen. + */ private fun peekUpdateEvent(): UpdateEvent? { val (previousVersion, newVersion) = UpdateMarker.read() ?: return null - val level = Version.fromString(newVersion).levelFrom(Version.fromString(previousVersion)) + val installed = Version.fromString(newVersion) + if (installed.compareTo(Version.fromString(config.currentVersion)) != 0) return null + val level = installed.levelFrom(Version.fromString(previousVersion)) return UpdateEvent(previousVersion, newVersion, level) } - private fun writeUpdateMarker() { - val targetVersion = pendingUpdateVersion ?: return + private fun writeUpdateMarker(targetVersion: String? = pendingUpdateVersion) { + if (targetVersion == null) return try { UpdateMarker.write(config.currentVersion, targetVersion) } catch ( @@ -379,6 +459,10 @@ public class NucleusUpdater( return UpdateResult.NotAvailable } + // Another instance already installed it: nothing to download, only a restart + // (pendingRestartVersion). + if (isInstalledOnDisk(remoteVersion)) return UpdateResult.NotAvailable + // On macOS, ignore the build-time system property so auto-detection // can prefer ZIP (silent install). Users can still force DMG via config.executableType. val format = @@ -432,6 +516,12 @@ public class NucleusUpdater( return UpdateResult.Available(updateInfo, level) } + private fun isInstalledOnDisk(version: Version): Boolean { + val install = WindowsHotUpdate.currentInstall(PlatformInfo.currentPlatform(), resolveExecutableType()) + val installed = install?.let(WindowsHotUpdate::installedVersionDir) ?: return false + return Version.fromString(installed.name) >= version + } + private fun resolveExecutableType(): ExecutableType { val explicit = config.executableType if (explicit != null) return ExecutableRuntime.parseType(explicit) diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/InstalledVersionWatcher.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/InstalledVersionWatcher.kt new file mode 100644 index 000000000..5b3b0acf4 --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/InstalledVersionWatcher.kt @@ -0,0 +1,83 @@ +package dev.nucleusframework.updater.internal + +import dev.nucleusframework.core.runtime.VersionedInstall +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.io.IOException +import java.nio.channels.OverlappingFileLockException +import java.nio.file.FileSystems +import java.nio.file.StandardWatchEventKinds +import java.util.concurrent.TimeUnit +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Publishes the version the launcher now starts when another process installed one next to the + * running version — typically another instance of an app without single instance. + * + * This is Chromium's `InstalledVersionMonitor` + `InstalledVersionPoller` pair: a change + * notification (here on `app\`, where the installer rewrites the launcher's `.cfg`) backed by a + * slow poll in case a notification is missed. The `.cfg` is written before the version it points + * to has finished extracting, so it is read under the shared install lock, which waits for an + * install in progress to complete. + */ +internal class InstalledVersionWatcher( + private val install: VersionedInstall, +) { + private val state = MutableStateFlow(read()) + + val version: StateFlow = state.asStateFlow() + + fun start() { + Thread(::watch, "nucleus-installed-version-watcher").apply { + isDaemon = true + priority = Thread.MIN_PRIORITY + start() + } + } + + private fun watch() { + try { + FileSystems.getDefault().newWatchService().use { watcher -> + File(install.root, APP_DIR_NAME).toPath().register( + watcher, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + ) + while (true) { + val key = watcher.poll(POLL_INTERVAL_MINUTES, TimeUnit.MINUTES) + key?.pollEvents() + key?.reset() + state.value = readSettled() + } + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } catch (e: IOException) { + logger.log(Level.WARNING, "Cannot watch ${install.root} for installed updates", e) + } + } + + /** Reads once no install is in progress; this very process installing keeps the last value. */ + private fun readSettled(): String? = + try { + WindowsHotUpdate.withInstallLock(install, shared = true) { read() } + } catch (e: IOException) { + if (e.cause is OverlappingFileLockException) { + state.value + } else { + logger.log(Level.FINE, "Install lock unavailable; reading without it", e) + read() + } + } + + private fun read(): String? = WindowsHotUpdate.installedVersionDir(install)?.name + + private companion object { + const val APP_DIR_NAME = "app" + const val POLL_INTERVAL_MINUTES = 30L + val logger: Logger = Logger.getLogger(InstalledVersionWatcher::class.java.name) + } +} diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/PlatformInstaller.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/PlatformInstaller.kt index 9af71793a..9c00b2363 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/PlatformInstaller.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/PlatformInstaller.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.updater.internal import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.core.runtime.UpdateHandoff import java.io.File import java.nio.file.Files import java.util.logging.Logger @@ -55,12 +56,13 @@ internal object PlatformInstaller { file: File, platform: Platform, restart: Boolean = true, + relaunchArguments: List = emptyList(), ) { val extension = file.name.substringAfterLast('.').lowercase() when { platform == Platform.MacOS && extension == "zip" -> installMacZip(file, restart) - platform == Platform.Windows -> installWindows(file, extension, restart) + platform == Platform.Windows -> installWindows(file, extension, restart, relaunchArguments) platform == Platform.Linux && extension == "appimage" -> installLinuxAppImage(file, restart) platform == Platform.Linux && (extension == "deb" || extension == "rpm") -> installLinuxPackage(file, extension, restart) @@ -331,15 +333,17 @@ internal object PlatformInstaller { file: File, extension: String, restart: Boolean, + relaunchArguments: List, ) { val pid = ProcessHandle.current().pid() val launcher = currentExecutablePath() val script = File(createUpdateWorkDir(), "nucleus-update.ps1") - script.writeText( + writePowerShellScript( + script, buildWindowsUpdateScript( pid = pid, installerCommand = windowsInstallerCommand(file, extension), - relaunchCommand = windowsRelaunchCommand(restart, launcher), + relaunchCommand = windowsRelaunchCommand(restart, launcher, relaunchArguments), artifactPath = file.absolutePath, scriptPath = script.absolutePath, ), @@ -355,6 +359,8 @@ internal object PlatformInstaller { script.absolutePath, ).redirectOutput(ProcessBuilder.Redirect.DISCARD) .redirectError(ProcessBuilder.Redirect.DISCARD) + // A classic update closes the app first: never let the installer think otherwise. + .apply { environment().remove(UpdateHandoff.ENV_HOT_INSTALL) } .start() } } diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsHotUpdate.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsHotUpdate.kt new file mode 100644 index 000000000..b13fe9e12 --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsHotUpdate.kt @@ -0,0 +1,418 @@ +package dev.nucleusframework.updater.internal + +import dev.nucleusframework.core.runtime.ExecutableType +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.core.runtime.SingleInstanceManager +import dev.nucleusframework.core.runtime.UpdateHandoff +import dev.nucleusframework.core.runtime.VersionedInstall +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.OverlappingFileLockException +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.system.exitProcess + +private val logger: Logger = Logger.getLogger(WindowsHotUpdate::class.java.name) + +/** + * Hot update of a Windows NSIS install: the new version is installed **while this one keeps + * running**, then launched, and this process only exits once the new version's first window is on + * screen — the application never disappears while it updates. + * + * It relies on the versioned layout the Gradle plugin builds for NSIS (`versions\\` + * holding the runtime and the app, see [VersionedInstall]): the installer writes the new version + * next to the running one and rewrites the launcher's `.cfg`, so nothing this process holds open is + * touched. The installer is told it runs as a hot update through + * [UpdateHandoff.ENV_HOT_INSTALL]; without that it would close the running application first. + * + * Installs are serialized across processes by a lock file in `versions\` — the role Chromium gives + * its single machine-wide updater: an instance that finds another one installing waits, then sees + * the new version already installed and only hands over to it. + * + * If the hot path cannot start, the classic close-install-relaunch update runs instead. If the + * installer itself fails, the application simply keeps running: the classic update would run the + * same installer, fail the same way, and close and reopen the app at every update check. + */ +@Suppress("TooManyFunctions") +internal object WindowsHotUpdate { + private const val INSTALL_TIMEOUT_MINUTES = 10L + private const val READY_TIMEOUT_MS = 30_000L + private const val READY_POLL_MS = 20L + private const val LOCK_ATTEMPTS = 50 + private const val LOCK_RETRY_MS = 100L + private const val UNINSTALLER_PREFIX = "Uninstall " + private const val INSTALL_LOCK_NAME = ".nucleus-install.lock" + + private val HOT_UPDATABLE_TYPES = setOf(ExecutableType.NSIS, ExecutableType.EXE, ExecutableType.NSIS_WEB) + + private val started = AtomicBoolean(false) + + /** Outcome of the locked part of a hot update. */ + private enum class InstallOutcome { INSTALLED, FAILED, CANNOT_START } + + /** The install to hot-update with [installer], or `null` when only a classic update applies. */ + fun eligibleInstall( + installer: File, + platform: Platform, + type: ExecutableType, + install: VersionedInstall? = UpdateHandoff.versionedInstall, + ): VersionedInstall? { + if (!installer.name.endsWith(".exe", ignoreCase = true)) return null + return currentInstall(platform, type, install) + } + + /** The versioned install this process can hot-update and hand over from, if any. */ + fun currentInstall( + platform: Platform, + type: ExecutableType, + install: VersionedInstall? = UpdateHandoff.versionedInstall, + ): VersionedInstall? { + if (System.getProperty(DISABLE_PROPERTY).toBoolean()) return null + if (platform != Platform.Windows || type !in HOT_UPDATABLE_TYPES) return null + return install?.takeIf { it.launcher.isFile && canWriteInstall(it) } + } + + /** + * A per-machine install (`Program Files`) is not writable by the running app: it could neither + * move its launcher aside nor delete the retired version, and the elevated installer does not + * reliably inherit [UpdateHandoff.ENV_HOT_INSTALL] through UAC — it would close the app anyway. + * Those installs take the classic update. Probed with a real file, since ACLs are what decide. + */ + internal fun canWriteInstall(install: VersionedInstall): Boolean = + try { + val probe = File.createTempFile(".nucleus-write-probe", null, install.versionsDir) + probe.delete() + true + } catch ( + @Suppress("SwallowedException") e: IOException, + ) { + logger.info("Install directory is not writable (${e.message}); using a classic update") + false + } + + /** + * Starts the hot update on a background thread and returns immediately: the application stays + * usable while the installer runs, and exits once the new version has taken over, launched + * with [relaunchArguments]. + */ + fun start( + installer: File, + install: VersionedInstall, + relaunchArguments: List, + ) { + if (!started.compareAndSet(false, true)) return + Thread({ run(installer, install, relaunchArguments) }, "nucleus-hot-update").start() + } + + /** + * Hands over to the version another instance already installed, without installing anything. + * Returns immediately; the process exits once the new version is on screen. + */ + fun startHandOff( + install: VersionedInstall, + relaunchArguments: List, + ) { + if (!started.compareAndSet(false, true)) return + Thread({ handOff(install, relaunchArguments) }, "nucleus-hot-update").start() + } + + private fun run( + installer: File, + install: VersionedInstall, + relaunchArguments: List, + ) { + val outcome = + try { + withInstallLock(install) { installLocked(installer, install) } + } catch (e: IOException) { + logger.log(Level.WARNING, "Could not take the install lock", e) + InstallOutcome.CANNOT_START + } + when (outcome) { + InstallOutcome.CANNOT_START -> { + logger.warning("Hot update could not start; falling back to a classic update") + started.set(false) + PlatformInstaller.install( + installer, + Platform.Windows, + restart = true, + relaunchArguments = relaunchArguments, + ) + } + InstallOutcome.FAILED -> { + logger.severe("Hot update failed; the application keeps running on its current version") + started.set(false) + } + InstallOutcome.INSTALLED -> { + installer.delete() + handOff(install, relaunchArguments) + } + } + } + + private fun installLocked( + installer: File, + install: VersionedInstall, + ): InstallOutcome { + // Another instance (an app without single instance) installed a newer version while this + // one waited for the lock, or earlier: installing again would overwrite files it may be + // running from. Just hand over. + installedVersionDir(install)?.let { installed -> + logger.info("${installed.name} is already installed; handing over to it") + return InstallOutcome.INSTALLED + } + val workDir = + try { + retireLaunchers(install.root) + createUpdateWorkDir() + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.WARNING, "Could not prepare the hot update", e) + return InstallOutcome.CANNOT_START + } + val installed = + try { + runInstaller(installer, install, workDir) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.WARNING, "Hot update installer failed", e) + null + } + if (installed == null) return InstallOutcome.FAILED + logger.info("Hot update installed ${installed.name}; handing over to it") + return InstallOutcome.INSTALLED + } + + /** + * Runs [block] under the cross-process install lock: exclusive for an install, [shared] for a + * reader that must not see an install half done. Blocks while another process holds it. + * + * A lock the same JVM already holds through another channel is reported by Java as an + * [OverlappingFileLockException] rather than waited for, so that case is retried briefly (the + * installed-version watcher only reads under the lock for a moment). + */ + internal fun withInstallLock( + install: VersionedInstall, + shared: Boolean = false, + block: () -> T, + ): T { + RandomAccessFile(File(install.versionsDir, INSTALL_LOCK_NAME), "rw").use { file -> + var attempt = 0 + while (true) { + try { + file.channel.lock(0, Long.MAX_VALUE, shared).use { return block() } + } catch (e: OverlappingFileLockException) { + if (++attempt >= LOCK_ATTEMPTS) throw IOException("Install lock held by this process", e) + Thread.sleep(LOCK_RETRY_MS) + } + } + } + } + + /** + * Frees every launcher at the install root for the installer. A running executable cannot be + * overwritten but can be renamed, so each one is moved aside and copied back: the copy is not + * mapped by any process, so the installer can replace it, and the launcher path — shortcuts, + * the Run key, protocol handlers — keeps working throughout the install. + * + * Returns the retired originals, which the new version deletes once this process has exited. + */ + internal fun retireLaunchers(root: File): List = + root + .listFiles { file -> + file.isFile && + file.name.endsWith(".exe", ignoreCase = true) && + !file.name.startsWith(UNINSTALLER_PREFIX) + }.orEmpty() + .mapNotNull { launcher -> + val suffix = "${System.nanoTime()}${UpdateHandoff.RETIRED_LAUNCHER_SUFFIX}" + val retired = File(root, "${launcher.name}.$suffix") + if (!launcher.renameTo(retired)) return@mapNotNull null + Files.copy(retired.toPath(), launcher.toPath()) + // jpackage ships the launcher read-only, and the installer cannot overwrite that. + launcher.setWritable(true) + retired + } + + /** + * Runs the installer in hot mode and returns the version directory it installed, or `null` when + * it failed or did not install a new version next to the running one. + */ + private fun runInstaller( + installer: File, + install: VersionedInstall, + workDir: File, + ): File? { + val script = File(workDir, "nucleus-hot-update.ps1") + // Tells the script the app quit on its own: it must not be relaunched then. A process the + // installer kills runs no shutdown hook, which is exactly the case the relaunch is for. + val exitedMarker = File(workDir, "app-exited") + Runtime.getRuntime().addShutdownHook(Thread { runCatching { exitedMarker.createNewFile() } }) + writePowerShellScript( + script, + buildWindowsHotUpdateScript( + pid = ProcessHandle.current().pid(), + installerPath = installer.absolutePath, + launcher = install.launcher.absolutePath, + exitedMarker = exitedMarker.absolutePath, + ), + ) + val process = + ProcessBuilder( + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + script.absolutePath, + ).redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .apply { environment()[UpdateHandoff.ENV_HOT_INSTALL] = "1" } + .start() + if (!process.waitFor(INSTALL_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { + logger.warning("Hot update installer did not finish within $INSTALL_TIMEOUT_MINUTES minutes") + return null + } + workDir.deleteRecursively() + val exitCode = process.exitValue() + if (exitCode != 0) { + logger.warning("Hot update installer exited with code $exitCode") + return null + } + return installedVersionDir(install) + } + + /** + * The version the launcher's `.cfg` now starts, when it is not the one this process runs — a + * newer version installed next to it, by this process or another. Read from the `.cfg` rather + * than derived from the update's version string, so the plugin's directory naming is the only + * source of truth. + */ + internal fun installedVersionDir(install: VersionedInstall): File? { + val cfg = File(install.root, "app/${install.launcher.nameWithoutExtension}.cfg") + if (!cfg.isFile || !install.launcher.isFile) return null + val runtimeLine = + cfg.readLines().firstOrNull { it.trim().startsWith(RUNTIME_KEY) } ?: return null + val runtimePath = runtimeLine.substringAfter('=').trim() + val prefix = "${ROOTDIR_MACRO}\\${UpdateHandoff.VERSIONS_DIR_NAME}\\" + if (!runtimePath.startsWith(prefix, ignoreCase = true)) return null + val versionName = runtimePath.removePrefix(prefix).substringBefore('\\') + val versionDir = File(install.versionsDir, versionName) + val isNew = !versionDir.name.equals(install.versionDir.name, ignoreCase = true) + return versionDir.takeIf { isNew && File(it, "runtime").isDirectory } + } + + /** + * Launches the new version with [relaunchArguments] and exits once it signals it is on screen. + * Should it quit before that, this process stays: an application that stays visible beats a gap. + */ + private fun handOff( + install: VersionedInstall, + relaunchArguments: List, + ) { + val workDir = createUpdateWorkDir() + val readyFile = File(workDir, "ready") + SingleInstanceManager.releaseForHandoff() + val successor = + try { + ProcessBuilder(listOf(install.launcher.absolutePath) + relaunchArguments) + .directory(install.root) + .apply { + environment().remove(UpdateHandoff.ENV_HOT_INSTALL) + environment()[UpdateHandoff.ENV_READY_FILE] = readyFile.absolutePath + environment()[UpdateHandoff.ENV_PREVIOUS_PID] = previousPids(install).joinToString(",") + }.redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.SEVERE, "Could not launch the updated application", e) + started.set(false) + return + } + + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(READY_TIMEOUT_MS) + while (!readyFile.isFile) { + if (!successor.isAlive) { + logger.severe( + "The updated application exited (code ${successor.exitValue()}) before showing a " + + "window; keeping this instance running", + ) + workDir.deleteRecursively() + started.set(false) + return + } + if (System.nanoTime() > deadline) { + logger.warning( + "The updated application did not signal readiness within ${READY_TIMEOUT_MS}ms " + + "(no Nucleus window? call UpdateHandoff.signalReady()); exiting anyway", + ) + break + } + Thread.sleep(READY_POLL_MS) + } + workDir.deleteRecursively() + exitProcess(0) + } + + /** + * This process, plus the launcher it runs under: the jpackage launcher restarts itself as a + * child, and the parent — running the retired launcher executable — outlives the JVM briefly. + * The new version waits for both before deleting what they hold. + */ + private fun previousPids(install: VersionedInstall): List { + val current = ProcessHandle.current() + val launcherParent = + current.parent().filter { parent -> + parent + .info() + .command() + .map { File(it).absoluteFile.parentFile == install.root } + .orElse(false) + } + return listOf(current.pid()) + launcherParent.map { listOf(it.pid()) }.orElse(emptyList()) + } + + /** Set to `true` to always take the classic close-install-relaunch path. */ + internal const val DISABLE_PROPERTY = "nucleus.updater.hotUpdate.disabled" + + private const val RUNTIME_KEY = "app.runtime" + private const val ROOTDIR_MACRO = "\$ROOTDIR" +} + +/** + * PowerShell that runs the NSIS installer as a hot update and waits for it. The environment + * carries [UpdateHandoff.ENV_HOT_INSTALL], so a hot-update-aware installer leaves the application + * running; should the installer close it anyway (one built without hot update support), the + * script relaunches it once the installer is done, exactly like a classic update — but not when + * the user quit the app during the install ([exitedMarker] exists then). + * + * Exits with the installer's exit code. + */ +internal fun buildWindowsHotUpdateScript( + pid: Long, + installerPath: String, + launcher: String, + exitedMarker: String, +): String = + """ + |${'$'}installer = Start-Process '${psSingleQuote(installerPath)}' -ArgumentList '/S', '--updated' -Wait -PassThru + |${'$'}code = ${'$'}installer.ExitCode + |${'$'}closedByInstaller = -not (Get-Process -Id $pid -ErrorAction SilentlyContinue) -and + | -not (Test-Path -LiteralPath '${psSingleQuote(exitedMarker)}') + |if (${'$'}closedByInstaller) { + | # The installer closed the application: relaunch it as a classic update would + | Remove-Item Env:${UpdateHandoff.ENV_HOT_INSTALL} -ErrorAction SilentlyContinue + | Start-Process '${psSingleQuote(launcher)}' + |} + |exit ${'$'}code + """.trimMargin() diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsUpdateScript.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsUpdateScript.kt index f9d194abd..9cc78a5a7 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsUpdateScript.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsUpdateScript.kt @@ -13,6 +13,19 @@ import java.io.File */ internal fun psSingleQuote(value: String): String = value.replace("'", "''") +/** + * Writes a PowerShell script as UTF-8 **with a BOM**. Windows PowerShell 5.1 reads a BOM-less + * script in the ANSI code page, so any non-ASCII path — the installer under + * `C:\Users\Hélène\AppData\Local\Temp`, the app under `...\Programs` — would be mangled and not + * found: the update silently did nothing for every user with an accented account name. + */ +internal fun writePowerShellScript( + script: File, + content: String, +) { + script.writeText("\uFEFF$content", Charsets.UTF_8) +} + /** * PowerShell that waits for the current process, runs the downloaded installer, * optionally relaunches, then deletes the artifact and itself. @@ -54,9 +67,43 @@ internal fun windowsInstallerCommand( internal fun windowsRelaunchCommand( restart: Boolean, launcher: String?, -): String = - if (restart && launcher != null) { - "\n# Relaunch the application\nStart-Process '${psSingleQuote(launcher)}'" - } else { - "" + arguments: List = emptyList(), +): String { + if (!restart || launcher == null) return "" + val argumentList = + if (arguments.isEmpty()) "" else " -ArgumentList '${psSingleQuote(windowsCommandLine(arguments))}'" + return "\n# Relaunch the application\nStart-Process '${psSingleQuote(launcher)}'$argumentList" +} + +/** + * Joins [arguments] into one Windows command line that `CommandLineToArgvW` (and so the JVM's + * `main(args)`) splits back into the same list. `Start-Process -ArgumentList` passes an array + * joined with bare spaces, which would split an argument holding a space. + */ +internal fun windowsCommandLine(arguments: List): String = + arguments.joinToString(" ") { argument -> + if (argument.isNotEmpty() && argument.none { it == ' ' || it == '\t' || it == '"' }) { + argument + } else { + buildString { + append('"') + var backslashes = 0 + for (c in argument) { + when (c) { + '\\' -> backslashes++ + '"' -> { + // Backslashes before a quote are doubled, and the quote itself escaped. + append("\\".repeat(backslashes * 2 + 1)).append('"') + backslashes = 0 + } + else -> { + append("\\".repeat(backslashes)).append(c) + backslashes = 0 + } + } + } + // Trailing backslashes are doubled so they do not escape the closing quote. + append("\\".repeat(backslashes * 2)).append('"') + } + } } diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/UpdateEventTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/UpdateEventTest.kt index fe5752580..6e9dda6ea 100644 --- a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/UpdateEventTest.kt +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/UpdateEventTest.kt @@ -15,14 +15,16 @@ class UpdateEventTest { @Before fun setup() { - updater = - NucleusUpdater { - currentVersion = "2.0.0" - provider = FakeUpdateProvider() - } + updater = updaterAt("2.0.0") UpdateMarker.delete() } + private fun updaterAt(version: String): NucleusUpdater = + NucleusUpdater { + currentVersion = version + provider = FakeUpdateProvider() + } + @After fun cleanup() { UpdateMarker.delete() @@ -74,7 +76,7 @@ class UpdateEventTest { fun `consumeUpdateEvent detects minor update level`() { UpdateMarker.write("1.0.0", "1.1.0") - val event = updater.consumeUpdateEvent() + val event = updaterAt("1.1.0").consumeUpdateEvent() assertNotNull(event) assertEquals(UpdateLevel.MINOR, event!!.updateLevel) } @@ -83,7 +85,7 @@ class UpdateEventTest { fun `consumeUpdateEvent detects patch update level`() { UpdateMarker.write("1.0.0", "1.0.1") - val event = updater.consumeUpdateEvent() + val event = updaterAt("1.0.1").consumeUpdateEvent() assertNotNull(event) assertEquals(UpdateLevel.PATCH, event!!.updateLevel) } @@ -92,8 +94,19 @@ class UpdateEventTest { fun `consumeUpdateEvent detects pre-release update level`() { UpdateMarker.write("1.0.0-beta.1", "1.0.0-beta.2") - val event = updater.consumeUpdateEvent() + val event = updaterAt("1.0.0-beta.2").consumeUpdateEvent() assertNotNull(event) assertEquals(UpdateLevel.PRE_RELEASE, event!!.updateLevel) } + + @Test + fun `a marker left by an install that did not complete is dropped, not reported`() { + // installAndRestart wrote it for 2.1.0, but the installer failed: still running 2.0.0. + UpdateMarker.write("2.0.0", "2.1.0") + + assertFalse(updater.wasJustUpdated()) + assertNull(updater.consumeUpdateEvent()) + // Consumed: the stale marker does not resurface once 2.1.0 is finally installed. + assertNull(updaterAt("2.1.0").consumeUpdateEvent()) + } } diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateMultiInstanceTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateMultiInstanceTest.kt new file mode 100644 index 000000000..f832fd0f7 --- /dev/null +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateMultiInstanceTest.kt @@ -0,0 +1,98 @@ +package dev.nucleusframework.updater + +import dev.nucleusframework.core.runtime.VersionedInstall +import dev.nucleusframework.updater.internal.InstalledVersionWatcher +import dev.nucleusframework.updater.internal.WindowsHotUpdate +import dev.nucleusframework.updater.internal.windowsCommandLine +import dev.nucleusframework.updater.internal.windowsRelaunchCommand +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class WindowsHotUpdateMultiInstanceTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun install(): VersionedInstall { + val root = tmp.newFolder("App") + val current = File(root, "versions/1.0.0").apply { File(this, "runtime").mkdirs() } + val launcher = File(root, "App.exe").apply { writeText("launcher") } + pointCfgAt(root, "1.0.0") + return VersionedInstall(root, current, launcher) + } + + private fun pointCfgAt( + root: File, + version: String, + ) { + File(root, "app").mkdirs() + File(root, "app/App.cfg").writeText("[Application]\r\napp.runtime=\$ROOTDIR\\versions\\$version\\runtime\r\n") + } + + @Test + fun `command line splits back into the same arguments`() { + assertEquals("plain", windowsCommandLine(listOf("plain"))) + assertEquals("\"C:\\My Docs\\a.txt\"", windowsCommandLine(listOf("C:\\My Docs\\a.txt"))) + assertEquals("\"say \\\"hi\\\"\"", windowsCommandLine(listOf("say \"hi\""))) + // Trailing backslashes must not escape the closing quote. + assertEquals("\"C:\\My Dir\\\\\"", windowsCommandLine(listOf("C:\\My Dir\\"))) + assertEquals("\"\" two", windowsCommandLine(listOf("", "two"))) + } + + @Test + fun `classic relaunch passes the arguments as one quoted command line`() { + val command = windowsRelaunchCommand(true, "C:\\App\\App.exe", listOf("C:\\it's here\\doc.txt")) + + assertTrue(command.contains("Start-Process 'C:\\App\\App.exe' -ArgumentList '\"C:\\it''s here\\doc.txt\"'")) + assertEquals( + "\n# Relaunch the application\nStart-Process 'C:\\App\\App.exe'", + windowsRelaunchCommand(true, "C:\\App\\App.exe"), + ) + } + + @Test + fun `a reader waits for an install in progress`() { + val install = install() + val installing = CountDownLatch(1) + val order = mutableListOf() + val installer = + thread { + WindowsHotUpdate.withInstallLock(install) { + installing.countDown() + Thread.sleep(400) + synchronized(order) { order += "install done" } + } + } + installing.await(5, TimeUnit.SECONDS) + + WindowsHotUpdate.withInstallLock(install, shared = true) { synchronized(order) { order += "read" } } + installer.join() + + assertEquals(listOf("install done", "read"), order) + } + + @Test + fun `watcher publishes a version another process installed`() { + val install = install() + val watcher = InstalledVersionWatcher(install).apply { start() } + assertNull(watcher.version.value) + + // What the other instance's installer leaves behind: the new version, then the cfg. + File(install.versionsDir, "1.1.0/runtime").mkdirs() + Thread.sleep(200) // let the watch service register before the change + pointCfgAt(install.root, "1.1.0") + + val seen = runBlocking { withTimeout(10_000) { watcher.version.first { it != null } } } + assertEquals("1.1.0", seen) + } +} diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateTest.kt new file mode 100644 index 000000000..cc0ca78ac --- /dev/null +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateTest.kt @@ -0,0 +1,145 @@ +package dev.nucleusframework.updater + +import dev.nucleusframework.core.runtime.ExecutableType +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.core.runtime.VersionedInstall +import dev.nucleusframework.updater.internal.WindowsHotUpdate +import dev.nucleusframework.updater.internal.buildWindowsHotUpdateScript +import dev.nucleusframework.updater.internal.writePowerShellScript +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class WindowsHotUpdateTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun install(): VersionedInstall { + val root = tmp.newFolder("App") + val current = File(root, "versions/1.0.0").apply { File(this, "runtime").mkdirs() } + val launcher = File(root, "App.exe").apply { writeText("launcher") } + return VersionedInstall(root, current, launcher) + } + + private fun writeCfg( + install: VersionedInstall, + version: String, + ) { + File(install.root, "app").mkdirs() + File(install.root, "app/App.cfg").writeText( + "[Application]\r\napp.runtime=\$ROOTDIR\\versions\\$version\\runtime\r\n" + + "app.classpath=\$ROOTDIR\\versions\\$version\\app\\app.jar\r\n", + ) + } + + @Test + fun `installed version is read back from the rewritten cfg`() { + val install = install() + File(install.versionsDir, "1.1.0/runtime").mkdirs() + writeCfg(install, "1.1.0") + + val installed = WindowsHotUpdate.installedVersionDir(install) + + assertEquals(File(install.versionsDir, "1.1.0"), installed) + } + + @Test + fun `cfg still pointing at the running version means nothing was installed`() { + val install = install() + writeCfg(install, "1.0.0") + + assertNull(WindowsHotUpdate.installedVersionDir(install)) + } + + @Test + fun `cfg pointing at a missing runtime means nothing was installed`() { + val install = install() + writeCfg(install, "1.1.0") + + assertNull(WindowsHotUpdate.installedVersionDir(install)) + } + + @Test + fun `launchers are retired and copied back writable, the uninstaller is left alone`() { + val install = install() + install.launcher.setWritable(false) // jpackage ships it read-only + val helper = File(install.root, "Helper.exe").apply { writeText("helper") } + val uninstaller = File(install.root, "Uninstall App.exe").apply { writeText("uninstaller") } + + val retired = WindowsHotUpdate.retireLaunchers(install.root) + + assertEquals(2, retired.size) + assertTrue(retired.all { it.isFile && it.name.endsWith(".nucleus-old") }) + assertEquals(setOf("launcher", "helper"), retired.map { it.readText() }.toSet()) + // The launcher paths keep working during the install, and the installer can replace them. + assertEquals("launcher", install.launcher.readText()) + assertTrue(install.launcher.canWrite()) + assertEquals("helper", helper.readText()) + assertEquals(listOf(uninstaller.name), install.root.list()!!.filter { it.startsWith("Uninstall") }) + } + + @Test + fun `only Windows NSIS installs of the versioned layout are eligible`() { + val install = install() + val exe = File(tmp.root, "app-1.1.0-nsis.exe") + + assertNotNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.NSIS, install)) + assertNotNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.EXE, install)) + assertNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.MSI, install)) + assertNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.NSIS, null)) + assertNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Linux, ExecutableType.NSIS, install)) + assertNull( + WindowsHotUpdate.eligibleInstall(File(tmp.root, "app.msi"), Platform.Windows, ExecutableType.NSIS, install), + ) + } + + @Test + fun `an install whose versions directory cannot be written is not eligible`() { + val install = install() + val exe = File(tmp.root, "app-1.1.0-nsis.exe") + // A plain file where the versions directory should be: creating the probe fails. + val readOnly = VersionedInstall(install.root, File(tmp.newFile("versions-file"), "1.0.0"), install.launcher) + + assertNotNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.NSIS, install)) + assertNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.NSIS, readOnly)) + } + + @Test + fun `PowerShell scripts are written with a BOM so non-ASCII paths survive`() { + val script = File(tmp.root, "update.ps1") + + writePowerShellScript(script, "Start-Process 'C:\\Users\\Hélène\\App.exe'") + + val bytes = script.readBytes() + assertEquals(listOf(0xEF, 0xBB, 0xBF), bytes.take(3).map { it.toInt() and 0xFF }) + assertTrue(String(bytes, Charsets.UTF_8).contains("Hélène")) + } + + @Test + fun `hot update script runs the installer silently and relaunches only if the app was closed`() { + val script = + buildWindowsHotUpdateScript( + pid = 4242, + installerPath = "C:\\Temp\\it's\\setup.exe", + launcher = "C:\\Apps\\App\\App.exe", + exitedMarker = "C:\\Temp\\work\\app-exited", + ) + + assertTrue( + script.contains( + "Start-Process 'C:\\Temp\\it''s\\setup.exe' -ArgumentList '/S', '--updated' -Wait -PassThru", + ), + ) + assertTrue(script.contains("-not (Get-Process -Id 4242 -ErrorAction SilentlyContinue)")) + // A user who quit during the install is not relaunched. + assertTrue(script.contains("-not (Test-Path -LiteralPath 'C:\\Temp\\work\\app-exited')")) + assertTrue(script.contains("Remove-Item Env:NUCLEUS_HOT_UPDATE")) + assertTrue(script.contains("Start-Process 'C:\\Apps\\App\\App.exe'")) + assertTrue(script.trimEnd().endsWith("exit \$code")) + } +}