fix(update): verify and apply the actual latest version
This commit is contained in:
@@ -4,7 +4,8 @@ import crypto from "node:crypto";
|
||||
import os from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "yaml";
|
||||
import { parse } from "yaml";
|
||||
import { extractFile } from "@electron/asar";
|
||||
|
||||
const EXPECTED_PUBLISH = Object.freeze({
|
||||
provider: "github",
|
||||
@@ -112,7 +113,7 @@ function requireNonEmptyFile(filePath, label) {
|
||||
return stat;
|
||||
}
|
||||
|
||||
function sha256NormalizedText(filePath) {
|
||||
function sha256NormalizedText(filePath) {
|
||||
const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
|
||||
}
|
||||
@@ -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) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
}
|
||||
@@ -321,10 +330,13 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||
assertEqual(appUpdateFields.owner, EXPECTED_PUBLISH.owner, "app-update.yml owner");
|
||||
assertEqual(appUpdateFields.repo, EXPECTED_PUBLISH.repo, "app-update.yml repo");
|
||||
|
||||
const expectedSetup = `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`;
|
||||
const expectedSetup = `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`;
|
||||
const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`;
|
||||
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 missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName)));
|
||||
|
||||
@@ -895,17 +895,13 @@ export class AppController {
|
||||
this.lastUpdateCheckAt = Date.now();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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(
|
||||
this.manager,
|
||||
() => installLatestUpdate(this.settings.updateRepo, cached, onProgress)
|
||||
);
|
||||
}
|
||||
|
||||
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
|
||||
const result = await runInstallWithResume(
|
||||
this.manager,
|
||||
() => installLatestUpdate(this.settings.updateRepo, undefined, onProgress)
|
||||
);
|
||||
if (result.started) {
|
||||
this.lastUpdateCheck = null;
|
||||
this.lastUpdateCheckAt = 0;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { AppSettings } from "../shared/types";
|
||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import packageJson from "../../package.json";
|
||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||
import packageJson from "../../package.json";
|
||||
import { resolveRuntimeAppVersion } from "./app-version";
|
||||
|
||||
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 DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
||||
|
||||
+22
-11
@@ -1491,6 +1491,14 @@ export function shouldApplyUpdateCheckResult(
|
||||
return completedGeneration === currentGeneration;
|
||||
}
|
||||
|
||||
export function shouldOpenUpdatePrompt(
|
||||
source: "manual" | "startup",
|
||||
latestTag: string,
|
||||
dismissedTag: string
|
||||
): boolean {
|
||||
return source === "manual" || !latestTag || latestTag !== dismissedTag;
|
||||
}
|
||||
|
||||
export async function runLatestUpdateCheck(
|
||||
generationRef: { current: number },
|
||||
check: () => Promise<UpdateCheckResult>,
|
||||
@@ -1523,6 +1531,7 @@ export function App(): ReactElement {
|
||||
const [scheduleCountdown, setScheduleCountdown] = useState("");
|
||||
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
|
||||
const updateCheckGenerationRef = useRef(0);
|
||||
const dismissedUpdateTagRef = useRef("");
|
||||
const settingsDirtyRef = useRef(false);
|
||||
const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">());
|
||||
const archivePasswordLoadGenerationRef = useRef(0);
|
||||
@@ -2589,7 +2598,15 @@ export function App(): ReactElement {
|
||||
releaseNotes: changelogText
|
||||
});
|
||||
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> => {
|
||||
@@ -5737,12 +5754,9 @@ export function App(): ReactElement {
|
||||
available={Boolean(availableUpdate)}
|
||||
currentVersion={availableUpdate?.currentVersion ?? appVersion}
|
||||
latestTag={availableUpdate?.latestTag ?? ""}
|
||||
onClose={() => {
|
||||
setUpdateDialogOpen(false);
|
||||
setUpdateInstallProgress(null);
|
||||
}}
|
||||
onClose={dismissUpdatePrompt}
|
||||
onInstall={() => { void installUpdate(); }}
|
||||
onLater={() => setUpdateDialogOpen(false)}
|
||||
onLater={dismissUpdatePrompt}
|
||||
onOpen={() => setUpdateDialogOpen(true)}
|
||||
open={updateDialogOpen || updateInstallProgress !== null}
|
||||
progress={updateInstallProgress ? {
|
||||
@@ -6708,12 +6722,9 @@ export function App(): ReactElement {
|
||||
available={Boolean(availableUpdate)}
|
||||
currentVersion={availableUpdate?.currentVersion ?? appVersion}
|
||||
latestTag={availableUpdate?.latestTag ?? ""}
|
||||
onClose={() => {
|
||||
setUpdateDialogOpen(false);
|
||||
setUpdateInstallProgress(null);
|
||||
}}
|
||||
onClose={dismissUpdatePrompt}
|
||||
onInstall={() => { void installUpdate(); }}
|
||||
onLater={() => setUpdateDialogOpen(false)}
|
||||
onLater={dismissUpdatePrompt}
|
||||
onOpen={() => setUpdateDialogOpen(true)}
|
||||
open={updateDialogOpen || updateInstallProgress !== null}
|
||||
progress={updateInstallProgress ? {
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--ui-update);
|
||||
background: var(--ui-success);
|
||||
color: var(--ui-update-text);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
@@ -174,8 +174,8 @@
|
||||
}
|
||||
|
||||
.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-dialog button:focus-visible,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
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 = {
|
||||
publish: {
|
||||
@@ -38,7 +39,21 @@ const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl)
|
||||
}
|
||||
) => 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 = [
|
||||
"LICENSE",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
@@ -172,6 +187,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, "win-unpacked/resources/app.asar", validAppAsar);
|
||||
writeFile(rootDir, "resources/installer.nsh", "!macro customCheckAppRunning\n${isUpdated}\nFIND_PROCESS\ntaskkill /f /im\n!macroend\n");
|
||||
|
||||
return rootDir;
|
||||
@@ -357,6 +373,13 @@ describe("public release metadata", () => {
|
||||
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", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(rootDir, "win-unpacked", "resources", "assets", "app_icon.ico"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
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 { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||
import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience";
|
||||
@@ -13,7 +13,13 @@ const callbacks = {
|
||||
onLater: () => {}
|
||||
};
|
||||
|
||||
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", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<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);
|
||||
});
|
||||
|
||||
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 theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
||||
|
||||
expect(theme).toMatch(/--ui-update:\s*#BAD0FC;/);
|
||||
expect(theme).toMatch(/--ui-update-hover:\s*#8AA5DC;/);
|
||||
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:hover\s*\{[^}]*background:\s*var\(--ui-update-hover\);/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*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);
|
||||
});
|
||||
|
||||
|
||||
+11
-2
@@ -44,8 +44,17 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("normalizes update repo input", () => {
|
||||
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", () => {
|
||||
expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/Multi-Debrid-Downloader");
|
||||
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo");
|
||||
|
||||
Reference in New Issue
Block a user