Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
425b94fd93 | ||
|
|
e0bdcb01fb | ||
|
|
c840a49f75 | ||
|
|
c535d8b830 | ||
|
|
248a387f01 |
@@ -6,7 +6,7 @@
|
||||
|
||||
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung.
|
||||
|
||||
Aktueller Stand: Version 1.0.8
|
||||
Aktueller Stand: Version 1.0.9
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aeromc",
|
||||
"version": "1.0.7",
|
||||
"license": "MIT",
|
||||
"version": "1.0.9",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"skinview3d": "^3.4.2",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
|
||||
+451
-439
@@ -1,440 +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;
|
||||
|
||||
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',
|
||||
});
|
||||
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) {
|
||||
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,
|
||||
};
|
||||
+195
-195
@@ -1,196 +1,196 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* cfimport.js – Instanzen aus dem CurseForge-Launcher übernehmen
|
||||
* ---------------------------------------------------------------
|
||||
* Unterstützt sowohl den Launcher-Wurzelordner als auch direkt den
|
||||
* Instances-Ordner. Gelesen werden nach Möglichkeit minecraftinstance.json
|
||||
* und die vorhandenen Spieldaten im Profilordner.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const META_FILES = ['minecraftinstance.json', 'instance.json'];
|
||||
|
||||
function readJson(file, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function existingMetaFile(dir) {
|
||||
return META_FILES.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file)) || null;
|
||||
}
|
||||
|
||||
function metaVersion(meta) {
|
||||
return String(
|
||||
(meta && (
|
||||
meta.gameVersion || meta.minecraftVersion || meta.mcVersion ||
|
||||
(meta.installedModpack && meta.installedModpack.gameVersion)
|
||||
)) || ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
function resolveInstancesDir(inputPath) {
|
||||
if (!inputPath || !fs.existsSync(inputPath)) return null;
|
||||
const directNames = new Set(['instances', 'minecraftinstances']);
|
||||
if (directNames.has(path.basename(inputPath).toLowerCase())) return inputPath;
|
||||
|
||||
const candidates = [
|
||||
path.join(inputPath, 'Instances'),
|
||||
path.join(inputPath, 'instances'),
|
||||
path.join(inputPath, 'minecraftInstances'),
|
||||
path.join(inputPath, 'Minecraft', 'Instances'),
|
||||
path.join(inputPath, 'minecraft', 'Instances'),
|
||||
];
|
||||
|
||||
const looksLikeWindowsInstall =
|
||||
/curseforge windows/i.test(inputPath) ||
|
||||
(fs.existsSync(path.join(inputPath, 'CurseForge.exe')) && fs.existsSync(path.join(inputPath, 'resources')));
|
||||
if (looksLikeWindowsInstall) {
|
||||
candidates.push(
|
||||
path.join(os.homedir(), 'curseforge', 'minecraft', 'Instances'),
|
||||
path.join(os.homedir(), 'CurseForge', 'minecraft', 'Instances'),
|
||||
);
|
||||
}
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
||||
}
|
||||
|
||||
function normalizeLoaderVersion(loader, rawValue, mcVersion) {
|
||||
let value = String(rawValue || '').trim();
|
||||
if (!value) return '';
|
||||
|
||||
if (loader === 'forge') {
|
||||
value = value.replace(/^forge-/i, '');
|
||||
if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1);
|
||||
return value;
|
||||
}
|
||||
if (loader === 'neoforge') {
|
||||
return value.replace(/^neoforge-/i, '');
|
||||
}
|
||||
if (loader === 'fabric') {
|
||||
value = value.replace(/^fabric(?:-loader)?-/i, '');
|
||||
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||
return value;
|
||||
}
|
||||
if (loader === 'quilt') {
|
||||
value = value.replace(/^quilt(?:-loader)?-/i, '');
|
||||
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function detectLoader(meta, mcVersion) {
|
||||
const raw = meta && (
|
||||
meta.baseModLoader || meta.modLoader || meta.modloader || meta.loader || meta.modLoaderId
|
||||
);
|
||||
const value = typeof raw === 'object'
|
||||
? (raw.name || raw.id || raw.value || raw.version || '')
|
||||
: (raw || '');
|
||||
const lower = String(value).toLowerCase();
|
||||
const objectVersion = raw && typeof raw === 'object'
|
||||
? (raw.version || raw.name || raw.id || '')
|
||||
: '';
|
||||
|
||||
if (!lower) return { loader: 'vanilla', loaderVersion: '' };
|
||||
if (lower.includes('neoforge')) return { loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', objectVersion || value, mcVersion) };
|
||||
if (lower.includes('fabric')) return { loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', objectVersion || value, mcVersion) };
|
||||
if (lower.includes('quilt')) return { loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', objectVersion || value, mcVersion) };
|
||||
if (lower.includes('forge')) return { loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', objectVersion || value, mcVersion) };
|
||||
return { loader: 'vanilla', loaderVersion: '' };
|
||||
}
|
||||
|
||||
function detectVersion(meta, loaderVersion) {
|
||||
return metaVersion(meta) || (loaderVersion && loaderVersion.includes('-') ? loaderVersion.split('-')[0] : '');
|
||||
}
|
||||
|
||||
function findGameDir(dir) {
|
||||
const nested = ['minecraft', '.minecraft']
|
||||
.map((name) => path.join(dir, name))
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
if (nested) return nested;
|
||||
|
||||
const markers = ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'options.txt'];
|
||||
return markers.some((name) => fs.existsSync(path.join(dir, name))) ? dir : null;
|
||||
}
|
||||
|
||||
function scan(inputPath) {
|
||||
const instancesDir = resolveInstancesDir(inputPath);
|
||||
if (!instancesDir) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const list = [];
|
||||
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = path.join(instancesDir, entry.name);
|
||||
const metaFile = existingMetaFile(dir);
|
||||
const meta = metaFile ? readJson(metaFile, {}) : {};
|
||||
const gameDir = findGameDir(dir);
|
||||
if (!metaFile && !gameDir) continue;
|
||||
|
||||
const versionHint = metaVersion(meta);
|
||||
const loaderInfo = detectLoader(meta, versionHint);
|
||||
list.push({
|
||||
folder: entry.name,
|
||||
dir,
|
||||
name: meta.name || meta.displayName || entry.name,
|
||||
group: '',
|
||||
notes: meta.notes || meta.summary || '',
|
||||
version: versionHint || detectVersion(meta, loaderInfo.loaderVersion),
|
||||
loader: loaderInfo.loader,
|
||||
loaderVersion: loaderInfo.loaderVersion,
|
||||
javaPath: String(meta.javaPath || meta.javaExecutable || '').trim(),
|
||||
minMemMb: Number(meta.minimumMemory || meta.minMemory || meta.minMemAlloc) || null,
|
||||
maxMemMb: Number(meta.maximumMemory || meta.maxMemory || meta.maxMemAlloc || meta.allocatedMemory) || null,
|
||||
gameDir: gameDir || dir,
|
||||
hasGameData: !!gameDir,
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { ok: true, instancesDir, count: list.length, instances: list };
|
||||
}
|
||||
|
||||
function copyInto(sourceDir, targetDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
if (META_FILES.includes(name)) continue;
|
||||
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function importOne(store, entry, copyData = true) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
notes: entry.notes,
|
||||
minecraft: {
|
||||
version: entry.version,
|
||||
loader: entry.loader,
|
||||
loaderVersion: entry.loaderVersion,
|
||||
},
|
||||
});
|
||||
|
||||
const patch = {};
|
||||
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
|
||||
patch.java = {
|
||||
path: entry.javaPath || '',
|
||||
minMemMb: entry.minMemMb || null,
|
||||
maxMemMb: entry.maxMemMb || null,
|
||||
extraArgs: '',
|
||||
};
|
||||
}
|
||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
copyInto(entry.gameDir, store.gameDir(created.id));
|
||||
copied = true;
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* cfimport.js – Instanzen aus dem CurseForge-Launcher übernehmen
|
||||
* ---------------------------------------------------------------
|
||||
* Unterstützt sowohl den Launcher-Wurzelordner als auch direkt den
|
||||
* Instances-Ordner. Gelesen werden nach Möglichkeit minecraftinstance.json
|
||||
* und die vorhandenen Spieldaten im Profilordner.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const META_FILES = ['minecraftinstance.json', 'instance.json'];
|
||||
|
||||
function readJson(file, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function existingMetaFile(dir) {
|
||||
return META_FILES.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file)) || null;
|
||||
}
|
||||
|
||||
function metaVersion(meta) {
|
||||
return String(
|
||||
(meta && (
|
||||
meta.gameVersion || meta.minecraftVersion || meta.mcVersion ||
|
||||
(meta.installedModpack && meta.installedModpack.gameVersion)
|
||||
)) || ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
function resolveInstancesDir(inputPath) {
|
||||
if (!inputPath || !fs.existsSync(inputPath)) return null;
|
||||
const directNames = new Set(['instances', 'minecraftinstances']);
|
||||
if (directNames.has(path.basename(inputPath).toLowerCase())) return inputPath;
|
||||
|
||||
const candidates = [
|
||||
path.join(inputPath, 'Instances'),
|
||||
path.join(inputPath, 'instances'),
|
||||
path.join(inputPath, 'minecraftInstances'),
|
||||
path.join(inputPath, 'Minecraft', 'Instances'),
|
||||
path.join(inputPath, 'minecraft', 'Instances'),
|
||||
];
|
||||
|
||||
const looksLikeWindowsInstall =
|
||||
/curseforge windows/i.test(inputPath) ||
|
||||
(fs.existsSync(path.join(inputPath, 'CurseForge.exe')) && fs.existsSync(path.join(inputPath, 'resources')));
|
||||
if (looksLikeWindowsInstall) {
|
||||
candidates.push(
|
||||
path.join(os.homedir(), 'curseforge', 'minecraft', 'Instances'),
|
||||
path.join(os.homedir(), 'CurseForge', 'minecraft', 'Instances'),
|
||||
);
|
||||
}
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
||||
}
|
||||
|
||||
function normalizeLoaderVersion(loader, rawValue, mcVersion) {
|
||||
let value = String(rawValue || '').trim();
|
||||
if (!value) return '';
|
||||
|
||||
if (loader === 'forge') {
|
||||
value = value.replace(/^forge-/i, '');
|
||||
if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1);
|
||||
return value;
|
||||
}
|
||||
if (loader === 'neoforge') {
|
||||
return value.replace(/^neoforge-/i, '');
|
||||
}
|
||||
if (loader === 'fabric') {
|
||||
value = value.replace(/^fabric(?:-loader)?-/i, '');
|
||||
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||
return value;
|
||||
}
|
||||
if (loader === 'quilt') {
|
||||
value = value.replace(/^quilt(?:-loader)?-/i, '');
|
||||
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function detectLoader(meta, mcVersion) {
|
||||
const raw = meta && (
|
||||
meta.baseModLoader || meta.modLoader || meta.modloader || meta.loader || meta.modLoaderId
|
||||
);
|
||||
const value = typeof raw === 'object'
|
||||
? (raw.name || raw.id || raw.value || raw.version || '')
|
||||
: (raw || '');
|
||||
const lower = String(value).toLowerCase();
|
||||
const objectVersion = raw && typeof raw === 'object'
|
||||
? (raw.version || raw.name || raw.id || '')
|
||||
: '';
|
||||
|
||||
if (!lower) return { loader: 'vanilla', loaderVersion: '' };
|
||||
if (lower.includes('neoforge')) return { loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', objectVersion || value, mcVersion) };
|
||||
if (lower.includes('fabric')) return { loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', objectVersion || value, mcVersion) };
|
||||
if (lower.includes('quilt')) return { loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', objectVersion || value, mcVersion) };
|
||||
if (lower.includes('forge')) return { loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', objectVersion || value, mcVersion) };
|
||||
return { loader: 'vanilla', loaderVersion: '' };
|
||||
}
|
||||
|
||||
function detectVersion(meta, loaderVersion) {
|
||||
return metaVersion(meta) || (loaderVersion && loaderVersion.includes('-') ? loaderVersion.split('-')[0] : '');
|
||||
}
|
||||
|
||||
function findGameDir(dir) {
|
||||
const nested = ['minecraft', '.minecraft']
|
||||
.map((name) => path.join(dir, name))
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
if (nested) return nested;
|
||||
|
||||
const markers = ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'options.txt'];
|
||||
return markers.some((name) => fs.existsSync(path.join(dir, name))) ? dir : null;
|
||||
}
|
||||
|
||||
function scan(inputPath) {
|
||||
const instancesDir = resolveInstancesDir(inputPath);
|
||||
if (!instancesDir) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const list = [];
|
||||
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = path.join(instancesDir, entry.name);
|
||||
const metaFile = existingMetaFile(dir);
|
||||
const meta = metaFile ? readJson(metaFile, {}) : {};
|
||||
const gameDir = findGameDir(dir);
|
||||
if (!metaFile && !gameDir) continue;
|
||||
|
||||
const versionHint = metaVersion(meta);
|
||||
const loaderInfo = detectLoader(meta, versionHint);
|
||||
list.push({
|
||||
folder: entry.name,
|
||||
dir,
|
||||
name: meta.name || meta.displayName || entry.name,
|
||||
group: '',
|
||||
notes: meta.notes || meta.summary || '',
|
||||
version: versionHint || detectVersion(meta, loaderInfo.loaderVersion),
|
||||
loader: loaderInfo.loader,
|
||||
loaderVersion: loaderInfo.loaderVersion,
|
||||
javaPath: String(meta.javaPath || meta.javaExecutable || '').trim(),
|
||||
minMemMb: Number(meta.minimumMemory || meta.minMemory || meta.minMemAlloc) || null,
|
||||
maxMemMb: Number(meta.maximumMemory || meta.maxMemory || meta.maxMemAlloc || meta.allocatedMemory) || null,
|
||||
gameDir: gameDir || dir,
|
||||
hasGameData: !!gameDir,
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { ok: true, instancesDir, count: list.length, instances: list };
|
||||
}
|
||||
|
||||
function copyInto(sourceDir, targetDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
if (META_FILES.includes(name)) continue;
|
||||
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function importOne(store, entry, copyData = true) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
notes: entry.notes,
|
||||
minecraft: {
|
||||
version: entry.version,
|
||||
loader: entry.loader,
|
||||
loaderVersion: entry.loaderVersion,
|
||||
},
|
||||
});
|
||||
|
||||
const patch = {};
|
||||
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
|
||||
patch.java = {
|
||||
path: entry.javaPath || '',
|
||||
minMemMb: entry.minMemMb || null,
|
||||
maxMemMb: entry.maxMemMb || null,
|
||||
extraArgs: '',
|
||||
};
|
||||
}
|
||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
copyInto(entry.gameDir, store.gameDir(created.id));
|
||||
copied = true;
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
module.exports = { scan, importOne, resolveInstancesDir };
|
||||
+10
-1
@@ -269,7 +269,16 @@ function destroyTray() {
|
||||
// Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden
|
||||
function applyAutostart(enabled) {
|
||||
try {
|
||||
app.setLoginItemSettings({ openAtLogin: !!enabled, args: enabled ? ['--hidden'] : [] });
|
||||
// Gleiches Problem wie bei den Desktop-Verknüpfungen (siehe verknuepfungsArgs
|
||||
// weiter oben): im Entwicklungsmodus zeigt process.execPath nur auf die nackte
|
||||
// electron.exe, die ohne Pfadangabe ihren eigenen leeren Startbildschirm zeigt
|
||||
// statt AeroMC zu laden. Zusätzlich: bei der Portable-Fassung zeigt
|
||||
// process.execPath auf den entpackten Temp-Ordner – der wäre morgen tot,
|
||||
// deshalb wie bei den Verknüpfungen über verknuepfungsZiel() auflösen.
|
||||
const args = enabled
|
||||
? (app.isPackaged ? ['--hidden'] : [path.join(__dirname, '..'), '--hidden'])
|
||||
: [];
|
||||
app.setLoginItemSettings({ openAtLogin: !!enabled, path: verknuepfungsZiel(), args });
|
||||
} catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ }
|
||||
if (enabled) createTray();
|
||||
else destroyTray();
|
||||
|
||||
+216
-216
@@ -1,217 +1,217 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PROFILE_FILES = [
|
||||
'launcher_profiles.json',
|
||||
'launcher_profiles_microsoft_store.json',
|
||||
'launcher_profiles_microsoft_store_2.json',
|
||||
];
|
||||
|
||||
const ROOT_COPY_DIRS = new Set([
|
||||
'config', 'defaultconfigs', 'kubejs', 'mods', 'resourcepacks', 'screenshots', 'shaderpacks', 'saves',
|
||||
]);
|
||||
|
||||
const ROOT_COPY_FILE_PATTERNS = [
|
||||
/^options.*\.(txt|of)$/i,
|
||||
/^servers\.dat(?:_old)?$/i,
|
||||
/^usercache\.json$/i,
|
||||
/^tl_skin_cape\.json$/i,
|
||||
/^journeymap.*\.(json|txt|cfg)$/i,
|
||||
];
|
||||
|
||||
function readJson(file, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLauncherRoot(inputPath) {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
const defaultRoot = path.join(appData, '.minecraft');
|
||||
const candidates = [];
|
||||
|
||||
if (inputPath && fs.existsSync(inputPath)) candidates.push(inputPath);
|
||||
candidates.push(defaultRoot);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const profileFile of PROFILE_FILES) {
|
||||
if (fs.existsSync(path.join(candidate, profileFile))) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProfileFile(rootDir) {
|
||||
return PROFILE_FILES.map((name) => path.join(rootDir, name)).find((file) => fs.existsSync(file)) || null;
|
||||
}
|
||||
|
||||
function normalizeLoaderVersion(loader, rawValue, mcVersion) {
|
||||
let value = String(rawValue || '').trim();
|
||||
if (!value) return '';
|
||||
|
||||
if (loader === 'forge') {
|
||||
value = value.replace(/^forge-/i, '');
|
||||
if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1);
|
||||
return value;
|
||||
}
|
||||
if (loader === 'neoforge') return value.replace(/^neoforge-/i, '');
|
||||
if (loader === 'fabric') {
|
||||
value = value.replace(/^fabric(?:-loader)?-/i, '');
|
||||
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||
return value;
|
||||
}
|
||||
if (loader === 'quilt') {
|
||||
value = value.replace(/^quilt(?:-loader)?-/i, '');
|
||||
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLastVersionId(lastVersionId) {
|
||||
const raw = String(lastVersionId || '').trim();
|
||||
if (!raw) return { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||
|
||||
let match = raw.match(/^fabric-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i);
|
||||
if (match) return { version: match[2], loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', match[1], match[2]) };
|
||||
|
||||
match = raw.match(/^quilt-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i);
|
||||
if (match) return { version: match[2], loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', match[1], match[2]) };
|
||||
|
||||
match = raw.match(/^(.+)-forge-([\w.-]+)$/i);
|
||||
if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) };
|
||||
|
||||
match = raw.match(/^forge-(.+)-([\w.-]+)$/i);
|
||||
if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) };
|
||||
|
||||
match = raw.match(/^(.+)-neoforge-([\w.-]+)$/i);
|
||||
if (match) return { version: match[1], loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', match[2], match[1]) };
|
||||
|
||||
if (/^\d+(?:\.\d+)+(?:-[\w.]+)?$/i.test(raw)) return { version: raw, loader: 'vanilla', loaderVersion: '' };
|
||||
return { version: raw, loader: 'vanilla', loaderVersion: '' };
|
||||
}
|
||||
|
||||
function parseJavaSettings(javaDir, javaArgs) {
|
||||
const args = String(javaArgs || '').trim();
|
||||
const minMatch = args.match(/(?:^|\s)-Xms(\d+)([mMgG])/);
|
||||
const maxMatch = args.match(/(?:^|\s)-Xmx(\d+)([mMgG])/);
|
||||
const toMb = (match) => {
|
||||
if (!match) return null;
|
||||
const num = Number(match[1]);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return match[2].toLowerCase() === 'g' ? num * 1024 : num;
|
||||
};
|
||||
|
||||
return {
|
||||
path: String(javaDir || '').trim(),
|
||||
minMemMb: toMb(minMatch),
|
||||
maxMemMb: toMb(maxMatch),
|
||||
extraArgs: args
|
||||
.replace(/(?:^|\s)-Xms\d+[mMgG]/g, ' ')
|
||||
.replace(/(?:^|\s)-Xmx\d+[mMgG]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function isImportableProfile(profile) {
|
||||
const type = String((profile && profile.type) || '').trim().toLowerCase();
|
||||
if (type === 'latest-release' || type === 'latest-snapshot') return false;
|
||||
const name = String((profile && profile.name) || '').trim();
|
||||
const lastVersionId = String((profile && profile.lastVersionId) || '').trim();
|
||||
return !!(name || lastVersionId);
|
||||
}
|
||||
|
||||
function shouldCopyRootEntry(name) {
|
||||
if (ROOT_COPY_DIRS.has(name)) return true;
|
||||
return ROOT_COPY_FILE_PATTERNS.some((pattern) => pattern.test(name));
|
||||
}
|
||||
|
||||
function scan(inputPath) {
|
||||
const rootDir = resolveLauncherRoot(inputPath);
|
||||
if (!rootDir) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const profileFile = resolveProfileFile(rootDir);
|
||||
const json = readJson(profileFile, {});
|
||||
const profiles = json.profiles || {};
|
||||
const list = [];
|
||||
const defaultRoot = path.normalize(rootDir).toLowerCase();
|
||||
|
||||
for (const [id, profile] of Object.entries(profiles)) {
|
||||
if (!isImportableProfile(profile)) continue;
|
||||
|
||||
const name = String(profile.name || '').trim() || String(profile.lastVersionId || '').trim() || id;
|
||||
const parsed = parseLastVersionId(profile.lastVersionId);
|
||||
const gameDir = path.normalize(String(profile.gameDir || rootDir));
|
||||
const java = parseJavaSettings(profile.javaDir, profile.javaArgs);
|
||||
|
||||
list.push({
|
||||
id,
|
||||
name,
|
||||
group: '',
|
||||
notes: 'Importiert aus dem Minecraft Launcher',
|
||||
version: parsed.version,
|
||||
loader: parsed.loader,
|
||||
loaderVersion: parsed.loaderVersion,
|
||||
javaPath: java.path,
|
||||
minMemMb: java.minMemMb,
|
||||
maxMemMb: java.maxMemMb,
|
||||
extraJavaArgs: java.extraArgs,
|
||||
gameDir,
|
||||
hasGameData: fs.existsSync(gameDir),
|
||||
usesDefaultGameDir: gameDir.toLowerCase() === defaultRoot,
|
||||
rootDir,
|
||||
});
|
||||
}
|
||||
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { ok: true, rootDir, count: list.length, instances: list };
|
||||
}
|
||||
|
||||
function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
if (/^launcher_profiles.*\.json$/i.test(name)) continue;
|
||||
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue;
|
||||
if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue;
|
||||
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function importOne(store, entry, copyData = true) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
notes: entry.notes,
|
||||
minecraft: {
|
||||
version: entry.version,
|
||||
loader: entry.loader,
|
||||
loaderVersion: entry.loaderVersion,
|
||||
},
|
||||
});
|
||||
|
||||
const patch = {};
|
||||
if (entry.javaPath || entry.minMemMb || entry.maxMemMb || entry.extraJavaArgs) {
|
||||
patch.java = {
|
||||
path: entry.javaPath || '',
|
||||
minMemMb: entry.minMemMb || null,
|
||||
maxMemMb: entry.maxMemMb || null,
|
||||
extraArgs: entry.extraJavaArgs || '',
|
||||
};
|
||||
}
|
||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
copyInto(entry.gameDir, store.gameDir(created.id), !!entry.usesDefaultGameDir);
|
||||
copied = true;
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PROFILE_FILES = [
|
||||
'launcher_profiles.json',
|
||||
'launcher_profiles_microsoft_store.json',
|
||||
'launcher_profiles_microsoft_store_2.json',
|
||||
];
|
||||
|
||||
const ROOT_COPY_DIRS = new Set([
|
||||
'config', 'defaultconfigs', 'kubejs', 'mods', 'resourcepacks', 'screenshots', 'shaderpacks', 'saves',
|
||||
]);
|
||||
|
||||
const ROOT_COPY_FILE_PATTERNS = [
|
||||
/^options.*\.(txt|of)$/i,
|
||||
/^servers\.dat(?:_old)?$/i,
|
||||
/^usercache\.json$/i,
|
||||
/^tl_skin_cape\.json$/i,
|
||||
/^journeymap.*\.(json|txt|cfg)$/i,
|
||||
];
|
||||
|
||||
function readJson(file, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLauncherRoot(inputPath) {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
const defaultRoot = path.join(appData, '.minecraft');
|
||||
const candidates = [];
|
||||
|
||||
if (inputPath && fs.existsSync(inputPath)) candidates.push(inputPath);
|
||||
candidates.push(defaultRoot);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const profileFile of PROFILE_FILES) {
|
||||
if (fs.existsSync(path.join(candidate, profileFile))) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProfileFile(rootDir) {
|
||||
return PROFILE_FILES.map((name) => path.join(rootDir, name)).find((file) => fs.existsSync(file)) || null;
|
||||
}
|
||||
|
||||
function normalizeLoaderVersion(loader, rawValue, mcVersion) {
|
||||
let value = String(rawValue || '').trim();
|
||||
if (!value) return '';
|
||||
|
||||
if (loader === 'forge') {
|
||||
value = value.replace(/^forge-/i, '');
|
||||
if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1);
|
||||
return value;
|
||||
}
|
||||
if (loader === 'neoforge') return value.replace(/^neoforge-/i, '');
|
||||
if (loader === 'fabric') {
|
||||
value = value.replace(/^fabric(?:-loader)?-/i, '');
|
||||
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||
return value;
|
||||
}
|
||||
if (loader === 'quilt') {
|
||||
value = value.replace(/^quilt(?:-loader)?-/i, '');
|
||||
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLastVersionId(lastVersionId) {
|
||||
const raw = String(lastVersionId || '').trim();
|
||||
if (!raw) return { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||
|
||||
let match = raw.match(/^fabric-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i);
|
||||
if (match) return { version: match[2], loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', match[1], match[2]) };
|
||||
|
||||
match = raw.match(/^quilt-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i);
|
||||
if (match) return { version: match[2], loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', match[1], match[2]) };
|
||||
|
||||
match = raw.match(/^(.+)-forge-([\w.-]+)$/i);
|
||||
if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) };
|
||||
|
||||
match = raw.match(/^forge-(.+)-([\w.-]+)$/i);
|
||||
if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) };
|
||||
|
||||
match = raw.match(/^(.+)-neoforge-([\w.-]+)$/i);
|
||||
if (match) return { version: match[1], loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', match[2], match[1]) };
|
||||
|
||||
if (/^\d+(?:\.\d+)+(?:-[\w.]+)?$/i.test(raw)) return { version: raw, loader: 'vanilla', loaderVersion: '' };
|
||||
return { version: raw, loader: 'vanilla', loaderVersion: '' };
|
||||
}
|
||||
|
||||
function parseJavaSettings(javaDir, javaArgs) {
|
||||
const args = String(javaArgs || '').trim();
|
||||
const minMatch = args.match(/(?:^|\s)-Xms(\d+)([mMgG])/);
|
||||
const maxMatch = args.match(/(?:^|\s)-Xmx(\d+)([mMgG])/);
|
||||
const toMb = (match) => {
|
||||
if (!match) return null;
|
||||
const num = Number(match[1]);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return match[2].toLowerCase() === 'g' ? num * 1024 : num;
|
||||
};
|
||||
|
||||
return {
|
||||
path: String(javaDir || '').trim(),
|
||||
minMemMb: toMb(minMatch),
|
||||
maxMemMb: toMb(maxMatch),
|
||||
extraArgs: args
|
||||
.replace(/(?:^|\s)-Xms\d+[mMgG]/g, ' ')
|
||||
.replace(/(?:^|\s)-Xmx\d+[mMgG]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function isImportableProfile(profile) {
|
||||
const type = String((profile && profile.type) || '').trim().toLowerCase();
|
||||
if (type === 'latest-release' || type === 'latest-snapshot') return false;
|
||||
const name = String((profile && profile.name) || '').trim();
|
||||
const lastVersionId = String((profile && profile.lastVersionId) || '').trim();
|
||||
return !!(name || lastVersionId);
|
||||
}
|
||||
|
||||
function shouldCopyRootEntry(name) {
|
||||
if (ROOT_COPY_DIRS.has(name)) return true;
|
||||
return ROOT_COPY_FILE_PATTERNS.some((pattern) => pattern.test(name));
|
||||
}
|
||||
|
||||
function scan(inputPath) {
|
||||
const rootDir = resolveLauncherRoot(inputPath);
|
||||
if (!rootDir) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const profileFile = resolveProfileFile(rootDir);
|
||||
const json = readJson(profileFile, {});
|
||||
const profiles = json.profiles || {};
|
||||
const list = [];
|
||||
const defaultRoot = path.normalize(rootDir).toLowerCase();
|
||||
|
||||
for (const [id, profile] of Object.entries(profiles)) {
|
||||
if (!isImportableProfile(profile)) continue;
|
||||
|
||||
const name = String(profile.name || '').trim() || String(profile.lastVersionId || '').trim() || id;
|
||||
const parsed = parseLastVersionId(profile.lastVersionId);
|
||||
const gameDir = path.normalize(String(profile.gameDir || rootDir));
|
||||
const java = parseJavaSettings(profile.javaDir, profile.javaArgs);
|
||||
|
||||
list.push({
|
||||
id,
|
||||
name,
|
||||
group: '',
|
||||
notes: 'Importiert aus dem Minecraft Launcher',
|
||||
version: parsed.version,
|
||||
loader: parsed.loader,
|
||||
loaderVersion: parsed.loaderVersion,
|
||||
javaPath: java.path,
|
||||
minMemMb: java.minMemMb,
|
||||
maxMemMb: java.maxMemMb,
|
||||
extraJavaArgs: java.extraArgs,
|
||||
gameDir,
|
||||
hasGameData: fs.existsSync(gameDir),
|
||||
usesDefaultGameDir: gameDir.toLowerCase() === defaultRoot,
|
||||
rootDir,
|
||||
});
|
||||
}
|
||||
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { ok: true, rootDir, count: list.length, instances: list };
|
||||
}
|
||||
|
||||
function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
if (/^launcher_profiles.*\.json$/i.test(name)) continue;
|
||||
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue;
|
||||
if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue;
|
||||
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function importOne(store, entry, copyData = true) {
|
||||
const created = store.createOrReplaceImportedInstance({
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
notes: entry.notes,
|
||||
minecraft: {
|
||||
version: entry.version,
|
||||
loader: entry.loader,
|
||||
loaderVersion: entry.loaderVersion,
|
||||
},
|
||||
});
|
||||
|
||||
const patch = {};
|
||||
if (entry.javaPath || entry.minMemMb || entry.maxMemMb || entry.extraJavaArgs) {
|
||||
patch.java = {
|
||||
path: entry.javaPath || '',
|
||||
minMemMb: entry.minMemMb || null,
|
||||
maxMemMb: entry.maxMemMb || null,
|
||||
extraArgs: entry.extraJavaArgs || '',
|
||||
};
|
||||
}
|
||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||
|
||||
let copied = false;
|
||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||
copyInto(entry.gameDir, store.gameDir(created.id), !!entry.usesDefaultGameDir);
|
||||
copied = true;
|
||||
}
|
||||
return { id: created.id, name: created.name, copied };
|
||||
}
|
||||
|
||||
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };
|
||||
+101
-101
@@ -1,101 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_GRACE_MS = 15000;
|
||||
|
||||
const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/;
|
||||
const FATAL_OUTPUT = [
|
||||
/---- Minecraft Crash Report ----/i,
|
||||
/\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i,
|
||||
/\b(?:This crash report has been saved to|Saving crash report to)\b/i,
|
||||
/\bA fatal error has been detected by the Java Runtime Environment\b/i,
|
||||
/Exception in thread "(?:Render thread|Client thread|main)"/i,
|
||||
/\[(?:Render thread|Client thread|main)\/FATAL\]/i,
|
||||
];
|
||||
|
||||
function createProcessWatchdog(options) {
|
||||
const graceMs = options.graceMs || DEFAULT_GRACE_MS;
|
||||
const schedule = options.setTimer || setTimeout;
|
||||
const cancel = options.clearTimer || clearTimeout;
|
||||
const buffers = { stdout: '', stderr: '' };
|
||||
let timer = null;
|
||||
let armed = false;
|
||||
let crashed = false;
|
||||
let ended = false;
|
||||
let forced = false;
|
||||
// bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen
|
||||
// wurde – im Gegensatz zu `armed` wird das NICHT durch markCrash()
|
||||
// zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt
|
||||
// weiterhin als "war ein regulärer Shutdown" erkennbar bleibt
|
||||
let sawCleanShutdown = false;
|
||||
|
||||
function clear() {
|
||||
if (timer !== null) cancel(timer);
|
||||
timer = null;
|
||||
armed = false;
|
||||
}
|
||||
|
||||
function markCrash() {
|
||||
crashed = true;
|
||||
clear();
|
||||
}
|
||||
|
||||
function scheduleKill() {
|
||||
if (timer !== null) cancel(timer);
|
||||
timer = schedule(() => {
|
||||
timer = null;
|
||||
if (ended || crashed || !armed || !options.isRunning()) return;
|
||||
try {
|
||||
forced = options.kill() === true;
|
||||
} catch {
|
||||
forced = false;
|
||||
}
|
||||
if (forced && options.onForce) options.onForce(graceMs);
|
||||
else if (!forced && options.isRunning() && options.onError) options.onError();
|
||||
}, graceMs);
|
||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||
}
|
||||
|
||||
function inspectLine(line) {
|
||||
const isCleanShutdown = CLEAN_SHUTDOWN.test(line);
|
||||
if (isCleanShutdown) sawCleanShutdown = true;
|
||||
if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) {
|
||||
markCrash();
|
||||
return;
|
||||
}
|
||||
if (ended || crashed || armed || !isCleanShutdown) return;
|
||||
|
||||
armed = true;
|
||||
scheduleKill();
|
||||
}
|
||||
|
||||
function feed(chunk, stream) {
|
||||
if (ended) return;
|
||||
const key = stream === 'stderr' ? 'stderr' : 'stdout';
|
||||
const text = buffers[key] + String(chunk || '');
|
||||
const lines = text.split(/\r?\n/);
|
||||
buffers[key] = lines.pop() || '';
|
||||
for (const line of lines) inspectLine(line);
|
||||
if (armed && !crashed && !ended && lines.length) scheduleKill();
|
||||
}
|
||||
|
||||
function end() {
|
||||
ended = true;
|
||||
clear();
|
||||
}
|
||||
|
||||
return {
|
||||
feed,
|
||||
end,
|
||||
get armed() { return armed; },
|
||||
get crashed() { return crashed; },
|
||||
get forced() { return forced; },
|
||||
get sawCleanShutdown() { return sawCleanShutdown; },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_GRACE_MS,
|
||||
CLEAN_SHUTDOWN,
|
||||
FATAL_OUTPUT,
|
||||
createProcessWatchdog,
|
||||
};
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_GRACE_MS = 15000;
|
||||
|
||||
const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/;
|
||||
const FATAL_OUTPUT = [
|
||||
/---- Minecraft Crash Report ----/i,
|
||||
/\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i,
|
||||
/\b(?:This crash report has been saved to|Saving crash report to)\b/i,
|
||||
/\bA fatal error has been detected by the Java Runtime Environment\b/i,
|
||||
/Exception in thread "(?:Render thread|Client thread|main)"/i,
|
||||
/\[(?:Render thread|Client thread|main)\/FATAL\]/i,
|
||||
];
|
||||
|
||||
function createProcessWatchdog(options) {
|
||||
const graceMs = options.graceMs || DEFAULT_GRACE_MS;
|
||||
const schedule = options.setTimer || setTimeout;
|
||||
const cancel = options.clearTimer || clearTimeout;
|
||||
const buffers = { stdout: '', stderr: '' };
|
||||
let timer = null;
|
||||
let armed = false;
|
||||
let crashed = false;
|
||||
let ended = false;
|
||||
let forced = false;
|
||||
// bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen
|
||||
// wurde – im Gegensatz zu `armed` wird das NICHT durch markCrash()
|
||||
// zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt
|
||||
// weiterhin als "war ein regulärer Shutdown" erkennbar bleibt
|
||||
let sawCleanShutdown = false;
|
||||
|
||||
function clear() {
|
||||
if (timer !== null) cancel(timer);
|
||||
timer = null;
|
||||
armed = false;
|
||||
}
|
||||
|
||||
function markCrash() {
|
||||
crashed = true;
|
||||
clear();
|
||||
}
|
||||
|
||||
function scheduleKill() {
|
||||
if (timer !== null) cancel(timer);
|
||||
timer = schedule(() => {
|
||||
timer = null;
|
||||
if (ended || crashed || !armed || !options.isRunning()) return;
|
||||
try {
|
||||
forced = options.kill() === true;
|
||||
} catch {
|
||||
forced = false;
|
||||
}
|
||||
if (forced && options.onForce) options.onForce(graceMs);
|
||||
else if (!forced && options.isRunning() && options.onError) options.onError();
|
||||
}, graceMs);
|
||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||
}
|
||||
|
||||
function inspectLine(line) {
|
||||
const isCleanShutdown = CLEAN_SHUTDOWN.test(line);
|
||||
if (isCleanShutdown) sawCleanShutdown = true;
|
||||
if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) {
|
||||
markCrash();
|
||||
return;
|
||||
}
|
||||
if (ended || crashed || armed || !isCleanShutdown) return;
|
||||
|
||||
armed = true;
|
||||
scheduleKill();
|
||||
}
|
||||
|
||||
function feed(chunk, stream) {
|
||||
if (ended) return;
|
||||
const key = stream === 'stderr' ? 'stderr' : 'stdout';
|
||||
const text = buffers[key] + String(chunk || '');
|
||||
const lines = text.split(/\r?\n/);
|
||||
buffers[key] = lines.pop() || '';
|
||||
for (const line of lines) inspectLine(line);
|
||||
if (armed && !crashed && !ended && lines.length) scheduleKill();
|
||||
}
|
||||
|
||||
function end() {
|
||||
ended = true;
|
||||
clear();
|
||||
}
|
||||
|
||||
return {
|
||||
feed,
|
||||
end,
|
||||
get armed() { return armed; },
|
||||
get crashed() { return crashed; },
|
||||
get forced() { return forced; },
|
||||
get sawCleanShutdown() { return sawCleanShutdown; },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_GRACE_MS,
|
||||
CLEAN_SHUTDOWN,
|
||||
FATAL_OUTPUT,
|
||||
createProcessWatchdog,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user