771 lines
24 KiB
TypeScript
771 lines
24 KiB
TypeScript
import fs from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import crypto from "node:crypto";
|
||
import { spawn } from "node:child_process";
|
||
import { Readable } from "node:stream";
|
||
import { pipeline } from "node:stream/promises";
|
||
import { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||
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 = 12000;
|
||
const CONNECT_TIMEOUT_MS = 30000;
|
||
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45000;
|
||
const RETRIES_PER_CANDIDATE = 3;
|
||
const RETRY_DELAY_MS = 1500;
|
||
const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||
const UPDATE_WEB_BASE = "https://codeberg.org";
|
||
const UPDATE_API_BASE = "https://codeberg.org/api/v1";
|
||
|
||
let activeUpdateAbortController: AbortController | null = null;
|
||
|
||
type ReleaseAsset = {
|
||
name: string;
|
||
browser_download_url: string;
|
||
digest: string;
|
||
};
|
||
|
||
type UpdateProgressCallback = (progress: UpdateInstallProgress) => void;
|
||
|
||
function safeEmitProgress(onProgress: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
|
||
if (!onProgress) {
|
||
return;
|
||
}
|
||
try {
|
||
onProgress(progress);
|
||
} catch {
|
||
// ignore renderer callback errors
|
||
}
|
||
}
|
||
|
||
export function normalizeUpdateRepo(repo: string): string {
|
||
const raw = String(repo || "").trim();
|
||
if (!raw) {
|
||
return DEFAULT_UPDATE_REPO;
|
||
}
|
||
|
||
const isValidRepoPart = (value: string): boolean => {
|
||
const part = String(value || "").trim();
|
||
if (!part || part === "." || part === "..") {
|
||
return false;
|
||
}
|
||
if (part.includes("..")) {
|
||
return false;
|
||
}
|
||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part);
|
||
};
|
||
|
||
const normalizeParts = (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) {
|
||
const owner = parts[0];
|
||
const repository = parts[1];
|
||
if (isValidRepoPart(owner) && isValidRepoPart(repository)) {
|
||
return `${owner}/${repository}`;
|
||
}
|
||
}
|
||
return "";
|
||
};
|
||
|
||
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 normalized = normalizeParts(url.pathname);
|
||
if (normalized) {
|
||
return normalized;
|
||
}
|
||
}
|
||
} catch {
|
||
// plain owner/repo value
|
||
}
|
||
|
||
const normalized = normalizeParts(raw);
|
||
return normalized || DEFAULT_UPDATE_REPO;
|
||
}
|
||
|
||
function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => {
|
||
controller.abort(new Error(`timeout:${ms}`));
|
||
}, ms);
|
||
return {
|
||
signal: controller.signal,
|
||
clear: () => clearTimeout(timer)
|
||
};
|
||
}
|
||
|
||
function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSignal {
|
||
if (!secondary) {
|
||
return primary;
|
||
}
|
||
return AbortSignal.any([primary, secondary]);
|
||
}
|
||
|
||
async function readJsonWithTimeout(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 payload = await Promise.race([
|
||
response.json().catch(() => null) as Promise<unknown>,
|
||
timeoutPromise
|
||
]);
|
||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||
return null;
|
||
}
|
||
return payload as Record<string, unknown>;
|
||
} finally {
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
}
|
||
|
||
function getDownloadBodyIdleTimeoutMs(): number {
|
||
const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
|
||
if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) {
|
||
return Math.floor(fromEnv);
|
||
}
|
||
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
|
||
}
|
||
|
||
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 maxLen = Math.max(current.length, latest.length);
|
||
for (let i = 0; i < maxLen; 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 createFallbackResult(repo: string): UpdateCheckResult {
|
||
const safeRepo = normalizeUpdateRepo(repo);
|
||
return {
|
||
updateAvailable: false,
|
||
currentVersion: APP_VERSION,
|
||
latestVersion: APP_VERSION,
|
||
latestTag: `v${APP_VERSION}`,
|
||
releaseUrl: `${UPDATE_WEB_BASE}/${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 || ""),
|
||
digest: String(asset.digest || "").trim()
|
||
}))
|
||
.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 || "",
|
||
setupAssetDigest: setup?.digest || ""
|
||
};
|
||
}
|
||
|
||
function isDraftOrPrereleaseRelease(payload: Record<string, unknown>): boolean {
|
||
return Boolean(payload.draft) || Boolean(payload.prerelease);
|
||
}
|
||
|
||
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(`${UPDATE_API_BASE}/repos/${safeRepo}/${endpoint}`, {
|
||
headers: {
|
||
Accept: "application/vnd.github+json",
|
||
"User-Agent": UPDATE_USER_AGENT
|
||
},
|
||
signal: timeout.signal
|
||
});
|
||
const payload = await readJsonWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS);
|
||
return {
|
||
ok: response.ok,
|
||
status: response.status,
|
||
payload
|
||
};
|
||
} finally {
|
||
timeout.clear();
|
||
}
|
||
}
|
||
|
||
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(`${UPDATE_WEB_BASE}/${safeRepo}/releases/latest/download/${encodedName}`);
|
||
if (latestTag) {
|
||
candidates.push(`${UPDATE_WEB_BASE}/${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 isRetryableDownloadError(error: unknown): boolean {
|
||
const status = readHttpStatusFromError(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 shouldTryNextDownloadCandidate(error: unknown): boolean {
|
||
const status = readHttpStatusFromError(error);
|
||
if (status >= 400 && status <= 599) {
|
||
return true;
|
||
}
|
||
return isRetryableDownloadError(error);
|
||
}
|
||
|
||
function deriveUpdateFileName(check: UpdateCheckResult, url: string): string {
|
||
const sanitizeUpdateAssetFileName = (rawName: string): string => {
|
||
const base = path.basename(String(rawName || "").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 sanitizeUpdateAssetFileName(fromName);
|
||
}
|
||
try {
|
||
const parsed = new URL(url);
|
||
return sanitizeUpdateAssetFileName(parsed.pathname || "update.exe");
|
||
} catch {
|
||
return "update.exe";
|
||
}
|
||
}
|
||
|
||
function normalizeSha256Digest(raw: string): string {
|
||
const text = String(raw || "").trim();
|
||
const prefixed = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
||
if (prefixed) {
|
||
return prefixed[1].toLowerCase();
|
||
}
|
||
const plain = text.match(/^([a-fA-F0-9]{64})$/);
|
||
return plain ? plain[1].toLowerCase() : "";
|
||
}
|
||
|
||
async function sha256File(filePath: string): Promise<string> {
|
||
const hash = crypto.createHash("sha256");
|
||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||
return await 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", () => resolve(hash.digest("hex").toLowerCase()));
|
||
});
|
||
}
|
||
|
||
async function verifyDownloadedInstaller(targetPath: string, expectedDigestRaw: string): Promise<void> {
|
||
const expectedDigest = normalizeSha256Digest(expectedDigestRaw);
|
||
if (!expectedDigest) {
|
||
logger.warn("Update-Asset ohne SHA256-Digest aus API; Integritätsprüfung übersprungen");
|
||
return;
|
||
}
|
||
const actualDigest = await sha256File(targetPath);
|
||
if (actualDigest !== expectedDigest) {
|
||
throw new Error("Update-Integritätsprüfung fehlgeschlagen (SHA256 mismatch)");
|
||
}
|
||
}
|
||
|
||
async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Promise<{ setupAssetUrl: string; setupAssetName: string; setupAssetDigest: 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;
|
||
}
|
||
if (isDraftOrPrereleaseRelease(release.payload)) {
|
||
continue;
|
||
}
|
||
const setup = pickSetupAsset(readReleaseAssets(release.payload));
|
||
if (!setup) {
|
||
continue;
|
||
}
|
||
return {
|
||
setupAssetUrl: setup.browser_download_url,
|
||
setupAssetName: setup.name,
|
||
setupAssetDigest: setup.digest
|
||
};
|
||
} 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,
|
||
error: compactErrorText(error)
|
||
};
|
||
}
|
||
}
|
||
|
||
async function downloadFile(url: string, targetPath: string, onProgress?: UpdateProgressCallback): Promise<void> {
|
||
const shutdownSignal = activeUpdateAbortController?.signal;
|
||
if (shutdownSignal?.aborted) {
|
||
throw new Error("aborted:update_shutdown");
|
||
}
|
||
logger.info(`Update-Download versucht: ${url}`);
|
||
const timeout = timeoutController(CONNECT_TIMEOUT_MS);
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(url, {
|
||
headers: {
|
||
"User-Agent": UPDATE_USER_AGENT
|
||
},
|
||
redirect: "follow",
|
||
signal: combineSignals(timeout.signal, shutdownSignal)
|
||
});
|
||
} finally {
|
||
timeout.clear();
|
||
}
|
||
if (!response.ok || !response.body) {
|
||
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
|
||
}
|
||
|
||
const totalBytesRaw = Number(response.headers.get("content-length") || NaN);
|
||
const totalBytes = Number.isFinite(totalBytesRaw) && totalBytesRaw > 0
|
||
? Math.max(0, Math.floor(totalBytesRaw))
|
||
: null;
|
||
let downloadedBytes = 0;
|
||
let lastProgressAt = 0;
|
||
const emitDownloadProgress = (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)})`;
|
||
safeEmitProgress(onProgress, {
|
||
stage: "downloading",
|
||
percent,
|
||
downloadedBytes,
|
||
totalBytes,
|
||
message
|
||
});
|
||
};
|
||
emitDownloadProgress(true);
|
||
|
||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>);
|
||
const target = fs.createWriteStream(targetPath);
|
||
const idleTimeoutMs = getDownloadBodyIdleTimeoutMs();
|
||
let idleTimer: NodeJS.Timeout | null = null;
|
||
const clearIdleTimer = (): void => {
|
||
if (idleTimer) {
|
||
clearTimeout(idleTimer);
|
||
idleTimer = null;
|
||
}
|
||
};
|
||
const onIdleTimeout = (): void => {
|
||
const timeoutError = new Error(`Update Download Body Timeout nach ${Math.ceil(idleTimeoutMs / 1000)}s`);
|
||
source.destroy(timeoutError);
|
||
target.destroy(timeoutError);
|
||
};
|
||
const resetIdleTimer = (): void => {
|
||
if (idleTimeoutMs <= 0) {
|
||
return;
|
||
}
|
||
clearIdleTimer();
|
||
idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs);
|
||
};
|
||
|
||
const onSourceData = (chunk: string | Buffer): void => {
|
||
downloadedBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
|
||
resetIdleTimer();
|
||
emitDownloadProgress(false);
|
||
};
|
||
const onSourceDone = (): void => {
|
||
clearIdleTimer();
|
||
};
|
||
|
||
if (idleTimeoutMs > 0) {
|
||
source.on("data", onSourceData);
|
||
source.on("end", onSourceDone);
|
||
source.on("close", onSourceDone);
|
||
source.on("error", onSourceDone);
|
||
target.on("close", onSourceDone);
|
||
target.on("error", onSourceDone);
|
||
resetIdleTimer();
|
||
}
|
||
|
||
try {
|
||
await pipeline(source, target);
|
||
} catch (error) {
|
||
try {
|
||
source.destroy();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
try {
|
||
target.destroy();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearIdleTimer();
|
||
source.off("data", onSourceData);
|
||
source.off("end", onSourceDone);
|
||
source.off("close", onSourceDone);
|
||
source.off("error", onSourceDone);
|
||
target.off("close", onSourceDone);
|
||
target.off("error", onSourceDone);
|
||
}
|
||
emitDownloadProgress(true);
|
||
logger.info(`Update-Download abgeschlossen: ${targetPath}`);
|
||
}
|
||
|
||
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 downloadWithRetries(url: string, targetPath: string, onProgress?: UpdateProgressCallback): Promise<void> {
|
||
const shutdownSignal = activeUpdateAbortController?.signal;
|
||
let lastError: unknown;
|
||
for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) {
|
||
if (shutdownSignal?.aborted) {
|
||
throw new Error("aborted:update_shutdown");
|
||
}
|
||
try {
|
||
await downloadFile(url, targetPath, onProgress);
|
||
return;
|
||
} catch (error) {
|
||
lastError = error;
|
||
try {
|
||
await fs.promises.rm(targetPath, { force: true });
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (attempt < RETRIES_PER_CANDIDATE && isRetryableDownloadError(error)) {
|
||
logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`);
|
||
await sleep(RETRY_DELAY_MS * attempt, shutdownSignal);
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
throw lastError;
|
||
}
|
||
|
||
async function downloadFromCandidates(candidates: string[], targetPath: string, onProgress?: UpdateProgressCallback): Promise<void> {
|
||
const shutdownSignal = activeUpdateAbortController?.signal;
|
||
let lastError: unknown = new Error("Update Download fehlgeschlagen");
|
||
|
||
logger.info(`Update-Download: ${candidates.length} Kandidat(en), je ${RETRIES_PER_CANDIDATE} Versuche`);
|
||
for (let index = 0; index < candidates.length; index += 1) {
|
||
if (shutdownSignal?.aborted) {
|
||
throw new Error("aborted:update_shutdown");
|
||
}
|
||
const candidate = candidates[index];
|
||
safeEmitProgress(onProgress, {
|
||
stage: "downloading",
|
||
percent: null,
|
||
downloadedBytes: 0,
|
||
totalBytes: null,
|
||
message: `Update-Download: Quelle ${index + 1}/${candidates.length}`
|
||
});
|
||
try {
|
||
await downloadWithRetries(candidate, targetPath, onProgress);
|
||
return;
|
||
} catch (error) {
|
||
lastError = error;
|
||
logger.warn(`Update-Download Kandidat ${index + 1}/${candidates.length} endgültig fehlgeschlagen: ${compactErrorText(error)}`);
|
||
if (index < candidates.length - 1 && shouldTryNextDownloadCandidate(error)) {
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
throw lastError;
|
||
}
|
||
|
||
export async function installLatestUpdate(
|
||
repo: string,
|
||
prechecked?: UpdateCheckResult,
|
||
onProgress?: UpdateProgressCallback
|
||
): Promise<UpdateInstallResult> {
|
||
if (activeUpdateAbortController && !activeUpdateAbortController.signal.aborted) {
|
||
safeEmitProgress(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 updateAbortController = new AbortController();
|
||
activeUpdateAbortController = updateAbortController;
|
||
|
||
const safeRepo = normalizeUpdateRepo(repo);
|
||
const check = prechecked && !prechecked.error
|
||
? prechecked
|
||
: await checkGitHubUpdate(safeRepo);
|
||
|
||
if (check.error) {
|
||
safeEmitProgress(onProgress, {
|
||
stage: "error",
|
||
percent: null,
|
||
downloadedBytes: 0,
|
||
totalBytes: null,
|
||
message: check.error
|
||
});
|
||
return { started: false, message: check.error };
|
||
}
|
||
if (!check.updateAvailable) {
|
||
safeEmitProgress(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 effectiveCheck: UpdateCheckResult = {
|
||
...check,
|
||
setupAssetUrl: String(check.setupAssetUrl || ""),
|
||
setupAssetName: String(check.setupAssetName || ""),
|
||
setupAssetDigest: String(check.setupAssetDigest || "")
|
||
};
|
||
|
||
if (!effectiveCheck.setupAssetUrl || !effectiveCheck.setupAssetDigest) {
|
||
const refreshed = await resolveSetupAssetFromApi(safeRepo, effectiveCheck.latestTag);
|
||
if (refreshed) {
|
||
effectiveCheck = {
|
||
...effectiveCheck,
|
||
setupAssetUrl: refreshed.setupAssetUrl,
|
||
setupAssetName: refreshed.setupAssetName,
|
||
setupAssetDigest: refreshed.setupAssetDigest
|
||
};
|
||
}
|
||
}
|
||
|
||
const candidates = buildDownloadCandidates(safeRepo, effectiveCheck);
|
||
if (candidates.length === 0) {
|
||
return { started: false, message: "Setup-Asset nicht gefunden" };
|
||
}
|
||
|
||
const fileName = deriveUpdateFileName(effectiveCheck, candidates[0]);
|
||
const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${process.pid}-${crypto.randomUUID()}-${fileName}`);
|
||
|
||
try {
|
||
safeEmitProgress(onProgress, {
|
||
stage: "starting",
|
||
percent: 0,
|
||
downloadedBytes: 0,
|
||
totalBytes: null,
|
||
message: "Update wird vorbereitet"
|
||
});
|
||
if (updateAbortController.signal.aborted) {
|
||
throw new Error("aborted:update_shutdown");
|
||
}
|
||
await downloadFromCandidates(candidates, 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"
|
||
});
|
||
await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || ""));
|
||
safeEmitProgress(onProgress, {
|
||
stage: "launching",
|
||
percent: 100,
|
||
downloadedBytes: 0,
|
||
totalBytes: null,
|
||
message: "Starte Update-Installer"
|
||
});
|
||
const child = spawn(targetPath, [], {
|
||
detached: true,
|
||
stdio: "ignore"
|
||
});
|
||
child.once("error", (spawnError) => {
|
||
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
|
||
});
|
||
child.unref();
|
||
safeEmitProgress(onProgress, {
|
||
stage: "done",
|
||
percent: 100,
|
||
downloadedBytes: 0,
|
||
totalBytes: null,
|
||
message: "Update-Installer gestartet"
|
||
});
|
||
return { started: true, message: "Update-Installer gestartet" };
|
||
} catch (error) {
|
||
try {
|
||
await fs.promises.rm(targetPath, { force: true });
|
||
} catch {
|
||
// ignore
|
||
}
|
||
const releaseUrl = String(effectiveCheck.releaseUrl || "").trim();
|
||
const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : "";
|
||
const message = `${compactErrorText(error)}${hint}`;
|
||
safeEmitProgress(onProgress, {
|
||
stage: "error",
|
||
percent: null,
|
||
downloadedBytes: 0,
|
||
totalBytes: null,
|
||
message
|
||
});
|
||
return { started: false, message };
|
||
} finally {
|
||
if (activeUpdateAbortController === updateAbortController) {
|
||
activeUpdateAbortController = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function abortActiveUpdateDownload(): void {
|
||
if (!activeUpdateAbortController || activeUpdateAbortController.signal.aborted) {
|
||
return;
|
||
}
|
||
activeUpdateAbortController.abort("shutdown");
|
||
}
|