Files
MrTerm/src/main/updater.js
T

151 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };