Upload via GUI (25 Dateien)

This commit is contained in:
Git Manager GUI
2026-08-02 10:14:02 +02:00
parent dc55e64ca3
commit a803f297ea
15 changed files with 2587 additions and 324 deletions
+440
View File
@@ -0,0 +1,440 @@
'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,
};
+196
View File
@@ -0,0 +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 };
}
module.exports = { scan, importOne, resolveInstancesDir };
+138 -31
View File
@@ -11,8 +11,7 @@
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
<header class="toolbar">
<button id="btn-new" class="tbtn" data-ic="plus">Instanz hinzufügen</button>
<button id="btn-import" class="tbtn" data-ic="import" title="Instanz aus .zip importieren">Importieren</button>
<button id="btn-modpack" class="tbtn" data-ic="package" title="Modrinth-Modpack (.mrpack)">Modpack</button>
<button id="btn-import" class="tbtn" data-ic="import" title="Instanzen importieren">Importieren</button>
<button id="btn-open-dir" class="tbtn" data-ic="folder" title="Ordner öffnen">Ordner</button>
<span class="tbsep"></span>
<button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button>
@@ -192,6 +191,7 @@
<button class="snav" data-pane="pane-start">Start &amp; Java</button>
<button class="snav" data-pane="pane-network">Netzwerk</button>
<button class="snav" data-pane="pane-accounts">Konten</button>
<button class="snav" data-pane="pane-backup">Backup</button>
<button class="snav" data-pane="pane-info">Info</button>
</nav>
<div class="settings-content">
@@ -229,12 +229,6 @@
</div>
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small>
</label>
<label class="field">
<span>Instanzen übernehmen</span>
<button id="btn-mmc" class="btn">Aus MultiMC importieren…</button>
<small class="hint">Übernimmt Instanzen samt Version, Loader, Gruppe und Spieldaten aus einem MultiMC-Ordner.</small>
</label>
</section>
<section class="settings-card">
@@ -426,6 +420,44 @@
</div>
</div>
<div id="pane-backup" class="spane hidden">
<div class="settings-hero">
<h4>Backup</h4>
<p>Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.</p>
</div>
<div class="settings-grid">
<section class="settings-card settings-card-wide">
<h5>Globales Backup</h5>
<small class="hint">Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.</small>
<div class="path-row" style="margin-top:14px">
<button id="s-backup-global" class="btn btn-primary">Backup erstellen</button>
<span id="s-backup-status" class="hint">Noch kein Backup erstellt.</span>
</div>
<div id="s-backup-progress" class="backup-progress hidden" aria-hidden="true">
<div class="backup-progress-bar"><div id="s-backup-progress-fill" class="backup-progress-fill"></div></div>
<div id="s-backup-progress-text" class="backup-progress-text hint">0 %</div>
</div>
</section>
<section class="settings-card settings-card-wide">
<h5>Wiederherstellen</h5>
<small class="hint">Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.</small>
<label class="field backup-select-field">
<span>Verfügbares Backup</span>
<select id="s-restore-backup"></select>
</label>
<div class="path-row" style="margin-top:14px">
<button id="s-restore-global" class="btn">Backup wiederherstellen</button>
<span id="s-restore-status" class="hint">Noch kein Backup wiederhergestellt.</span>
</div>
<div id="s-restore-progress" class="backup-progress hidden" aria-hidden="true">
<div class="backup-progress-bar"><div id="s-restore-progress-fill" class="backup-progress-fill"></div></div>
<div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div>
</div>
</section>
</div>
</div>
<div id="pane-info" class="spane hidden">
<div class="settings-hero">
<h4>Info</h4>
@@ -643,6 +675,7 @@
<span class="upd-arrow"></span>
<span id="upd-new" class="upd-newv"></span>
</p>
<p id="upd-channel" class="upd-channel hint">Update-Kanal: </p>
<div id="upd-notes" class="upd-notes"></div>
<div id="upd-progress" class="upd-progress hidden">
<div class="upd-bar"><div id="upd-bar-fill" class="upd-bar-fill"></div></div>
@@ -657,41 +690,115 @@
</div>
</div>
<!-- ===================== Dialog: MultiMC-Import ===================== -->
<div id="modal-mmc" class="modal hidden">
<!-- ===================== Dialog: Import ===================== -->
<div id="modal-import" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3>Instanzen aus MultiMC übernehmen</h3>
<h3>Importieren</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<label class="field">
<span>MultiMC-Ordner</span>
<div class="path-row">
<input id="mmc-path" type="text" placeholder="z. B. E:\Minecraft\MultiMC" />
<button id="mmc-pick" class="btn">Wählen…</button>
<button id="mmc-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="mmc-status" class="hint">Gib den Ordner an, in dem MultiMC.exe liegt.</small>
</label>
<div class="mods-tabs import-tabs">
<button id="import-tab-zip" class="mtab on" data-kind="zip">ZIP-Archiv</button>
<button id="import-tab-modpack" class="mtab" data-kind="modpack">Modpack</button>
<button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button>
<button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button>
<button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
</div>
<div id="mmc-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="mmc-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="mmc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
</label>
<div id="import-pane-zip" class="import-pane">
<p class="import-copy">Importiert eine exportierte AeroMC-Instanz aus einem ZIP-Archiv.</p>
<p class="hint">Wähle ein Archiv mit einer gültigen instance.json. Der Launcher legt daraus automatisch eine neue Instanz an.</p>
</div>
<div id="import-pane-modpack" class="import-pane hidden">
<p class="import-copy">Importiert ein Modrinth-Modpack als neue Instanz.</p>
<p class="hint">Unterstützt werden .mrpack und kompatible ZIP-Dateien mit modrinth.index.json.</p>
</div>
<div id="import-pane-mc" class="import-pane hidden">
<label class="field">
<span>Minecraft-Launcher-Ordner</span>
<div class="path-row">
<input id="mc-path" type="text" placeholder="z. B. C:\Users\DeinName\AppData\Roaming\.minecraft" />
<button id="mc-pick" class="btn">Wählen…</button>
<button id="mc-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="mc-status" class="hint">Leer lassen nutzt automatisch den Standardpfad unter Windows.</small>
</label>
<div id="mc-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="mc-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="mc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Mods, Welten, Konfiguration)</span>
</label>
</div>
<div id="mc-list" class="mods-list"></div>
</div>
</div>
<div id="import-pane-cf" class="import-pane hidden">
<label class="field">
<span>CurseForge-Instanzen</span>
<div class="path-row">
<input id="cf-path" type="text" placeholder="z. B. C:\Users\DeinName\curseforge\minecraft\Instances" />
<button id="cf-pick" class="btn">Wählen…</button>
<button id="cf-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="cf-status" class="hint">Standardpfad unter Windows: C:\Users\DeinName\curseforge\minecraft\Instances</small>
</label>
<div id="cf-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="cf-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="cf-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
</label>
</div>
<div id="cf-list" class="mods-list"></div>
</div>
</div>
<div id="import-pane-mmc" class="import-pane hidden">
<label class="field">
<span>MultiMC- oder Prism-Ordner</span>
<div class="path-row">
<input id="mmc-path" type="text" placeholder="z. B. E:\Minecraft\MultiMC" />
<button id="mmc-pick" class="btn">Wählen…</button>
<button id="mmc-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="mmc-status" class="hint">Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.</small>
</label>
<div id="mmc-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="mmc-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="mmc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
</label>
</div>
<div id="mmc-list" class="mods-list"></div>
</div>
<div id="mmc-list" class="mods-list"></div>
</div>
</div>
<div class="modal-foot">
<div id="mmc-progress" class="hint"></div>
<div id="mc-progress" class="hint hidden"></div>
<div id="mmc-progress" class="hint hidden"></div>
<div id="cf-progress" class="hint hidden"></div>
<div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button>
<button id="mmc-do" class="btn btn-primary" disabled>Übernehmen</button>
<button id="import-zip-do" class="btn btn-primary">ZIP wählen</button>
<button id="import-modpack-do" class="btn btn-primary hidden">Modpack wählen…</button>
<button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
</div>
</div>
</div>
+34 -2
View File
@@ -150,6 +150,36 @@ async function prepareLibraries(vj, sharedDir, nativesDir, onProgress) {
return classpath;
}
function classpathArtifactKey(filePath, sharedDir) {
const libDir = path.join(sharedDir, 'libraries');
const rel = path.relative(libDir, filePath);
if (rel.startsWith('..') || path.isAbsolute(rel)) return path.normalize(filePath).toLowerCase();
const parts = rel.split(path.sep);
if (parts.length < 4) return rel.replace(/\\/g, '/').toLowerCase();
const fileName = parts[parts.length - 1];
const version = parts[parts.length - 2];
const artifact = parts[parts.length - 3];
const group = parts.slice(0, -3).join('/').toLowerCase();
const base = fileName.replace(/\.[^.]+$/, '');
const prefix = `${artifact}-${version}`;
const classifier = base.startsWith(prefix + '-') ? base.slice(prefix.length + 1).toLowerCase() : '';
return `${group}:${artifact.toLowerCase()}:${classifier}`;
}
function dedupeClasspath(classpath, sharedDir) {
const seen = new Set();
const out = [];
for (const filePath of classpath) {
const key = classpathArtifactKey(filePath, sharedDir);
if (seen.has(key)) continue;
seen.add(key);
out.push(filePath);
}
return out;
}
// entpackt eine Natives-JAR (ZIP) nach nativesDir (ohne externe Abhängigkeit)
async function extractNatives(jarPath, nativesDir, extractRule) {
ensureDir(nativesDir);
@@ -407,6 +437,8 @@ async function prepareAndLaunch(opts, onProgress) {
effectiveVj = mergeLoader(vj, fvj);
}
const finalClasspath = dedupeClasspath(classpath, opts.sharedDir);
// Ohne Login: alles vorbereitet, aber kein Start (kein Crack-Bypass)
if (!opts.auth) {
return {
@@ -425,7 +457,7 @@ async function prepareAndLaunch(opts, onProgress) {
gameDir: opts.gameDir,
sharedDir: opts.sharedDir,
nativesDir,
classpath,
classpath: finalClasspath,
minMemMb: opts.minMemMb || 2048,
maxMemMb: opts.maxMemMb || 4096,
extraArgs: opts.extraArgs || '',
@@ -475,5 +507,5 @@ async function prepareAndLaunch(opts, onProgress) {
module.exports = {
prepareAndLaunch, getVersionJson, buildArgs, pickJava, rulesAllow, prepareLibraries, prepareAssets,
downloadIfNeeded, extractNatives,
downloadIfNeeded, extractNatives, mergeLoader, dedupeClasspath, classpathArtifactKey,
};
+106 -3
View File
@@ -20,7 +20,10 @@ const auth = require('./auth');
const updater = require('./updater');
const icons = require('./icons');
const bedrock = require('./bedrock');
const backup = require('./backup');
const mmcimport = require('./mmcimport');
const cfimport = require('./cfimport');
const mcimport = require('./mcimport');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici');
@@ -412,7 +415,7 @@ async function importMrpack(buf, zip) {
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
const created = store.createInstance({
const created = store.createOrReplaceImportedInstance({
name: index.name || 'Modpack',
minecraft: { version: deps.minecraft || '', loader, loaderVersion },
});
@@ -495,8 +498,8 @@ ipcMain.handle('instances:import', async () => {
throw new Error('Keine gültige Instanz im Archiv (instance.json fehlt).');
}
const imported = JSON.parse(fs.readFileSync(path.join(base, 'instance.json'), 'utf8'));
const created = store.createInstance({
name: (imported.name || 'Import') + ' (Import)',
const created = store.createOrReplaceImportedInstance({
name: imported.name || 'Import',
icon: imported.icon || 'grass',
notes: imported.notes || '',
minecraft: imported.minecraft || {},
@@ -519,6 +522,54 @@ ipcMain.handle('instances:import', async () => {
}
});
ipcMain.handle('backup:global', async () => {
const destinationPath = backup.resolveAutoBackupPath(app.getPath('documents'));
try {
const result = await backup.createGlobalBackup({
instancesDir: store.getInstancesDir(),
userDataDir: app.getPath('userData'),
includeSettings: true,
destinationPath,
onProgress: (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
},
});
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:progress', { mode: 'create', percent: 100, detail: 'Backup abgeschlossen' });
}
return result;
} catch (err) {
return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message };
}
});
ipcMain.handle('backup:list', async () => backup.listGlobalBackups(app.getPath('documents')));
ipcMain.handle('backup:restore', async (_e, sourcePath) => {
if (!sourcePath || !String(sourcePath).trim()) {
return { ok: false, message: 'Kein Backup ausgewählt.' };
}
try {
const currentSettings = store.getSettings();
const result = await backup.restoreGlobalBackup({
sourcePath,
instancesDir: store.getInstancesDir(),
userDataDir: app.getPath('userData'),
includeSettings: true,
instancesDirSetting: currentSettings.instancesDir || null,
onProgress: (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
},
});
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:progress', { mode: 'restore', percent: 100, detail: 'Wiederherstellen abgeschlossen' });
}
return result;
} catch (err) {
return { ok: false, message: 'Wiederherstellen fehlgeschlagen: ' + err.message };
}
});
// Phase 2: echter Vanilla-Download & -Start (Start selbst ist an Login gebunden).
ipcMain.handle('instances:launch', async (_e, id, accountId) => {
const inst = store.getInstance(id);
@@ -666,6 +717,58 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => {
return { ok: true, imported: done, failed };
});
ipcMain.handle('cf:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('cf:scan', (_e, folder) => {
try { return cfimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('cf:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try { done.push(cfimport.importOne(store, entry, copyData !== false)); }
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Minecraft-Launcher-Ordner waehlen (meist .minecraft)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('mc:scan', (_e, folder) => {
try { return mcimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('mc:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try { done.push(mcimport.importOne(store, entry, copyData !== false)); }
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
// ---------------------------------------------------------------------------
// IPC Minecraft Bedrock (Windows-Store-App)
// ---------------------------------------------------------------------------
+217
View File
@@ -0,0 +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 };
}
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };
+1 -1
View File
@@ -113,7 +113,7 @@ function scan(inputPath) {
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
*/
function importOne(store, entry, copyData = true) {
const created = store.createInstance({
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
notes: entry.notes,
+24
View File
@@ -45,6 +45,14 @@ contextBridge.exposeInMainWorld('api', {
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
launchInstance: (id, accountId) => ipcRenderer.invoke('instances:launch', id, accountId),
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
onBackupProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('backup:progress', l);
return () => ipcRenderer.removeListener('backup:progress', l);
},
importInstance: () => ipcRenderer.invoke('instances:import'),
importModpack: () => ipcRenderer.invoke('modpack:import'),
onLaunchProgress: (cb) => {
@@ -95,6 +103,22 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('mmc:progress', l);
return () => ipcRenderer.removeListener('mmc:progress', l);
},
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
onCfProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('cf:progress', l);
return () => ipcRenderer.removeListener('cf:progress', l);
},
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
onMcProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('mc:progress', l);
return () => ipcRenderer.removeListener('mc:progress', l);
},
// Bedrock (Windows-Store-Ausgabe)
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
+680 -65
View File
@@ -473,6 +473,7 @@ const LOADER_LABEL = {
// ---------- Zustand ----------
let instances = [];
let currentDetailId = null;
let selectedInstanceIds = new Set();
let editingId = null;
let confirmAction = null;
let mcVersionData = null; // gecachte Mojang-Versionsliste
@@ -525,14 +526,235 @@ function applyTheme(theme) {
// ---------- Instanzen aus MultiMC übernehmen ----------
let mmcFound = [];
let cfFound = [];
let mcFound = [];
let importKind = 'zip';
function openMmc() {
async function ensureDefaultMcScan() {
if (importKind !== 'mc') return;
if (el('mc-path').value.trim()) return;
if (mcFound.length) return;
await scanMc();
}
function setImportKind(kind) {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc'].forEach((entryKind) => {
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
});
el('import-zip-do').classList.toggle('hidden', importKind !== 'zip');
el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack');
el('mc-do').classList.toggle('hidden', importKind !== 'mc');
el('cf-do').classList.toggle('hidden', importKind !== 'cf');
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
}
function openImportDialog(kind = 'zip') {
mcFound = [];
el('mc-result').classList.add('hidden');
el('mc-do').disabled = true;
el('mc-progress').textContent = '';
el('mc-progress').classList.add('hidden');
el('mc-status').textContent = 'Leer lassen nutzt automatisch den Standardpfad unter Windows.';
el('cf-result').classList.add('hidden');
el('cf-do').disabled = true;
el('cf-progress').textContent = '';
el('cf-progress').classList.add('hidden');
el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances';
el('mmc-result').classList.add('hidden');
el('mmc-do').disabled = true;
el('mmc-progress').textContent = '';
el('mmc-status').textContent = 'Gib den Ordner an, in dem MultiMC.exe liegt.';
show('modal-mmc');
el('mmc-path').focus();
el('mmc-progress').classList.add('hidden');
el('mmc-status').textContent = 'Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.';
setImportKind(kind);
show('modal-import');
if (importKind === 'mc') {
el('mc-path').focus();
ensureDefaultMcScan();
}
if (importKind === 'cf') el('cf-path').focus();
if (importKind === 'mmc') el('mmc-path').focus();
}
async function runZipImport() {
el('import-zip-do').disabled = true;
try {
const res = await window.api.importInstance();
if (res.canceled) return;
if (res.ok) {
await reload();
hide('modal-import');
toast('Importiert: ' + res.instance.name);
return;
}
toast(res.message || 'Import fehlgeschlagen.', true);
} finally {
el('import-zip-do').disabled = false;
}
}
async function runModpackImport() {
el('import-modpack-do').disabled = true;
status('Importiere Modpack …');
try {
const res = await window.api.importModpack();
status('Bereit'); el('status-right').textContent = '';
if (res.canceled) return;
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`);
return;
}
toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
} finally {
status('Bereit'); el('status-right').textContent = '';
el('import-modpack-do').disabled = false;
}
}
async function scanMc() {
const folder = el('mc-path').value.trim();
el('mc-status').textContent = 'Suche Launcher-Profile …';
const res = await window.api.mcScan(folder);
if (!res.ok) {
el('mc-status').textContent = '✖ Dort wurde kein launcher_profiles.json gefunden.';
el('mc-result').classList.add('hidden');
el('mc-do').disabled = true;
return;
}
mcFound = res.instances;
if (!folder && res.rootDir) el('mc-path').value = res.rootDir;
el('mc-status').textContent = `${res.count} Profil${res.count === 1 ? '' : 'e'} gefunden.`;
el('mc-result').classList.remove('hidden');
el('mc-all').checked = true;
renderMcList();
}
function renderMcList() {
const box = el('mc-list');
box.innerHTML = '';
if (!mcFound.length) { box.innerHTML = '<div class="mods-empty">Keine Profile gefunden.</div>'; return; }
mcFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'ohne Version', loaderTxt, inst.usesDefaultGameDir ? 'Standard-Spielordner' : 'eigener Spielordner']
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateMcButton();
}
function selectedMc() {
return [...document.querySelectorAll('#mc-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => mcFound[Number(c.dataset.idx)]);
}
function updateMcButton() {
const n = selectedMc().length;
el('mc-do').disabled = n === 0;
el('mc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runMcImport() {
const entries = selectedMc();
if (!entries.length) return;
const copy = el('mc-copy').checked;
el('mc-do').disabled = true;
el('mc-progress').textContent = 'Übernehme …';
el('mc-progress').classList.remove('hidden');
const res = await window.api.mcImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
async function scanCf() {
const folder = el('cf-path').value.trim();
if (!folder) { el('cf-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
el('cf-status').textContent = 'Suche Instanzen …';
const res = await window.api.cfScan(folder);
if (!res.ok) {
el('cf-status').textContent = '✖ Dort wurde kein CurseForge-Instances-Ordner gefunden.';
el('cf-result').classList.add('hidden');
el('cf-do').disabled = true;
return;
}
cfFound = res.instances;
el('cf-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`;
el('cf-result').classList.remove('hidden');
el('cf-all').checked = true;
renderCfList();
}
function renderCfList() {
const box = el('cf-list');
box.innerHTML = '';
if (!cfFound.length) { box.innerHTML = '<div class="mods-empty">Keine Instanzen gefunden.</div>'; return; }
cfFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'ohne Version', loaderTxt]
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateCfButton();
}
function selectedCf() {
return [...document.querySelectorAll('#cf-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => cfFound[Number(c.dataset.idx)]);
}
function updateCfButton() {
const n = selectedCf().length;
el('cf-do').disabled = n === 0;
el('cf-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runCfImport() {
const entries = selectedCf();
if (!entries.length) return;
const copy = el('cf-copy').checked;
el('cf-do').disabled = true;
el('cf-progress').textContent = 'Übernehme …';
el('cf-progress').classList.remove('hidden');
const res = await window.api.cfImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
async function scanMmc() {
@@ -594,10 +816,11 @@ async function runMmcImport() {
const copy = el('mmc-copy').checked;
el('mmc-do').disabled = true;
el('mmc-progress').textContent = 'Übernehme …';
el('mmc-progress').classList.remove('hidden');
const res = await window.api.mmcImport(entries, copy);
await reload();
hide('modal-mmc');
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
@@ -741,11 +964,12 @@ function showUpdateDialog(info) {
pendingUpdate = info;
el('upd-current').textContent = formatVersionLabel(info.currentVersion);
el('upd-new').textContent = formatVersionLabel(info.latestVersion);
el('upd-channel').textContent = `Update-Kanal: ${info.installKind === 'portable' ? 'Portable' : 'Setup'}`;
el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.';
el('upd-progress').classList.add('hidden');
el('upd-bar-fill').style.width = '0%';
el('upd-install').disabled = !info.asset;
el('upd-install').textContent = info.asset ? 'Installieren' : 'Kein Installer im Release';
el('upd-install').textContent = info.asset ? 'Installieren' : `Kein passendes ${info.installKind === 'portable' ? 'Portable' : 'Setup'}-Update im Release`;
show('modal-update');
}
@@ -954,9 +1178,114 @@ function applyIcons(root = document) {
}
// ---------- Instanz-Kacheln ----------
const SINGLE_SELECTION_BUTTON_IDS = [
'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens',
'p-mcfolder', 'p-configfolder', 'p-instfolder', 'p-shortcut', 'p-export', 'p-copy',
];
function orderedSelectedInstanceIds() {
return instances.map((inst) => inst.id).filter((id) => selectedInstanceIds.has(id));
}
function syncCardSelectionUi() {
document.querySelectorAll('.card').forEach((card) => {
card.classList.toggle('sel', selectedInstanceIds.has(card.dataset.id));
});
}
function setSingleSelectionActionsDisabled(disabled) {
SINGLE_SELECTION_BUTTON_IDS.forEach((id) => { el(id).disabled = disabled; });
const groupLine = el('p-groupline');
groupLine.style.pointerEvents = disabled ? 'none' : '';
groupLine.style.color = disabled ? 'var(--dim)' : '';
}
async function refreshSelectionPanel() {
const selectedIds = orderedSelectedInstanceIds();
const count = selectedIds.length;
const panelC = el('p-content');
const panelE = el('p-empty');
syncCardSelectionUi();
if (!count) {
currentDetailId = null;
setSingleSelectionActionsDisabled(false);
el('p-delete').disabled = false;
el('p-delete').textContent = 'Löschen';
panelC.classList.add('hidden');
panelE.classList.remove('hidden');
el('status-left').textContent = 'Bereit';
el('status-play').textContent = 'Keine Instanz ausgewählt';
el('status-total').textContent = '';
return;
}
if (count > 1) {
currentDetailId = null;
setSingleSelectionActionsDisabled(true);
el('p-delete').disabled = false;
el('p-delete').textContent = `${count} Instanzen löschen`;
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
el('p-icon').innerHTML = iconOf('chest', 60);
el('p-name').textContent = `${count} Instanzen ausgewählt`;
el('p-groupline').textContent = 'Mehrfachauswahl aktiv';
el('status-left').textContent = `${count} Instanzen ausgewählt`;
el('status-play').textContent = 'Strg+Klick wählt weitere Instanzen aus oder ab';
el('status-total').textContent = 'Sammellöschen verfügbar';
return;
}
const id = selectedIds[0];
currentDetailId = id;
setSingleSelectionActionsDisabled(false);
el('p-delete').disabled = false;
el('p-delete').textContent = 'Löschen';
window.api.saveSettings({ lastSelectedId: id || '' });
const inst = id ? await window.api.getInstance(id) : null;
if (!inst) {
selectedInstanceIds.delete(id);
panelC.classList.add('hidden');
panelE.classList.remove('hidden');
el('status-left').textContent = 'Bereit';
el('status-play').textContent = 'Keine Instanz ausgewählt';
el('status-total').textContent = '';
return;
}
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
const iconData = inst.customImage ? await window.api.instanceIcon(id) : null;
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
el('p-name').textContent = inst.name;
el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe';
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
}
async function applySelection(ids) {
const valid = new Set(instances.map((inst) => inst.id));
selectedInstanceIds = new Set((ids || []).filter((id) => valid.has(id)));
await refreshSelectionPanel();
}
async function toggleInstanceSelection(id) {
const next = new Set(selectedInstanceIds);
if (next.has(id)) next.delete(id);
else next.add(id);
await applySelection([...next]);
}
function buildCard(inst) {
const card = document.createElement('div');
card.className = 'card' + (inst.id === currentDetailId ? ' sel' : '');
card.className = 'card' + (selectedInstanceIds.has(inst.id) ? ' sel' : '');
card.dataset.id = inst.id;
const iconHtml = inst.iconData
@@ -974,14 +1303,17 @@ function buildCard(inst) {
<div class="card-name">${escapeHtml(inst.name)}</div>`;
// einfacher Klick = auswählen (MultiMC), Doppelklick = starten
card.addEventListener('click', () => selectInstance(inst.id));
card.addEventListener('click', (e) => {
if (e.ctrlKey || e.metaKey) toggleInstanceSelection(inst.id);
else selectInstance(inst.id);
});
card.addEventListener('dblclick', () => launch(inst.id));
// Rechtsklick: Instanz auswählen und Kontextmenü öffnen
card.addEventListener('contextmenu', (e) => {
card.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (!selectedInstanceIds.has(inst.id)) await selectInstance(inst.id);
openContextMenu(e.clientX, e.clientY); // sofort anzeigen
selectInstance(inst.id); // Auswahl folgt
});
// Drag & Drop in Gruppen
@@ -1060,7 +1392,10 @@ function fillGroupDatalist() {
async function reload() {
instances = await window.api.listInstances();
const valid = new Set(instances.map((inst) => inst.id));
selectedInstanceIds = new Set([...selectedInstanceIds].filter((id) => valid.has(id)));
render();
await refreshSelectionPanel();
}
// ---------- Minecraft-Versionen ----------
@@ -1299,35 +1634,7 @@ async function saveInstance() {
// ---------- Auswahl + rechte Aktionsleiste (MultiMC-Stil) ----------
async function selectInstance(id) {
currentDetailId = id;
document.querySelectorAll('.card').forEach((c) => c.classList.toggle('sel', c.dataset.id === id));
// Auswahl merken, damit sie beim nächsten Start wiederhergestellt wird
window.api.saveSettings({ lastSelectedId: id || '' });
const inst = id ? await window.api.getInstance(id) : null;
const panelC = el('p-content');
const panelE = el('p-empty');
if (!inst) {
panelC.classList.add('hidden');
panelE.classList.remove('hidden');
el('status-left').textContent = 'Bereit';
el('status-play').textContent = 'Keine Instanz ausgewählt';
el('status-total').textContent = '';
return;
}
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
const iconData = inst.customImage ? await window.api.instanceIcon(id) : null;
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
el('p-name').textContent = inst.name;
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
await applySelection(id ? [id] : []);
}
let launching = false;
@@ -1703,6 +2010,14 @@ async function openSettings() {
el('s-proxy-port').value = s.proxyPort || '';
el('s-proxy-user').value = s.proxyUser || '';
el('s-proxy-pass').value = s.proxyPass || '';
el('s-backup-status').className = 'hint';
el('s-backup-status').textContent = 'Noch kein Backup erstellt.';
el('s-backup-global').disabled = false;
el('s-restore-status').className = 'hint';
el('s-restore-status').textContent = 'Noch kein Backup wiederhergestellt.';
await refreshGlobalBackupOptions();
resetBackupProgressUi();
setBackupButtonsBusy(false);
el('s-instances-dir').value = await window.api.getInstancesDir();
el('s-java').value = s.javaPath || '';
el('s-min-mem').value = s.defaultMinMemMb;
@@ -1809,6 +2124,257 @@ async function saveSettings() {
await reload();
}
function resetBackupProgressUi() {
['backup', 'restore'].forEach((kind) => {
clearBackupProgressTimer(kind);
backupProgressUiState[kind].shownPercent = 0;
backupProgressUiState[kind].targetPercent = 0;
backupProgressUiState[kind].detail = '';
backupProgressUiState[kind].packing = false;
el(`s-${kind}-progress`).dataset.percent = '0';
el(`s-${kind}-progress`).dataset.complete = '0';
el(`s-${kind}-progress`).classList.add('hidden');
el(`s-${kind}-progress-fill`).style.width = '0%';
el(`s-${kind}-progress-fill`).classList.remove('backup-progress-fill-busy');
el(`s-${kind}-progress-text`).textContent = '0 %';
});
}
const backupProgressActive = { backup: false, restore: false };
const backupProgressUiState = {
backup: { timer: null, shownPercent: 0, targetPercent: 0, detail: '', packing: false },
restore: { timer: null, shownPercent: 0, targetPercent: 0, detail: '', packing: false },
};
function clearBackupProgressTimer(prefix) {
const state = backupProgressUiState[prefix];
if (!state.timer) return;
clearInterval(state.timer);
state.timer = null;
}
function renderBackupProgress(prefix) {
const state = backupProgressUiState[prefix];
const box = el(`s-${prefix}-progress`);
const fill = el(`s-${prefix}-progress-fill`);
const text = el(`s-${prefix}-progress-text`);
const displayPercent = state.targetPercent >= 100 ? 100 : Math.floor(state.shownPercent);
box.dataset.percent = String(displayPercent);
box.dataset.complete = state.targetPercent >= 100 && state.shownPercent >= 100 ? '1' : '0';
box.classList.remove('hidden');
fill.classList.toggle('backup-progress-fill-busy', state.packing && state.targetPercent < 99);
fill.style.width = `${Math.max(0, Math.min(100, state.shownPercent))}%`;
text.textContent = `${displayPercent} %${state.detail ? ' · ' + state.detail : ''}`;
}
function ensureBackupProgressTimer(prefix) {
const state = backupProgressUiState[prefix];
if (state.timer) return;
state.timer = setInterval(() => {
const gap = state.targetPercent - state.shownPercent;
if (gap > 0.05) {
const step = state.packing ? Math.max(0.18, gap * 0.18) : Math.max(0.8, gap * 0.4);
state.shownPercent = Math.min(state.targetPercent, state.shownPercent + step);
} else {
state.shownPercent = state.targetPercent;
}
renderBackupProgress(prefix);
if (state.targetPercent >= 100 && state.shownPercent >= 100) clearBackupProgressTimer(prefix);
}, 120);
}
function setBackupProgressActive(mode, active) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
backupProgressActive[prefix] = !!active;
}
function applyBackupProgressEvent(mode, percent, detail) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
if (!backupProgressActive[prefix]) return;
updateBackupProgressUi(mode, percent, detail);
}
function setBackupButtonsBusy(busy) {
const restoreSelect = el('s-restore-backup');
const hasPlaceholderOnly = restoreSelect.options.length === 1 && !restoreSelect.options[0].value;
const canRestore = restoreSelect.options.length > 0 && !hasPlaceholderOnly && !!restoreSelect.value;
el('s-backup-global').disabled = busy;
restoreSelect.disabled = busy || !canRestore;
el('s-restore-global').disabled = busy || !canRestore;
}
function formatBackupSize(bytes) {
const value = Number(bytes) || 0;
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function formatBackupModified(isoString) {
try {
return new Date(isoString).toLocaleString('de-DE');
} catch {
return isoString || '';
}
}
async function refreshGlobalBackupOptions(selectedPath) {
const select = el('s-restore-backup');
const backups = await window.api.listGlobalBackups();
select.innerHTML = '';
if (!backups.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'Keine Backups gefunden';
select.appendChild(option);
select.disabled = true;
el('s-restore-global').disabled = true;
return [];
}
backups.forEach((backup) => {
const option = document.createElement('option');
option.value = backup.path;
option.textContent = `${backup.name} · ${formatBackupModified(backup.modifiedAt)} · ${formatBackupSize(backup.size)}`;
select.appendChild(option);
});
select.value = backups.some((backup) => backup.path === selectedPath) ? selectedPath : backups[0].path;
select.disabled = false;
el('s-restore-global').disabled = !select.value;
return backups;
}
function updateBackupProgressUi(mode, percent, detail) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
const state = backupProgressUiState[prefix];
const nextPercent = Math.max(0, Math.min(100, Number(percent) || 0));
const completed = state.targetPercent >= 100;
if (nextPercent === 0) {
state.shownPercent = 0;
state.targetPercent = 0;
state.detail = detail || '';
state.packing = false;
renderBackupProgress(prefix);
return;
}
if ((completed && nextPercent < 100) || nextPercent < state.targetPercent) {
return;
}
state.targetPercent = nextPercent;
state.detail = detail || '';
state.packing = /^Packe ZIP-Archiv/.test(state.detail) && nextPercent < 99;
if (nextPercent >= 99 || (nextPercent - state.shownPercent) >= 20 || state.shownPercent === 0) {
state.shownPercent = nextPercent;
}
renderBackupProgress(prefix);
if (state.shownPercent < state.targetPercent) ensureBackupProgressTimer(prefix);
else if (state.targetPercent >= 100) clearBackupProgressTimer(prefix);
}
async function runGlobalBackup() {
const statusLine = el('s-backup-status');
setBackupButtonsBusy(true);
setBackupProgressActive('create', true);
statusLine.className = 'hint';
updateBackupProgressUi('create', 0, 'Bereite Zielordner vor');
statusLine.textContent = 'Erstelle Backup in Dokumente/AeroMC Launcher/Backups ...';
try {
const res = await window.api.createGlobalBackup();
if (res.canceled) {
statusLine.className = 'hint';
statusLine.textContent = 'Backup abgebrochen.';
resetBackupProgressUi();
setBackupProgressActive('create', false);
return;
}
if (res.ok) {
updateBackupProgressUi('create', 100, 'Backup abgeschlossen');
setBackupProgressActive('create', false);
statusLine.className = 'hint status-ok';
statusLine.textContent = 'Backup erfolgreich erstellt';
await refreshGlobalBackupOptions(res.path);
toast('Globales Backup erstellt: ' + res.path);
return;
}
setBackupProgressActive('create', false);
statusLine.className = 'hint status-bad';
statusLine.textContent = '✖ ' + (res.message || 'Backup fehlgeschlagen.');
toast(res.message || 'Backup fehlgeschlagen.', true);
} finally {
setBackupProgressActive('create', false);
setBackupButtonsBusy(false);
}
}
async function runGlobalRestore() {
const statusLine = el('s-restore-status');
const selectedBackup = el('s-restore-backup').value;
if (!selectedBackup) {
statusLine.className = 'hint status-bad';
statusLine.textContent = 'Bitte ein Backup auswählen.';
return;
}
setBackupButtonsBusy(true);
setBackupProgressActive('restore', true);
updateBackupProgressUi('restore', 0, 'Warte auf Backup-Datei');
statusLine.className = 'hint';
statusLine.textContent = 'Stelle Backup wieder her ...';
try {
const res = await window.api.restoreGlobalBackup(selectedBackup);
if (res.canceled) {
statusLine.className = 'hint';
statusLine.textContent = 'Wiederherstellen abgebrochen.';
resetBackupProgressUi();
setBackupProgressActive('restore', false);
return;
}
if (!res.ok) {
setBackupProgressActive('restore', false);
statusLine.className = 'hint status-bad';
statusLine.textContent = '✖ ' + (res.message || 'Wiederherstellen fehlgeschlagen.');
toast(res.message || 'Wiederherstellen fehlgeschlagen.', true);
return;
}
updateBackupProgressUi('restore', 100, 'Wiederherstellen abgeschlossen');
setBackupProgressActive('restore', false);
statusLine.className = 'hint status-ok';
statusLine.textContent = 'Backup erfolgreich wiederhergestellt';
const s = await window.api.getSettings();
applyTheme(s.theme || 'dark');
iso3dEnabled = s.iso3dIcons !== false;
await applyBedrockVisibility();
await applySkinBackground();
hide('modal-settings');
await reload();
toast('Backup wiederhergestellt: ' + res.path);
} finally {
setBackupProgressActive('restore', false);
setBackupButtonsBusy(false);
}
}
function promptGlobalRestore() {
askConfirm(
'Backup wiederherstellen',
'Das überschreibt alle aktuellen Instanzen mit dem Inhalt des Backups. Fortfahren?',
'Wiederherstellen',
runGlobalRestore,
);
}
// ---------- Microsoft-Login / Kontoverwaltung ----------
async function refreshAccountButton() {
const acc = await window.api.authStatus();
@@ -1960,21 +2526,7 @@ function wire() {
else toast('Noch keine Instanz vorhanden.', false);
});
el('btn-import').addEventListener('click', async () => {
const res = await window.api.importInstance();
if (res.canceled) return;
if (res.ok) { await reload(); toast('Importiert: ' + res.instance.name); }
else toast(res.message || 'Import fehlgeschlagen.', true);
});
el('btn-modpack').addEventListener('click', async () => {
status('Importiere Modpack …');
const res = await window.api.importModpack();
status('Bereit'); el('status-right').textContent = '';
if (res.canceled) return;
if (res.ok) { await reload(); toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`); }
else toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
});
el('btn-import').addEventListener('click', () => { openImportDialog('zip'); });
// Skin-Hintergrund an/aus (wie MultiMCs Katzen-Knopf)
el('btn-skin').addEventListener('click', async () => {
@@ -2017,8 +2569,39 @@ function wire() {
if (h.ok) toast('Anmelde-Dienste wieder erreichbar.');
});
// MultiMC-Import
el('btn-mmc').addEventListener('click', () => { hide('modal-settings'); openMmc(); });
// Import
document.querySelectorAll('.import-tabs .mtab').forEach((tab) => {
tab.addEventListener('click', async () => {
setImportKind(tab.dataset.kind);
if (tab.dataset.kind === 'mc') await ensureDefaultMcScan();
});
});
el('import-zip-do').addEventListener('click', runZipImport);
el('import-modpack-do').addEventListener('click', runModpackImport);
el('mc-pick').addEventListener('click', async () => {
const p = await window.api.mcPickFolder();
if (p) { el('mc-path').value = p; await scanMc(); }
});
el('mc-scan').addEventListener('click', scanMc);
el('mc-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanMc(); });
el('mc-all').addEventListener('change', (e) => {
document.querySelectorAll('#mc-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateMcButton();
});
el('mc-list').addEventListener('change', updateMcButton);
el('mc-do').addEventListener('click', runMcImport);
el('cf-pick').addEventListener('click', async () => {
const p = await window.api.cfPickFolder();
if (p) { el('cf-path').value = p; await scanCf(); }
});
el('cf-scan').addEventListener('click', scanCf);
el('cf-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanCf(); });
el('cf-all').addEventListener('change', (e) => {
document.querySelectorAll('#cf-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateCfButton();
});
el('cf-list').addEventListener('change', updateCfButton);
el('cf-do').addEventListener('click', runCfImport);
el('mmc-pick').addEventListener('click', async () => {
const p = await window.api.mmcPickFolder();
if (p) { el('mmc-path').value = p; await scanMmc(); }
@@ -2031,8 +2614,17 @@ function wire() {
});
el('mmc-list').addEventListener('change', updateMmcButton);
el('mmc-do').addEventListener('click', runMmcImport);
window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden');
});
window.api.onCfProgress((p) => {
el('cf-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('cf-progress').classList.remove('hidden');
});
window.api.onMmcProgress((p) => {
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mmc-progress').classList.remove('hidden');
});
// Bedrock
@@ -2083,9 +2675,9 @@ function wire() {
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-shortcut').addEventListener('click', () => toast('Verknüpfung erstellen folgt in einem Update.', false));
el('p-edit').addEventListener('click', async () => { const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-notes').addEventListener('click', async () => { const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-groupline').addEventListener('click', async () => { const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-edit').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-notes').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-groupline').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-mods').addEventListener('click', openMods);
el('p-worlds').addEventListener('click', openContent);
el('p-screens').addEventListener('click', openScreens);
@@ -2104,16 +2696,36 @@ function wire() {
await reload(); await selectInstance(copy.id); toast('Kopiert: ' + copy.name);
});
el('p-delete').addEventListener('click', async () => {
const inst = await window.api.getInstance(currentDetailId);
if (!inst) return;
askConfirm('Instanz löschen',
`${inst.name}" wird mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
const ids = orderedSelectedInstanceIds();
if (!ids.length) return;
if (ids.length === 1) {
const inst = await window.api.getInstance(ids[0]);
if (!inst) return;
askConfirm('Instanz löschen',
`${inst.name}" wird mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
'Endgültig löschen',
async () => {
await window.api.deleteInstance(ids[0]);
selectedInstanceIds.clear();
await reload();
await selectInstance(instances.length ? instances[0].id : null);
toast('Gelöscht.');
});
return;
}
const names = instances.filter((inst) => selectedInstanceIds.has(inst.id)).map((inst) => inst.name);
const preview = names.slice(0, 3).map((name) => `${name}"`).join(', ');
const more = names.length > 3 ? ` und ${names.length - 3} weitere` : '';
askConfirm('Instanzen löschen',
`${ids.length} Instanzen (${preview}${more}) werden mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
'Endgültig löschen',
async () => {
await window.api.deleteInstance(currentDetailId);
for (const id of ids) await window.api.deleteInstance(id);
selectedInstanceIds.clear();
await reload();
await selectInstance(instances.length ? instances[0].id : null);
toast('Gelöscht.');
toast(`${ids.length} Instanzen gelöscht.`);
});
});
@@ -2161,6 +2773,8 @@ function wire() {
if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); }
});
el('s-java').addEventListener('change', (e) => refreshJavaStatus(e.target.value.trim()));
el('s-backup-global').addEventListener('click', runGlobalBackup);
el('s-restore-global').addEventListener('click', promptGlobalRestore);
el('btn-save-settings').addEventListener('click', saveSettings);
document.querySelectorAll('.snav').forEach((b) => b.addEventListener('click', () => setSettingsPane(b.dataset.pane)));
el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); });
@@ -2228,6 +2842,7 @@ window.addEventListener('DOMContentLoaded', async () => {
} catch { applyTheme('dark'); }
await applySkinBackground();
window.api.onLaunchProgress(onLaunchEvt);
window.api.onBackupProgress((p) => applyBackupProgressEvent(p.mode, p.percent || 0, p.detail || ''));
window.api.onAuthCode(onAuthCode);
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await applySkinBackground(); });
window.api.onAuthHealth(showAuthHealth);
+67 -10
View File
@@ -125,8 +125,30 @@ function modsIndexFile(id) {
// ---------- installierte Mods (Metadaten je Instanz) ----------
function scanModsDir(id) {
const dir = modsDir(id);
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /\.(jar|zip)$/i.test(entry.name))
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => ({
projectId: null,
title: entry.name.replace(/\.(jar|zip)$/i, ''),
filename: entry.name,
versionNumber: 'Importiert',
}));
}
function listMods(id) {
return readJson(modsIndexFile(id), []);
const indexed = readJson(modsIndexFile(id), []);
const merged = new Map();
for (const entry of indexed) {
if (entry && entry.filename) merged.set(entry.filename, entry);
}
for (const entry of scanModsDir(id)) {
if (!merged.has(entry.filename)) merged.set(entry.filename, entry);
}
return [...merged.values()];
}
function addMod(id, entry) {
@@ -230,6 +252,16 @@ function getInstance(id) {
return readJson(instanceFile(id), null);
}
function normalizeInstanceName(name) {
return String(name || '').trim().toLowerCase();
}
function findInstanceByName(name) {
const needle = normalizeInstanceName(name);
if (!needle) return null;
return listInstances().find((inst) => normalizeInstanceName(inst.name) === needle) || null;
}
// Ordnername aus dem Instanznamen ableiten (wie MultiMC), ungültige Zeichen raus
function slugForName(name) {
const base = String(name || 'Instanz').trim()
@@ -248,20 +280,43 @@ function uniqueFolderId(name) {
return id;
}
function createInstance(data) {
const inst = defaultInstance(data && data.name);
inst.id = uniqueFolderId(inst.name); // Ordner = Instanzname statt Zufalls-ID
if (data) {
if (data.icon) inst.icon = data.icon;
if (data.minecraft) Object.assign(inst.minecraft, data.minecraft);
if (data.notes) inst.notes = data.notes;
if (data.group !== undefined) inst.group = data.group;
}
function applyInstanceData(inst, data) {
if (!data) return inst;
if (data.name) inst.name = data.name;
if (data.icon) inst.icon = data.icon;
if (data.minecraft) Object.assign(inst.minecraft, data.minecraft);
if (data.notes !== undefined) inst.notes = data.notes;
if (data.group !== undefined) inst.group = data.group;
return inst;
}
function writeInstance(inst) {
ensureDir(gameDir(inst.id));
writeJson(instanceFile(inst.id), inst);
return inst;
}
function createInstance(data) {
const inst = applyInstanceData(defaultInstance(data && data.name), data);
inst.id = uniqueFolderId(inst.name); // Ordner = Instanzname statt Zufalls-ID
return writeInstance(inst);
}
function createOrReplaceImportedInstance(data) {
const existing = findInstanceByName(data && data.name);
if (!existing) return createInstance(data);
const inst = applyInstanceData(defaultInstance(data && data.name), data);
inst.id = existing.id;
inst.created = existing.created || inst.created;
inst.lastPlayed = existing.lastPlayed || null;
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
const p = instancePath(existing.id);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
return writeInstance(inst);
}
function updateInstance(id, patch) {
const inst = getInstance(id);
if (!inst) throw new Error('Instanz nicht gefunden: ' + id);
@@ -313,7 +368,9 @@ module.exports = {
removeMod,
listInstances,
getInstance,
findInstanceByName,
createInstance,
createOrReplaceImportedInstance,
updateInstance,
deleteInstance,
duplicateInstance,
+19 -1
View File
@@ -80,7 +80,6 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
/* farbige Toolbar-Icons wie in MultiMC */
#btn-new svg{color:#3a9c3a}
#btn-import svg{color:#3d7ab5}
#btn-modpack svg{color:#8e5bc7}
#btn-open-dir svg{color:#d8a72f}
#btn-console svg{color:#5a6470}
#btn-account svg{color:#3a9c3a}
@@ -112,6 +111,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.hr{height:1px;background:var(--border);margin:6px 2px}
.sbtn{display:block;width:100%;text-align:center;padding:5px 4px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)}
.sbtn:hover{background:#e2e6ec;border-color:var(--border2)}
.sbtn:disabled{opacity:.5;cursor:default}
.sbtn:disabled:hover{background:none;border-color:transparent}
.sbtn.play{font-weight:600}
.sbtn.del:hover{color:var(--danger)}
@@ -177,6 +178,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.field textarea{resize:vertical}
.field-row{display:flex;gap:12px;flex-wrap:wrap}.field-row .field{flex:1 1 0;min-width:0}
.field-icon{max-width:210px;min-width:0}
.backup-select-field{margin-top:14px;margin-bottom:0}
.backup-select-field select{width:100%}
.hint{font-size:12px}
.path-row{display:flex;gap:8px}.path-row input{flex:1}
.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
@@ -247,6 +250,10 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.mod-meta{font-size:11px;color:var(--dim);margin-top:2px}
.mod-action{flex:none}
/* Import */
.import-pane{min-height:180px}
.import-copy{margin:0 0 10px;font-size:13px;line-height:1.5}
/* MultiMC-Import */
.mmc-bar{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px}
@@ -369,10 +376,21 @@ img.account-av{object-fit:cover;image-rendering:auto}
.upd-old{color:var(--dim)}
.upd-arrow{color:var(--dim)}
.upd-newv{font-weight:700;color:var(--accent)}
.upd-channel{margin:-6px 0 12px;text-align:center}
.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px}
.upd-progress{margin:10px 0}
.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden}
.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s}
.backup-progress{margin-top:12px}
.backup-progress-bar{height:10px;background:var(--border2);border-radius:999px;overflow:hidden}
.backup-progress-fill{height:100%;width:0;background:linear-gradient(90deg,#3a7bc8,#5aa1e6);transition:width .2s;background-size:220% 100%}
.backup-progress-fill-busy{animation:backup-progress-shift 1.2s linear infinite}
.backup-progress-text{margin-top:6px}
@keyframes backup-progress-shift{
from{background-position:0 0}
to{background-position:220% 0}
}
/* Kontextmenü (Rechtsklick auf Instanz) */
.ctx{
+30 -9
View File
@@ -17,6 +17,13 @@ const { spawn } = require('child_process');
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
const UA = 'AeroMC-Launcher';
function detectInstallKind() {
const portableExe = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
const execName = path.basename(portableExe || process.execPath || '').toLowerCase();
if (portableExe || execName.includes('portable')) return 'portable';
return 'setup';
}
// "1.2.3" -> [1,2,3]; führendes "v" und Zusätze wie "-beta" werden ignoriert
function parseVersion(v) {
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
@@ -34,13 +41,21 @@ function compareVersions(a, b) {
return 0;
}
// Windows-Installer im Release finden (.exe bevorzugt, sonst .zip)
function pickAsset(release) {
function isPortableAsset(name) {
return /portable/i.test(name) && /\.exe$/i.test(name);
}
function isSetupAsset(name) {
return /setup/i.test(name) && /\.exe$/i.test(name);
}
// passendes Windows-Artefakt im Release finden
function pickAsset(release, installKind) {
const assets = release.assets || [];
return assets.find((a) => /\.exe$/i.test(a.name))
|| assets.find((a) => /setup.*\.exe$/i.test(a.name))
|| assets.find((a) => /\.zip$/i.test(a.name))
|| null;
if (installKind === 'portable') {
return assets.find((a) => isPortableAsset(a.name)) || null;
}
return assets.find((a) => isSetupAsset(a.name)) || null;
}
/*
@@ -48,6 +63,7 @@ function pickAsset(release) {
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
*/
async function checkForUpdate(currentVersion) {
const installKind = detectInstallKind();
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
const list = await res.json();
@@ -59,14 +75,15 @@ async function checkForUpdate(currentVersion) {
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
const latest = releases[0];
const available = compareVersions(latest.tag_name, currentVersion) > 0;
const asset = pickAsset(latest);
const asset = pickAsset(latest, installKind);
return {
available,
currentVersion,
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
installKind,
notes: latest.body || '',
published: latest.published_at || latest.created_at || null,
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size } : null,
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : null,
url: latest.html_url,
};
}
@@ -77,6 +94,10 @@ async function checkForUpdate(currentVersion) {
*/
async function downloadAndInstall(asset, onProgress) {
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
const installKind = detectInstallKind();
if (asset.kind && asset.kind !== installKind) {
throw new Error('Das angebotene Update passt nicht zur laufenden Paketart.');
}
const dir = path.join(os.tmpdir(), 'aeromc-update');
fs.mkdirSync(dir, { recursive: true });
@@ -112,4 +133,4 @@ function runInstaller(installerPath) {
return true;
}
module.exports = { checkForUpdate, downloadAndInstall, runInstaller, compareVersions, parseVersion };
module.exports = { checkForUpdate, downloadAndInstall, runInstaller, compareVersions, parseVersion, detectInstallKind };