Files
Multi-Debrid-Downloader/src/main/update.ts
T

1042 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import * as childProcess from "node:child_process";
import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants";
import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from "../shared/types";
import { compactErrorText, humanSize } from "./utils";
import { logger } from "./logger";
const RELEASE_FETCH_TIMEOUT_MS = 12_000;
const CONNECT_TIMEOUT_MS = 30_000;
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
const RETRIES_PER_CANDIDATE = 3;
const RETRY_DELAY_MS = 1_500;
const MAX_DOWNLOAD_PASSES = 3;
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
type UpdateSource = {
name: string;
webBase: string;
apiBase: string;
};
type ReleaseAsset = {
name: string;
browser_download_url: string;
digest: string;
};
type UpdateProgressCallback = (progress: UpdateInstallProgress) => void;
type ExpectedDigest = {
algorithm: "sha256" | "sha512";
digest: string;
encoding: "hex" | "base64";
};
const UPDATE_SOURCES: UpdateSource[] = [
{ name: "github", webBase: "https://github.com", apiBase: "https://api.github.com" }
];
const PRIMARY_SOURCE = UPDATE_SOURCES[0];
const WEB_BASE = PRIMARY_SOURCE.webBase;
const API_BASE = PRIMARY_SOURCE.apiBase;
let activeAbortController: AbortController | null = null;
function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
if (!cb) return;
try {
cb(progress);
} catch {
}
}
export function parseVersionParts(version: string): number[] {
const cleaned = version.replace(/^v/i, "").trim();
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
}
export function isRemoteNewer(currentVersion: string, latestVersion: string): boolean {
const current = parseVersionParts(currentVersion);
const latest = parseVersionParts(latestVersion);
const len = Math.max(current.length, latest.length);
for (let i = 0; i < len; i += 1) {
const a = current[i] ?? 0;
const b = latest[i] ?? 0;
if (b > a) return true;
if (b < a) return false;
}
return false;
}
function isValidRepoPart(value: string): boolean {
const part = String(value || "").trim();
if (!part || part === "." || part === ".." || part.includes("..")) return false;
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part);
}
function extractOwnerRepo(input: string): string {
const cleaned = input
.replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
.replace(/^(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
.replace(/^git@(?:codeberg\.org|github\.com):/i, "")
.replace(/\.git$/i, "")
.replace(/^\/+|\/+$/g, "");
const parts = cleaned.split("/").filter(Boolean);
if (parts.length >= 2 && isValidRepoPart(parts[0]) && isValidRepoPart(parts[1])) {
return `${parts[0]}/${parts[1]}`;
}
return "";
}
export function normalizeUpdateRepo(repo: string): string {
const raw = String(repo || "").trim();
if (!raw) return DEFAULT_UPDATE_REPO;
try {
const url = new URL(raw);
const host = url.hostname.toLowerCase();
if (
host === "codeberg.org"
|| host === "www.codeberg.org"
|| host === "github.com"
|| host === "www.github.com"
) {
const result = extractOwnerRepo(url.pathname);
if (result) return result;
}
} catch {
}
const result = extractOwnerRepo(raw);
return result || DEFAULT_UPDATE_REPO;
}
function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms);
return { signal: ctrl.signal, clear: () => clearTimeout(timer) };
}
function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSignal {
if (!secondary) return primary;
return AbortSignal.any([primary, secondary]);
}
async function readJsonBody(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> {
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
void response.body?.cancel().catch(() => undefined);
reject(new Error(`timeout:${timeoutMs}`));
}, timeoutMs);
});
try {
const data = await Promise.race([
response.json().catch(() => null) as Promise<unknown>,
timeoutPromise,
]);
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
return data as Record<string, unknown>;
} finally {
if (timer) clearTimeout(timer);
}
}
async function readTextBody(response: Response, timeoutMs: number): Promise<string> {
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
void response.body?.cancel().catch(() => undefined);
reject(new Error(`timeout:${timeoutMs}`));
}, timeoutMs);
});
try {
return String(await Promise.race([response.text(), timeoutPromise]) || "");
} finally {
if (timer) clearTimeout(timer);
}
}
function getBodyIdleTimeout(): number {
const env = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
if (Number.isFinite(env) && env >= 1000 && env <= 30 * 60 * 1000) return Math.floor(env);
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
}
function normalizeBase64(raw: string): string {
return String(raw || "")
.trim()
.replace(/-/g, "+")
.replace(/_/g, "/")
.replace(/=+$/g, "");
}
export function parseExpectedDigest(raw: string): ExpectedDigest | null {
const text = String(raw || "").trim();
if (!text) return null;
const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
if (pre256hex) {
return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" };
}
const pre256b64 = text.match(/^sha256:([A-Za-z0-9+/_-]{43,44}={0,1})$/i);
if (pre256b64) {
return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" };
}
const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
if (pre512hex) {
return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" };
}
const pre512b64 = text.match(/^sha512:([A-Za-z0-9+/_-]{86,88}={0,2})$/i);
if (pre512b64) {
return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" };
}
if (/^[a-fA-F0-9]{64}$/.test(text)) {
return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" };
}
if (/^[a-fA-F0-9]{128}$/.test(text)) {
return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" };
}
const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
if (plain512b64) {
return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" };
}
const plain256b64 = text.match(/^([A-Za-z0-9+/_-]{43,44}={0,1})$/);
if (plain256b64) {
return { algorithm: "sha256", digest: normalizeBase64(plain256b64[1]), encoding: "base64" };
}
logger.warn(`Unrecognized digest format (${text.length} chars): ${text.slice(0, 40)}...`);
return null;
}
async function hashFile(filePath: string, algorithm: "sha256" | "sha512", encoding: "hex" | "base64"): Promise<string> {
const hash = crypto.createHash(algorithm);
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
return new Promise<string>((resolve, reject) => {
stream.on("data", (chunk: string | Buffer) => {
hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
stream.on("error", reject);
stream.on("end", () => {
const result = hash.digest(encoding);
resolve(encoding === "hex" ? result.toLowerCase() : result);
});
});
}
function normalizeNameForMatch(value: string): string {
const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || "";
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function stripYamlQuotes(raw: string): string {
return String(raw || "").trim().replace(/^['"]+|['"]+$/g, "").trim();
}
function extractSha512Value(raw: string): string {
const stripped = stripYamlQuotes(raw);
const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
if (b64) return b64[1];
const hex = stripped.match(/^([a-fA-F0-9]{128})$/);
if (hex) return hex[1];
return "";
}
function parseSha512FromLatestYml(content: string, setupAssetName: string): string {
const lines = String(content || "").split(/\r?\n/);
const target = normalizeNameForMatch(setupAssetName);
let topLevelPath = "";
let topLevelSha = "";
let currentFileUrl = "";
let firstFileSha = "";
for (const rawLine of lines) {
const line = String(rawLine);
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
if (fileUrlItem?.[1]) {
currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
continue;
}
const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
if (urlMatch?.[1]) {
currentFileUrl = stripYamlQuotes(urlMatch[1]);
continue;
}
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
if (pathMatch?.[1]) {
topLevelPath = stripYamlQuotes(pathMatch[1]);
continue;
}
const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i);
if (!shaMatch?.[1]) continue;
const sha = extractSha512Value(shaMatch[1]);
if (!sha) continue;
if (currentFileUrl) {
if (!firstFileSha) firstFileSha = sha;
if (target && normalizeNameForMatch(currentFileUrl) === target) {
return sha;
}
currentFileUrl = "";
continue;
}
if (!topLevelSha) topLevelSha = sha;
}
if (target && topLevelPath && topLevelSha) {
if (normalizeNameForMatch(topLevelPath) === target) {
return topLevelSha;
}
}
return topLevelSha || firstFileSha || "";
}
async function verifyBinaryShape(filePath: string): Promise<void> {
const stats = await fs.promises.stat(filePath);
if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
throw new Error("Update-Installer ungültig (Datei zu klein)");
}
const handle = await fs.promises.open(filePath, "r");
try {
const header = Buffer.alloc(2);
const result = await handle.read(header, 0, 2, 0);
if (result.bytesRead < 2 || header[0] !== 0x4d || header[1] !== 0x5a) {
throw new Error("Update-Installer ungültig (keine EXE-Datei)");
}
} finally {
await handle.close();
}
}
async function verifyDownloadedInstaller(filePath: string, digestRaw: string): Promise<void> {
await verifyBinaryShape(filePath);
const expected = parseExpectedDigest(digestRaw);
if (!expected) {
if (String(process.env.RD_ALLOW_UNSIGNED_UPDATE || "").trim() === "1") {
logger.warn("Update-Asset ohne gültigen SHA-Digest (RD_ALLOW_UNSIGNED_UPDATE=1) - nur EXE-Basisprüfung durchgeführt");
return;
}
throw new Error("Update-Asset ohne gültigen SHA-Digest");
}
const actualRaw = await hashFile(filePath, expected.algorithm, expected.encoding);
const actual = expected.encoding === "base64" ? normalizeBase64(actualRaw) : actualRaw;
const expectedNorm = expected.encoding === "base64" ? normalizeBase64(expected.digest) : expected.digest;
if (actual !== expectedNorm) {
const algo = expected.algorithm.toUpperCase();
logger.error(
`${algo} mismatch!\n Erwartet: ${expectedNorm.slice(0, 40)}...\n Tatsächlich: ${actual.slice(0, 40)}...`
);
throw new Error(`Update-Integritätsprüfung fehlgeschlagen (${algo} mismatch)`);
}
logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
}
async function fetchRelease(repo: string, endpoint: string): Promise<{
ok: boolean;
status: number;
payload: Record<string, unknown> | null;
}> {
const tc = timeoutController(RELEASE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/repos/${repo}/${endpoint}`, {
headers: { Accept: "application/vnd.github+json", "User-Agent": USER_AGENT },
signal: tc.signal,
});
const payload = await readJsonBody(response, RELEASE_FETCH_TIMEOUT_MS);
return { ok: response.ok, status: response.status, payload };
} finally {
tc.clear();
}
}
function readAssets(payload: Record<string, unknown>): ReleaseAsset[] {
const raw = Array.isArray(payload.assets) ? (payload.assets as Array<Record<string, unknown>>) : [];
return raw
.map((a) => ({
name: String(a.name || ""),
browser_download_url: String(a.browser_download_url || ""),
digest: String(a.digest || "").trim(),
}))
.filter((a) => a.name && a.browser_download_url);
}
function pickSetupAsset(assets: ReleaseAsset[]): ReleaseAsset | null {
const executables = assets.filter((a) => /\.(exe|msi|msix|msixbundle)$/i.test(a.name));
if (executables.length === 0) return null;
return (
executables.find((a) => /setup/i.test(a.name))
|| executables.find((a) => !/portable/i.test(a.name))
|| executables[0]
);
}
function pickLatestYml(assets: ReleaseAsset[]): ReleaseAsset | null {
return (
assets.find((a) => /^latest\.ya?ml$/i.test(a.name))
|| assets.find((a) => /latest/i.test(a.name) && /\.ya?ml$/i.test(a.name))
|| null
);
}
function isDraftOrPrerelease(payload: Record<string, unknown>): boolean {
return Boolean(payload.draft) || Boolean(payload.prerelease);
}
function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: string): 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 || fallbackUrl);
const setup = pickSetupAsset(readAssets(payload));
const body = typeof payload.body === "string" ? payload.body.trim() : "";
return {
updateAvailable: isRemoteNewer(APP_VERSION, latestVersion),
currentVersion: APP_VERSION,
latestVersion,
latestTag,
releaseUrl,
setupAssetUrl: setup?.browser_download_url || "",
setupAssetName: setup?.name || "",
setupAssetDigest: setup?.digest || "",
releaseNotes: body || undefined,
};
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
const trimmed = String(value || "").trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
out.push(trimmed);
}
return out;
}
function extractFileNameFromUrl(url: string): string {
try {
const parsed = new URL(url);
const fileName = path.basename(parsed.pathname || "");
return fileName ? decodeURIComponent(fileName) : "";
} catch {
return "";
}
}
function deriveTagFromUrl(releaseUrl: string): string {
try {
const match = new URL(releaseUrl).pathname.match(/\/releases\/tag\/([^/?#]+)/i);
return match?.[1] ? decodeURIComponent(match[1]) : "";
} catch {
return "";
}
}
function deriveNameVariants(name: string, url: string): string[] {
const directName = String(name || "").trim();
const fromUrl = extractFileNameFromUrl(url);
const source = directName || fromUrl;
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, fromUrl, dashed]);
}
function buildCandidates(repo: string, check: UpdateCheckResult): string[] {
const assetUrl = String(check.setupAssetUrl || "").trim();
const tag = String(check.latestTag || "").trim() || deriveTagFromUrl(String(check.releaseUrl || ""));
const names = deriveNameVariants(String(check.setupAssetName || ""), assetUrl);
const urls: string[] = [assetUrl];
if (tag && names.length > 0) {
for (const name of names) {
urls.push(`${WEB_BASE}/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`);
}
}
if (!tag && names.length > 0) {
for (const name of names) {
urls.push(`${WEB_BASE}/${repo}/releases/latest/download/${encodeURIComponent(name)}`);
}
}
return uniqueStrings(urls);
}
function deriveFileName(check: UpdateCheckResult, url: string): string {
const sanitize = (raw: string): string => {
const base = path.basename(String(raw || "").trim());
if (!base) return "update.exe";
const safe = base.replace(/[\\/:*?"<>|]/g, "_").replace(/^\.+/, "").trim();
return safe || "update.exe";
};
const fromName = String(check.setupAssetName || "").trim();
if (fromName) return sanitize(fromName);
try {
return sanitize(new URL(url).pathname || "update.exe");
} catch {
return "update.exe";
}
}
function httpStatusFromError(error: unknown): number {
const match = String(error || "").match(/HTTP\s+(\d{3})/i);
return match ? Number(match[1]) : 0;
}
function isRetryable(error: unknown): boolean {
const status = httpStatusFromError(error);
if (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 shouldTryNextCandidate(error: unknown): boolean {
const status = httpStatusFromError(error);
if (status >= 400 && status <= 599) return true;
return isRetryable(error);
}
function isIntegrityError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("integrit") || text.includes("mismatch");
}
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
if (signal.aborted) throw new Error("aborted:update_shutdown");
return new Promise((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
signal.removeEventListener("abort", onAbort);
resolve();
}, Math.max(0, ms));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal.removeEventListener("abort", onAbort);
reject(new Error("aborted:update_shutdown"));
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function downloadFile(
url: string,
targetPath: string,
onProgress?: UpdateProgressCallback,
): Promise<void> {
const shutdown = activeAbortController?.signal;
if (shutdown?.aborted) throw new Error("aborted:update_shutdown");
logger.info(`Update-Download versucht: ${url}`);
const tc = timeoutController(CONNECT_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(url, {
headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "identity" },
redirect: "follow",
signal: combineSignals(tc.signal, shutdown),
});
} finally {
tc.clear();
}
if (!response.ok || !response.body) {
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
}
const clRaw = Number(response.headers.get("content-length") || NaN);
const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;
let downloadedBytes = 0;
let lastProgressAt = 0;
const reportProgress = (force: boolean): void => {
const now = Date.now();
if (!force && now - lastProgressAt < 160) return;
lastProgressAt = now;
const percent = totalBytes && totalBytes > 0
? Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)))
: null;
const message = totalBytes && percent !== null
? `Update wird heruntergeladen: ${percent}% (${humanSize(downloadedBytes)} / ${humanSize(totalBytes)})`
: `Update wird heruntergeladen (${humanSize(downloadedBytes)})`;
emitProgress(onProgress, { stage: "downloading", percent, downloadedBytes, totalBytes, message });
};
reportProgress(true);
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.tmp`;
const writeStream = fs.createWriteStream(tempPath);
const reader = response.body.getReader();
const idleMs = getBodyIdleTimeout();
let idleTimer: NodeJS.Timeout | null = null;
let idleTimedOut = false;
const clearIdle = (): void => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
};
const resetIdle = (): void => {
clearIdle();
if (idleMs > 0) {
idleTimer = setTimeout(() => {
idleTimedOut = true;
reader.cancel().catch(() => undefined);
}, idleMs);
}
};
try {
resetIdle();
for (;;) {
if (shutdown?.aborted) {
await reader.cancel().catch(() => undefined);
throw new Error("aborted:update_shutdown");
}
const { done, value } = await reader.read();
if (done) break;
const buf = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
if (!writeStream.write(buf)) {
await new Promise<void>((resolve) => writeStream.once("drain", resolve));
}
downloadedBytes += buf.byteLength;
resetIdle();
reportProgress(false);
}
} catch (error) {
writeStream.destroy();
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
if (idleTimedOut) {
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
}
throw error;
} finally {
clearIdle();
}
await new Promise<void>((resolve, reject) => {
writeStream.end(() => resolve());
writeStream.on("error", reject);
});
if (idleTimedOut) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
}
if (totalBytes && downloadedBytes !== totalBytes) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
}
await fs.promises.rename(tempPath, targetPath);
reportProgress(true);
logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
}
async function downloadWithRetries(
url: string,
targetPath: string,
onProgress?: UpdateProgressCallback,
): Promise<void> {
const shutdown = activeAbortController?.signal;
let lastError: unknown;
for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) {
if (shutdown?.aborted) throw new Error("aborted:update_shutdown");
try {
await downloadFile(url, targetPath, onProgress);
return;
} catch (error) {
lastError = error;
await fs.promises.rm(targetPath, { force: true }).catch(() => {});
if (attempt < RETRIES_PER_CANDIDATE && isRetryable(error)) {
logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`);
await sleep(RETRY_DELAY_MS * attempt, shutdown);
continue;
}
break;
}
}
throw lastError;
}
async function downloadFromCandidates(
candidates: string[],
targetPath: string,
onProgress?: UpdateProgressCallback,
): Promise<void> {
const shutdown = activeAbortController?.signal;
let lastError: unknown = new Error("Update Download fehlgeschlagen");
logger.info(`Update-Download: ${candidates.length} Kandidat(en), je ${RETRIES_PER_CANDIDATE} Versuche`);
for (let i = 0; i < candidates.length; i += 1) {
if (shutdown?.aborted) throw new Error("aborted:update_shutdown");
emitProgress(onProgress, {
stage: "downloading",
percent: null,
downloadedBytes: 0,
totalBytes: null,
message: `Update-Download: Quelle ${i + 1}/${candidates.length}`,
});
try {
await downloadWithRetries(candidates[i], targetPath, onProgress);
return;
} catch (error) {
lastError = error;
logger.warn(`Update-Download Kandidat ${i + 1}/${candidates.length} endgültig fehlgeschlagen: ${compactErrorText(error)}`);
if (i < candidates.length - 1 && shouldTryNextCandidate(error)) continue;
break;
}
}
throw lastError;
}
async function resolveAssetFromApi(repo: string, tag: string): Promise<{
setupAssetUrl: string;
setupAssetName: string;
setupAssetDigest: string;
} | null> {
const endpoints = uniqueStrings([
tag ? `releases/tags/${encodeURIComponent(tag)}` : "",
"releases/latest",
]);
for (const ep of endpoints) {
try {
const { ok, payload } = await fetchRelease(repo, ep);
if (!ok || !payload || isDraftOrPrerelease(payload)) continue;
const setup = pickSetupAsset(readAssets(payload));
if (!setup) continue;
return {
setupAssetUrl: setup.browser_download_url,
setupAssetName: setup.name,
setupAssetDigest: setup.digest,
};
} catch {
}
}
return null;
}
async function resolveDigestFromYml(repo: string, tag: string, setupName: string): Promise<string> {
const endpoints = uniqueStrings([
tag ? `releases/tags/${encodeURIComponent(tag)}` : "",
"releases/latest",
]);
for (const ep of endpoints) {
try {
const { ok, payload } = await fetchRelease(repo, ep);
if (!ok || !payload || isDraftOrPrerelease(payload)) continue;
const yml = pickLatestYml(readAssets(payload));
if (!yml) continue;
const tc = timeoutController(RELEASE_FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(yml.browser_download_url, {
headers: { "User-Agent": USER_AGENT },
signal: tc.signal,
});
} finally {
tc.clear();
}
if (!response.ok) continue;
const yamlText = await readTextBody(response, RELEASE_FETCH_TIMEOUT_MS);
const sha = parseSha512FromLatestYml(yamlText, setupName);
if (sha) return `sha512:${sha}`;
} catch {
}
}
return "";
}
export function buildInstallerLaunchArgs(): string[] {
return ["/S", "--updated", "--force-run"];
}
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
const safeRepo = normalizeUpdateRepo(repo);
const fallbackUrl = `${WEB_BASE}/${safeRepo}/releases/latest`;
const fallback: UpdateCheckResult = {
updateAvailable: false,
currentVersion: APP_VERSION,
latestVersion: APP_VERSION,
latestTag: `v${APP_VERSION}`,
releaseUrl: fallbackUrl,
};
try {
const { ok, status, payload } = await fetchRelease(safeRepo, "releases/latest");
if (!ok || !payload) {
const reason = String((payload?.message as string) || `HTTP ${status}`);
return { ...fallback, error: reason };
}
return parseReleasePayload(payload, fallbackUrl);
} catch (error) {
return { ...fallback, error: compactErrorText(error) };
}
}
export async function installLatestUpdate(
repo: string,
prechecked?: UpdateCheckResult,
onProgress?: UpdateProgressCallback,
): Promise<UpdateInstallResult> {
if (activeAbortController && !activeAbortController.signal.aborted) {
emitProgress(onProgress, {
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
message: "Update-Download läuft bereits",
});
return { started: false, message: "Update-Download läuft bereits" };
}
const abortCtrl = new AbortController();
activeAbortController = abortCtrl;
const safeRepo = normalizeUpdateRepo(repo);
const check = prechecked && !prechecked.error
? prechecked
: await checkGitHubUpdate(safeRepo);
if (check.error) {
activeAbortController = null;
emitProgress(onProgress, {
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
message: check.error,
});
return { started: false, message: check.error };
}
if (!check.updateAvailable) {
activeAbortController = null;
emitProgress(onProgress, {
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
message: "Kein neues Update verfügbar",
});
return { started: false, message: "Kein neues Update verfügbar" };
}
let effective: UpdateCheckResult = {
...check,
setupAssetUrl: String(check.setupAssetUrl || ""),
setupAssetName: String(check.setupAssetName || ""),
setupAssetDigest: String(check.setupAssetDigest || ""),
};
if (!effective.setupAssetUrl || !effective.setupAssetDigest) {
const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
if (refreshed) {
effective = {
...effective,
setupAssetUrl: refreshed.setupAssetUrl,
setupAssetName: refreshed.setupAssetName,
setupAssetDigest: refreshed.setupAssetDigest,
};
}
}
if (!effective.setupAssetDigest && effective.setupAssetUrl) {
const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
if (digest) {
effective = { ...effective, setupAssetDigest: digest };
logger.info("Update-Integritätsdigest aus latest.yml übernommen");
}
}
let candidates = buildCandidates(safeRepo, effective);
if (candidates.length === 0) {
activeAbortController = null;
emitProgress(onProgress, {
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
message: "Setup-Asset nicht gefunden",
});
return { started: false, message: "Setup-Asset nicht gefunden" };
}
const fileName = deriveFileName(effective, candidates[0]);
const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${process.pid}-${crypto.randomUUID()}-${fileName}`);
try {
emitProgress(onProgress, {
stage: "starting", percent: 0, downloadedBytes: 0, totalBytes: null,
message: "Update wird vorbereitet",
});
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
let verified = false;
let lastVerifyError: unknown = null;
let integrityError: unknown = null;
for (let pass = 0; pass < MAX_DOWNLOAD_PASSES && !verified; pass += 1) {
logger.info(`Update-Download Kandidaten (Pass ${pass + 1}/${MAX_DOWNLOAD_PASSES}): ${candidates.join(" | ")}`);
lastVerifyError = null;
for (let i = 0; i < candidates.length; i += 1) {
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
try {
await downloadWithRetries(candidates[i], targetPath, onProgress);
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
emitProgress(onProgress, {
stage: "verifying", percent: 100, downloadedBytes: 0, totalBytes: null,
message: `Prüfe Installer-Integrität (${i + 1}/${candidates.length})`,
});
await verifyDownloadedInstaller(targetPath, String(effective.setupAssetDigest || ""));
verified = true;
break;
} catch (error) {
lastVerifyError = error;
if (!integrityError && isIntegrityError(error)) {
integrityError = error;
}
await fs.promises.rm(targetPath, { force: true }).catch(() => {});
if (i < candidates.length - 1) {
logger.warn(`Update-Kandidat ${i + 1}/${candidates.length} verworfen: ${compactErrorText(error)}`);
}
}
}
if (verified) break;
const status = httpStatusFromError(lastVerifyError);
const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null);
if (!shouldRefresh) break;
logger.warn(`Pass ${pass + 1} fehlgeschlagen (${integrityError ? "Integritätsfehler" : "HTTP 404"}), aktualisiere Kandidaten...`);
const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
if (refreshed) {
effective = {
...effective,
setupAssetUrl: refreshed.setupAssetUrl || effective.setupAssetUrl,
setupAssetName: refreshed.setupAssetName || effective.setupAssetName,
setupAssetDigest: refreshed.setupAssetDigest || effective.setupAssetDigest,
};
}
const ymlDigest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
if (ymlDigest) {
effective = { ...effective, setupAssetDigest: ymlDigest };
logger.info("Update-Integritätsdigest aus latest.yml aktualisiert");
}
const refreshedCandidates = buildCandidates(safeRepo, effective);
if (refreshedCandidates.length > 0) {
const changed = refreshedCandidates.length !== candidates.length
|| refreshedCandidates.some((v, idx) => v !== candidates[idx]);
if (changed) {
candidates = refreshedCandidates;
logger.info("Kandidatenliste aktualisiert");
}
}
if (integrityError) {
integrityError = null;
logger.warn("SHA-Mismatch erkannt, erneuter Download-Versuch");
}
}
if (!verified) {
throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
}
emitProgress(onProgress, {
stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null,
message: "Starte stille Update-Installation",
});
const child = childProcess.spawn(targetPath, buildInstallerLaunchArgs(), {
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.once("error", (spawnError) => {
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
});
child.unref();
emitProgress(onProgress, {
stage: "done", percent: 100, downloadedBytes: 0, totalBytes: null,
message: "Update wird im Hintergrund installiert und danach neu gestartet",
});
return { started: true, message: "Stille Update-Installation gestartet" };
} catch (error) {
try {
await fs.promises.rm(targetPath, { force: true });
} catch {
}
const releaseUrl = String(effective.releaseUrl || "").trim();
const hint = releaseUrl ? ` Manuell: ${releaseUrl}` : "";
const message = `${compactErrorText(error)}${hint}`;
emitProgress(onProgress, {
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
message,
});
return { started: false, message };
} finally {
if (activeAbortController === abortCtrl) {
activeAbortController = null;
}
}
}
export function abortActiveUpdateDownload(): void {
if (!activeAbortController || activeAbortController.signal.aborted) return;
activeAbortController.abort("shutdown");
}