fix(update): verify and apply the actual latest version

This commit is contained in:
Sucukdeluxe
2026-08-21 07:39:37 +02:00
parent 4cebc6fefd
commit 6efdca6885
10 changed files with 154 additions and 41 deletions
+12
View File
@@ -5,6 +5,7 @@ import os from "node:os";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { parse } from "yaml"; import { parse } from "yaml";
import { extractFile } from "@electron/asar";
const EXPECTED_PUBLISH = Object.freeze({ const EXPECTED_PUBLISH = Object.freeze({
provider: "github", provider: "github",
@@ -140,6 +141,14 @@ function hasExtraResource(extraResources, expected) {
)); ));
} }
function readPackagedMainVersion(asarPath) {
requireNonEmptyFile(asarPath, "packaged app.asar");
const packagePayload = JSON.parse(extractFile(asarPath, "package.json").toString("utf8"));
const mainBundle = extractFile(asarPath, path.win32.join("build", "main", "main", "main.js")).toString("utf8");
const bundled = /name:\s*["']multi-debrid-downloader["']\s*,\s*version:\s*["']([^"']+)["']/.exec(mainBundle)?.[1] || "";
return { packageVersion: String(packagePayload?.version || ""), bundledVersion: bundled };
}
function sha256File(filePath) { function sha256File(filePath) {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
} }
@@ -325,6 +334,9 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`; const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`;
const expectedBlockmap = `${expectedSetup}.blockmap`; const expectedBlockmap = `${expectedSetup}.blockmap`;
assertEqual(latestFields.path, expectedSetup, "latest.yml path"); assertEqual(latestFields.path, expectedSetup, "latest.yml path");
const packagedVersions = readPackagedMainVersion(path.join(releaseDir, "win-unpacked", "resources", "app.asar"));
assertEqual(packagedVersions.packageVersion, version, "packaged package version");
assertEqual(packagedVersions.bundledVersion, version, "packaged main bundle version");
const requiredArtifacts = [expectedSetup, expectedPortable, expectedBlockmap]; const requiredArtifacts = [expectedSetup, expectedPortable, expectedBlockmap];
const missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName))); const missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName)));
+1 -5
View File
@@ -898,13 +898,9 @@ export class AppController {
} }
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> { public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
? this.lastUpdateCheck
: undefined;
const result = await runInstallWithResume( const result = await runInstallWithResume(
this.manager, this.manager,
() => installLatestUpdate(this.settings.updateRepo, cached, onProgress) () => installLatestUpdate(this.settings.updateRepo, undefined, onProgress)
); );
if (result.started) { if (result.started) {
this.lastUpdateCheck = null; this.lastUpdateCheck = null;
+24
View File
@@ -0,0 +1,24 @@
import fs from "node:fs";
import path from "node:path";
function normalizedVersion(value: unknown): string {
const version = String(value || "").trim();
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version) ? version : "";
}
export function resolveRuntimeAppVersion(
fallbackVersion: string,
resourcesPath: string = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath || ""
): string {
if (resourcesPath) {
for (const relativePath of ["app.asar/package.json", "app/package.json"]) {
try {
const payload = JSON.parse(fs.readFileSync(path.join(resourcesPath, ...relativePath.split("/")), "utf8")) as { version?: unknown };
const version = normalizedVersion(payload.version);
if (version) return version;
} catch {
}
}
}
return normalizedVersion(fallbackVersion) || "0.0.0";
}
+2 -1
View File
@@ -3,9 +3,10 @@ import os from "node:os";
import { AppSettings } from "../shared/types"; import { AppSettings } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import packageJson from "../../package.json"; import packageJson from "../../package.json";
import { resolveRuntimeAppVersion } from "./app-version";
export const APP_NAME = "Multi Debrid Downloader"; export const APP_NAME = "Multi Debrid Downloader";
export const APP_VERSION: string = packageJson.version; export const APP_VERSION: string = resolveRuntimeAppVersion(packageJson.version);
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
+22 -11
View File
@@ -1491,6 +1491,14 @@ export function shouldApplyUpdateCheckResult(
return completedGeneration === currentGeneration; return completedGeneration === currentGeneration;
} }
export function shouldOpenUpdatePrompt(
source: "manual" | "startup",
latestTag: string,
dismissedTag: string
): boolean {
return source === "manual" || !latestTag || latestTag !== dismissedTag;
}
export async function runLatestUpdateCheck( export async function runLatestUpdateCheck(
generationRef: { current: number }, generationRef: { current: number },
check: () => Promise<UpdateCheckResult>, check: () => Promise<UpdateCheckResult>,
@@ -1523,6 +1531,7 @@ export function App(): ReactElement {
const [scheduleCountdown, setScheduleCountdown] = useState(""); const [scheduleCountdown, setScheduleCountdown] = useState("");
const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
const updateCheckGenerationRef = useRef(0); const updateCheckGenerationRef = useRef(0);
const dismissedUpdateTagRef = useRef("");
const settingsDirtyRef = useRef(false); const settingsDirtyRef = useRef(false);
const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">()); const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">());
const archivePasswordLoadGenerationRef = useRef(0); const archivePasswordLoadGenerationRef = useRef(0);
@@ -2589,7 +2598,15 @@ export function App(): ReactElement {
releaseNotes: changelogText releaseNotes: changelogText
}); });
setUpdateInstallProgress(null); setUpdateInstallProgress(null);
setUpdateDialogOpen(true); setUpdateDialogOpen(shouldOpenUpdatePrompt(source, result.latestTag, dismissedUpdateTagRef.current));
};
const dismissUpdatePrompt = (): void => {
if (availableUpdate?.latestTag) {
dismissedUpdateTagRef.current = availableUpdate.latestTag;
}
setUpdateDialogOpen(false);
setUpdateInstallProgress(null);
}; };
const installUpdate = async (): Promise<void> => { const installUpdate = async (): Promise<void> => {
@@ -5737,12 +5754,9 @@ export function App(): ReactElement {
available={Boolean(availableUpdate)} available={Boolean(availableUpdate)}
currentVersion={availableUpdate?.currentVersion ?? appVersion} currentVersion={availableUpdate?.currentVersion ?? appVersion}
latestTag={availableUpdate?.latestTag ?? ""} latestTag={availableUpdate?.latestTag ?? ""}
onClose={() => { onClose={dismissUpdatePrompt}
setUpdateDialogOpen(false);
setUpdateInstallProgress(null);
}}
onInstall={() => { void installUpdate(); }} onInstall={() => { void installUpdate(); }}
onLater={() => setUpdateDialogOpen(false)} onLater={dismissUpdatePrompt}
onOpen={() => setUpdateDialogOpen(true)} onOpen={() => setUpdateDialogOpen(true)}
open={updateDialogOpen || updateInstallProgress !== null} open={updateDialogOpen || updateInstallProgress !== null}
progress={updateInstallProgress ? { progress={updateInstallProgress ? {
@@ -6708,12 +6722,9 @@ export function App(): ReactElement {
available={Boolean(availableUpdate)} available={Boolean(availableUpdate)}
currentVersion={availableUpdate?.currentVersion ?? appVersion} currentVersion={availableUpdate?.currentVersion ?? appVersion}
latestTag={availableUpdate?.latestTag ?? ""} latestTag={availableUpdate?.latestTag ?? ""}
onClose={() => { onClose={dismissUpdatePrompt}
setUpdateDialogOpen(false);
setUpdateInstallProgress(null);
}}
onInstall={() => { void installUpdate(); }} onInstall={() => { void installUpdate(); }}
onLater={() => setUpdateDialogOpen(false)} onLater={dismissUpdatePrompt}
onOpen={() => setUpdateDialogOpen(true)} onOpen={() => setUpdateDialogOpen(true)}
open={updateDialogOpen || updateInstallProgress !== null} open={updateDialogOpen || updateInstallProgress !== null}
progress={updateInstallProgress ? { progress={updateInstallProgress ? {
+2 -2
View File
@@ -164,7 +164,7 @@
justify-content: center; justify-content: center;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
background: var(--ui-update); background: var(--ui-success);
color: var(--ui-update-text); color: var(--ui-update-text);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
@@ -174,7 +174,7 @@
} }
.md-update-trigger:hover { .md-update-trigger:hover {
background: var(--ui-update-hover); background: color-mix(in srgb, var(--ui-success) 82%, #000);
} }
.md-update-trigger:focus-visible, .md-update-trigger:focus-visible,
+31
View File
@@ -0,0 +1,31 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveRuntimeAppVersion } from "../src/main/app-version";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("runtime app version", () => {
it("uses the version from the installed app package instead of a stale bundled fallback", () => {
const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-version-"));
tempDirs.push(resourcesPath);
fs.mkdirSync(path.join(resourcesPath, "app.asar"));
fs.writeFileSync(path.join(resourcesPath, "app.asar", "package.json"), JSON.stringify({ version: "2.0.51" }), "utf8");
expect(resolveRuntimeAppVersion("2.0.50", resourcesPath)).toBe("2.0.51");
});
it("uses the bundled fallback when no installed package metadata is available", () => {
const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-version-"));
tempDirs.push(resourcesPath);
expect(resolveRuntimeAppVersion("2.0.51", resourcesPath)).toBe("2.0.51");
});
});
+23
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { createPackage } from "@electron/asar";
type ReleaseVerification = { type ReleaseVerification = {
publish: { publish: {
@@ -39,6 +40,20 @@ const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl)
) => ArchiveVerification; ) => ArchiveVerification;
}; };
const fixtureRoots: string[] = []; const fixtureRoots: string[] = [];
async function createFixtureAsar(version: string): Promise<Buffer> {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-asar-"));
const sourceDir = path.join(rootDir, "source");
const outputPath = path.join(rootDir, "app.asar");
fs.mkdirSync(path.join(sourceDir, "build", "main", "main"), { recursive: true });
fs.writeFileSync(path.join(sourceDir, "package.json"), JSON.stringify({ name: "multi-debrid-downloader", version }), "utf8");
fs.writeFileSync(path.join(sourceDir, "build", "main", "main", "main.js"), `var package_default = { name: "multi-debrid-downloader", version: "${version}" };`, "utf8");
await createPackage(sourceDir, outputPath);
const payload = fs.readFileSync(outputPath);
fs.rmSync(rootDir, { recursive: true, force: true });
return payload;
}
const validAppAsar = await createFixtureAsar("1.7.233");
const staleAppAsar = await createFixtureAsar("1.7.232");
const redistributionFiles = [ const redistributionFiles = [
"LICENSE", "LICENSE",
"THIRD_PARTY_NOTICES.md", "THIRD_PARTY_NOTICES.md",
@@ -172,6 +187,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, "win-unpacked/resources/app.asar", validAppAsar);
writeFile(rootDir, "resources/installer.nsh", "!macro customCheckAppRunning\n${isUpdated}\nFIND_PROCESS\ntaskkill /f /im\n!macroend\n"); writeFile(rootDir, "resources/installer.nsh", "!macro customCheckAppRunning\n${isUpdated}\nFIND_PROCESS\ntaskkill /f /im\n!macroend\n");
return rootDir; return rootDir;
@@ -357,6 +373,13 @@ describe("public release metadata", () => {
expect(() => verifyPublicRelease(rootDir)).toThrow(/NSIS|update|installer/i); expect(() => verifyPublicRelease(rootDir)).toThrow(/NSIS|update|installer/i);
}); });
it("rejects a packaged main bundle built before the release version changed", () => {
const rootDir = createReleaseFixture();
fs.writeFileSync(path.join(rootDir, "win-unpacked", "resources", "app.asar"), staleAppAsar);
expect(() => verifyPublicRelease(rootDir)).toThrow(/main bundle|version/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"));
+10 -4
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App"; import { runLatestUpdateCheck, shouldApplyUpdateCheckResult, shouldOpenUpdatePrompt } from "../src/renderer/App";
import type { UpdateCheckResult } from "../src/shared/types"; import type { UpdateCheckResult } from "../src/shared/types";
import { AppHeader } from "../src/renderer/shell/AppHeader"; import { AppHeader } from "../src/renderer/shell/AppHeader";
import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience"; import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience";
@@ -14,6 +14,12 @@ const callbacks = {
}; };
describe("update experience", () => { describe("update experience", () => {
it("keeps a dismissed update version closed until the app restarts or a newer version appears", () => {
expect(shouldOpenUpdatePrompt("startup", "v2.0.51", "v2.0.51")).toBe(false);
expect(shouldOpenUpdatePrompt("startup", "v2.0.52", "v2.0.51")).toBe(true);
expect(shouldOpenUpdatePrompt("manual", "v2.0.51", "v2.0.51")).toBe(true);
});
it("renders the available update and prompt as one accessible experience", () => { it("renders the available update and prompt as one accessible experience", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<UpdateExperience <UpdateExperience
@@ -146,15 +152,15 @@ describe("update experience", () => {
expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s); expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s);
}); });
it("uses a light-blue update affordance and a bounded scrollable changelog", () => { it("uses a green update affordance and a bounded scrollable changelog", () => {
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8"); const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
expect(theme).toMatch(/--ui-update:\s*#BAD0FC;/); expect(theme).toMatch(/--ui-update:\s*#BAD0FC;/);
expect(theme).toMatch(/--ui-update-hover:\s*#8AA5DC;/); expect(theme).toMatch(/--ui-update-hover:\s*#8AA5DC;/);
expect(theme).toMatch(/--ui-update-text:\s*#181A1F;/); expect(theme).toMatch(/--ui-update-text:\s*#181A1F;/);
expect(css).toMatch(/\.md-update-trigger\s*\{[^}]*background:\s*var\(--ui-update\);[^}]*color:\s*var\(--ui-update-text\);/s); expect(css).toMatch(/\.md-update-trigger\s*\{[^}]*background:\s*var\(--ui-success\);[^}]*color:\s*var\(--ui-update-text\);/s);
expect(css).toMatch(/\.md-update-trigger:hover\s*\{[^}]*background:\s*var\(--ui-update-hover\);/s); expect(css).toMatch(/\.md-update-trigger:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 82%, #000\);/s);
expect(css).toMatch(/\.md-update-release-notes pre\s*\{[^}]*max-height:\s*min\(360px, 45vh\);[^}]*overflow-y:\s*auto;/s); expect(css).toMatch(/\.md-update-release-notes pre\s*\{[^}]*max-height:\s*min\(360px, 45vh\);[^}]*overflow-y:\s*auto;/s);
}); });
+9
View File
@@ -45,6 +45,15 @@ afterEach(() => {
}); });
describe("update", () => { describe("update", () => {
it("always refreshes release metadata before installing instead of using the previous check result", () => {
const controller = fs.readFileSync(new URL("../src/main/app-controller.ts", import.meta.url), "utf8");
const install = controller.slice(controller.indexOf("public async installUpdate"), controller.indexOf("public addLinks"));
expect(install).toContain("installLatestUpdate(this.settings.updateRepo, undefined, onProgress)");
expect(install).not.toContain("cacheAgeMs");
expect(install).not.toContain("this.lastUpdateCheck &&");
});
it("normalizes update repo input", () => { it("normalizes update repo input", () => {
expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/Multi-Debrid-Downloader"); expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/Multi-Debrid-Downloader");
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo"); expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");