From bc86c29d575f76b21c80e559a3c7b75ca9f9b054 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 21 Aug 2026 12:19:20 +0200 Subject: [PATCH] release: prepare v2.0.53 updater handoff Launch the verified NSIS installer directly before application shutdown, wait for Windows to confirm process creation, and keep the application open on spawn failure. Move process waiting into NSIS with a bounded graceful and post-force termination check, and cover the handoff with updater regression tests. --- CHANGELOG.md | 8 +++ package-lock.json | 4 +- package.json | 2 +- resources/installer.nsh | 18 +++++- src/main/update.ts | 44 +++----------- tests/product-metadata.test.ts | 3 + tests/update-launch-order.test.ts | 61 +++----------------- tests/update.test.ts | 95 ++++++++++++++++++++----------- 8 files changed, 108 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4920c92..be964b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Multi-Debrid Downloader are documented in this file. ## [Unreleased] +## [2.0.53] - 2026-08-21 + +### In-app updates + +- Started the verified NSIS installer directly before application shutdown so Windows Server and RDP process-job cleanup cannot terminate an intermediate PowerShell launcher. +- Kept the running application open when Windows rejects the installer process instead of reporting a successful update and quitting without an installer. +- Delegated the complete process handoff to the installer with a 60-second graceful wait and a verified post-termination wait before replacing application files. + ## [2.0.52] - 2026-08-21 ### Rolling account statistics diff --git a/package-lock.json b/package-lock.json index 2d7266a..8142eaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-debrid-downloader", - "version": "2.0.52", + "version": "2.0.53", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-debrid-downloader", - "version": "2.0.52", + "version": "2.0.53", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 1618255..84988fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-debrid-downloader", - "version": "2.0.52", + "version": "2.0.53", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/resources/installer.nsh b/resources/installer.nsh index 079405d..da18f3d 100644 --- a/resources/installer.nsh +++ b/resources/installer.nsh @@ -9,11 +9,23 @@ Goto update_ready ${endif} IntOp $R1 $R1 + 1 - ${if} $R1 >= 40 + ${if} $R1 >= 300 nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "USERNAME eq %USERNAME%"` Pop $R0 - Sleep 500 - Goto update_ready + StrCpy $R2 0 + update_force_wait: + !insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0 + ${if} $R0 != 0 + Goto update_force_ready + ${endif} + IntOp $R2 $R2 + 1 + ${if} $R2 >= 150 + Quit + ${endif} + Sleep 200 + Goto update_force_wait + update_force_ready: + Goto update_ready ${endif} Sleep 200 Goto update_wait diff --git a/src/main/update.ts b/src/main/update.ts index 80a40b6..178e783 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -896,35 +896,6 @@ async function resolveDigestFromYml(repo: string, tag: string, setupName: string 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); @@ -1126,16 +1097,19 @@ export async function installLatestUpdate( message: "Starte stille Update-Installation", }); - const deferredLaunch = buildDeferredInstallerLaunch(targetPath); - const child = childProcess.spawn(deferredLaunch.command, deferredLaunch.args, { + const child = childProcess.spawn(targetPath, buildInstallerLaunchArgs(), { detached: true, stdio: "ignore", windowsHide: true, }); - child.once("error", (spawnError) => { - logger.error(`Update-Launcher Start fehlgeschlagen: ${compactErrorText(spawnError)}`); - }); - child.unref(); + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", (spawnError) => { + logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`); + reject(spawnError); + }); + }); + child.unref(); emitProgress(onProgress, { stage: "done", percent: 100, downloadedBytes: 0, totalBytes: null, diff --git a/tests/product-metadata.test.ts b/tests/product-metadata.test.ts index 77f9bb7..891bee3 100644 --- a/tests/product-metadata.test.ts +++ b/tests/product-metadata.test.ts @@ -40,6 +40,9 @@ describe("Multi-Debrid-Downloader product metadata", () => { expect(installer).toContain("${isUpdated}"); expect(installer).toContain("FIND_PROCESS"); expect(installer).toContain("taskkill /f /im"); + expect(installer).toContain("$R1 >= 300"); + expect(installer).toContain("update_force_wait:"); + expect(installer).toContain("update_force_ready:"); }); it("moves the legacy user data directory without losing runtime state", () => { diff --git a/tests/update-launch-order.test.ts b/tests/update-launch-order.test.ts index 0b3b03d..01c9b40 100644 --- a/tests/update-launch-order.test.ts +++ b/tests/update-launch-order.test.ts @@ -1,58 +1,13 @@ 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"; +import { describe, expect, it } from "vitest"; -const tempDirs: string[] = []; +describe("update process handoff", () => { + it("starts the verified installer before shutdown and delegates waiting to NSIS", () => { + const updateSource = fs.readFileSync(new URL("../src/main/update.ts", import.meta.url), "utf8"); + const launchBlock = updateSource.slice(updateSource.indexOf('message: "Starte stille Update-Installation"'), updateSource.indexOf('message: "Update wird im Hintergrund installiert')); -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(); + expect(launchBlock).toContain("childProcess.spawn(targetPath, buildInstallerLaunchArgs()"); + expect(launchBlock).not.toContain("powershell.exe"); + expect(launchBlock).not.toContain("buildDeferredInstallerLaunch"); }); }); diff --git a/tests/update.test.ts b/tests/update.test.ts index 4ed7748..51c75cb 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -2,15 +2,20 @@ import fs from "node:fs"; import crypto from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; -const { spawnMock, unrefMock, onceMock } = vi.hoisted(() => { - const unref = vi.fn(); - const once = vi.fn((_event: string, _handler: (...args: unknown[]) => void) => ({ - unref - })); - const spawn = vi.fn(() => ({ - once, - unref - })); +const { spawnMock, unrefMock, onceMock } = vi.hoisted(() => { + const unref = vi.fn(); + const child: { once: ReturnType; unref: ReturnType } = { + once: vi.fn(), + unref + }; + const once = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === "spawn") { + queueMicrotask(() => handler()); + } + return child; + }); + child.once = once; + const spawn = vi.fn(() => child); return { spawnMock: spawn, unrefMock: unref, @@ -22,7 +27,7 @@ vi.mock("node:child_process", () => ({ spawn: spawnMock })); -import { buildDeferredInstallerLaunch, buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update"; +import { buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update"; import { APP_VERSION } from "../src/main/constants"; import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types"; @@ -167,24 +172,6 @@ describe("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); const executableDigest = sha256Hex(executablePayload); @@ -559,7 +546,7 @@ describe("update", () => { expect(result.message).toMatch(/sha512|integrit|mismatch/i); }); - it("emits install progress events while downloading and launching update", async () => { + it("emits install progress events while downloading and launching update", async () => { const executablePayload = fs.readFileSync(process.execPath); const digest = sha256Hex(executablePayload); @@ -594,7 +581,7 @@ describe("update", () => { }); expect(result.started).toBe(true); - expect(spawnMock).toHaveBeenCalledWith(expect.stringMatching(/powershell\.exe$/i), expect.arrayContaining(["-EncodedCommand"]), expect.objectContaining({ + expect(spawnMock).toHaveBeenCalledWith(expect.stringMatching(/rd-update[\\/].*setup\.exe$/i), ["/S", "--updated", "--force-run"], expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true @@ -604,9 +591,51 @@ describe("update", () => { expect(progressEvents.some((entry) => entry.stage === "downloading")).toBe(true); expect(progressEvents.some((entry) => entry.stage === "verifying")).toBe(true); expect(progressEvents.some((entry) => entry.stage === "launching")).toBe(true); - expect(progressEvents.some((entry) => entry.stage === "done")).toBe(true); - }); -}); + expect(progressEvents.some((entry) => entry.stage === "done")).toBe(true); + }); + + it("keeps the application running when Windows rejects the installer process", async () => { + const executablePayload = fs.readFileSync(process.execPath); + const digest = sha256Hex(executablePayload); + globalThis.fetch = (async (): Promise => new Response(executablePayload, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(executablePayload.length) + } + })) as typeof fetch; + + const child: { once: ReturnType; unref: ReturnType } = { + once: vi.fn(), + unref: vi.fn() + }; + child.once.mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + if (event === "error") { + queueMicrotask(() => handler(new Error("spawn EACCES"))); + } + return child; + }); + spawnMock.mockReturnValueOnce(child); + + const progressEvents: UpdateInstallProgress[] = []; + const result = await installLatestUpdate("owner/repo", { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/setup.exe", + setupAssetName: "setup.exe", + setupAssetDigest: `sha256:${digest}` + }, (progress) => progressEvents.push(progress)); + + expect(result.started).toBe(false); + expect(result.message).toContain("spawn EACCES"); + expect(child.unref).not.toHaveBeenCalled(); + expect(progressEvents.some((entry) => entry.stage === "done")).toBe(false); + expect(progressEvents.at(-1)?.stage).toBe("error"); + }); +}); describe("normalizeUpdateRepo extended", () => { it("handles trailing slashes and extra path segments", () => {