326 lines
9.8 KiB
JavaScript
326 lines
9.8 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) {
|
|
if (!Array.isArray(assets)) return null;
|
|
// Prefer asset with "setup" in the name (case-insensitive)
|
|
const setup = assets.find(a =>
|
|
/setup/i.test(a.name) && /\.exe$/i.test(a.name)
|
|
);
|
|
if (setup) return setup;
|
|
// Fallback: any .exe
|
|
return assets.find(a => /\.exe$/i.test(a.name)) || null;
|
|
}
|
|
|
|
function findLatestYml(assets) {
|
|
if (!Array.isArray(assets)) return null;
|
|
return assets.find(a => /^latest\.yml$/i.test(a.name)) || null;
|
|
}
|
|
|
|
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-Upload'
|
|
}
|
|
});
|
|
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);
|
|
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, fetchImpl = fetch) {
|
|
if (!url) return null;
|
|
try {
|
|
const res = await fetchImpl(url, { redirect: 'follow' });
|
|
const text = await res.text();
|
|
// Extract sha512 from latest.yml
|
|
const match = text.match(/sha512:\s*([A-Za-z0-9+/=]+)/);
|
|
return match ? match[1] : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
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)');
|
|
}
|
|
|
|
// 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);
|
|
|
|
// Stage: verifying
|
|
if (onProgress) onProgress({ stage: 'verifying', percent: 0 });
|
|
|
|
if (!verifyExeHeader(fileBuffer)) {
|
|
throw new Error('Heruntergeladene Datei ist keine gültige EXE');
|
|
}
|
|
|
|
// Optional SHA-512 verification from latest.yml
|
|
const expectedSha = await parseLatestYml(check.latestYmlUrl, fetchImpl);
|
|
if (expectedSha) {
|
|
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
|
|
if (actualSha !== expectedSha) {
|
|
// Try hex comparison
|
|
const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex');
|
|
if (actualHex !== expectedSha.toLowerCase()) {
|
|
throw new Error('SHA-512 Prüfung fehlgeschlagen');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write to disk
|
|
fs.writeFileSync(installerPath, fileBuffer);
|
|
|
|
const prepared = {
|
|
installerPath,
|
|
assetName: check.assetName,
|
|
remoteVersion: check.remoteVersion || '',
|
|
transportTag: check.transportTag || ''
|
|
};
|
|
if (onProgress) onProgress({ stage: 'prepared', percent: 100 });
|
|
return prepared;
|
|
|
|
} catch (err) {
|
|
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 };
|