From f4172f5c2a035f8b3e147916a0889ec67dc5da4d Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 10 Mar 2026 02:33:22 +0100 Subject: [PATCH] feat: add auto-updater module for Gitea releases --- lib/updater.js | 245 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 lib/updater.js diff --git a/lib/updater.js b/lib/updater.js new file mode 100644 index 0000000..c723be6 --- /dev/null +++ b/lib/updater.js @@ -0,0 +1,245 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { app } = require('electron'); +const { request } = require('undici'); + +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 CHECK_TIMEOUT = 15000; +const DOWNLOAD_TIMEOUT = 600000; // 10 min +const IDLE_TIMEOUT = 45000; + +let cachedCheck = null; +let cachedCheckTs = 0; +const CACHE_TTL = 10 * 60 * 1000; // 10 min + +let activeAbort = null; + +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 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); + if (signal) signal.addEventListener('abort', () => controller.abort()); + + try { + const res = await fetch(url, { + method: 'GET', + signal: controller.signal, + redirect: 'follow' + }); + return await res.json(); + } 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 = release.tag_name || release.name || ''; + const currentVersion = getCurrentVersion(); + + if (!isNewer(remoteVersion, currentVersion)) { + cachedCheck = { available: false, currentVersion, remoteVersion }; + 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' }; + } + + cachedCheck = { + available: true, + currentVersion, + remoteVersion: remoteVersion.replace(/^v/i, ''), + releaseUrl: release.html_url, + assetUrl: setupAsset.browser_download_url, + assetSize: setupAsset.size, + assetName: setupAsset.name, + latestYmlUrl: latestYml ? latestYml.browser_download_url : null, + releaseNotes: release.body || '' + }; + cachedCheckTs = Date.now(); + return cachedCheck; +} + +async function parseLatestYml(url) { + if (!url) return null; + try { + const res = await fetch(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 installUpdate(onProgress) { + if (activeAbort) activeAbort.abort(); + activeAbort = new AbortController(); + const signal = activeAbort.signal; + + try { + // Stage: starting + if (onProgress) onProgress({ stage: 'starting', percent: 0 }); + + // Check or use cached + let check = cachedCheck; + if (!check || !check.available) { + check = await checkForUpdate(); + } + if (!check || !check.available) { + throw new Error('Kein Update verfuegbar'); + } + + // Stage: downloading + const tmpDir = app.getPath('temp'); + const installerPath = path.join(tmpDir, check.assetName); + + const { body, statusCode } = await request(check.assetUrl, { + method: 'GET', + signal, + maxRedirections: 5, + headersTimeout: IDLE_TIMEOUT, + bodyTimeout: DOWNLOAD_TIMEOUT + }); + + if (statusCode < 200 || statusCode >= 300) { + throw new Error(`Download fehlgeschlagen: HTTP ${statusCode}`); + } + + const totalBytes = check.assetSize || 0; + let downloadedBytes = 0; + const chunks = []; + + for await (const chunk of body) { + if (signal.aborted) throw new Error('Abgebrochen'); + chunks.push(chunk); + downloadedBytes += chunk.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 gueltige EXE'); + } + + // Optional SHA-512 verification from latest.yml + const expectedSha = await parseLatestYml(check.latestYmlUrl); + 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 Pruefung fehlgeschlagen'); + } + } + } + + // Write to disk + fs.writeFileSync(installerPath, fileBuffer); + + // Stage: launching + if (onProgress) onProgress({ stage: 'launching', percent: 100 }); + + const { spawn } = require('child_process'); + spawn(installerPath, ['/S', '--updated', '--force-run'], { + detached: true, + stdio: 'ignore' + }).unref(); + + // Stage: done + if (onProgress) onProgress({ stage: 'done', percent: 100 }); + + setTimeout(() => app.quit(), 900); + + } catch (err) { + if (onProgress) onProgress({ stage: 'error', error: err.message }); + throw err; + } finally { + activeAbort = null; + } +} + +function abortUpdate() { + if (activeAbort) { + activeAbort.abort(); + activeAbort = null; + } +} + +module.exports = { checkForUpdate, installUpdate, abortUpdate };