1123 lines
39 KiB
TypeScript
1123 lines
39 KiB
TypeScript
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";
|
||
|
||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
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}`;
|
||
|
||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
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";
|
||
};
|
||
|
||
// ─── Update Sources ────────────────────────────────────────────────────────────
|
||
|
||
const UPDATE_SOURCES: UpdateSource[] = [
|
||
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
|
||
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
|
||
{ 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;
|
||
|
||
// ─── Module State ──────────────────────────────────────────────────────────────
|
||
|
||
let activeAbortController: AbortController | null = null;
|
||
|
||
// ─── Progress Helper ───────────────────────────────────────────────────────────
|
||
|
||
function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
|
||
if (!cb) return;
|
||
try {
|
||
cb(progress);
|
||
} catch {
|
||
// ignore renderer callback errors
|
||
}
|
||
}
|
||
|
||
// ─── Version Utilities ─────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
}
|
||
|
||
// ─── Repository Normalization ──────────────────────────────────────────────────
|
||
|
||
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|git\.24-music\.de)\//i, "")
|
||
.replace(/^(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "")
|
||
.replace(/^git@(?:codeberg\.org|github\.com|git\.24-music\.de):/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"
|
||
|| host === "git.24-music.de"
|
||
) {
|
||
const result = extractOwnerRepo(url.pathname);
|
||
if (result) return result;
|
||
}
|
||
} catch {
|
||
// not a URL, try as plain text
|
||
}
|
||
|
||
const result = extractOwnerRepo(raw);
|
||
return result || DEFAULT_UPDATE_REPO;
|
||
}
|
||
|
||
// ─── Network Utilities ─────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
}
|
||
|
||
// ─── Digest Parsing & Verification ─────────────────────────────────────────────
|
||
//
|
||
// SHA-256 = 32 bytes → hex: 64 chars, base64: 43-44 chars (+ up to 1 padding =)
|
||
// SHA-512 = 64 bytes → hex: 128 chars, base64: 86-88 chars (+ up to 2 padding =)
|
||
|
||
function normalizeBase64(raw: string): string {
|
||
return String(raw || "")
|
||
.trim()
|
||
.replace(/-/g, "+") // URL-safe → standard
|
||
.replace(/_/g, "/") // URL-safe → standard
|
||
.replace(/=+$/g, ""); // strip padding for consistent comparison
|
||
}
|
||
|
||
export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
||
const text = String(raw || "").trim();
|
||
if (!text) return null;
|
||
|
||
// ── Prefixed: sha256:<value> ──
|
||
|
||
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" };
|
||
}
|
||
|
||
// ── Prefixed: sha512:<value> ──
|
||
|
||
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" };
|
||
}
|
||
|
||
// ── Plain hex ──
|
||
|
||
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" };
|
||
}
|
||
|
||
// ── Plain base64 (SHA-512 first since it's longer → won't accidentally match SHA-256) ──
|
||
|
||
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);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ─── latest.yml Parsing ────────────────────────────────────────────────────────
|
||
|
||
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);
|
||
// Base64 SHA-512: 86-88 chars + optional padding
|
||
const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
||
if (b64) return b64[1];
|
||
// Hex SHA-512: exactly 128 hex chars
|
||
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);
|
||
|
||
// File entry URL (inside files: array)
|
||
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
|
||
if (fileUrlItem?.[1]) {
|
||
currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
|
||
continue;
|
||
}
|
||
|
||
// Top-level or non-array URL
|
||
const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
|
||
if (urlMatch?.[1]) {
|
||
currentFileUrl = stripYamlQuotes(urlMatch[1]);
|
||
continue;
|
||
}
|
||
|
||
// Top-level path
|
||
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
|
||
if (pathMatch?.[1]) {
|
||
topLevelPath = stripYamlQuotes(pathMatch[1]);
|
||
continue;
|
||
}
|
||
|
||
// SHA-512 value (handles quoted and unquoted)
|
||
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;
|
||
}
|
||
|
||
// Try matching via top-level path
|
||
if (target && topLevelPath && topLevelSha) {
|
||
if (normalizeNameForMatch(topLevelPath) === target) {
|
||
return topLevelSha;
|
||
}
|
||
}
|
||
|
||
return topLevelSha || firstFileSha || "";
|
||
}
|
||
|
||
// ─── Installer Verification ───────────────────────────────────────────────────
|
||
|
||
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`);
|
||
}
|
||
|
||
// ─── Release API ───────────────────────────────────────────────────────────────
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
// ─── Download Candidates ───────────────────────────────────────────────────────
|
||
|
||
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";
|
||
}
|
||
}
|
||
|
||
// ─── Error Classification ──────────────────────────────────────────────────────
|
||
|
||
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");
|
||
}
|
||
|
||
// ─── Sleep ─────────────────────────────────────────────────────────────────────
|
||
|
||
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 });
|
||
});
|
||
}
|
||
|
||
// ─── Download Engine ───────────────────────────────────────────────────────────
|
||
|
||
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}`);
|
||
|
||
// Connect with timeout
|
||
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})`);
|
||
}
|
||
|
||
// Parse content-length
|
||
const clRaw = Number(response.headers.get("content-length") || NaN);
|
||
const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;
|
||
|
||
// Progress tracking
|
||
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);
|
||
|
||
// Prepare filesystem
|
||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||
const tempPath = `${targetPath}.tmp`;
|
||
const writeStream = fs.createWriteStream(tempPath);
|
||
const reader = response.body.getReader();
|
||
|
||
// Idle timeout tracking
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Stream body to disk
|
||
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();
|
||
}
|
||
|
||
// Flush and close write stream
|
||
await new Promise<void>((resolve, reject) => {
|
||
writeStream.end(() => resolve());
|
||
writeStream.on("error", reject);
|
||
});
|
||
|
||
// Handle idle timeout on clean reader exit
|
||
if (idleTimedOut) {
|
||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
|
||
}
|
||
|
||
// Verify completeness
|
||
if (totalBytes && downloadedBytes !== totalBytes) {
|
||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
|
||
}
|
||
|
||
// Atomic rename temp → final
|
||
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;
|
||
}
|
||
|
||
// ─── Asset Resolution Helpers ──────────────────────────────────────────────────
|
||
|
||
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 {
|
||
// try next endpoint
|
||
}
|
||
}
|
||
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 {
|
||
// try next endpoint
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
// ─── Public API ────────────────────────────────────────────────────────────────
|
||
|
||
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> {
|
||
// Prevent concurrent updates
|
||
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);
|
||
|
||
// Resolve update check
|
||
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" };
|
||
}
|
||
|
||
// Mutable effective state for enrichment
|
||
let effective: UpdateCheckResult = {
|
||
...check,
|
||
setupAssetUrl: String(check.setupAssetUrl || ""),
|
||
setupAssetName: String(check.setupAssetName || ""),
|
||
setupAssetDigest: String(check.setupAssetDigest || ""),
|
||
};
|
||
|
||
// Enrich: resolve asset from API if needed
|
||
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,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Enrich: resolve digest from latest.yml if still missing
|
||
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");
|
||
}
|
||
}
|
||
|
||
// Build download candidates
|
||
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");
|
||
|
||
// ── Download + verify with retry passes ──
|
||
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;
|
||
|
||
// Refresh candidates on 404 or integrity mismatch
|
||
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");
|
||
}
|
||
|
||
// ── Launch installer ──
|
||
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 {
|
||
// ignore
|
||
}
|
||
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");
|
||
}
|