From 20c32d39c8d1fd6ea3d901b0f0bea9c9722159da Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 1 Mar 2026 16:19:35 +0100 Subject: [PATCH] Harden updater candidate fallback on Codeberg 404s --- src/main/update.ts | 161 ++++++++++++++++++++++++++++++++++--------- tests/update.test.ts | 4 +- 2 files changed, 131 insertions(+), 34 deletions(-) diff --git a/src/main/update.ts b/src/main/update.ts index 1a7464b..95ec28b 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -361,19 +361,67 @@ function uniqueStrings(values: string[]): string[] { return out; } +function deriveTagFromReleaseUrl(releaseUrl: string): string { + const raw = String(releaseUrl || "").trim(); + if (!raw) { + return ""; + } + try { + const parsed = new URL(raw); + const match = parsed.pathname.match(/\/releases\/tag\/([^/?#]+)/i); + return match?.[1] ? decodeURIComponent(match[1]) : ""; + } catch { + return ""; + } +} + +function extractFileNameFromUrl(url: string): string { + const raw = String(url || "").trim(); + if (!raw) { + return ""; + } + try { + const parsed = new URL(raw); + const fileName = path.basename(parsed.pathname || ""); + return fileName ? decodeURIComponent(fileName) : ""; + } catch { + return ""; + } +} + +function deriveSetupNameVariants(setupAssetName: string, setupAssetUrl: string): string[] { + const directName = String(setupAssetName || "").trim(); + const fromUrlName = extractFileNameFromUrl(setupAssetUrl); + const source = directName || fromUrlName; + if (!source) { + return []; + } + + const ext = path.extname(source); + const stem = ext ? source.slice(0, -ext.length) : source; + const dashed = `${stem.replace(/\s+/g, "-")}${ext}`; + return uniqueStrings([source, fromUrlName, dashed]); +} + 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 latestTag = String(check.latestTag || "").trim() || deriveTagFromReleaseUrl(String(check.releaseUrl || "")); const candidates = [setupAssetUrl]; - if (setupAssetName) { - const encodedName = encodeURIComponent(setupAssetName); - candidates.push(`${UPDATE_WEB_BASE}/${safeRepo}/releases/latest/download/${encodedName}`); - if (latestTag) { + const nameVariants = deriveSetupNameVariants(setupAssetName, setupAssetUrl); + if (latestTag && nameVariants.length > 0) { + for (const name of nameVariants) { + const encodedName = encodeURIComponent(name); candidates.push(`${UPDATE_WEB_BASE}/${safeRepo}/releases/download/${encodeURIComponent(latestTag)}/${encodedName}`); } } + if (!latestTag && nameVariants.length > 0) { + for (const name of nameVariants) { + const encodedName = encodeURIComponent(name); + candidates.push(`${UPDATE_WEB_BASE}/${safeRepo}/releases/latest/download/${encodedName}`); + } + } return uniqueStrings(candidates); } @@ -922,7 +970,7 @@ export async function installLatestUpdate( } } - const candidates = buildDownloadCandidates(safeRepo, effectiveCheck); + let candidates = buildDownloadCandidates(safeRepo, effectiveCheck); if (candidates.length === 0) { return { started: false, message: "Setup-Asset nicht gefunden" }; } @@ -943,38 +991,87 @@ export async function installLatestUpdate( } let verified = false; let lastVerifyError: unknown = null; - for (let index = 0; index < candidates.length; index += 1) { - const candidate = candidates[index]; - try { - await downloadWithRetries(candidate, targetPath, onProgress); - if (updateAbortController.signal.aborted) { - throw new Error("aborted:update_shutdown"); - } - safeEmitProgress(onProgress, { - stage: "verifying", - percent: 100, - downloadedBytes: 0, - totalBytes: null, - message: `Prüfe Installer-Integrität (${index + 1}/${candidates.length})` - }); - await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || "")); - verified = true; - break; - } catch (error) { - lastVerifyError = error; + let integrityError: unknown = null; + for (let pass = 0; pass < 2 && !verified; pass += 1) { + logger.info(`Update-Download Kandidaten (${pass + 1}/2): ${candidates.join(" | ")}`); + lastVerifyError = null; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; try { - await fs.promises.rm(targetPath, { force: true }); - } catch { - // ignore + await downloadWithRetries(candidate, targetPath, onProgress); + if (updateAbortController.signal.aborted) { + throw new Error("aborted:update_shutdown"); + } + safeEmitProgress(onProgress, { + stage: "verifying", + percent: 100, + downloadedBytes: 0, + totalBytes: null, + message: `Prüfe Installer-Integrität (${index + 1}/${candidates.length})` + }); + await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || "")); + verified = true; + break; + } catch (error) { + lastVerifyError = error; + const errorText = compactErrorText(error).toLowerCase(); + if (!integrityError && (errorText.includes("integrit") || errorText.includes("mismatch"))) { + integrityError = error; + } + try { + await fs.promises.rm(targetPath, { force: true }); + } catch { + // ignore + } + if (index < candidates.length - 1) { + logger.warn(`Update-Kandidat ${index + 1}/${candidates.length} verworfen: ${compactErrorText(error)}`); + } } - if (index >= candidates.length - 1) { - throw error; + } + + if (verified) { + break; + } + + const status = readHttpStatusFromError(lastVerifyError); + let shouldRetryAfterRefresh = false; + if (pass === 0 && status === 404) { + const refreshed = await resolveSetupAssetFromApi(safeRepo, effectiveCheck.latestTag); + if (refreshed) { + effectiveCheck = { + ...effectiveCheck, + setupAssetUrl: refreshed.setupAssetUrl || effectiveCheck.setupAssetUrl, + setupAssetName: refreshed.setupAssetName || effectiveCheck.setupAssetName, + setupAssetDigest: refreshed.setupAssetDigest || effectiveCheck.setupAssetDigest + }; } - logger.warn(`Update-Kandidat ${index + 1}/${candidates.length} verworfen: ${compactErrorText(error)}`); + if (!effectiveCheck.setupAssetDigest && effectiveCheck.setupAssetUrl) { + const digestFromYml = await resolveSetupDigestFromLatestYml(safeRepo, effectiveCheck.latestTag, effectiveCheck.setupAssetName || ""); + if (digestFromYml) { + effectiveCheck = { + ...effectiveCheck, + setupAssetDigest: digestFromYml + }; + logger.info("Update-Integritätsdigest aus latest.yml übernommen"); + } + } + + const refreshedCandidates = buildDownloadCandidates(safeRepo, effectiveCheck); + const changed = refreshedCandidates.length > 0 + && (refreshedCandidates.length !== candidates.length + || refreshedCandidates.some((value, idx) => value !== candidates[idx])); + if (changed) { + logger.warn("Update-404 erkannt, Kandidatenliste aus API neu geladen"); + candidates = refreshedCandidates; + shouldRetryAfterRefresh = true; + } + } + if (!shouldRetryAfterRefresh) { + break; } } if (!verified) { - throw lastVerifyError || new Error("Update-Download fehlgeschlagen"); + throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen"); } safeEmitProgress(onProgress, { stage: "launching", diff --git a/tests/update.test.ts b/tests/update.test.ts index 874190d..22e17ef 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -95,7 +95,7 @@ describe("update", () => { if (url.includes("stale-setup.exe")) { return new Response("missing", { status: 404 }); } - if (url.includes("/releases/latest/download/")) { + if (url.includes("/releases/download/v9.9.9/")) { return new Response(executablePayload, { status: 200, headers: { "Content-Type": "application/octet-stream" } @@ -117,7 +117,7 @@ describe("update", () => { const result = await installLatestUpdate("owner/repo", prechecked); expect(result.started).toBe(true); - expect(requestedUrls.some((url) => url.includes("/releases/latest/download/"))).toBe(true); + expect(requestedUrls.some((url) => url.includes("/releases/download/v9.9.9/"))).toBe(true); expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1); });