Files
MrTerm/src/main/updater.js
T

198 lines
9.3 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');
// 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 };