Release v1.4.8 with updater fallback recovery and extraction hardening
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-27 19:28:58 +01:00
parent 3b9c4a4e88
commit 333a912d67
7 changed files with 294 additions and 55 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.7", "version": "1.4.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.7", "version": "1.4.8",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.7", "version": "1.4.8",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -27,6 +27,10 @@ export class AppController {
private megaWebFallback: MegaWebFallback; private megaWebFallback: MegaWebFallback;
private lastUpdateCheck: UpdateCheckResult | null = null;
private lastUpdateCheckAt = 0;
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
public constructor() { public constructor() {
@ -100,11 +104,25 @@ export class AppController {
} }
public async checkUpdates(): Promise<UpdateCheckResult> { public async checkUpdates(): Promise<UpdateCheckResult> {
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<UpdateInstallResult> { public async installUpdate(): Promise<UpdateInstallResult> {
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 } { public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {

View File

@ -787,7 +787,7 @@ export class DownloadManager extends EventEmitter {
continue; 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 extractDirIsUnique = (extractDirUsage.get(pathKey(pkg.extractDir)) || 0) === 1;
const hasExtractedOutput = extractDirIsUnique && this.directoryHasAnyFiles(pkg.extractDir); const hasExtractedOutput = extractDirIsUnique && this.directoryHasAnyFiles(pkg.extractDir);
if (!hasExtractMarker && !hasExtractedOutput) { if (!hasExtractMarker && !hasExtractedOutput) {
@ -823,7 +823,7 @@ export class DownloadManager extends EventEmitter {
continue; 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; let removed = 0;
for (const targetPath of targets) { for (const targetPath of targets) {

View File

@ -9,6 +9,15 @@ import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants";
import { UpdateCheckResult, UpdateInstallResult } from "../shared/types"; import { UpdateCheckResult, UpdateInstallResult } from "../shared/types";
import { compactErrorText } from "./utils"; 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 { export function normalizeUpdateRepo(repo: string): string {
const raw = String(repo || "").trim(); const raw = String(repo || "").trim();
if (!raw) { if (!raw) {
@ -79,57 +88,183 @@ function isRemoteNewer(currentVersion: string, latestVersion: string): boolean {
return false; return false;
} }
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> { function createFallbackResult(repo: string): UpdateCheckResult {
const safeRepo = normalizeUpdateRepo(repo); const safeRepo = normalizeUpdateRepo(repo);
const fallback: UpdateCheckResult = { return {
updateAvailable: false, updateAvailable: false,
currentVersion: APP_VERSION, currentVersion: APP_VERSION,
latestVersion: APP_VERSION, latestVersion: APP_VERSION,
latestTag: `v${APP_VERSION}`, latestTag: `v${APP_VERSION}`,
releaseUrl: `https://github.com/${safeRepo}/releases/latest` releaseUrl: `https://github.com/${safeRepo}/releases/latest`
}; };
}
function readReleaseAssets(payload: Record<string, unknown>): ReleaseAsset[] {
const assets = Array.isArray(payload.assets) ? payload.assets as Array<Record<string, unknown>> : [];
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<string, unknown>, 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<string, unknown> | 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<string, unknown> | null;
return {
ok: response.ok,
status: response.status,
payload
};
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
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<UpdateCheckResult> {
const safeRepo = normalizeUpdateRepo(repo);
const fallback = createFallbackResult(safeRepo);
try { try {
const timeout = timeoutController(15000); const release = await fetchReleasePayload(safeRepo, "releases/latest");
let response: Response; if (!release.ok || !release.payload) {
try { const reason = String((release.payload?.message as string) || `HTTP ${release.status}`);
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<string, unknown> | null;
if (!response.ok || !payload) {
const reason = String((payload?.message as string) || `HTTP ${response.status}`);
return { ...fallback, error: reason }; return { ...fallback, error: reason };
} }
const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim(); return parseReleasePayload(release.payload, fallback);
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<Record<string, unknown>> : [];
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 || ""
};
} catch (error) { } catch (error) {
return { return {
...fallback, ...fallback,
@ -139,12 +274,12 @@ export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult
} }
async function downloadFile(url: string, targetPath: string): Promise<void> { async function downloadFile(url: string, targetPath: string): Promise<void> {
const timeout = timeoutController(10 * 60 * 1000); const timeout = timeoutController(DOWNLOAD_TIMEOUT_MS);
let response: Response; let response: Response;
try { try {
response = await fetch(url, { response = await fetch(url, {
headers: { headers: {
"User-Agent": "RD-Node-Downloader/1.1.18" "User-Agent": UPDATE_USER_AGENT
}, },
signal: timeout.signal signal: timeout.signal
}); });
@ -161,23 +296,71 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
await pipeline(source, target); await pipeline(source, target);
} }
export async function installLatestUpdate(repo: string): Promise<UpdateInstallResult> { async function downloadFromCandidates(candidates: string[], targetPath: string): Promise<void> {
const check = await checkGitHubUpdate(repo); 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<UpdateInstallResult> {
const safeRepo = normalizeUpdateRepo(repo);
const check = prechecked && !prechecked.error
? prechecked
: await checkGitHubUpdate(safeRepo);
if (check.error) { if (check.error) {
return { started: false, message: check.error }; return { started: false, message: check.error };
} }
if (!check.updateAvailable) { if (!check.updateAvailable) {
return { started: false, message: "Kein neues Update verfügbar" }; 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" }; 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}`); const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${fileName}`);
try { try {
await downloadFile(downloadUrl, targetPath); await downloadFromCandidates(candidates, targetPath);
const child = spawn(targetPath, [], { const child = spawn(targetPath, [], {
detached: true, detached: true,
stdio: "ignore" stdio: "ignore"

View File

@ -197,6 +197,7 @@ export interface UpdateCheckResult {
latestTag: string; latestTag: string;
releaseUrl: string; releaseUrl: string;
setupAssetUrl?: string; setupAssetUrl?: string;
setupAssetName?: string;
error?: string; error?: string;
} }

View File

@ -1,6 +1,8 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { APP_VERSION } from "../src/main/constants";
import { UpdateCheckResult } from "../src/shared/types";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@ -69,5 +71,40 @@ describe("update", () => {
const result = await checkGitHubUpdate("owner/repo"); const result = await checkGitHubUpdate("owner/repo");
expect(result.updateAvailable).toBe(true); expect(result.updateAvailable).toBe(true);
expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe"); 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<Response> => {
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);
}); });
}); });