diff --git a/package-lock.json b/package-lock.json index 8274a20..5352d31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.7", + "version": "1.4.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.7", + "version": "1.4.8", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index fc0d652..335bd27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.7", + "version": "1.4.8", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 8ac4bed..99846f1 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -27,6 +27,10 @@ export class AppController { private megaWebFallback: MegaWebFallback; + private lastUpdateCheck: UpdateCheckResult | null = null; + + private lastUpdateCheckAt = 0; + private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); public constructor() { @@ -100,11 +104,25 @@ export class AppController { } public async checkUpdates(): Promise { - return checkGitHubUpdate(this.settings.updateRepo); + const result = await checkGitHubUpdate(this.settings.updateRepo); + if (!result.error) { + this.lastUpdateCheck = result; + this.lastUpdateCheckAt = Date.now(); + } + return result; } public async installUpdate(): Promise { - return installLatestUpdate(this.settings.updateRepo); + const cacheAgeMs = Date.now() - this.lastUpdateCheckAt; + const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000 + ? this.lastUpdateCheck + : undefined; + const result = await installLatestUpdate(this.settings.updateRepo, cached); + if (result.started) { + this.lastUpdateCheck = null; + this.lastUpdateCheckAt = 0; + } + return result; } public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index a82d2fd..90f99b3 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -787,7 +787,7 @@ export class DownloadManager extends EventEmitter { continue; } - const hasExtractMarker = items.some((item) => /entpack/i.test(item.fullStatus)); + const hasExtractMarker = items.some((item) => isExtractedLabel(item.fullStatus)); const extractDirIsUnique = (extractDirUsage.get(pathKey(pkg.extractDir)) || 0) === 1; const hasExtractedOutput = extractDirIsUnique && this.directoryHasAnyFiles(pkg.extractDir); if (!hasExtractMarker && !hasExtractedOutput) { @@ -823,7 +823,7 @@ export class DownloadManager extends EventEmitter { continue; } - logger.info(`Nachträgliches Cleanup geprüft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => /entpack/i.test(this.session.items[id]?.fullStatus || ""))}`); + logger.info(`Nachträgliches Cleanup geprüft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => isExtractedLabel(this.session.items[id]?.fullStatus || ""))}`); let removed = 0; for (const targetPath of targets) { diff --git a/src/main/update.ts b/src/main/update.ts index afb4087..f9b498a 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -9,6 +9,15 @@ import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants"; import { UpdateCheckResult, UpdateInstallResult } from "../shared/types"; import { compactErrorText } from "./utils"; +const RELEASE_FETCH_TIMEOUT_MS = 12000; +const DOWNLOAD_TIMEOUT_MS = 8 * 60 * 1000; +const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; + +type ReleaseAsset = { + name: string; + browser_download_url: string; +}; + export function normalizeUpdateRepo(repo: string): string { const raw = String(repo || "").trim(); if (!raw) { @@ -79,57 +88,183 @@ function isRemoteNewer(currentVersion: string, latestVersion: string): boolean { return false; } -export async function checkGitHubUpdate(repo: string): Promise { +function createFallbackResult(repo: string): UpdateCheckResult { const safeRepo = normalizeUpdateRepo(repo); - const fallback: UpdateCheckResult = { + return { updateAvailable: false, currentVersion: APP_VERSION, latestVersion: APP_VERSION, latestTag: `v${APP_VERSION}`, releaseUrl: `https://github.com/${safeRepo}/releases/latest` }; +} + +function readReleaseAssets(payload: Record): ReleaseAsset[] { + const assets = Array.isArray(payload.assets) ? payload.assets as Array> : []; + return assets + .map((asset) => ({ + name: String(asset.name || ""), + browser_download_url: String(asset.browser_download_url || "") + })) + .filter((asset) => asset.name && asset.browser_download_url); +} + +function pickSetupAsset(assets: ReleaseAsset[]): ReleaseAsset | null { + const installable = assets.filter((asset) => /\.(exe|msi|msix|msixbundle)$/i.test(asset.name)); + if (installable.length === 0) { + return null; + } + + return installable.find((asset) => /setup/i.test(asset.name)) + || installable.find((asset) => !/portable/i.test(asset.name)) + || installable[0]; +} + +function parseReleasePayload(payload: Record, fallback: UpdateCheckResult): UpdateCheckResult { + const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim(); + const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION; + const releaseUrl = String(payload.html_url || fallback.releaseUrl); + const setup = pickSetupAsset(readReleaseAssets(payload)); + + return { + updateAvailable: isRemoteNewer(APP_VERSION, latestVersion), + currentVersion: APP_VERSION, + latestVersion, + latestTag, + releaseUrl, + setupAssetUrl: setup?.browser_download_url || "", + setupAssetName: setup?.name || "" + }; +} + +async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise<{ ok: boolean; status: number; payload: Record | null }> { + const timeout = timeoutController(RELEASE_FETCH_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(`https://api.github.com/repos/${safeRepo}/${endpoint}`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": UPDATE_USER_AGENT + }, + signal: timeout.signal + }); + } finally { + timeout.clear(); + } + + const payload = await response.json().catch(() => null) as Record | null; + return { + ok: response.ok, + status: response.status, + payload + }; +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + const normalized = String(value || "").trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + out.push(normalized); + } + return out; +} + +function buildDownloadCandidates(safeRepo: string, check: UpdateCheckResult): string[] { + const setupAssetName = String(check.setupAssetName || "").trim(); + const setupAssetUrl = String(check.setupAssetUrl || "").trim(); + const latestTag = String(check.latestTag || "").trim(); + + const candidates = [setupAssetUrl]; + if (setupAssetName) { + const encodedName = encodeURIComponent(setupAssetName); + candidates.push(`https://github.com/${safeRepo}/releases/latest/download/${encodedName}`); + if (latestTag) { + candidates.push(`https://github.com/${safeRepo}/releases/download/${encodeURIComponent(latestTag)}/${encodedName}`); + } + } + + return uniqueStrings(candidates); +} + +function readHttpStatusFromError(error: unknown): number { + const text = String(error || ""); + const match = text.match(/HTTP\s+(\d{3})/i); + return match ? Number(match[1]) : 0; +} + +function isRecoverableDownloadError(error: unknown): boolean { + const status = readHttpStatusFromError(error); + if (status === 404 || status === 403 || status === 429 || status >= 500) { + return true; + } + + const text = String(error || "").toLowerCase(); + return text.includes("timeout") + || text.includes("fetch failed") + || text.includes("network") + || text.includes("econnreset") + || text.includes("enotfound") + || text.includes("aborted"); +} + +function deriveUpdateFileName(check: UpdateCheckResult, url: string): string { + const fromName = String(check.setupAssetName || "").trim(); + if (fromName) { + return fromName; + } + try { + const parsed = new URL(url); + return path.basename(parsed.pathname || "update.exe") || "update.exe"; + } catch { + return "update.exe"; + } +} + +async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Promise<{ setupAssetUrl: string; setupAssetName: string } | null> { + const endpointCandidates = uniqueStrings([ + tagHint ? `releases/tags/${encodeURIComponent(tagHint)}` : "", + "releases/latest" + ]); + + for (const endpoint of endpointCandidates) { + try { + const release = await fetchReleasePayload(safeRepo, endpoint); + if (!release.ok || !release.payload) { + continue; + } + const setup = pickSetupAsset(readReleaseAssets(release.payload)); + if (!setup) { + continue; + } + return { + setupAssetUrl: setup.browser_download_url, + setupAssetName: setup.name + }; + } catch { + // ignore and continue with next endpoint candidate + } + } + + return null; +} + +export async function checkGitHubUpdate(repo: string): Promise { + const safeRepo = normalizeUpdateRepo(repo); + const fallback = createFallbackResult(safeRepo); try { - const timeout = timeoutController(15000); - let response: Response; - try { - response = await fetch(`https://api.github.com/repos/${safeRepo}/releases/latest`, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "RD-Node-Downloader/1.1.14" - }, - signal: timeout.signal - }); - } finally { - timeout.clear(); - } - const payload = await response.json().catch(() => null) as Record | null; - if (!response.ok || !payload) { - const reason = String((payload?.message as string) || `HTTP ${response.status}`); + const release = await fetchReleasePayload(safeRepo, "releases/latest"); + if (!release.ok || !release.payload) { + const reason = String((release.payload?.message as string) || `HTTP ${release.status}`); return { ...fallback, error: reason }; } - const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim(); - const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION; - const releaseUrl = String(payload.html_url || fallback.releaseUrl); - const assets = Array.isArray(payload.assets) ? payload.assets as Array> : []; - const exeAssets = assets - .map((asset) => ({ - name: String(asset.name || ""), - browser_download_url: String(asset.browser_download_url || "") - })) - .filter((asset) => asset.browser_download_url && /\.exe$/i.test(asset.name)); - const setup = exeAssets.find((asset) => /setup/i.test(asset.name)) - || exeAssets.find((asset) => !/portable/i.test(asset.name)); - - return { - updateAvailable: isRemoteNewer(APP_VERSION, latestVersion), - currentVersion: APP_VERSION, - latestVersion, - latestTag, - releaseUrl, - setupAssetUrl: setup?.browser_download_url || "" - }; + return parseReleasePayload(release.payload, fallback); } catch (error) { return { ...fallback, @@ -139,12 +274,12 @@ export async function checkGitHubUpdate(repo: string): Promise { - const timeout = timeoutController(10 * 60 * 1000); + const timeout = timeoutController(DOWNLOAD_TIMEOUT_MS); let response: Response; try { response = await fetch(url, { headers: { - "User-Agent": "RD-Node-Downloader/1.1.18" + "User-Agent": UPDATE_USER_AGENT }, signal: timeout.signal }); @@ -161,23 +296,71 @@ async function downloadFile(url: string, targetPath: string): Promise { await pipeline(source, target); } -export async function installLatestUpdate(repo: string): Promise { - const check = await checkGitHubUpdate(repo); +async function downloadFromCandidates(candidates: string[], targetPath: string): Promise { + let lastError: unknown = new Error("Update Download fehlgeschlagen"); + + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + try { + await downloadFile(candidate, targetPath); + return; + } catch (error) { + lastError = error; + try { + await fs.promises.rm(targetPath, { force: true }); + } catch { + // ignore + } + if (index < candidates.length - 1 && isRecoverableDownloadError(error)) { + continue; + } + break; + } + } + + throw lastError; +} + +export async function installLatestUpdate(repo: string, prechecked?: UpdateCheckResult): Promise { + const safeRepo = normalizeUpdateRepo(repo); + const check = prechecked && !prechecked.error + ? prechecked + : await checkGitHubUpdate(safeRepo); + if (check.error) { return { started: false, message: check.error }; } if (!check.updateAvailable) { return { started: false, message: "Kein neues Update verfügbar" }; } - const downloadUrl = check.setupAssetUrl || check.releaseUrl; - if (!check.setupAssetUrl) { + + let effectiveCheck: UpdateCheckResult = { + ...check, + setupAssetUrl: String(check.setupAssetUrl || ""), + setupAssetName: String(check.setupAssetName || "") + }; + + if (!effectiveCheck.setupAssetUrl) { + const refreshed = await resolveSetupAssetFromApi(safeRepo, effectiveCheck.latestTag); + if (refreshed) { + effectiveCheck = { + ...effectiveCheck, + setupAssetUrl: refreshed.setupAssetUrl, + setupAssetName: refreshed.setupAssetName + }; + } + } + + const candidates = buildDownloadCandidates(safeRepo, effectiveCheck); + if (candidates.length === 0) { return { started: false, message: "Setup-Asset nicht gefunden" }; } - const fileName = path.basename(new URL(downloadUrl).pathname || "update.exe") || "update.exe"; + const fileName = deriveUpdateFileName(effectiveCheck, candidates[0]); const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${fileName}`); + try { - await downloadFile(downloadUrl, targetPath); + await downloadFromCandidates(candidates, targetPath); const child = spawn(targetPath, [], { detached: true, stdio: "ignore" diff --git a/src/shared/types.ts b/src/shared/types.ts index 08c8b06..677d2b9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -197,6 +197,7 @@ export interface UpdateCheckResult { latestTag: string; releaseUrl: string; setupAssetUrl?: string; + setupAssetName?: string; error?: string; } diff --git a/tests/update.test.ts b/tests/update.test.ts index 4b524da..ef3d110 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -1,6 +1,8 @@ +import fs from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { checkGitHubUpdate, normalizeUpdateRepo } from "../src/main/update"; +import { checkGitHubUpdate, installLatestUpdate, normalizeUpdateRepo } from "../src/main/update"; import { APP_VERSION } from "../src/main/constants"; +import { UpdateCheckResult } from "../src/shared/types"; const originalFetch = globalThis.fetch; @@ -69,5 +71,40 @@ describe("update", () => { const result = await checkGitHubUpdate("owner/repo"); expect(result.updateAvailable).toBe(true); expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe"); + expect(result.setupAssetName).toBe("Real-Debrid-Downloader Setup 9.9.9.exe"); + }); + + it("falls back to alternate download URL when setup asset URL returns 404", async () => { + const executablePayload = fs.readFileSync(process.execPath); + const requestedUrls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + requestedUrls.push(url); + + if (url.includes("stale-setup.exe")) { + return new Response("missing", { status: 404 }); + } + if (url.includes("/releases/latest/download/")) { + return new Response(executablePayload, { + status: 200, + headers: { "Content-Type": "application/octet-stream" } + }); + } + return new Response("missing", { status: 404 }); + }) as typeof fetch; + + const prechecked: UpdateCheckResult = { + updateAvailable: true, + currentVersion: APP_VERSION, + latestVersion: "9.9.9", + latestTag: "v9.9.9", + releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9", + setupAssetUrl: "https://example.invalid/stale-setup.exe", + setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe" + }; + + const result = await installLatestUpdate("owner/repo", prechecked); + expect(result.started).toBe(true); + expect(requestedUrls.some((url) => url.includes("/releases/latest/download/"))).toBe(true); }); });