Release v1.4.8 with updater fallback recovery and extraction hardening
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
3b9c4a4e88
commit
333a912d67
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<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> {
|
||||
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 } {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,48 +88,43 @@ function isRemoteNewer(currentVersion: string, latestVersion: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
|
||||
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`
|
||||
};
|
||||
|
||||
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<string, unknown> | null;
|
||||
if (!response.ok || !payload) {
|
||||
const reason = String((payload?.message as string) || `HTTP ${response.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);
|
||||
function readReleaseAssets(payload: Record<string, unknown>): ReleaseAsset[] {
|
||||
const assets = Array.isArray(payload.assets) ? payload.assets as Array<Record<string, unknown>> : [];
|
||||
const exeAssets = assets
|
||||
return 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));
|
||||
.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),
|
||||
@ -128,8 +132,139 @@ export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult
|
||||
latestVersion,
|
||||
latestTag,
|
||||
releaseUrl,
|
||||
setupAssetUrl: setup?.browser_download_url || ""
|
||||
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 {
|
||||
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 };
|
||||
}
|
||||
|
||||
return parseReleasePayload(release.payload, fallback);
|
||||
} catch (error) {
|
||||
return {
|
||||
...fallback,
|
||||
@ -139,12 +274,12 @@ export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, targetPath: string): Promise<void> {
|
||||
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<void> {
|
||||
await pipeline(source, target);
|
||||
}
|
||||
|
||||
export async function installLatestUpdate(repo: string): Promise<UpdateInstallResult> {
|
||||
const check = await checkGitHubUpdate(repo);
|
||||
async function downloadFromCandidates(candidates: string[], targetPath: string): Promise<void> {
|
||||
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) {
|
||||
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"
|
||||
|
||||
@ -197,6 +197,7 @@ export interface UpdateCheckResult {
|
||||
latestTag: string;
|
||||
releaseUrl: string;
|
||||
setupAssetUrl?: string;
|
||||
setupAssetName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user