fix(update): launch installers after the current app exits

This commit is contained in:
Sucukdeluxe
2026-08-21 02:35:53 +02:00
parent da4b6f295c
commit ce75012110
10 changed files with 203 additions and 27 deletions
+8
View File
@@ -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"));
+11
View File
@@ -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);
+16 -4
View File
@@ -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"));
+58
View File
@@ -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<void> {
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<void>((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();
});
});
+24 -6
View File
@@ -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
}));