Upload via GUI (25 Dateien)
This commit is contained in:
14
README.md
14
README.md
@@ -26,6 +26,7 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
|
||||
- Reimport überschreibt vorhandene Instanzen mit gleichem Namen statt Dubletten anzulegen
|
||||
- Export, Kopieren, Gruppieren und Verwalten von Instanzen
|
||||
- Mehrfachauswahl mit Strg-Klick für Sammellöschen mehrerer Instanzen
|
||||
- Globales Backup und Wiederherstellung für alle Instanzen direkt im Einstellungsbereich
|
||||
- Verwaltung von Welten, Screenshots und Spielordnern direkt aus dem Launcher
|
||||
- Bedrock-Bereich für Start und Verwaltung vorhandener Bedrock-Daten unter Windows
|
||||
- Update-Funktion für neue Launcher-Versionen
|
||||
@@ -41,6 +42,8 @@ Für die meisten Nutzer ist die Setup-Version die bessere Wahl.
|
||||
|
||||
Die portable Datei ist praktisch zum Mitnehmen, braucht beim Öffnen aber in der Regel etwas länger.
|
||||
|
||||
Automatische Updates bleiben dabei sauber getrennt: Die Setup-Version erhält nur Setup-Updates, die portable Variante nur Portable-Updates. Ein Wechsel zwischen beiden Varianten passiert nicht automatisch.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Windows x64
|
||||
@@ -71,6 +74,17 @@ Wenn bereits eine Instanz mit demselben Namen existiert, wird sie beim Import er
|
||||
|
||||
Beim Import aus dem offiziellen Minecraft Launcher werden nur echte benutzerdefinierte Profile angezeigt. Standardprofile wie Latest Release oder Latest Snapshot werden ausgeblendet. Nutzt ein Profil den gemeinsamen Standard-Spielordner, übernimmt AeroMC daraus nur spielrelevante Daten wie Mods, Welten und Konfiguration statt den kompletten Launcher-Cache zu kopieren.
|
||||
|
||||
## Backup und Wiederherstellung
|
||||
|
||||
Im Bereich Einstellungen > Backup können komplette Sicherungen aller Instanzen erstellt und wieder eingespielt werden.
|
||||
|
||||
- Backups werden automatisch unter Dokumente\AeroMC Launcher\Backups gespeichert
|
||||
- Gesichert werden alle Instanzen sowie die Launcher-Einstellungen
|
||||
- Microsoft-Konten und Tokens werden bewusst nicht mitgesichert
|
||||
- Vorhandene Backups werden im Wiederherstellen-Bereich direkt in einem Dropdown aufgelistet
|
||||
|
||||
Wichtig: Beim Wiederherstellen werden alle aktuell vorhandenen Instanzen mit dem Inhalt des gewählten Backups überschrieben. Die Launcher-Einstellungen werden ebenfalls aus dem Backup übernommen.
|
||||
|
||||
## Was AeroMC verwaltet
|
||||
|
||||
- Minecraft-Versionen und gemeinsame Spieldateien
|
||||
|
||||
815
package-lock.json
generated
815
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.0.0-beta",
|
||||
"version": "1.0.1-beta",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://M_Viper.de",
|
||||
"main": "src/main.js",
|
||||
@@ -39,7 +39,9 @@
|
||||
"electron-builder": "^24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"skinview3d": "^3.4.2",
|
||||
"undici": "^6.28.0"
|
||||
"undici": "^6.28.0",
|
||||
"unzipper": "^0.12.5"
|
||||
}
|
||||
}
|
||||
|
||||
440
src/backup.js
Normal file
440
src/backup.js
Normal 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
src/cfimport.js
Normal file
196
src/cfimport.js
Normal 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 };
|
||||
137
src/index.html
137
src/index.html
@@ -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 & 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,22 +690,89 @@
|
||||
</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">
|
||||
<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="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>MultiMC-Ordner</span>
|
||||
<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 liegt.</small>
|
||||
<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">
|
||||
@@ -687,11 +787,18 @@
|
||||
<div id="mmc-list" class="mods-list"></div>
|
||||
</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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
109
src/main.js
109
src/main.js
@@ -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
src/mcimport.js
Normal file
217
src/mcimport.js
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
737
src/renderer.js
737
src/renderer.js
@@ -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,17 +2696,37 @@ 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);
|
||||
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(currentDetailId);
|
||||
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 () => {
|
||||
for (const id of ids) await window.api.deleteInstance(id);
|
||||
selectedInstanceIds.clear();
|
||||
await reload();
|
||||
await selectInstance(instances.length ? instances[0].id : null);
|
||||
toast(`${ids.length} Instanzen gelöscht.`);
|
||||
});
|
||||
});
|
||||
|
||||
// Ordner-Buttons in den Inhalte-/Screenshots-Dialogen
|
||||
@@ -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);
|
||||
|
||||
69
src/store.js
69
src/store.js
@@ -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) {
|
||||
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) inst.notes = data.notes;
|
||||
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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user