fix(update): launch installers after the current app exits
This commit is contained in:
@@ -96,6 +96,7 @@
|
|||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||||
|
"include": "resources/installer.nsh",
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,6 +14,7 @@ const EXPECTED_PUBLISH = Object.freeze({
|
|||||||
const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader";
|
const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader";
|
||||||
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
|
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([
|
const REQUIRED_BUILD_FILES = Object.freeze([
|
||||||
"resources/extractor-jvm/**/*",
|
"resources/extractor-jvm/**/*",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
@@ -285,7 +286,14 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
|
|||||||
assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner");
|
assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner");
|
||||||
assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo");
|
assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo");
|
||||||
assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS 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");
|
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 buildFiles = Array.isArray(build.files) ? build.files : [];
|
||||||
const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry));
|
const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry));
|
||||||
if (missingBuildFiles.length > 0) {
|
if (missingBuildFiles.length > 0) {
|
||||||
|
|||||||
+1
-1
@@ -403,7 +403,7 @@ function registerIpcHandlers(): void {
|
|||||||
if (result.started) {
|
if (result.started) {
|
||||||
updateQuitTimer = setTimeout(() => {
|
updateQuitTimer = setTimeout(() => {
|
||||||
app.quit();
|
app.quit();
|
||||||
}, 5000);
|
}, 250);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-2
@@ -897,6 +897,35 @@ export function buildInstallerLaunchArgs(): string[] {
|
|||||||
return ["/S", "--updated", "--force-run"];
|
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<UpdateCheckResult> {
|
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
|
||||||
const safeRepo = normalizeUpdateRepo(repo);
|
const safeRepo = normalizeUpdateRepo(repo);
|
||||||
const fallbackUrl = `${WEB_BASE}/${safeRepo}/releases/latest`;
|
const fallbackUrl = `${WEB_BASE}/${safeRepo}/releases/latest`;
|
||||||
@@ -1097,13 +1126,14 @@ export async function installLatestUpdate(
|
|||||||
message: "Starte stille Update-Installation",
|
message: "Starte stille Update-Installation",
|
||||||
});
|
});
|
||||||
|
|
||||||
const child = childProcess.spawn(targetPath, buildInstallerLaunchArgs(), {
|
const deferredLaunch = buildDeferredInstallerLaunch(targetPath);
|
||||||
|
const child = childProcess.spawn(deferredLaunch.command, deferredLaunch.args, {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
child.once("error", (spawnError) => {
|
child.once("error", (spawnError) => {
|
||||||
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
|
logger.error(`Update-Launcher Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
|
||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ describe("desktop shell", () => {
|
|||||||
expect(sparklineBlock).toContain("window.setInterval(tick, 750)");
|
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", () => {
|
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 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"));
|
const stateUpdates = source.slice(source.indexOf("unsubscribe = window.rd.onStateUpdate"), source.indexOf("unsubClipboard = window.rd.onClipboardDetected"));
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ describe("Multi-Debrid-Downloader product metadata", () => {
|
|||||||
expect(supportBundle).toContain("mdd-support-bundle-");
|
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", () => {
|
it("moves the legacy user data directory without losing runtime state", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-user-data-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-user-data-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ function createReleaseFixture(): string {
|
|||||||
],
|
],
|
||||||
nsis: {
|
nsis: {
|
||||||
artifactName: "${productName}-Setup-${version}.${ext}",
|
artifactName: "${productName}-Setup-${version}.${ext}",
|
||||||
|
include: "resources/installer.nsh",
|
||||||
oneClick: false,
|
oneClick: false,
|
||||||
perMachine: false,
|
perMachine: false,
|
||||||
allowToChangeInstallationDirectory: true,
|
allowToChangeInstallationDirectory: true,
|
||||||
@@ -171,6 +172,7 @@ function createReleaseFixture(): string {
|
|||||||
writeRedistributionFiles(rootDir, true);
|
writeRedistributionFiles(rootDir, true);
|
||||||
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
|
writeFile(rootDir, "assets/app_icon.ico", "application-icon");
|
||||||
writeFile(rootDir, "win-unpacked/resources/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;
|
return rootDir;
|
||||||
}
|
}
|
||||||
@@ -345,6 +347,16 @@ describe("public release metadata", () => {
|
|||||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
|
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", () => {
|
it("rejects a packaged application without its window and tray icon", () => {
|
||||||
const rootDir = createReleaseFixture();
|
const rootDir = createReleaseFixture();
|
||||||
fs.rmSync(path.join(rootDir, "win-unpacked", "resources", "assets", "app_icon.ico"));
|
fs.rmSync(path.join(rootDir, "win-unpacked", "resources", "assets", "app_icon.ico"));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-2
@@ -22,7 +22,7 @@ vi.mock("node:child_process", () => ({
|
|||||||
spawn: spawnMock
|
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 { APP_VERSION } from "../src/main/constants";
|
||||||
import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types";
|
import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types";
|
||||||
|
|
||||||
@@ -158,6 +158,24 @@ describe("update", () => {
|
|||||||
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);
|
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 () => {
|
it("falls back to alternate download URL when setup asset URL returns 404", async () => {
|
||||||
const executablePayload = fs.readFileSync(process.execPath);
|
const executablePayload = fs.readFileSync(process.execPath);
|
||||||
const executableDigest = sha256Hex(executablePayload);
|
const executableDigest = sha256Hex(executablePayload);
|
||||||
@@ -567,7 +585,7 @@ describe("update", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.started).toBe(true);
|
expect(result.started).toBe(true);
|
||||||
expect(spawnMock).toHaveBeenCalledWith(expect.any(String), ["/S", "--updated", "--force-run"], expect.objectContaining({
|
expect(spawnMock).toHaveBeenCalledWith(expect.stringMatching(/powershell\.exe$/i), expect.arrayContaining(["-EncodedCommand"]), expect.objectContaining({
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
|
|||||||
Reference in New Issue
Block a user