Files
AeroMc-Launcher/src/backup.js
T
2026-08-14 08:17:05 +00:00

452 lines
16 KiB
JavaScript

'use strict';
const archiver = require('archiver');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { pipeline } = require('stream/promises');
const unzipper = require('unzipper');
function pad2(value) {
return String(value).padStart(2, '0');
}
function defaultBackupFileName(date = new Date()) {
return 'AeroMC-Backup-' +
date.getFullYear() + '-' +
pad2(date.getMonth() + 1) + '-' +
pad2(date.getDate()) + '_' +
pad2(date.getHours()) + '-' +
pad2(date.getMinutes()) + '-' +
pad2(date.getSeconds()) +
'.zip';
}
function defaultBackupDir(documentsDir) {
return path.join(documentsDir, 'AeroMC Launcher', 'Backups');
}
function resolveAutoBackupPath(documentsDir, date = new Date()) {
const dir = defaultBackupDir(documentsDir);
ensureDir(dir);
const baseName = defaultBackupFileName(date);
const ext = path.extname(baseName);
const stem = baseName.slice(0, -ext.length);
let attempt = 0;
let candidate = path.join(dir, baseName);
while (fs.existsSync(candidate)) {
attempt += 1;
candidate = path.join(dir, `${stem}-${attempt}${ext}`);
}
return candidate;
}
function listGlobalBackups(documentsDir) {
const dir = defaultBackupDir(documentsDir);
ensureDir(dir);
return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /\.zip$/i.test(entry.name) && !/\.partial\.zip$/i.test(entry.name))
.map((entry) => {
const filePath = path.join(dir, entry.name);
const stat = fs.statSync(filePath);
return {
name: entry.name,
path: filePath,
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => {
const timeDiff = new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
return timeDiff || a.name.localeCompare(b.name, 'de');
});
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function countInstanceDirs(instancesDir) {
if (!fs.existsSync(instancesDir)) return 0;
return fs.readdirSync(instancesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
}
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
} catch {
return fallback;
}
}
function writeJson(file, data) {
ensureDir(path.dirname(file));
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
}
function emitProgress(cb, mode, percent, detail) {
if (!cb) return;
cb({ mode, percent: Math.max(0, Math.min(100, Math.round(percent))), detail: detail || '' });
}
function normalizeZipEntryPath(entryPath) {
const parts = String(entryPath || '')
.replace(/\\/g, '/')
.split('/')
.filter(Boolean);
if (!parts.length) return '';
if (parts.some((part) => part === '.' || part === '..')) {
throw new Error('Das Backup-Archiv enthält ungültige Pfade.');
}
return path.join(...parts);
}
async function extractBackupArchive(sourcePath, destinationDir, options) {
const opts = options || {};
const onProgress = opts.onProgress;
const mode = opts.mode || 'restore';
const startPercent = opts.startPercent ?? 5;
const endPercent = opts.endPercent ?? 18;
const detail = opts.detail || 'Entpacke Backup';
const directory = await unzipper.Open.file(sourcePath);
const files = directory.files || [];
const fileEntries = files.filter((entry) => entry.type !== 'Directory');
const totalBytes = fileEntries.reduce((sum, entry) => sum + Number(entry.uncompressedSize || 0), 0);
let processedBytes = 0;
let processedEntries = 0;
ensureDir(destinationDir);
if (!files.length) {
emitProgress(onProgress, mode, endPercent, detail + ' abgeschlossen');
return;
}
for (const entry of files) {
const relativePath = normalizeZipEntryPath(entry.path);
if (!relativePath) continue;
const targetPath = path.join(destinationDir, relativePath);
if (entry.type === 'Directory') {
ensureDir(targetPath);
continue;
}
ensureDir(path.dirname(targetPath));
await pipeline(entry.stream(), fs.createWriteStream(targetPath));
processedBytes += Number(entry.uncompressedSize || 0);
processedEntries += 1;
const ratio = totalBytes > 0
? Math.max(0, Math.min(1, processedBytes / totalBytes))
: Math.max(0, Math.min(1, processedEntries / Math.max(1, fileEntries.length)));
const percent = startPercent + ((endPercent - startPercent) * ratio);
emitProgress(onProgress, mode, percent, detail + ' ...');
}
}
function createBackupArchive(options) {
const opts = options || {};
const instancesDir = opts.instancesDir;
const settingsFile = opts.settingsFile;
const includeSettings = opts.includeSettings !== false;
const backupInfo = opts.backupInfo || {};
const destinationPath = opts.destinationPath;
const onProgress = opts.onProgress;
const mode = opts.mode || 'create';
const startPercent = opts.startPercent ?? 15;
const endPercent = opts.endPercent ?? 98;
const detail = opts.detail || 'Packe ZIP-Archiv';
return new Promise((resolve, reject) => {
ensureDir(path.dirname(destinationPath));
const output = fs.createWriteStream(destinationPath);
const archive = archiver('zip', { store: true });
let done = false;
const fail = (err) => {
if (done) return;
done = true;
try { archive.destroy(); } catch { /* ignore */ }
try { output.destroy(); } catch { /* ignore */ }
reject(err);
};
output.on('close', () => {
if (done) return;
done = true;
resolve({ bytes: archive.pointer() });
});
output.on('error', fail);
archive.on('error', fail);
archive.on('warning', (err) => {
if (err && err.code === 'ENOENT') return;
fail(err);
});
archive.on('progress', (progress) => {
const totalBytes = progress && progress.fs ? progress.fs.totalBytes : 0;
const processedBytes = progress && progress.fs ? progress.fs.processedBytes : 0;
if (!totalBytes) return;
const ratio = Math.max(0, Math.min(1, processedBytes / totalBytes));
const percent = startPercent + ((endPercent - startPercent) * ratio);
emitProgress(onProgress, mode, percent, detail + ' ...');
});
archive.pipe(output);
if (fs.existsSync(instancesDir)) archive.directory(instancesDir, 'instances');
else archive.append('', { name: 'instances/.keep' });
if (includeSettings && settingsFile && fs.existsSync(settingsFile)) {
archive.file(settingsFile, { name: 'settings.json' });
}
archive.append(`${JSON.stringify(backupInfo, null, 2)}\n`, { name: 'backup-info.json' });
try {
const finalizeResult = archive.finalize();
if (finalizeResult && typeof finalizeResult.catch === 'function') finalizeResult.catch(fail);
} catch (err) {
fail(err);
}
});
}
function copyDirContentsTracked(sourceDir, targetDir, options) {
const opts = options || {};
const mode = opts.mode || 'create';
const onProgress = opts.onProgress;
const startPercent = opts.startPercent ?? 0;
const endPercent = opts.endPercent ?? 100;
const detailPrefix = opts.detailPrefix || 'Kopiere';
ensureDir(targetDir);
if (!fs.existsSync(sourceDir)) {
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
return;
}
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
if (!entries.length) {
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
return;
}
entries.forEach((entry, index) => {
fs.cpSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name), { recursive: true });
const ratio = (index + 1) / entries.length;
const percent = startPercent + ((endPercent - startPercent) * ratio);
emitProgress(onProgress, mode, percent, `${detailPrefix}: ${entry.name}`);
});
}
function moveDirTracked(sourceDir, targetDir, options) {
const opts = options || {};
const mode = opts.mode || 'restore';
const onProgress = opts.onProgress;
const startPercent = opts.startPercent ?? 0;
const endPercent = opts.endPercent ?? 100;
const detailPrefix = opts.detailPrefix || 'Verschiebe';
if (!fs.existsSync(sourceDir)) {
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
return;
}
ensureDir(path.dirname(targetDir));
try {
fs.renameSync(sourceDir, targetDir);
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
return;
} catch (err) {
if (!err || !['EXDEV', 'EPERM', 'EACCES'].includes(err.code)) throw err;
}
copyDirContentsTracked(sourceDir, targetDir, opts);
try { fs.rmSync(sourceDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
function resolveExtractedBackupRoot(extractDir) {
if (fs.existsSync(path.join(extractDir, 'instances'))) return extractDir;
const entries = fs.readdirSync(extractDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
if (entries.length === 1) {
const nested = path.join(extractDir, entries[0].name);
if (fs.existsSync(path.join(nested, 'instances'))) return nested;
}
return null;
}
async function createGlobalBackup(options) {
const instancesDir = options && options.instancesDir;
const userDataDir = options && options.userDataDir;
const destinationPath = options && options.destinationPath;
const includeSettings = !options || options.includeSettings !== false;
const onProgress = options && options.onProgress;
if (!instancesDir || !userDataDir || !destinationPath) throw new Error('Backup-Parameter unvollständig.');
const settingsFile = path.join(userDataDir, 'settings.json');
const workingArchivePath = destinationPath.replace(/\.zip$/i, '') + '.partial.zip';
const backupInfo = {
createdAt: new Date().toISOString(),
instanceCount: countInstanceDirs(instancesDir),
includeSettings,
app: 'AeroMC',
version: 1,
};
try {
emitProgress(onProgress, 'create', 5, 'Bereite Backup vor');
emitProgress(onProgress, 'create', 10, 'Ermittle Backup-Inhalt');
if (fs.existsSync(destinationPath)) fs.unlinkSync(destinationPath);
if (fs.existsSync(workingArchivePath)) fs.unlinkSync(workingArchivePath);
emitProgress(onProgress, 'create', 90, 'Packe ZIP-Archiv');
await createBackupArchive({
instancesDir,
settingsFile,
includeSettings,
backupInfo,
destinationPath: workingArchivePath,
mode: 'create',
onProgress,
startPercent: 15,
endPercent: 98,
detail: 'Packe ZIP-Archiv',
});
emitProgress(onProgress, 'create', 99, 'Finalisiere Backup');
fs.renameSync(workingArchivePath, destinationPath);
emitProgress(onProgress, 'create', 100, 'Backup abgeschlossen');
return {
ok: true,
path: destinationPath,
instanceCount: countInstanceDirs(instancesDir),
includedSettings: includeSettings && fs.existsSync(settingsFile),
};
} finally {
try {
if (fs.existsSync(workingArchivePath)) fs.rmSync(workingArchivePath, { force: true });
} catch { /* ignore */ }
}
}
async function restoreGlobalBackup(options) {
const instancesDir = options && options.instancesDir;
const userDataDir = options && options.userDataDir;
const sourcePath = options && options.sourcePath;
const includeSettings = !!(options && options.includeSettings);
const onProgress = options && options.onProgress;
const instancesDirSetting = options && Object.prototype.hasOwnProperty.call(options, 'instancesDirSetting')
? options.instancesDirSetting
: null;
if (!instancesDir || !userDataDir || !sourcePath) throw new Error('Restore-Parameter unvollständig.');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-restore-'));
const extractDir = path.join(tmp, 'extract');
const rollbackDir = path.join(tmp, 'rollback');
const rollbackInstances = path.join(rollbackDir, 'instances');
const settingsFile = path.join(userDataDir, 'settings.json');
const rollbackSettings = path.join(rollbackDir, 'settings.json');
let settingsPreviouslyExisted = false;
// wird erst true, sobald der aktuelle Stand tatsächlich weggesichert wurde -
// vorher darf der Rollback nichts anfassen, sonst löscht ein früher Fehler
// (z. B. ungültiges Archiv) den unveränderten, intakten Originalbestand
let originalMovedAway = false;
try {
ensureDir(extractDir);
emitProgress(onProgress, 'restore', 5, 'Entpacke Backup');
await extractBackupArchive(sourcePath, extractDir, {
mode: 'restore',
onProgress,
startPercent: 5,
endPercent: 18,
detail: 'Entpacke Backup',
});
const backupRoot = resolveExtractedBackupRoot(extractDir);
if (!backupRoot) throw new Error('Das Archiv enthält kein gültiges AeroMC-Backup.');
emitProgress(onProgress, 'restore', 18, 'Backup geprüft');
const sourceInstances = path.join(backupRoot, 'instances');
const sourceSettings = path.join(backupRoot, 'settings.json');
ensureDir(rollbackDir);
emitProgress(onProgress, 'restore', 25, 'Sichere aktuellen Stand');
moveDirTracked(instancesDir, rollbackInstances, {
mode: 'restore',
onProgress,
startPercent: 28,
endPercent: 40,
detailPrefix: 'Sichere aktuelle Instanzen',
});
originalMovedAway = true;
settingsPreviouslyExisted = fs.existsSync(settingsFile);
if (settingsPreviouslyExisted) fs.copyFileSync(settingsFile, rollbackSettings);
ensureDir(path.dirname(instancesDir));
moveDirTracked(sourceInstances, instancesDir, {
mode: 'restore',
onProgress,
startPercent: 45,
endPercent: 78,
detailPrefix: 'Stelle Instanzen wieder her',
});
let restoredSettings = false;
if (includeSettings && fs.existsSync(sourceSettings)) {
const restored = readJson(sourceSettings, {});
restored.instancesDir = instancesDirSetting;
writeJson(settingsFile, restored);
restoredSettings = true;
emitProgress(onProgress, 'restore', 90, 'Stelle Einstellungen wieder her');
} else {
emitProgress(onProgress, 'restore', 90, 'Einstellungen übersprungen');
}
emitProgress(onProgress, 'restore', 100, 'Wiederherstellen abgeschlossen');
return {
ok: true,
path: sourcePath,
instanceCount: countInstanceDirs(instancesDir),
restoredSettings,
};
} catch (err) {
// Nur zurückrollen, wenn wir den ursprünglichen Bestand überhaupt schon
// angefasst haben (siehe originalMovedAway oben). Schlägt die
// Wiederherstellung vorher fehl (ungültiges/beschädigtes Archiv o. Ä.),
// ist instancesDir noch der unveränderte Originalbestand - den lassen wir
// dann bewusst in Ruhe, statt ihn zu löschen.
if (originalMovedAway) {
try {
if (fs.existsSync(instancesDir)) fs.rmSync(instancesDir, { recursive: true, force: true });
if (fs.existsSync(rollbackInstances)) fs.cpSync(rollbackInstances, instancesDir, { recursive: true });
else ensureDir(instancesDir);
if (includeSettings) {
if (fs.existsSync(rollbackSettings)) fs.copyFileSync(rollbackSettings, settingsFile);
else if (!settingsPreviouslyExisted && fs.existsSync(settingsFile)) fs.rmSync(settingsFile, { force: true });
}
} catch { /* ignore rollback errors */ }
}
throw err;
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
module.exports = {
createGlobalBackup,
restoreGlobalBackup,
defaultBackupFileName,
defaultBackupDir,
listGlobalBackups,
resolveAutoBackupPath,
};