From ce7501211087d72f9709cb0ad12b0fab5d183c36 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 21 Aug 2026 02:35:53 +0200 Subject: [PATCH] fix(update): launch installers after the current app exits --- package.json | 7 ++-- resources/installer.nsh | 30 ++++++++++++++ scripts/verify_public_release.mjs | 14 +++++-- src/main/main.ts | 2 +- src/main/update.ts | 50 ++++++++++++++++++----- tests/app-shell.test.tsx | 8 ++++ tests/product-metadata.test.ts | 11 +++++ tests/public-release-metadata.test.ts | 20 +++++++-- tests/update-launch-order.test.ts | 58 +++++++++++++++++++++++++++ tests/update.test.ts | 30 +++++++++++--- 10 files changed, 203 insertions(+), 27 deletions(-) create mode 100644 resources/installer.nsh create mode 100644 tests/update-launch-order.test.ts diff --git a/package.json b/package.json index 862ec8f..40f54af 100644 --- a/package.json +++ b/package.json @@ -94,9 +94,10 @@ "icon": "assets/app_icon.ico", "signAndEditExecutable": false }, - "nsis": { - "artifactName": "${productName}-Setup-${version}.${ext}", - "oneClick": false, + "nsis": { + "artifactName": "${productName}-Setup-${version}.${ext}", + "include": "resources/installer.nsh", + "oneClick": false, "perMachine": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true diff --git a/resources/installer.nsh b/resources/installer.nsh new file mode 100644 index 0000000..079405d --- /dev/null +++ b/resources/installer.nsh @@ -0,0 +1,30 @@ +!macro customCheckAppRunning + !insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0 + ${if} $R0 == 0 + ${if} ${isUpdated} + StrCpy $R1 0 + update_wait: + !insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0 + ${if} $R0 != 0 + Goto update_ready + ${endif} + IntOp $R1 $R1 + 1 + ${if} $R1 >= 40 + nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "USERNAME eq %USERNAME%"` + Pop $R0 + Sleep 500 + Goto update_ready + ${endif} + Sleep 200 + Goto update_wait + update_ready: + ${else} + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDCANCEL IDOK manual_stop + Quit + manual_stop: + nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "USERNAME eq %USERNAME%"` + Pop $R0 + Sleep 500 + ${endif} + ${endif} +!macroend diff --git a/scripts/verify_public_release.mjs b/scripts/verify_public_release.mjs index 9706830..a8a03b5 100644 --- a/scripts/verify_public_release.mjs +++ b/scripts/verify_public_release.mjs @@ -13,7 +13,8 @@ const EXPECTED_PUBLISH = Object.freeze({ }); const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader"; const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}"; -const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}"; +const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}"; +const EXPECTED_NSIS_INCLUDE = "resources/installer.nsh"; const REQUIRED_BUILD_FILES = Object.freeze([ "resources/extractor-jvm/**/*", "LICENSE", @@ -284,8 +285,15 @@ export function verifyPublicRelease(rootDir = process.cwd()) { assertEqual(publish.provider, EXPECTED_PUBLISH.provider, "package.json publish provider"); assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner"); assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo"); - assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS artifactName"); - assertEqual(build.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName"); + assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS artifactName"); + assertEqual(build.nsis?.include, EXPECTED_NSIS_INCLUDE, "package.json NSIS update include"); + assertEqual(build.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName"); + const nsisInclude = readRequiredFile(path.join(absoluteRoot, ...EXPECTED_NSIS_INCLUDE.split("/"))); + for (const requiredText of ["!macro customCheckAppRunning", "${isUpdated}", "FIND_PROCESS", "taskkill /f /im"]) { + if (!nsisInclude.includes(requiredText)) { + throw new Error(`NSIS update include omits ${requiredText}`); + } + } const buildFiles = Array.isArray(build.files) ? build.files : []; const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry)); if (missingBuildFiles.length > 0) { diff --git a/src/main/main.ts b/src/main/main.ts index 20487df..5af8909 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -403,7 +403,7 @@ function registerIpcHandlers(): void { if (result.started) { updateQuitTimer = setTimeout(() => { app.quit(); - }, 5000); + }, 250); } return result; }); diff --git a/src/main/update.ts b/src/main/update.ts index c18870c..80a40b6 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -893,9 +893,38 @@ async function resolveDigestFromYml(repo: string, tag: string, setupName: string return ""; } -export function buildInstallerLaunchArgs(): string[] { - return ["/S", "--updated", "--force-run"]; -} +export function buildInstallerLaunchArgs(): string[] { + return ["/S", "--updated", "--force-run"]; +} + +function quotePowerShellLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function buildDeferredInstallerLaunch( + targetPath: string, + currentProcessId: number = process.pid, + systemRoot: string = process.env.SystemRoot || "C:\\Windows" +): { command: string; args: string[] } { + const installerArgs = buildInstallerLaunchArgs().map(quotePowerShellLiteral).join(","); + const script = [ + "$deadline=(Get-Date).AddMinutes(2)", + `while ((Get-Process -Id ${Math.max(1, Math.floor(currentProcessId))} -ErrorAction SilentlyContinue) -and ((Get-Date) -lt $deadline)) { Start-Sleep -Milliseconds 200 }`, + `Start-Process -FilePath ${quotePowerShellLiteral(targetPath)} -ArgumentList @(${installerArgs}) -WindowStyle Hidden` + ].join("; "); + return { + command: path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), + args: [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64") + ] + }; +} export async function checkGitHubUpdate(repo: string): Promise { const safeRepo = normalizeUpdateRepo(repo); @@ -1097,13 +1126,14 @@ export async function installLatestUpdate( message: "Starte stille Update-Installation", }); - const child = childProcess.spawn(targetPath, buildInstallerLaunchArgs(), { - detached: true, - stdio: "ignore", - windowsHide: true, - }); - child.once("error", (spawnError) => { - logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`); + const deferredLaunch = buildDeferredInstallerLaunch(targetPath); + const child = childProcess.spawn(deferredLaunch.command, deferredLaunch.args, { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.once("error", (spawnError) => { + logger.error(`Update-Launcher Start fehlgeschlagen: ${compactErrorText(spawnError)}`); }); child.unref(); diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index d43379b..137744f 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -65,6 +65,14 @@ describe("desktop shell", () => { expect(sparklineBlock).toContain("window.setInterval(tick, 750)"); }); + it("quits promptly after handing the update to the deferred installer launcher", () => { + const source = readFileSync(new URL("../src/main/main.ts", import.meta.url), "utf8"); + const install = source.slice(source.indexOf("handleTrusted(IPC_CHANNELS.INSTALL_UPDATE"), source.indexOf("handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL")); + + expect(install).toContain("}, 250)"); + expect(install).not.toContain("}, 5000)"); + }); + it("does not let live download snapshots overwrite the local column order", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const stateUpdates = source.slice(source.indexOf("unsubscribe = window.rd.onStateUpdate"), source.indexOf("unsubClipboard = window.rd.onClipboardDetected")); diff --git a/tests/product-metadata.test.ts b/tests/product-metadata.test.ts index 2bede4c..77f9bb7 100644 --- a/tests/product-metadata.test.ts +++ b/tests/product-metadata.test.ts @@ -31,6 +31,17 @@ describe("Multi-Debrid-Downloader product metadata", () => { expect(supportBundle).toContain("mdd-support-bundle-"); }); + it("packages an update-safe NSIS process handoff for older app versions", () => { + const packageJson = JSON.parse(fs.readFileSync(path.resolve("package.json"), "utf8")); + const installer = fs.readFileSync(path.resolve("resources", "installer.nsh"), "utf8"); + + expect(packageJson.build.nsis.include).toBe("resources/installer.nsh"); + expect(installer).toContain("!macro customCheckAppRunning"); + expect(installer).toContain("${isUpdated}"); + expect(installer).toContain("FIND_PROCESS"); + expect(installer).toContain("taskkill /f /im"); + }); + it("moves the legacy user data directory without losing runtime state", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-user-data-")); tempDirs.push(root); diff --git a/tests/public-release-metadata.test.ts b/tests/public-release-metadata.test.ts index 3d83ccd..f4d3a26 100644 --- a/tests/public-release-metadata.test.ts +++ b/tests/public-release-metadata.test.ts @@ -142,9 +142,10 @@ function createReleaseFixture(): string { to: "assets/app_icon.ico" } ], - nsis: { - artifactName: "${productName}-Setup-${version}.${ext}", - oneClick: false, + nsis: { + artifactName: "${productName}-Setup-${version}.${ext}", + include: "resources/installer.nsh", + oneClick: false, perMachine: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true @@ -171,6 +172,7 @@ function createReleaseFixture(): string { writeRedistributionFiles(rootDir, true); writeFile(rootDir, "assets/app_icon.ico", "application-icon"); writeFile(rootDir, "win-unpacked/resources/assets/app_icon.ico", "application-icon"); + writeFile(rootDir, "resources/installer.nsh", "!macro customCheckAppRunning\n${isUpdated}\nFIND_PROCESS\ntaskkill /f /im\n!macroend\n"); return rootDir; } @@ -196,7 +198,7 @@ describe("public release metadata", () => { expect(result.missingArtifacts).toEqual([]); }); - it("rejects a package configured for a different GitHub owner", () => { + it("rejects a package configured for a different GitHub owner", () => { const rootDir = createReleaseFixture(); const packagePath = path.join(rootDir, "package.json"); const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); @@ -345,6 +347,16 @@ describe("public release metadata", () => { expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/); }); + it("rejects release metadata without the update-safe NSIS include", () => { + const rootDir = createReleaseFixture(); + const packagePath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + delete packageJson.build.nsis.include; + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + + expect(() => verifyPublicRelease(rootDir)).toThrow(/NSIS|update|installer/i); + }); + it("rejects a packaged application without its window and tray icon", () => { const rootDir = createReleaseFixture(); fs.rmSync(path.join(rootDir, "win-unpacked", "resources", "assets", "app_icon.ico")); diff --git a/tests/update-launch-order.test.ts b/tests/update-launch-order.test.ts new file mode 100644 index 0000000..0b3b03d --- /dev/null +++ b/tests/update-launch-order.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildDeferredInstallerLaunch } from "../src/main/update"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +async function waitForFile(filePath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!fs.existsSync(filePath)) { + if (Date.now() >= deadline) { + throw new Error("marker timeout"); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +describe.runIf(process.platform === "win32")("deferred update launch", () => { + it("starts the installer only after the previous application process exits", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-update-launch-")); + tempDirs.push(root); + const installerPath = path.join(root, "installer.cmd"); + const markerPath = path.join(root, "started.txt"); + fs.writeFileSync(installerPath, `@echo off\r\n> "${markerPath}" echo started\r\n`, "utf8"); + const previous = spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "ping 127.0.0.1 -n 3 > nul"], { + stdio: "ignore", + windowsHide: true + }); + if (!previous.pid) { + throw new Error("previous process missing pid"); + } + const deferred = buildDeferredInstallerLaunch(installerPath, previous.pid); + const launcher = spawn(deferred.command, deferred.args, { + stdio: "ignore", + windowsHide: true + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(fs.existsSync(markerPath)).toBe(false); + + await new Promise((resolve, reject) => { + previous.once("exit", () => resolve()); + previous.once("error", reject); + }); + await waitForFile(markerPath, 5_000); + expect(fs.readFileSync(markerPath, "utf8").trim()).toBe("started"); + + launcher.unref(); + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts index 72cd19d..253c1fc 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -22,7 +22,7 @@ vi.mock("node:child_process", () => ({ spawn: spawnMock })); -import { buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update"; +import { buildDeferredInstallerLaunch, buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update"; import { APP_VERSION } from "../src/main/constants"; import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types"; @@ -154,9 +154,27 @@ describe("update", () => { ].join("\n")); }); - it("uses silent NSIS install flags with auto-run after update", () => { - expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]); - }); + it("uses silent NSIS install flags with auto-run after update", () => { + expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]); + }); + + it("defers the installer until the current application process has exited", () => { + const launch = buildDeferredInstallerLaunch("C:\\Temp\\MDD Update\\setup.exe", 4242, "C:\\Windows"); + const encoded = launch.args.at(-1) || ""; + const script = Buffer.from(encoded, "base64").toString("utf16le"); + + expect(launch.command).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + expect(launch.args.slice(0, -1)).toEqual([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-EncodedCommand" + ]); + expect(script.indexOf("Get-Process -Id 4242")).toBeLessThan(script.indexOf("Start-Process -FilePath 'C:\\Temp\\MDD Update\\setup.exe'")); + expect(script).toContain("@('/S','--updated','--force-run')"); + }); it("falls back to alternate download URL when setup asset URL returns 404", async () => { const executablePayload = fs.readFileSync(process.execPath); @@ -567,8 +585,8 @@ describe("update", () => { }); expect(result.started).toBe(true); - expect(spawnMock).toHaveBeenCalledWith(expect.any(String), ["/S", "--updated", "--force-run"], expect.objectContaining({ - detached: true, + expect(spawnMock).toHaveBeenCalledWith(expect.stringMatching(/powershell\.exe$/i), expect.arrayContaining(["-EncodedCommand"]), expect.objectContaining({ + detached: true, stdio: "ignore", windowsHide: true }));