Allow updater manifests from the Gitea and GitHub release pipelines to use different separator characters in the same installer filename while preserving exact version, size, checksum, and executable validation.
361 lines
12 KiB
JavaScript
361 lines
12 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { app } = require('electron');
|
|
|
|
const UPDATE_REPO = 'Administrator/Multi-Hoster-Upload';
|
|
const GITEA_BASE = 'https://git.24-music.de';
|
|
const API_URL = `${GITEA_BASE}/api/v1/repos/${UPDATE_REPO}/releases?limit=1`;
|
|
const GITHUB_RELEASE_URL = 'https://api.github.com/repos/Sucukdeluxe/Multi-Hoster-Upload/releases/tags';
|
|
|
|
const CHECK_TIMEOUT = 15000;
|
|
|
|
let cachedCheck = null;
|
|
let cachedCheckTs = 0;
|
|
const CACHE_TTL = 10 * 60 * 1000; // 10 min
|
|
|
|
let activeAbort = null;
|
|
const launchedInstallerPaths = new Set();
|
|
|
|
function getCurrentVersion() {
|
|
return app.getVersion();
|
|
}
|
|
|
|
function parseVersion(str) {
|
|
const clean = String(str || '').replace(/^v/i, '').trim();
|
|
const parts = clean.split('.').map(Number);
|
|
return {
|
|
major: parts[0] || 0,
|
|
minor: parts[1] || 0,
|
|
patch: parts[2] || 0
|
|
};
|
|
}
|
|
|
|
function isNewer(remote, current) {
|
|
const r = parseVersion(remote);
|
|
const c = parseVersion(current);
|
|
if (r.major !== c.major) return r.major > c.major;
|
|
if (r.minor !== c.minor) return r.minor > c.minor;
|
|
return r.patch > c.patch;
|
|
}
|
|
|
|
function resolveReleaseVersion(release) {
|
|
for (const value of [release && release.name, release && release.tag_name]) {
|
|
const match = String(value || '').match(/(?:^|[^\d])v?(\d+\.\d+\.\d+)(?=$|[^\d.])/i);
|
|
if (match) return match[1];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function pickSetupAsset(assets, remoteVersion = '') {
|
|
if (!Array.isArray(assets)) return null;
|
|
const candidates = assets.filter(asset => {
|
|
const name = String(asset?.name || '');
|
|
return /setup/i.test(name) && /\.exe$/i.test(name) && (!remoteVersion || name.includes(remoteVersion));
|
|
});
|
|
return candidates.length === 1 ? candidates[0] : null;
|
|
}
|
|
|
|
function findLatestYml(assets) {
|
|
if (!Array.isArray(assets)) return null;
|
|
return assets.find(a => /^latest\.yml$/i.test(a.name)) || null;
|
|
}
|
|
|
|
function normalizeInstallerName(value) {
|
|
return path.basename(String(value || '')).toLowerCase().replace(/[ ._]+/g, '');
|
|
}
|
|
|
|
async function fetchJson(url, signal) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
|
|
const onAbort = () => controller.abort();
|
|
if (signal) signal.addEventListener('abort', onAbort);
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
signal: controller.signal,
|
|
redirect: 'follow'
|
|
});
|
|
const text = await res.text();
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw new Error(`Update-Server Antwort war kein JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
|
}
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
}
|
|
}
|
|
|
|
async function fetchGithubReleaseNotes(remoteVersion, fallback = '', fetchImpl = fetch) {
|
|
const version = String(remoteVersion || '').replace(/^v/i, '').trim();
|
|
if (!/^\d+\.\d+\.\d+$/.test(version)) return String(fallback || '');
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
|
|
try {
|
|
const response = await fetchImpl(`${GITHUB_RELEASE_URL}/v${version}`, {
|
|
method: 'GET',
|
|
redirect: 'follow',
|
|
signal: controller.signal,
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'User-Agent': 'Multi-Hoster-Uploader'
|
|
}
|
|
});
|
|
if (!response.ok) return String(fallback || '');
|
|
const release = await response.json();
|
|
const notes = typeof release?.body === 'string' ? release.body.trim() : '';
|
|
return notes || String(fallback || '');
|
|
} catch {
|
|
return String(fallback || '');
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function checkForUpdate() {
|
|
// Return cached result if fresh
|
|
if (cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) {
|
|
return cachedCheck;
|
|
}
|
|
|
|
const releases = await fetchJson(API_URL);
|
|
|
|
if (!Array.isArray(releases) || releases.length === 0) {
|
|
return { available: false };
|
|
}
|
|
|
|
const release = releases[0];
|
|
const remoteVersion = resolveReleaseVersion(release);
|
|
const transportTag = release.tag_name || '';
|
|
const currentVersion = getCurrentVersion();
|
|
|
|
if (!isNewer(remoteVersion, currentVersion)) {
|
|
cachedCheck = { available: false, currentVersion, remoteVersion, transportTag };
|
|
cachedCheckTs = Date.now();
|
|
return cachedCheck;
|
|
}
|
|
|
|
const setupAsset = pickSetupAsset(release.assets, remoteVersion);
|
|
const latestYml = findLatestYml(release.assets);
|
|
|
|
if (!setupAsset) {
|
|
return { available: false, reason: 'Kein Setup-Asset im Release gefunden' };
|
|
}
|
|
|
|
const releaseNotes = await fetchGithubReleaseNotes(remoteVersion, release.body || '');
|
|
cachedCheck = {
|
|
available: true,
|
|
currentVersion,
|
|
remoteVersion,
|
|
transportTag,
|
|
releaseUrl: release.html_url,
|
|
assetUrl: setupAsset.browser_download_url,
|
|
assetSize: setupAsset.size,
|
|
assetName: setupAsset.name,
|
|
latestYmlUrl: latestYml ? latestYml.browser_download_url : null,
|
|
releaseNotes
|
|
};
|
|
cachedCheckTs = Date.now();
|
|
return cachedCheck;
|
|
}
|
|
|
|
async function parseLatestYml(url, expected = {}, fetchImpl = fetch) {
|
|
if (!url) throw new Error('Prüfsummen-Metadaten fehlen');
|
|
try {
|
|
const res = await fetchImpl(url, { redirect: 'follow' });
|
|
if (!res.ok) throw new Error(`Prüfsummen-Metadaten konnten nicht geladen werden: HTTP ${res.status}`);
|
|
const text = await res.text();
|
|
const version = text.match(/^version:\s*([^\r\n]+)$/m)?.[1]?.trim() || '';
|
|
const assetPath = text.match(/^path:\s*([^\r\n]+)$/m)?.[1]?.trim() || '';
|
|
const sha512 = text.match(/^sha512:\s*([A-Za-z0-9+/=]+)$/m)?.[1] || '';
|
|
const sizeText = text.match(/^\s*size:\s*(\d+)\s*$/m)?.[1] || '';
|
|
const size = Number(sizeText);
|
|
if (!version || !assetPath || !sha512 || !Number.isSafeInteger(size) || size <= 0) {
|
|
throw new Error('Prüfsummen-Metadaten sind unvollständig');
|
|
}
|
|
const decodedSha = Buffer.from(sha512, 'base64');
|
|
if (decodedSha.length !== 64 || decodedSha.toString('base64') !== sha512) {
|
|
throw new Error('Prüfsummen-Metadaten enthalten keine gültige SHA-512-Prüfsumme');
|
|
}
|
|
if (expected.version && version !== expected.version) {
|
|
throw new Error('Prüfsummen-Metadaten gehören zu einer anderen Version');
|
|
}
|
|
if (expected.assetName && normalizeInstallerName(assetPath) !== normalizeInstallerName(expected.assetName)) {
|
|
throw new Error('Prüfsummen-Metadaten gehören nicht zum ausgewählten Installer');
|
|
}
|
|
if (expected.assetSize && size !== Number(expected.assetSize)) {
|
|
throw new Error('Prüfsummen-Metadaten enthalten eine abweichende Dateigröße');
|
|
}
|
|
return { version, path: assetPath, size, sha512 };
|
|
} catch (error) {
|
|
if (error && /Prüfsummen-Metadaten/.test(error.message)) throw error;
|
|
throw new Error(`Prüfsummen-Metadaten konnten nicht geladen werden: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function verifyExeHeader(buf) {
|
|
// Check MZ header
|
|
if (buf.length < 128 * 1024) return false;
|
|
return buf[0] === 0x4D && buf[1] === 0x5A; // 'MZ'
|
|
}
|
|
|
|
async function prepareUpdate(onProgress, options = {}) {
|
|
if (activeAbort) activeAbort.abort();
|
|
activeAbort = new AbortController();
|
|
const signal = activeAbort.signal;
|
|
const fetchImpl = options.fetchImpl || fetch;
|
|
let stagedInstallerPath = '';
|
|
|
|
try {
|
|
// Stage: starting
|
|
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
|
|
|
|
// Check or use cached
|
|
let check = options.checkResult || cachedCheck;
|
|
if (!check || !check.available) {
|
|
check = await checkForUpdate();
|
|
}
|
|
if (!check || !check.available) {
|
|
throw new Error('Kein Update verfügbar');
|
|
}
|
|
if (!check.assetUrl || !check.assetName) {
|
|
throw new Error('Update-Asset unvollständig (URL oder Name fehlt)');
|
|
}
|
|
if (!check.latestYmlUrl) {
|
|
throw new Error('Prüfsummen-Metadaten fehlen');
|
|
}
|
|
const manifest = await parseLatestYml(check.latestYmlUrl, {
|
|
version: check.remoteVersion,
|
|
assetName: check.assetName,
|
|
assetSize: check.assetSize
|
|
}, fetchImpl);
|
|
const expectedSha = manifest.sha512;
|
|
|
|
// Stage: downloading
|
|
const tmpDir = options.tempDir || app.getPath('temp');
|
|
const installerPath = path.join(tmpDir, check.assetName);
|
|
|
|
const res = await fetchImpl(check.assetUrl, {
|
|
method: 'GET',
|
|
signal,
|
|
redirect: 'follow'
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
|
|
}
|
|
|
|
const totalBytes = check.assetSize || 0;
|
|
let downloadedBytes = 0;
|
|
const chunks = [];
|
|
|
|
const DOWNLOAD_STALL_MS = 45000;
|
|
let stallTimer = null;
|
|
const reader = res.body.getReader();
|
|
while (true) {
|
|
if (signal.aborted) throw new Error('Abgebrochen');
|
|
let chunk;
|
|
try {
|
|
chunk = await Promise.race([
|
|
reader.read(),
|
|
new Promise((_, reject) => { stallTimer = setTimeout(() => reject(new Error('__STALL__')), DOWNLOAD_STALL_MS); })
|
|
]);
|
|
} catch (e) {
|
|
if (e && e.message === '__STALL__') {
|
|
try { activeAbort.abort(); } catch {}
|
|
throw new Error('Download hängt — seit 45 s keine Daten (Netzwerk/Server überlastet). Bitte laufende Uploads stoppen und erneut versuchen.');
|
|
}
|
|
throw e;
|
|
} finally {
|
|
if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; }
|
|
}
|
|
const { done, value } = chunk;
|
|
if (done) break;
|
|
chunks.push(value);
|
|
downloadedBytes += value.length;
|
|
if (onProgress) {
|
|
onProgress({
|
|
stage: 'downloading',
|
|
percent: totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 0,
|
|
bytesDownloaded: downloadedBytes,
|
|
bytesTotal: totalBytes
|
|
});
|
|
}
|
|
}
|
|
|
|
const fileBuffer = Buffer.concat(chunks);
|
|
if (fileBuffer.length !== manifest.size) {
|
|
throw new Error('Heruntergeladene Datei hat eine abweichende Größe');
|
|
}
|
|
|
|
// Stage: verifying
|
|
if (onProgress) onProgress({ stage: 'verifying', percent: 0 });
|
|
|
|
if (!verifyExeHeader(fileBuffer)) {
|
|
throw new Error('Heruntergeladene Datei ist keine gültige EXE');
|
|
}
|
|
|
|
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
|
|
if (actualSha !== expectedSha) {
|
|
const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex');
|
|
if (actualHex !== expectedSha.toLowerCase()) {
|
|
throw new Error('SHA-512 Prüfung fehlgeschlagen');
|
|
}
|
|
}
|
|
|
|
stagedInstallerPath = path.join(tmpDir, `${path.basename(check.assetName)}.${crypto.randomUUID()}.download`);
|
|
fs.writeFileSync(stagedInstallerPath, fileBuffer);
|
|
fs.rmSync(installerPath, { force: true });
|
|
fs.renameSync(stagedInstallerPath, installerPath);
|
|
stagedInstallerPath = '';
|
|
|
|
const prepared = {
|
|
installerPath,
|
|
assetName: check.assetName,
|
|
remoteVersion: check.remoteVersion || '',
|
|
transportTag: check.transportTag || ''
|
|
};
|
|
if (onProgress) onProgress({ stage: 'prepared', percent: 100 });
|
|
return prepared;
|
|
|
|
} catch (err) {
|
|
if (stagedInstallerPath) fs.rmSync(stagedInstallerPath, { force: true });
|
|
if (onProgress) onProgress({ stage: 'error', error: err.message });
|
|
throw err;
|
|
} finally {
|
|
activeAbort = null;
|
|
}
|
|
}
|
|
|
|
function launchPreparedUpdate(prepared, options = {}) {
|
|
const installerPath = prepared && typeof prepared.installerPath === 'string' ? prepared.installerPath : '';
|
|
if (!installerPath) throw new Error('Vorbereitetes Update ist unvollständig');
|
|
const key = path.resolve(installerPath).toLowerCase();
|
|
if (launchedInstallerPaths.has(key)) return false;
|
|
const spawnImpl = options.spawnImpl || require('child_process').spawn;
|
|
launchedInstallerPaths.add(key);
|
|
try {
|
|
spawnImpl(installerPath, ['/S', '--updated', '--force-run'], {
|
|
detached: true,
|
|
stdio: 'ignore'
|
|
}).unref();
|
|
return true;
|
|
} catch (error) {
|
|
launchedInstallerPaths.delete(key);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function abortUpdate() {
|
|
if (activeAbort) {
|
|
activeAbort.abort();
|
|
activeAbort = null;
|
|
}
|
|
}
|
|
|
|
module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml };
|