diff --git a/scripts/verify_public_release.mjs b/scripts/verify_public_release.mjs index a8a03b5..070e7a1 100644 --- a/scripts/verify_public_release.mjs +++ b/scripts/verify_public_release.mjs @@ -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))); diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 06a0cfb..48dec31 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -895,17 +895,13 @@ export class AppController { this.lastUpdateCheckAt = Date.now(); } return result; - } - - public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise { - 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 { + const result = await runInstallWithResume( + this.manager, + () => installLatestUpdate(this.settings.updateRepo, undefined, onProgress) + ); if (result.started) { this.lastUpdateCheck = null; this.lastUpdateCheckAt = 0; diff --git a/src/main/app-version.ts b/src/main/app-version.ts new file mode 100644 index 0000000..7fa010f --- /dev/null +++ b/src/main/app-version.ts @@ -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"; +} diff --git a/src/main/constants.ts b/src/main/constants.ts index 911a1d3..9d4c71c 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -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"; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f080da2..2c735a2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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, @@ -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 => { @@ -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 ? { diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css index 06791be..4f44320 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -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, diff --git a/tests/app-version.test.ts b/tests/app-version.test.ts new file mode 100644 index 0000000..6e8cfc6 --- /dev/null +++ b/tests/app-version.test.ts @@ -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"); + }); +}); diff --git a/tests/public-release-metadata.test.ts b/tests/public-release-metadata.test.ts index f4d3a26..5ee5076 100644 --- a/tests/public-release-metadata.test.ts +++ b/tests/public-release-metadata.test.ts @@ -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 { + 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")); diff --git a/tests/update-experience.test.tsx b/tests/update-experience.test.tsx index 1af1c80..3aae223 100644 --- a/tests/update-experience.test.tsx +++ b/tests/update-experience.test.tsx @@ -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( { 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); }); diff --git a/tests/update.test.ts b/tests/update.test.ts index 253c1fc..4ed7748 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -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");