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", () => {