198 lines
9.3 KiB
JavaScript
198 lines
9.3 KiB
JavaScript
// 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');
|
||
|
||
// Startet ein Programm und liefert den Exit-Code ('ENOENT', falls es nicht existiert).
|
||
// Electron/Chromium setzt unter Linux PR_SET_NO_NEW_PRIVS, das alle Kindprozesse erben – dann
|
||
// funktionieren weder pkexec noch sudo. Deshalb über systemd-run --user starten: Elternprozess ist
|
||
// dann der systemd-User-Manager, ohne diesen Schalter.
|
||
const GUI_ENV = ['DISPLAY', 'WAYLAND_DISPLAY', 'XAUTHORITY', 'XDG_RUNTIME_DIR', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS'];
|
||
function spawnCode(cmd, args) {
|
||
return new Promise((resolve) => {
|
||
const p = spawn(cmd, args, { stdio: 'ignore' });
|
||
p.on('error', (e) => resolve(e.code === 'ENOENT' ? 'ENOENT' : e.message));
|
||
p.on('exit', (c) => resolve(c));
|
||
});
|
||
}
|
||
let hasSystemdRun;
|
||
async function run(cmd, args) {
|
||
if (hasSystemdRun === undefined) hasSystemdRun = (await spawnCode('systemd-run', ['--user', '--quiet', '--wait', '--collect', 'true'])) === 0;
|
||
if (!hasSystemdRun) return spawnCode(cmd, args);
|
||
const inPath = cmd.includes('/') ? fs.existsSync(cmd) : (process.env.PATH || '').split(':').some((d) => d && fs.existsSync(path.join(d, cmd)));
|
||
if (!inPath) return 'ENOENT';
|
||
const env = GUI_ENV.filter((k) => process.env[k]).flatMap((k) => ['-E', `${k}=${process.env[k]}`]);
|
||
return spawnCode('systemd-run', ['--user', '--quiet', '--wait', '--collect', ...env, '--', cmd, ...args]);
|
||
}
|
||
|
||
// Fallback ohne Polkit-Agent: pacman -U per sudo in einem Terminalfenster; wartet, bis das Fenster geschlossen ist.
|
||
// Der Erfolg wird über eine Marker-Datei erkannt, da nicht jedes Terminal den Exit-Code durchreicht.
|
||
async function installInTerminal(file) {
|
||
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||
const marker = file + '.ok';
|
||
try { fs.unlinkSync(marker); } catch {}
|
||
const script = `sudo /usr/bin/pacman -U --noconfirm ${q(file)} && touch ${q(marker)} || { echo; read -rp "[Enter]"; }`;
|
||
const terms = [
|
||
['konsole', ['--nofork', '-e', 'bash', '-c', script]],
|
||
['gnome-terminal', ['--wait', '--', 'bash', '-c', script]],
|
||
['kitty', ['bash', '-c', script]],
|
||
['alacritty', ['-e', 'bash', '-c', script]],
|
||
['foot', ['bash', '-c', script]],
|
||
['xfce4-terminal', ['--disable-server', '-x', 'bash', '-c', script]],
|
||
['xterm', ['-e', 'bash', '-c', script]],
|
||
];
|
||
for (const [cmd, args] of terms) {
|
||
const c = await run(cmd, args);
|
||
if (c === 'ENOENT') continue;
|
||
return fs.existsSync(marker) ? 0 : c || 1;
|
||
}
|
||
return 'ENOENT';
|
||
}
|
||
|
||
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)) {
|
||
// 1. pkexec (grafische Passwortabfrage über den Polkit-Agent)
|
||
let code = await run('pkexec', ['/usr/bin/pacman', '-U', '--noconfirm', file]);
|
||
// 126/127 = Abfrage abgebrochen bzw. kein Polkit-Agent → Terminal mit sudo öffnen
|
||
if (code === 126 || code === 127 || code === 'ENOENT') code = await installInTerminal(file);
|
||
if (code === 'ENOENT') throw new Error(i18n.t('Automatic installation is not possible here. The file is located at: {file}', { file }));
|
||
if (code !== 0) throw new Error(i18n.t('pacman exited with code {code}', { code }));
|
||
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 };
|