Upload via GUI (44 Dateien)
This commit is contained in:
+451
-451
@@ -1,452 +1,452 @@
|
||||
'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,
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user