// Auto-Update über Gitea/Forgejo-Releases (z. B. https://git.mrblake.cc/MrBlake/MrTerm). // Erwartete Release-Assets: *Setup*.exe (NSIS), *portable*.exe, *.AppImage, *.pacman const { app } = require('electron'); const { spawn } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); const i18n = require('../i18n'); const DEFAULT_REPO = 'https://git.mrblake.cc/MrBlake/MrTerm'; function parseRepo(url) { const u = new URL((url || DEFAULT_REPO).replace(/\.git$/, '').replace(/\/+$/, '')); const [owner, repo] = u.pathname.split('/').filter(Boolean); if (!owner || !repo) throw new Error(i18n.t('Invalid repository URL: {url}', { url })); return { base: u.origin, owner, repo }; } function cmpVersion(a, b) { const pa = String(a).replace(/^v/i, '').split(/[.-]/).map((x) => parseInt(x, 10) || 0); const pb = String(b).replace(/^v/i, '').split(/[.-]/).map((x) => parseInt(x, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); } return 0; } // Welche Paketart läuft gerade? function installKind() { if (!app.isPackaged) return 'dev'; if (process.platform === 'win32') return process.env.PORTABLE_EXECUTABLE_FILE ? 'portable' : 'nsis'; if (process.env.APPIMAGE) return 'appimage'; return 'pacman'; } function pickAsset(assets, kind) { const by = (fn) => assets.find((a) => fn(a.name.toLowerCase())); switch (kind) { case 'nsis': return by((n) => n.endsWith('.exe') && n.includes('setup')) || by((n) => n.endsWith('.exe') && !n.includes('portable')); case 'portable': return by((n) => n.endsWith('.exe') && n.includes('portable')) || by((n) => n.endsWith('.exe')); case 'appimage': return by((n) => n.endsWith('.appimage')); case 'pacman': return by((n) => n.endsWith('.pacman') || n.endsWith('.pkg.tar.zst')) || by((n) => n.endsWith('.appimage')); default: return process.platform === 'win32' ? by((n) => n.endsWith('.exe')) : by((n) => n.endsWith('.appimage')); } } class Updater { constructor(store, send) { this.store = store; this.send = send; this.latest = null; this.file = null; } headers() { const t = this.store.get().settings.updateToken; return { Accept: 'application/json', ...(t ? { Authorization: `token ${t}` } : {}) }; } async check() { const { base, owner, repo } = parseRepo(this.store.get().settings.updateUrl); const res = await fetch(`${base}/api/v1/repos/${owner}/${repo}/releases?limit=20`, { headers: this.headers() }); if (res.status === 404) throw new Error(i18n.t('Repository not found (private? Then add an access token in the settings).')); if (!res.ok) throw new Error(i18n.t('Update server responded with {status}', { status: res.status })); const allowPre = this.store.get().settings.updatePrerelease; const rel = (await res.json()).find((r) => !r.draft && (allowPre || !r.prerelease)); const current = app.getVersion(); // Version im Quellcode (Standard-Branch) – zeigt an, wenn Code neuer ist als das letzte Release let repoVersion = null; try { const pj = await fetch(`${base}/api/v1/repos/${owner}/${repo}/raw/package.json`, { headers: this.headers() }); if (pj.ok) repoVersion = (await pj.json()).version || null; } catch { /* optional */ } const newerInRepo = repoVersion && cmpVersion(repoVersion, current) > 0 && (!rel || cmpVersion(repoVersion, rel.tag_name) > 0) ? repoVersion : null; if (!rel) return { current, available: false, noRelease: true, repoVersion: newerInRepo }; const kind = installKind(); const asset = pickAsset(rel.assets || [], kind); this.latest = { version: rel.tag_name.replace(/^v/i, ''), notes: rel.body || '', url: rel.html_url, asset, kind }; return { current, repoVersion: newerInRepo, available: cmpVersion(rel.tag_name, current) > 0, version: this.latest.version, notes: this.latest.notes, url: rel.html_url, asset: asset ? { name: asset.name, size: asset.size } : null, kind, }; } async download() { if (!this.latest) await this.check(); const { asset } = this.latest; if (!asset) throw new Error(i18n.t('No matching package for this system found in the release.')); const dir = path.join(os.tmpdir(), 'mrterm-update'); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, asset.name); const res = await fetch(asset.browser_download_url, { headers: { ...this.headers(), Accept: 'application/octet-stream' } }); if (!res.ok) throw new Error(i18n.t('Download failed ({status})', { status: res.status })); const total = Number(res.headers.get('content-length')) || asset.size || 0; const out = fs.createWriteStream(file + '.part'); let done = 0, last = 0; for await (const chunk of res.body) { out.write(chunk); done += chunk.length; if (Date.now() - last > 150) { last = Date.now(); this.send('update:progress', done, total); } } await new Promise((r) => out.end(r)); fs.renameSync(file + '.part', file); this.send('update:progress', total, total); this.file = file; return file; } async install() { if (!this.file) await this.download(); const file = this.file; const kind = this.latest.kind; if (kind === 'nsis') { // Stilles Update, danach App automatisch neu starten spawn(file, ['/S', '--force-run'], { detached: true, stdio: 'ignore' }).unref(); setTimeout(() => app.quit(), 300); } else if (kind === 'portable') { const target = path.join(path.dirname(process.env.PORTABLE_EXECUTABLE_FILE), path.basename(file)); fs.copyFileSync(file, target); spawn(target, [], { detached: true, stdio: 'ignore' }).unref(); setTimeout(() => app.quit(), 300); } else if (kind === 'appimage') { const target = process.env.APPIMAGE; const tmp = target + '.new'; fs.copyFileSync(file, tmp); fs.chmodSync(tmp, 0o755); fs.renameSync(tmp, target); app.relaunch({ execPath: target }); app.exit(0); } else if (kind === 'pacman' && /\.(pacman|pkg\.tar\.zst)$/.test(file)) { await new Promise((resolve, reject) => { const p = spawn('pkexec', ['pacman', '-U', '--noconfirm', file], { stdio: 'ignore' }); p.on('error', reject); p.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(i18n.t('pacman exited with code {code}', { code: c }))))); }); app.relaunch(); app.exit(0); } else { throw new Error(i18n.t('Automatic installation is not possible here. The file is located at: {file}', { file })); } } } module.exports = { Updater, DEFAULT_REPO };