1 Commits
1.0.6 ... 1.0.7

Author SHA1 Message Date
Git Manager GUI
bafad25059 Upload via GUI (43 Dateien) 2026-08-05 09:04:48 +02:00
12 changed files with 1433 additions and 79 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "aeromc",
"version": "1.0.5",
"version": "1.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aeromc",
"version": "1.0.5",
"version": "1.0.6",
"license": "MIT",
"dependencies": {
"archiver": "^7.0.1",

View File

@@ -1,7 +1,7 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.0.5",
"version": "1.0.6",
"description": "AeroMC Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"main": "src/main.js",

View File

@@ -14,7 +14,12 @@ const path = require('path');
const { execFile, spawn } = require('child_process');
const PKG_FAMILY = 'Microsoft.MinecraftUWP_8wekyb3d8bbwe';
const APP_ID = PKG_FAMILY + '!App';
// Der Teil nach "!" (Application Id) ist NICHT stabil Microsoft hat ihn
// schon mindestens einmal geändert (früher "App", inzwischen "Game").
// Deshalb zur Laufzeit über Get-StartApps auflösen statt fest zu verdrahten;
// dieser Wert dient nur als letzter Rückfallwert, falls die Auflösung
// fehlschlägt (z. B. PowerShell blockiert).
const FALLBACK_APP_ID = PKG_FAMILY + '!Game';
function comMojangDir() {
return path.join(process.env.LOCALAPPDATA || '', 'Packages', PKG_FAMILY,
@@ -49,30 +54,87 @@ async function detect() {
// Start
// ---------------------------------------------------------------------------
let cachedAppId = null;
/*
* Ermittelt die aktuell gültige AUMID über Get-StartApps das ist derselbe
* Weg, den auch das Startmenü nutzt, und bildet daher immer die Application
* Id ab, die Windows gerade tatsächlich zum Starten akzeptiert.
*/
async function resolveAppId() {
if (cachedAppId) return cachedAppId;
const out = await runPwsh(
`(Get-StartApps | Where-Object { $_.AppID -like '${PKG_FAMILY}*' } `
+ `| Select-Object -First 1 -ExpandProperty AppID)`);
cachedAppId = out || FALLBACK_APP_ID;
return cachedAppId;
}
/*
* Startet Bedrock direkt.
* Wichtig: Der AppsFolder-Pfad darf NICHT in Anführungszeichen stehen sonst
* öffnet der Explorer nur ein Ordnerfenster. Daher windowsVerbatimArguments.
*/
function launch() {
const target = 'shell:appsFolder\\' + APP_ID;
async function launch() {
const appId = await resolveAppId();
const target = 'shell:appsFolder\\' + appId;
// Wichtig: Es darf pro Klick nur EINE Aktivierung tatsächlich lostreten.
// Die App-Aktivierung lässt sich nicht abbrechen wenn wir bei einer
// Verzögerung (Xbox-Dienste, Kaltstart) automatisch einen zweiten Weg
// probieren, laufen im Zweifel beide an und Bedrock öffnet sich doppelt.
// Die anderen Wege dienen daher nur als Rückfall, falls das Spawnen
// selbst scheitert (Programm nicht gefunden o. Ä.), nicht als
// Reaktion auf einen bloß langsamen Start.
const versuche = [
// 1) genau der Aufruf, der sich unter Windows als zuverlässig erwiesen hat
// 1) explorer direkt, ohne zusätzliche Anführungszeichen entspricht
// exakt dem manuellen "explorer.exe shell:appsFolder\...", der
// nachweislich zuverlässig funktioniert
['explorer.exe', [target], { windowsVerbatimArguments: true }],
// 2) über die Eingabeaufforderung
['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }],
// 3) Rückfallweg über eine versteckte PowerShell aus einem
// windowless/detached Kindprozess heraus offenbar nicht überall
// zuverlässig, daher nur letzter Versuch
['powershell', ['-NoProfile', '-WindowStyle', 'Hidden', '-Command',
`Start-Process explorer.exe -ArgumentList '${target}'`], {}],
// 2) explorer direkt, ohne zusätzliche Anführungszeichen
['explorer.exe', [target], { windowsVerbatimArguments: true }],
// 3) über die Eingabeaufforderung
['cmd', ['/c', 'start', '', target], { windowsVerbatimArguments: true }],
];
let methode = null;
for (const [cmd, args, extra] of versuche) {
try {
const p = spawn(cmd, args, Object.assign({ detached: true, stdio: 'ignore', windowsHide: true }, extra));
p.unref();
return { ok: true, method: cmd };
} catch { /* nächsten Weg versuchen */ }
methode = cmd;
break; // nur diesen einen Weg tatsächlich auslösen
} catch { /* Spawnen selbst fehlgeschlagen nächsten Weg versuchen */ }
}
if (!methode) return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' };
// Aktivierung kann dauern (v. a. beim ersten Start nach längerer Pause)
// hier nur zur ehrlichen Rückmeldung abwarten, OHNE bei Zeitüberschreitung
// einen weiteren Versuch zu starten (siehe Hinweis oben).
const bestaetigt = await waitForBedrockProcess(15000);
return { ok: true, method: methode, confirmed: bestaetigt };
}
function isBedrockRunning() {
return new Promise((resolve) => {
execFile('tasklist', ['/FI', 'IMAGENAME eq Minecraft.Windows.exe', '/FO', 'CSV', '/NH'],
{ windowsHide: true, timeout: 5000 },
(err, stdout) => resolve(!err && /Minecraft\.Windows\.exe/i.test(String(stdout || ''))));
});
}
function warten(ms) {
return new Promise((resolve) => { setTimeout(resolve, ms); });
}
async function waitForBedrockProcess(timeoutMs) {
const start = Date.now();
// sofort prüfen (falls schon ein Fenster lief) und danach im Kurztakt
for (;;) {
if (await isBedrockRunning()) return true;
if (Date.now() - start >= timeoutMs) return false;
await warten(500);
}
return { ok: false, message: 'Bedrock konnte nicht gestartet werden.' };
}
// ---------------------------------------------------------------------------

View File

@@ -10,7 +10,7 @@
<body>
<!-- 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-new" class="tbtn" data-ic="plus" title="Neue Instanz (Strg+N)">Instanz hinzufügen</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>
@@ -19,7 +19,7 @@
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
<span class="tbgrow"></span>
<div class="search-wrap">
<input id="search" type="text" placeholder="Suchen…" autocomplete="off" />
<input id="search" type="text" placeholder="Suchen… (Strg+F)" autocomplete="off" title="Strg+F fokussiert die Suche" />
</div>
<button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button>
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
@@ -61,7 +61,7 @@
<div class="hr"></div>
<button id="p-shortcut" class="sbtn">Verknüpfung erstellen</button>
<button id="p-export" class="sbtn">Instanz exportieren</button>
<button id="p-delete" class="sbtn del">Löschen</button>
<button id="p-delete" class="sbtn del" title="Entf">Löschen</button>
<button id="p-copy" class="sbtn">Instanz kopieren</button>
</div>
</aside>
@@ -171,7 +171,11 @@
</label>
<label class="field">
<span>Zusätzliche JVM-Argumente</span>
<div class="path-row">
<input id="f-extra-args" type="text" placeholder="z. B. -XX:+UseG1GC" />
<button id="f-extra-args-aikar" class="btn btn-mini" type="button"
title="Fügt eine bewährte Sammlung von Garbage-Collector-Flags ein (bekannt als „Aikar's Flags"), die bei größeren Modpacks Ruckler durch Garbage Collection reduzieren kann.">GC-Flags einfügen</button>
</div>
</label>
<label class="field">
<span>Pre-Launch-Befehl <small class="opt">(läuft vor dem Start)</small></span>
@@ -253,6 +257,14 @@
</label>
<small id="s-bedrock-hint" class="hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small>
</section>
<section class="settings-card">
<h5>Benachrichtigungen</h5>
<label class="checkbox-field">
<input id="s-notify" type="checkbox" /> <span>Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird</span>
</label>
<small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small>
</section>
</div>
</div>
@@ -294,6 +306,14 @@
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
</label>
</div>
<label class="checkbox-field">
<input id="s-bgskin-cycle" type="checkbox" /> <span>Bei mehreren angemeldeten Konten automatisch durchwechseln</span>
</label>
<label class="field">
<span>Wechsel alle <small class="opt">(Sekunden)</small></span>
<input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" />
<small class="hint">Läuft nur, wenn 2 oder mehr Konten angemeldet sind, und beginnt nach dem letzten Konto wieder von vorn.</small>
</label>
</section>
</div>
</div>
@@ -659,6 +679,7 @@
</div>
<div id="m-pane-installed" class="hidden">
<div class="mmc-bar">
<button id="m-add-file" class="btn btn-mini" data-ic="plus">Datei hinzufügen…</button>
<button id="m-check-updates" class="btn btn-mini">Nach Updates suchen</button>
<button id="m-apply-updates" class="btn btn-primary btn-mini hidden">Alle aktualisieren</button>
<span id="m-update-status" class="hint"></span>
@@ -980,6 +1001,7 @@
<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>
<button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button>
</div>
<div id="import-pane-zip" class="import-pane">
@@ -1089,11 +1111,51 @@
<div id="mmc-list" class="mods-list"></div>
</div>
</div>
<div id="import-pane-technic" class="import-pane hidden">
<p class="import-copy">Modpack direkt von der Technic-Plattform installieren oder eine bereits vorhandene Technic-Installation importieren.</p>
<div class="field-row" style="align-items:flex-end">
<label class="field" style="flex:1">
<span>Technic-Katalog durchsuchen</span>
<input id="tp-query" type="text" placeholder="z. B. Hexxit, Tekxit …" autocomplete="off" />
</label>
<button id="tp-search-btn" class="btn btn-primary">Suchen</button>
</div>
<div id="tp-status" class="hint" style="margin-top:8px">Gib einen Suchbegriff ein oder lasse das Feld leer für beliebte Packs.</div>
<div id="tp-results" class="mods-list" style="margin-top:10px;max-height:220px;overflow:auto"></div>
<div class="modal-sep" style="margin:16px 0"></div>
<label class="field">
<span>Oder: vorhandenen Technic-Ordner importieren</span>
<div class="path-row">
<input id="technic-path" type="text" placeholder="z. B. C:\Users\DeinName\AppData\Roaming\.technic" />
<button id="technic-pick" class="btn">Wählen…</button>
<button id="technic-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="technic-status" class="hint">Leer lassen nutzt automatisch den Standardpfad unter Windows (%APPDATA%\.technic).</small>
</label>
<div id="technic-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="technic-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="technic-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Mods, Welten, Konfiguration)</span>
</label>
</div>
<div id="technic-list" class="mods-list"></div>
<p class="hint" style="margin-top:8px">Minecraft-Version und Mod-Loader werden nach bestem Wissen aus dem Pack erkannt bitte nach dem Import kurz prüfen, falls ein Pack ungewöhnlich aufgebaut ist.</p>
</div>
</div>
</div>
<div class="modal-foot">
<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 id="technic-progress" class="hint hidden"></div>
<div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button>
<button id="import-zip-do" class="btn btn-primary">ZIP wählen…</button>
@@ -1101,6 +1163,7 @@
<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>
<button id="technic-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
*/
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage } = require('electron');
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
@@ -25,10 +25,13 @@ const backup = require('./backup');
const mmcimport = require('./mmcimport');
const cfimport = require('./cfimport');
const mcimport = require('./mcimport');
const technicimport = require('./technicimport');
const technicdiscover = require('./technicdiscover');
const javadl = require('./javadl');
const crashreports = require('./crashreports');
const skins = require('./skins');
const servers = require('./servers');
const mcping = require('./mcping');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici');
@@ -217,10 +220,18 @@ app.whenReady().then(() => {
setTimeout(async () => {
try {
const info = await updater.checkForUpdate(app.getVersion());
if (info.available && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:available', info);
if (mainWindow && !mainWindow.isDestroyed()) {
// Ergebnis immer melden, damit die Info-Seite auch "aktuell" anzeigen
// kann nicht erst wenn der Nutzer manuell auf "Suchen" klickt
mainWindow.webContents.send('update:checked', info);
if (info.available) mainWindow.webContents.send('update:available', info);
}
} catch (err) {
// offline o. Ä. kein Absturz, aber die Info-Seite darf den Fehler zeigen
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:checked', { error: (err && err.message) || String(err) });
}
}
} catch { /* offline o. Ä. still ignorieren */ }
}, 4000);
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
@@ -348,6 +359,10 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
if (!isDep && !firstError) firstError = res.message;
return;
}
// merken, welche Pflicht-Abhängigkeiten dieser Mod mitbringt damit
// wir beim Entfernen warnen können, wenn ein anderer Mod ihn braucht
res.entry.requiredDeps = (res.dependencies || [])
.filter((d) => d.required && d.projectId).map((d) => d.projectId);
store.addMod(id, res.entry, art || 'mod');
added.push(res.entry);
for (const dep of res.dependencies || []) {
@@ -363,6 +378,8 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
if (!isDep && !firstError) firstError = res.message;
return;
}
res.entry.requiredDeps = (res.dependencies || [])
.filter((d) => d.dependency_type === 'required' && d.project_id).map((d) => d.project_id);
store.addMod(id, res.entry, art || 'mod');
added.push(res.entry);
for (const dep of res.dependencies || []) {
@@ -382,6 +399,52 @@ ipcMain.handle('mods:install', async (_e, id, project, art) => {
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
});
/*
* Lokale Datei(en) direkt in den Inhaltsordner kopieren für Mods, die
* nicht über Modrinth/CurseForge zu finden sind (Dev-Build, privater Mod,
* per Hand zugeschickt). Die Datei taucht danach ganz normal in der
* "Installiert"-Liste auf, weil listMods() den Ordner ohnehin mitscannt.
*/
ipcMain.handle('mods:addFile', async (_e, id, art) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const typ = services.inhaltsArt(art || 'mod');
const filters = (art || 'mod') === 'mod'
? [{ name: 'Mod-Datei', extensions: ['jar', 'zip'] }]
: [{ name: typ.label || 'Datei', extensions: ['zip'] }];
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Datei hinzufügen',
properties: ['openFile', 'multiSelections'],
filters: filters.concat([{ name: 'Alle Dateien', extensions: ['*'] }]),
});
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
const dir = store.inhaltsDir(id, art || 'mod');
fs.mkdirSync(dir, { recursive: true });
const added = [];
const failed = [];
for (const src of res.filePaths) {
try {
if (!/\.(jar|zip)$/i.test(src)) {
failed.push({ name: path.basename(src), message: 'Nur .jar/.zip-Dateien werden unterstützt.' });
continue;
}
const name = path.basename(src);
const ext = path.extname(name);
const base = name.slice(0, -ext.length);
let dest = path.join(dir, name);
// Namenskollision -> durchnummerieren statt eine vorhandene Datei zu überschreiben
let n = 2;
while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext}`); n++; }
fs.copyFileSync(src, dest);
added.push(path.basename(dest));
} catch (err) {
failed.push({ name: path.basename(src), message: err.message });
}
}
return { ok: true, added, failed };
});
// verfügbare Loader-Versionen (Fabric/Quilt) für eine MC-Version
ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
try { return await loaders.listLoaderVersions(loader, mcVersion); }
@@ -389,6 +452,8 @@ ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
});
ipcMain.handle('mods:remove', (_e, id, filename, art) => store.removeMod(id, filename, art || 'mod'));
// prüft, ob andere installierte Mods diesen hier als Pflicht-Abhängigkeit brauchen
ipcMain.handle('mods:dependents', (_e, id, filename, art) => store.findDependents(id, filename, art || 'mod'));
// Vorhandener Arbeitsspeicher Grundlage für die Prüfung der RAM-Angaben
function gesamtSpeicherMb() {
@@ -432,6 +497,18 @@ ipcMain.handle('servers:rename', (_e, id, ip, neuerName) => {
return servers.benenneUm(store.gameDir(id), ip, neuerName);
});
// Live-Status eines Servers abfragen (MOTD/Spielerzahl/Version) wie der
// Vanilla-Mehrspieler-Bildschirm, ohne sich wirklich zu verbinden
ipcMain.handle('servers:ping', async (_e, ip) => {
const s = String(ip || '').trim();
const m = s.match(/^([^:]+)(?::(\d+))?$/);
if (!m) return { ok: false, message: 'Ungültige Adresse.' };
const host = m[1];
const port = m[2] ? Number(m[2]) : 25565;
try { return await mcping.pingServer(host, port); }
catch (err) { return { ok: false, message: err.message }; }
});
// denselben Server in weitere Instanzen übertragen
ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => {
const erledigt = [];
@@ -737,6 +814,36 @@ ipcMain.handle('instances:export', async (_e, id) => {
}
});
/*
* Schnellsicherung EINER Welt als ZIP unabhängig vom globalen
* Backup-System, landet in einem eigenen Unterordner der Dokumente.
*/
ipcMain.handle('world:backup', async (_e, id, worldName) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const worldDir = path.join(store.gameDir(id), 'saves', String(worldName || ''));
if (path.dirname(worldDir) !== path.join(store.gameDir(id), 'saves') || !fs.existsSync(worldDir)) {
return { ok: false, message: 'Welt nicht gefunden.' };
}
try {
const zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen',
(inst.name || 'Instanz').replace(/[^\w.-]+/g, '_'));
fs.mkdirSync(zielOrdner, { recursive: true });
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
const safeWorld = String(worldName).replace(/[^\w.-]+/g, '_');
const zielDatei = path.join(zielOrdner, `${safeWorld}_${zeitstempel}.zip`);
if (fs.existsSync(zielDatei)) fs.unlinkSync(zielDatei);
await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force",
{ VN_SRC: worldDir, VN_DST: zielDatei });
notify('Welt gesichert', `${worldName} (${inst.name}) wurde gesichert.`);
return { ok: true, path: zielDatei };
} catch (err) {
return { ok: false, message: 'Sicherung fehlgeschlagen: ' + err.message };
}
});
// ---- Modpack-Import (Modrinth .mrpack) ----
async function importMrpack(buf, zip) {
@@ -947,6 +1054,7 @@ ipcMain.handle('modpack:update', async (_e, id, versionId) => {
}),
});
notify('Modpack aktualisiert', `${inst.name} ist auf Version ${version.version_number} aktualisiert.`);
return {
ok: true,
neueNummer: version.version_number,
@@ -1091,12 +1199,47 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
store.updateInstance(inst.id, { modpack: mp });
result.instance = store.getInstance(inst.id);
}
if (result && result.ok && result.instance) {
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
} catch (err) {
return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message };
}
});
/*
* Kern des Modpack-Datei-Imports, unabhängig davon ob der Pfad aus einem
* Dateidialog oder per Drag&Drop aufs Fenster kommt.
* notModpack: true heißt "kein Modpack-Format erkannt" der Aufrufer kann
* dann noch andere ZIP-Interpretationen probieren (z. B. Instanz-Export).
*/
async function importModpackFromPath(filePath) {
try {
const buf = fs.readFileSync(filePath);
const zip = forge.readZipIndex(buf);
let result = null;
if (zip['modrinth.index.json']) {
result = await importMrpack(buf, zip);
} else if (zip['manifest.json']) {
const zugang = services.curseforgeZugang(store.getSettings());
if (!zugang) {
return { ok: false, needsKey: true,
message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' };
}
result = await importCurseForge(buf, zip, zugang);
} else {
return { ok: false, notModpack: true, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
}
if (result && result.ok && result.instance) {
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
} catch (err) {
return { ok: false, notModpack: true, message: 'Modpack-Import fehlgeschlagen: ' + err.message };
}
}
ipcMain.handle('modpack:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Modpack importieren',
@@ -1104,22 +1247,7 @@ ipcMain.handle('modpack:import', async () => {
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
try {
const buf = fs.readFileSync(res.filePaths[0]);
const zip = forge.readZipIndex(buf);
if (zip['modrinth.index.json']) return await importMrpack(buf, zip);
if (zip['manifest.json']) {
const zugang = services.curseforgeZugang(store.getSettings());
if (!zugang) {
return { ok: false, needsKey: true,
message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' };
}
return await importCurseForge(buf, zip, zugang);
}
return { ok: false, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
} catch (err) {
return { ok: false, message: 'Modpack-Import fehlgeschlagen: ' + err.message };
}
return importModpackFromPath(res.filePaths[0]);
});
// Verknüpfung auf dem Desktop, die genau diese Instanz startet
@@ -1150,18 +1278,17 @@ ipcMain.handle('instances:shortcut', (_e, id) => {
}
});
ipcMain.handle('instances:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Instanz importieren',
properties: ['openFile'],
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
/*
* Kern des ZIP-Instanz-Imports (AeroMC-Export oder einzelne MultiMC/Prism-
* Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per
* Drag&Drop aufs Fenster kommt.
*/
async function importInstanceZipFromPath(filePath) {
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
try {
fs.mkdirSync(tmp, { recursive: true });
await runPwsh('Expand-Archive -Path $env:VN_SRC -DestinationPath $env:VN_DST -Force',
{ VN_SRC: res.filePaths[0], VN_DST: tmp });
{ VN_SRC: filePath, VN_DST: tmp });
// instance.json finden (auch in Unterordner)
let base = tmp;
@@ -1203,8 +1330,88 @@ ipcMain.handle('instances:import', async () => {
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
}
}
ipcMain.handle('instances:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Instanz importieren',
properties: ['openFile'],
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
});
if (res.canceled) return { ok: false, canceled: true };
return importInstanceZipFromPath(res.filePaths[0]);
});
/*
* Datei per Drag&Drop aufs Fenster gezogen. Anhand der Endung/des Inhalts
* wird entschieden, was damit passieren soll:
* .jar -> als Mod/Ressourcenpaket/Shader in die übergebene
* Instanz kopieren (targetId/targetArt vom Renderer,
* je nachdem was gerade offen bzw. ausgewählt ist)
* .mrpack / .zip -> zuerst als Modpack versuchen (Modrinth/CurseForge),
* sonst als AeroMC-/MultiMC-Instanz-Export
*/
ipcMain.handle('files:drop', async (_e, filePath, targetId, targetArt) => {
if (!filePath || !fs.existsSync(filePath)) return { ok: false, message: 'Datei nicht gefunden.' };
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jar') {
if (!targetId) {
return { ok: false, message: 'Bitte zuerst eine Instanz öffnen (Mods-Dialog), dann die Datei dort hineinziehen.' };
}
const inst = store.getInstance(targetId);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const art = targetArt || 'mod';
const dir = store.inhaltsDir(targetId, art);
fs.mkdirSync(dir, { recursive: true });
try {
const name = path.basename(filePath);
const ext2 = path.extname(name);
const base = name.slice(0, -ext2.length);
let dest = path.join(dir, name);
let n = 2;
while (fs.existsSync(dest)) { dest = path.join(dir, `${base} (${n})${ext2}`); n++; }
fs.copyFileSync(filePath, dest);
return { ok: true, kind: 'mod', added: [path.basename(dest)] };
} catch (err) {
return { ok: false, message: err.message };
}
}
if (ext === '.mrpack' || ext === '.zip') {
const modpack = await importModpackFromPath(filePath);
// .mrpack ist immer ein Modpack bei Fehlschlag direkt die Meldung
// zeigen, statt es zusätzlich noch als Instanz-Export zu probieren
if (!modpack.notModpack || ext === '.mrpack') return Object.assign({ kind: 'modpack' }, modpack);
// .zip ohne erkanntes Modpack-Format -> als Instanz-Export/-Import versuchen
const instanceRes = await importInstanceZipFromPath(filePath);
return Object.assign({ kind: 'instance' }, instanceRes);
}
return { ok: false, message: 'Nicht unterstützter Dateityp: ' + (ext || '(ohne Endung)') };
});
/*
* Windows-Benachrichtigung für abgeschlossene Hintergrund-Aktionen (Backup,
* größere Downloads) nur wenn die Einstellung an ist UND das Fenster
* gerade nicht im Vordergrund ist (sonst sieht man's eh schon live).
*/
function notify(title, body) {
try {
if (store.getSettings().desktopNotifications === false) return;
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused()) return;
if (!Notification.isSupported()) return;
const n = new Notification({ title, body, silent: false });
n.on('click', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
n.show();
} catch { /* rein informativ darf nie einen Ablauf stören */ }
}
// ---------------------------------------------------------------------------
// Sicherungen: von Hand und nach Zeitplan
// ---------------------------------------------------------------------------
@@ -1232,6 +1439,7 @@ async function erstelleBackup({ still } = {}) {
if (!still && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:progress', { mode: 'create', percent: 100, detail: 'Backup abgeschlossen' });
}
if (result && result.ok) notify('Backup abgeschlossen', 'Die Sicherung aller Instanzen ist fertig.');
return result;
} catch (err) {
return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message };
@@ -1590,6 +1798,54 @@ ipcMain.handle('mc:import', (_e, entries, copyData) => {
return { ok: true, imported: done, failed };
});
ipcMain.handle('technic:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Technic-Ordner wählen (oder direkt den modpacks-Ordner)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('technic:scan', (_e, folder) => {
try { return technicimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('technic:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = technicimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('technic:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
ipcMain.handle('technicPlatform:search', async (_e, query) => {
try { return { ok: true, hits: await technicdiscover.search(query) }; }
catch (err) { return { ok: false, message: err.message }; }
});
ipcMain.handle('technicPlatform:install', async (_e, slug) => {
try {
const res = await technicdiscover.installFromPlatform(store, slug, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('technicPlatform:progress', p);
});
if (res.ok && res.instance) notify('Modpack installiert', `${res.instance.name} ist einsatzbereit.`);
return res;
} catch (err) {
return { ok: false, message: err.message };
}
});
// ---------------------------------------------------------------------------
// IPC Minecraft Bedrock (Windows-Store-App)
// ---------------------------------------------------------------------------

150
src/mcping.js Normal file
View File

@@ -0,0 +1,150 @@
'use strict';
/*
* mcping.js "Server List Ping" (SLP), das Protokoll, das auch der
* Vanilla-Mehrspieler-Bildschirm nutzt, um MOTD/Spielerzahl/Version
* abzufragen, ohne sich wirklich zu verbinden.
*
* Ablauf (siehe minecraft.wiki/w/Server_List_Ping):
* 1) Handshake-Paket senden (Protokollversion, Adresse, Port, next state=1)
* 2) Status-Request-Paket senden (leer)
* 3) Status-Response-Paket empfangen: enthält einen JSON-String
* Alle Pakete sind mit ihrer Länge als VarInt vorangestellt.
*/
const net = require('net');
function encodeVarInt(value) {
const bytes = [];
let v = value;
do {
let temp = v & 0b01111111;
v = v >>> 7;
if (v !== 0) temp |= 0b10000000;
bytes.push(temp);
} while (v !== 0);
return Buffer.from(bytes);
}
function encodeString(str) {
const strBuf = Buffer.from(str, 'utf8');
return Buffer.concat([encodeVarInt(strBuf.length), strBuf]);
}
function encodeUShort(n) {
const b = Buffer.alloc(2);
b.writeUInt16BE(n & 0xffff, 0);
return b;
}
// liest einen VarInt aus buf ab offset -> { value, next } oder null,
// wenn noch nicht genug Bytes angekommen sind
function decodeVarInt(buf, offset) {
let numRead = 0;
let result = 0;
let read;
do {
if (offset + numRead >= buf.length) return null;
read = buf[offset + numRead];
result |= (read & 0b01111111) << (7 * numRead);
numRead++;
if (numRead > 5) throw new Error('VarInt zu groß');
} while ((read & 0b10000000) !== 0);
return { value: result >>> 0, next: offset + numRead };
}
// "description" kann ein reiner String oder eine Chat-Komponente
// ({text, extra:[...]}) sein hier auf reinen Text reduzieren
function extractMotdText(desc) {
if (!desc) return '';
if (typeof desc === 'string') return desc;
let text = String(desc.text || '');
if (Array.isArray(desc.extra)) {
for (const part of desc.extra) text += extractMotdText(part);
}
return text;
}
// Formatierungscodes (§a, §l, …) sind fürs Spiel gedacht, nicht für unsere Liste
function stripFormatting(text) {
return String(text || '').replace(/§./g, '').replace(/\s+/g, ' ').trim();
}
/*
* Fragt einen Server ab.
* -> { ok, motd, version, protocol, online, max, sample, favicon, latencyMs }
* oder { ok: false, message }
*/
function pingServer(host, port = 25565, timeoutMs = 4000) {
return new Promise((resolve) => {
const start = Date.now();
const socket = new net.Socket();
let buffer = Buffer.alloc(0);
let finished = false;
const done = (result) => {
if (finished) return;
finished = true;
try { socket.destroy(); } catch { /* egal */ }
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.on('timeout', () => done({ ok: false, message: 'Zeitüberschreitung.' }));
socket.on('error', (err) => done({ ok: false, message: err.code === 'ENOTFOUND' ? 'Adresse nicht gefunden.' : err.message }));
socket.connect(port, host, () => {
const handshakeBody = Buffer.concat([
encodeVarInt(0x00),
encodeVarInt(-1), // Protokollversion "unbekannt" für den Status-Abfrage-Zweck egal
encodeString(host),
encodeUShort(port),
encodeVarInt(1), // next state: 1 = status
]);
const handshakePacket = Buffer.concat([encodeVarInt(handshakeBody.length), handshakeBody]);
const requestBody = encodeVarInt(0x00);
const requestPacket = Buffer.concat([encodeVarInt(requestBody.length), requestBody]);
socket.write(Buffer.concat([handshakePacket, requestPacket]));
});
socket.on('data', (chunk) => {
if (finished) return;
buffer = Buffer.concat([buffer, chunk]);
try {
const lenInfo = decodeVarInt(buffer, 0);
if (!lenInfo) return; // Länge noch nicht vollständig angekommen
const packetEnd = lenInfo.next + lenInfo.value;
if (buffer.length < packetEnd) return; // Paket noch nicht vollständig da
const idInfo = decodeVarInt(buffer, lenInfo.next);
if (!idInfo) return;
const jsonLenInfo = decodeVarInt(buffer, idInfo.next);
if (!jsonLenInfo) return;
const jsonStart = jsonLenInfo.next;
const jsonEnd = jsonStart + jsonLenInfo.value;
if (buffer.length < jsonEnd) return;
const data = JSON.parse(buffer.toString('utf8', jsonStart, jsonEnd));
done({
ok: true,
latencyMs: Date.now() - start,
motd: stripFormatting(extractMotdText(data.description)),
version: (data.version && data.version.name) || '',
protocol: data.version ? data.version.protocol : null,
online: data.players ? data.players.online : null,
max: data.players ? data.players.max : null,
sample: (data.players && Array.isArray(data.players.sample))
? data.players.sample.map((p) => String(p.name || '')).filter(Boolean)
: [],
favicon: typeof data.favicon === 'string' ? data.favicon : '',
});
} catch (err) {
done({ ok: false, message: 'Ungültige Antwort: ' + err.message });
}
});
});
}
module.exports = { pingServer };

View File

@@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld('api', {
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
systemMemory: () => ipcRenderer.invoke('system:memory'),
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
@@ -42,6 +44,7 @@ contextBridge.exposeInMainWorld('api', {
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
logsList: (id) => ipcRenderer.invoke('logs:list', id),
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
@@ -68,6 +71,7 @@ contextBridge.exposeInMainWorld('api', {
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
@@ -91,6 +95,8 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('instance:launch-request', l);
},
importModpack: () => ipcRenderer.invoke('modpack:import'),
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
@@ -169,6 +175,21 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('mc:progress', l);
return () => ipcRenderer.removeListener('mc:progress', l);
},
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
onTechnicProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('technic:progress', l);
return () => ipcRenderer.removeListener('technic:progress', l);
},
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
onTechnicPlatformProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('technicPlatform:progress', l);
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
},
// Bedrock (Windows-Store-Ausgabe)
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
@@ -192,6 +213,14 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('update:available', l);
return () => ipcRenderer.removeListener('update:available', l);
},
// Ergebnis des automatischen Checks beim Start auch wenn kein Update
// verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei
// verfügbarem Update feuert)
onUpdateChecked: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('update:checked', l);
return () => ipcRenderer.removeListener('update:checked', l);
},
onUpdateProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('update:progress', l);

View File

@@ -470,6 +470,17 @@ const LOADER_LABEL = {
neoforge: 'NeoForge', quilt: 'Quilt',
};
// Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren
// bei größeren Modpacks spürbar GC-bedingte Ruckler. Wirkt erst ab genug RAM
// sinnvoll, schadet aber auch bei wenig RAM nicht.
const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 '
+ '-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch '
+ '-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M '
+ '-XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 '
+ '-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 '
+ '-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem '
+ '-XX:MaxTenuringThreshold=1';
// ---------- Zustand ----------
let instances = [];
let currentDetailId = null;
@@ -528,6 +539,7 @@ function applyTheme(theme) {
let mmcFound = [];
let cfFound = [];
let mcFound = [];
let technicFound = [];
let importKind = 'zip';
async function ensureDefaultMcScan() {
@@ -538,8 +550,8 @@ async function ensureDefaultMcScan() {
}
function setImportKind(kind) {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc'].forEach((entryKind) => {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].forEach((entryKind) => {
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
});
@@ -548,9 +560,11 @@ function setImportKind(kind) {
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('technic-do').classList.toggle('hidden', importKind !== 'technic');
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);
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
}
function openImportDialog(kind = 'zip') {
@@ -923,6 +937,132 @@ async function runMmcImport() {
if (instances.length) await selectInstance(instances[0].id);
}
// ---------- Technic-Plattform: Modpack-Katalog ----------
async function searchTechnicPlatform() {
const query = el('tp-query').value.trim();
const box = el('tp-results');
const st = el('tp-status');
st.textContent = 'Suche …';
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
const res = await window.api.technicPlatformSearch(query);
if (!res.ok) {
st.textContent = 'Fehler: ' + (res.message || 'Suche fehlgeschlagen.');
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
return;
}
const hits = res.hits || [];
st.textContent = hits.length ? `${hits.length} Treffer` : 'Keine Treffer.';
box.innerHTML = '';
if (!hits.length) { box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>'; return; }
for (const h of hits) {
const item = document.createElement('div');
item.className = 'mod-item';
item.innerHTML =
`<div class="mod-icon">${h.iconUrl ? `<img src="${escapeHtml(h.iconUrl)}" alt="" style="width:32px;height:32px;border-radius:4px;object-fit:cover" />` : '📦'}</div>
<div class="mod-info"><div class="mod-name">${escapeHtml(h.name)}</div></div>
<div class="mod-action"></div>`;
const btn = document.createElement('button');
btn.className = 'btn btn-primary btn-mini';
btn.textContent = 'Installieren';
btn.addEventListener('click', () => installTechnicFromSearch(h, btn));
item.querySelector('.mod-action').appendChild(btn);
box.appendChild(item);
}
}
async function installTechnicFromSearch(hit, btn) {
if (!hit || !hit.slug) return;
const prev = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const st = el('tp-status');
st.textContent = `Installiere „${hit.name}" …`;
try {
const res = await window.api.technicPlatformInstall(hit.slug);
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack installiert: ${res.instance.name}`);
await selectInstance(res.instance.id);
} else {
st.textContent = '✖ ' + (res.message || 'Installation fehlgeschlagen.');
toast(res.message || 'Installation fehlgeschlagen.', true);
}
} finally {
if (btn) { btn.disabled = false; btn.textContent = prev; }
}
}
async function scanTechnic() {
const folder = el('technic-path').value.trim();
el('technic-status').textContent = 'Suche Modpacks …';
const res = await window.api.technicScan(folder);
if (!res.ok) {
el('technic-status').textContent = '✖ Dort wurde kein modpacks-Ordner gefunden.';
el('technic-result').classList.add('hidden');
el('technic-do').disabled = true;
return;
}
technicFound = res.instances;
if (!folder && res.instancesDir) el('technic-path').value = res.instancesDir;
el('technic-status').textContent = `${res.count} Modpack${res.count === 1 ? '' : 's'} gefunden.`;
el('technic-result').classList.remove('hidden');
el('technic-all').checked = true;
renderTechnicList();
}
function renderTechnicList() {
const box = el('technic-list');
box.innerHTML = '';
if (!technicFound.length) { box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>'; return; }
technicFound.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 || 'Version unbekannt', 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);
});
updateTechnicButton();
}
function selectedTechnic() {
return [...document.querySelectorAll('#technic-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => technicFound[Number(c.dataset.idx)]);
}
function updateTechnicButton() {
const n = selectedTechnic().length;
el('technic-do').disabled = n === 0;
el('technic-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runTechnicImport() {
const entries = selectedTechnic();
if (!entries.length) return;
const copy = el('technic-copy').checked;
el('technic-do').disabled = true;
el('technic-progress').textContent = 'Übernehme …';
el('technic-progress').classList.remove('hidden');
const res = await window.api.technicImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
// ---------- Bedrock (Windows-Store-Ausgabe) ----------
let bedrockKind = 'worlds';
@@ -1121,6 +1261,27 @@ function onUpdateProgress(p) {
}
}
// Aktualisiert die Statuszeile im Info-Bereich genutzt sowohl vom manuellen
// "Nach Updates suchen"-Knopf als auch vom automatischen Check beim Start.
function renderUpdateStatus(info) {
const st = el('s-update-status');
if (!st) return;
st.classList.remove('status-ok', 'status-bad');
if (info.checking) {
st.textContent = 'Suche nach Updates …';
} else if (info.error) {
st.classList.add('status-bad');
st.textContent = '✖ ' + info.error;
} else if (info.noReleases) {
st.textContent = 'Noch keine Veröffentlichung vorhanden.';
} else if (info.available) {
st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`;
} else {
st.classList.add('status-ok');
st.textContent = `✓ Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`;
}
}
// ---------- Zustand der Anmelde-Dienste in der Statusleiste ----------
function showAuthHealth(h) {
const box = el('status-auth');
@@ -1135,11 +1296,15 @@ function showAuthHealth(h) {
const AUTH_SERVICE_COUNT = 3;
// ---------- Skin-Hintergrund (wie MultiMCs Katze, nur mit eigenem Skin) ----------
async function applySkinBackground() {
// uuidOverride: wird vom Konten-Wechsler (siehe unten) genutzt, um gezielt
// einen bestimmten Skin zu zeigen, ohne die eigentliche Einstellung anzufassen
async function applySkinBackground(uuidOverride) {
const s = await window.api.getSettings();
const root = document.documentElement;
let uuid = '';
if (s.bgSkin === 'active') {
if (uuidOverride) {
uuid = uuidOverride;
} else if (s.bgSkin === 'active') {
const acc = await window.api.authStatus();
uuid = acc && acc.profile ? acc.profile.id : '';
} else if (s.bgSkin) {
@@ -1172,6 +1337,44 @@ async function applySkinBackground() {
root.style.setProperty('--bg-skin', 'none');
}
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
let skinCycleTimer = null;
let skinCycleIndex = 0;
function stopSkinCycle() {
if (skinCycleTimer) { clearInterval(skinCycleTimer); skinCycleTimer = null; }
}
async function skinCycleTick() {
const accs = await window.api.authAccounts();
if (accs.length < 2) { stopSkinCycle(); await applySkinBackground(); return; }
skinCycleIndex = (skinCycleIndex + 1) % accs.length; // am Ende wieder von vorn
await applySkinBackground(accs[skinCycleIndex].id);
}
/*
* Einstiegspunkt für alles, was den Skin-Hintergrund neu anzeigen soll.
* Ersetzt die frühere direkte applySkinBackground()-Aufrufe: kümmert sich
* zusätzlich darum, den Kontenwechsel-Timer je nach Einstellung zu
* starten/stoppen, statt dass jede Aufrufstelle das selbst tun müsste.
*/
async function refreshSkinBackground() {
stopSkinCycle();
const s = await window.api.getSettings();
if (s.bgSkinCycle) {
const accs = await window.api.authAccounts();
if (accs.length >= 2) {
skinCycleIndex = 0;
await applySkinBackground(accs[0].id);
const seconds = Math.max(5, Number(s.bgSkinCycleSeconds) || 30);
skinCycleTimer = setInterval(skinCycleTick, seconds * 1000);
return;
}
// weniger als 2 Konten angemeldet -> normal weiter wie ohne Wechsel
}
await applySkinBackground();
}
// Pose-Vorlagen für den lokalen 3D-Renderer (eigene Nachbildungen bekannter Motive)
const SKIN_POSES = {
walking: { label: 'Laufend', fov: 36, zoom: 0.62, apply: (p) => {
@@ -1936,9 +2139,11 @@ async function renderServers() {
<div class="mod-info">
<div class="mod-name">${escapeHtml(s.name || s.ip)}</div>
<div class="mod-meta">${escapeHtml(s.ip)}</div>
<div class="mod-meta srv-status hint">Wird geprüft …</div>
</div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
pingServerRow(s.ip, item.querySelector('.srv-status'));
const starten = document.createElement('button');
starten.className = 'btn btn-primary btn-mini';
@@ -1992,6 +2197,24 @@ async function renderServers() {
}
}
// Fragt Live-Status (MOTD/Spieler/Version) ab und trägt ihn in die Zeile ein.
// Läuft unabhängig für jede Zeile, ohne die Liste selbst zu blockieren.
async function pingServerRow(ip, statusEl) {
if (!statusEl) return;
const res = await window.api.serversPing(ip);
// Zeile kann inzwischen aus dem DOM entfernt worden sein (Liste neu aufgebaut)
if (!statusEl.isConnected) return;
if (!res.ok) {
statusEl.textContent = '⚠ ' + (res.message || 'nicht erreichbar');
statusEl.classList.add('status-bad');
return;
}
const spieler = res.max != null ? `${res.online ?? '?'}/${res.max} Spieler` : '';
statusEl.textContent = [res.motd, spieler, res.version, res.latencyMs + ' ms']
.filter(Boolean).join(' · ');
statusEl.classList.add('status-ok');
}
async function addServer() {
const eintrag = { name: el('srv-name').value.trim(), ip: el('srv-ip').value.trim() };
const res = await window.api.serversAdd(currentDetailId, eintrag);
@@ -2130,7 +2353,7 @@ async function setzeSkin() {
el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.';
toast('Skin geändert.');
await openSkin(); // Vorschau mit dem neuen Stand auffrischen
await applySkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin
await refreshSkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin
} else {
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
btn.disabled = false;
@@ -2145,7 +2368,7 @@ async function setzeSkinZurueck() {
if (res.ok) {
toast('Skin auf Standard zurückgesetzt.');
await openSkin();
await applySkinBackground();
await refreshSkinBackground();
} else {
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
}
@@ -2431,6 +2654,24 @@ function renderContentList(boxId, items, sub, emoji) {
`<div class="mod-icon">${emoji}</div>
<div class="mod-info"><div class="mod-name">${escapeHtml(it.name)}</div></div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
if (sub === 'saves') {
const sichern = document.createElement('button');
sichern.className = 'btn btn-mini';
sichern.textContent = 'Sichern';
sichern.title = 'Nur diese Welt jetzt als ZIP sichern (Dokumente\\AeroMC Launcher\\Welten-Sicherungen)';
sichern.addEventListener('click', async () => {
sichern.disabled = true;
const orig = sichern.textContent;
sichern.textContent = 'Sichere …';
const res = await window.api.backupWorld(currentDetailId, it.name);
sichern.disabled = false;
sichern.textContent = orig;
if (res.ok) toast(`${it.name}" gesichert.`);
else toast(res.message || 'Sicherung fehlgeschlagen.', true);
});
aktionen.appendChild(sichern);
}
const rm = document.createElement('button');
rm.className = 'btn btn-danger btn-mini';
rm.textContent = 'Löschen';
@@ -2438,7 +2679,7 @@ function renderContentList(boxId, items, sub, emoji) {
'Löschen', `${it.name}" wird unwiderruflich gelöscht. Fortfahren?`, '🗑 Löschen',
async () => { await window.api.deleteContent(currentDetailId, sub, it.name); await renderContentLists(); toast('Gelöscht: ' + it.name); },
));
item.querySelector('.mod-action').appendChild(rm);
aktionen.appendChild(rm);
box.appendChild(item);
}
}
@@ -2586,9 +2827,24 @@ async function refreshInstalled() {
btn.className = 'btn btn-danger btn-mini';
btn.textContent = 'Entfernen';
btn.addEventListener('click', async () => {
const tatsaechlichEntfernen = async () => {
await window.api.modRemove(currentDetailId, m.filename, modArt);
toast('Entfernt: ' + m.title);
await refreshInstalled();
};
const abhaenger = await window.api.modDependents(currentDetailId, m.filename, modArt);
if (abhaenger.length) {
const namen = abhaenger.map((n) => `${n}"`).join(', ');
askConfirm(
'Wird noch gebraucht',
`${namen} ${abhaenger.length === 1 ? 'benötigt' : 'benötigen'}${m.title}" als Pflicht-Abhängigkeit. `
+ 'Wird sie trotzdem entfernt, funktionieren diese Mods vermutlich nicht mehr richtig. Trotzdem entfernen?',
'Trotzdem entfernen',
tatsaechlichEntfernen,
);
} else {
await tatsaechlichEntfernen();
}
});
aktionen.appendChild(btn);
box.appendChild(item);
@@ -2791,9 +3047,13 @@ async function openSettings(pane) {
}
sel.value = s.bgSkin || '';
el('s-bgskin-pose').value = s.bgSkinPose || 'walking';
el('s-bgskin-cycle').checked = !!s.bgSkinCycle;
el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30;
sel.disabled = !!s.bgSkinCycle;
el('s-sortby').value = s.sortBy || 'lastPlayed';
el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false;
el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled;
el('s-autobackup-every').value = s.autoBackupEvery || 'daily';
@@ -2908,9 +3168,12 @@ async function saveSettings() {
globalPostExit: el('s-global-post').value.trim(),
bgSkin: el('s-bgskin').value,
bgSkinPose: el('s-bgskin-pose').value,
bgSkinCycle: el('s-bgskin-cycle').checked,
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
sortBy: el('s-sortby').value,
iso3dIcons: el('s-iso3d').checked,
bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked,
autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked,
autoBackupEvery: el('s-autobackup-every').value,
@@ -2942,7 +3205,7 @@ async function saveSettings() {
applyTheme(patch.theme);
iso3dEnabled = patch.iso3dIcons;
await applyBedrockVisibility();
await applySkinBackground();
await refreshSkinBackground();
hide('modal-settings');
toast('Einstellungen gespeichert.');
await reload();
@@ -3180,7 +3443,7 @@ async function runGlobalRestore() {
applyTheme(s.theme || 'dark');
iso3dEnabled = s.iso3dIcons !== false;
await applyBedrockVisibility();
await applySkinBackground();
await refreshSkinBackground();
hide('modal-settings');
await reload();
toast('Backup wiederhergestellt: ' + res.path);
@@ -3267,7 +3530,7 @@ async function renderAccounts(boxId = 'login-accounts') {
await window.api.authSetActive(a.id);
await refreshAllAccountLists();
await refreshAccountButton();
await applySkinBackground();
await refreshSkinBackground();
toast('Aktiv: ' + a.name);
});
actions.appendChild(use);
@@ -3392,8 +3655,9 @@ function wire() {
// Skin-Hintergrund an/aus (wie MultiMCs Katzen-Knopf)
el('btn-skin').addEventListener('click', async () => {
const s = await window.api.getSettings();
if (s.bgSkin) {
await window.api.saveSettings({ bgSkin: '' });
if (s.bgSkin || s.bgSkinCycle) {
// auch den Kontenwechsel mit ausschalten, sonst würde er sofort wieder anspringen
await window.api.saveSettings({ bgSkin: '', bgSkinCycle: false });
toast('Skin-Hintergrund aus.');
} else {
const acc = await window.api.authStatus();
@@ -3401,7 +3665,7 @@ function wire() {
await window.api.saveSettings({ bgSkin: 'active' });
toast('Skin-Hintergrund an: ' + acc.profile.name);
}
await applySkinBackground();
await refreshSkinBackground();
});
// Kontextmenü: Einträge lösen die Aktionen der rechten Leiste aus
@@ -3442,6 +3706,13 @@ function wire() {
searchModpacks();
}
}
if (tab.dataset.kind === 'technic') {
const box = el('tp-results');
if (box && !box.dataset.loaded) {
box.dataset.loaded = '1';
searchTechnicPlatform();
}
}
});
});
el('import-zip-do').addEventListener('click', runZipImport);
@@ -3485,6 +3756,23 @@ function wire() {
});
el('mmc-list').addEventListener('change', updateMmcButton);
el('mmc-do').addEventListener('click', runMmcImport);
el('tp-search-btn').addEventListener('click', searchTechnicPlatform);
el('tp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchTechnicPlatform(); });
window.api.onTechnicPlatformProgress((p) => {
el('tp-status').textContent = `Lade Mods … ${p.current}/${p.total}${p.name ? ' (' + p.name + ')' : ''}`;
});
el('technic-pick').addEventListener('click', async () => {
const p = await window.api.technicPickFolder();
if (p) { el('technic-path').value = p; await scanTechnic(); }
});
el('technic-scan').addEventListener('click', scanTechnic);
el('technic-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanTechnic(); });
el('technic-all').addEventListener('change', (e) => {
document.querySelectorAll('#technic-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateTechnicButton();
});
el('technic-list').addEventListener('change', updateTechnicButton);
el('technic-do').addEventListener('click', runTechnicImport);
window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden');
@@ -3497,13 +3785,33 @@ function wire() {
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mmc-progress').classList.remove('hidden');
});
window.api.onTechnicProgress((p) => {
el('technic-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('technic-progress').classList.remove('hidden');
});
// Bedrock
el('btn-bedrock').addEventListener('click', openBedrock);
el('bd-play').addEventListener('click', async () => {
const btn = el('bd-play');
// Ein Klick löst eine nicht abbrechbare App-Aktivierung aus, die etwas
// dauern kann Button sperren, damit ein ungeduldiger zweiter Klick
// nicht zu einem doppelt geöffneten Spiel führt.
if (btn.disabled) return;
btn.disabled = true;
toast('Bedrock wird gestartet …');
try {
const res = await window.api.bedrockLaunch();
if (res && res.ok) toast('Bedrock wird gestartet …');
else toast('Start fehlgeschlagen: ' + ((res && res.message) || 'unbekannter Fehler'), true);
if (res && res.ok) {
if (!res.confirmed) {
toast('Start ausgelöst, konnte aber nicht bestätigt werden. Falls sich nichts öffnet, bitte erneut versuchen.');
}
} else {
toast('Start fehlgeschlagen: ' + ((res && res.message) || 'unbekannter Fehler'), true);
}
} finally {
btn.disabled = false;
}
});
el('bd-import').addEventListener('click', async () => {
const res = await window.api.bedrockInstallAddon();
@@ -3673,6 +3981,16 @@ function wire() {
});
el('crash-console').addEventListener('click', () => { hide('modal-crash'); openConsole(); });
el('m-add-file').addEventListener('click', async () => {
const res = await window.api.modAddFile(currentDetailId, modArt);
if (res.canceled) return;
if (!res.ok) { toast(res.message || 'Datei konnte nicht hinzugefügt werden.', true); return; }
const n = res.added.length;
const f = res.failed.length;
if (n) toast(`${n} Datei${n === 1 ? '' : 'en'} hinzugefügt${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0 && !n);
else if (f) toast(res.failed[0].message || 'Hinzufügen fehlgeschlagen.', true);
await refreshInstalled();
});
el('m-check-updates').addEventListener('click', checkModUpdates);
el('m-apply-updates').addEventListener('click', applyModUpdates);
window.api.onModUpdateProgress((p) => setUpdateStatus(`Suche … ${p.geprueft}/${p.gesamt} (${p.titel})`));
@@ -3697,10 +4015,20 @@ function wire() {
bgSkin: el('s-bgskin').value,
bgSkinPose: el('s-bgskin-pose').value,
});
await applySkinBackground();
await refreshSkinBackground();
};
el('s-bgskin').addEventListener('change', previewSkin);
el('s-bgskin-pose').addEventListener('change', previewSkin);
const previewSkinCycle = async () => {
el('s-bgskin').disabled = el('s-bgskin-cycle').checked;
await window.api.saveSettings({
bgSkinCycle: el('s-bgskin-cycle').checked,
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
});
await refreshSkinBackground();
};
el('s-bgskin-cycle').addEventListener('change', previewSkinCycle);
el('s-bgskin-cycle-seconds').addEventListener('change', previewSkinCycle);
el('s-scan-java').addEventListener('click', scanJava);
el('f-java-detected').addEventListener('change', (e) => {
if (e.target.value) el('f-java').value = e.target.value;
@@ -3709,6 +4037,13 @@ function wire() {
const p = await window.api.pickJava();
if (p) { el('f-java').value = p; el('f-java-detected').value = ''; }
});
el('f-extra-args-aikar').addEventListener('click', () => {
const feld = el('f-extra-args');
const vorhanden = feld.value.trim();
if (vorhanden.includes('G1NewSizePercent')) { toast('Die GC-Flags stehen schon drin.'); return; }
feld.value = vorhanden ? vorhanden + ' ' + AIKAR_FLAGS : AIKAR_FLAGS;
toast('GC-Flags eingefügt.');
});
el('s-java-detected').addEventListener('change', async (e) => {
if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); }
});
@@ -3744,18 +4079,10 @@ function wire() {
el('upd-open').addEventListener('click', openReleasePage);
if (el('s-check-update')) {
el('s-check-update').addEventListener('click', async () => {
const st = el('s-update-status');
st.textContent = 'Suche nach Updates …';
renderUpdateStatus({ checking: true });
const info = await window.api.updateCheck();
if (info.error) { st.textContent = '✖ ' + info.error; return; }
if (info.noReleases) { st.textContent = 'Noch keine Veröffentlichung vorhanden.'; return; }
if (info.available) {
st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`;
hide('modal-settings');
showUpdateDialog(info);
} else {
st.textContent = `Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`;
}
renderUpdateStatus(info);
if (info.available) { hide('modal-settings'); showUpdateDialog(info); }
});
}
@@ -3818,8 +4145,93 @@ function wire() {
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open) open.classList.add('hidden');
return;
}
const strg = e.ctrlKey || e.metaKey;
const modalOffen = document.querySelector('.modal:not(.hidden)');
const inEingabe = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)
|| document.activeElement.isContentEditable;
// Strg+F: Suche fokussieren nicht wenn gerade ein Dialog offen ist,
// der eine eigene Suche hat (Mods, Modpack-Katalog, Import …)
if (strg && e.key.toLowerCase() === 'f' && !modalOffen) {
e.preventDefault();
el('search').focus();
el('search').select();
return;
}
// Strg+N: neue Instanz anlegen
if (strg && e.key.toLowerCase() === 'n' && !modalOffen) {
e.preventDefault();
el('btn-new').click();
return;
}
// Entf: markierte Instanz(en) löschen nicht während man irgendwo tippt
if (e.key === 'Delete' && !modalOffen && !inEingabe) {
e.preventDefault();
el('p-delete').click();
}
});
wireFileDrop();
}
// ---------- Datei-Drag&Drop aufs Fenster (.jar/.mrpack/.zip) ----------
function wireFileDrop() {
let dragDepth = 0; // dragenter/-leave feuern auch für Kindelemente
const istDatei = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
window.addEventListener('dragenter', (e) => {
if (!istDatei(e)) return;
e.preventDefault();
dragDepth++;
document.body.classList.add('file-drop-active');
});
window.addEventListener('dragover', (e) => {
if (!istDatei(e)) return;
e.preventDefault();
});
window.addEventListener('dragleave', (e) => {
if (!istDatei(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) document.body.classList.remove('file-drop-active');
});
window.addEventListener('drop', async (e) => {
if (!istDatei(e)) return;
e.preventDefault();
dragDepth = 0;
document.body.classList.remove('file-drop-active');
const files = [...(e.dataTransfer.files || [])].filter((f) => f.path);
for (const f of files) await handleDroppedFile(f.path);
});
}
async function handleDroppedFile(filePath) {
const ext = ('.' + filePath.split('.').pop()).toLowerCase();
if (!['.jar', '.mrpack', '.zip'].includes(ext)) {
toast('Nicht unterstützter Dateityp: ' + filePath.split(/[\\/]/).pop(), true);
return;
}
// Für .jar: läuft der Mods-Dialog gerade, zählt die dort gewählte
// Inhaltsart (Mod/Ressourcenpaket/Shader) sonst landet es als Mod
// direkt in der aktuell ausgewählten Instanz.
const modsOpen = !el('modal-mods').classList.contains('hidden');
const targetArt = modsOpen ? modArt : 'mod';
const res = await window.api.filesDrop(filePath, currentDetailId, targetArt);
if (!res.ok) { toast(res.message || 'Import fehlgeschlagen.', true); return; }
if (res.kind === 'mod') {
toast(`Datei hinzugefügt: ${res.added[0]}`);
if (modsOpen) await refreshInstalled();
return;
}
await reload();
if (res.instance) {
toast(`Instanz übernommen: ${res.instance.name}`);
await selectInstance(res.instance.id);
} else {
toast('Import abgeschlossen.');
}
}
window.addEventListener('DOMContentLoaded', async () => {
@@ -3831,11 +4243,11 @@ window.addEventListener('DOMContentLoaded', async () => {
iso3dEnabled = s.iso3dIcons !== false;
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
} catch { applyTheme('dark'); }
await applySkinBackground();
await refreshSkinBackground();
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.onAuthRefreshed(async () => { await refreshAccountButton(); await refreshSkinBackground(); });
window.api.onAuthHealth(showAuthHealth);
// Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen
window.api.onAccountsChanged(() => { refreshAllAccountLists(); });
@@ -3846,6 +4258,7 @@ window.addEventListener('DOMContentLoaded', async () => {
});
window.api.onUpdateAvailable(showUpdateDialog);
window.api.onUpdateProgress(onUpdateProgress);
window.api.onUpdateChecked(renderUpdateStatus);
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
await loadMcIcons(); // echte Minecraft-Icons bereitstellen
await applyBedrockVisibility();

View File

@@ -75,9 +75,12 @@ const DEFAULT_SETTINGS = {
sortBy: 'lastPlayed', // lastPlayed | name
iso3dIcons: true, // Block-Symbole räumlich darstellen
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
proxyEnabled: false, // HTTP-Proxy für Downloads/Netzwerk
proxyHost: '',
@@ -232,6 +235,24 @@ function addMod(id, entry, art = 'mod') {
return list;
}
/*
* Findet installierte Mods, die den per "filename" identifizierten Mod als
* Pflicht-Abhängigkeit brauchen (requiredDeps wird beim Installieren über
* AeroMC gesetzt von Hand hinzugefügte oder vor diesem Feature installierte
* Mods haben keine requiredDeps und werden daher nicht als Ziel erkannt,
* können aber selbst weiterhin als Abhängiger auftauchen).
* -> [Titel, Titel, ...] der abhängigen Mods
*/
function findDependents(id, filename, art = 'mod') {
const list = listMods(id, art);
const target = list.find((m) => m.filename === filename);
if (!target || !target.projectId) return [];
return list
.filter((m) => m.filename !== filename
&& Array.isArray(m.requiredDeps) && m.requiredDeps.includes(target.projectId))
.map((m) => m.title || m.filename);
}
function removeMod(id, filename, art = 'mod') {
const list = listMods(id, art).filter((m) => m.filename !== filename);
writeJson(modsIndexFile(id, art), list);
@@ -443,6 +464,7 @@ module.exports = {
listMods,
addMod,
removeMod,
findDependents,
setModEnabled,
listInstances,
getInstance,

View File

@@ -141,6 +141,17 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px}
.card.dragging{opacity:.4}
/* Datei von außen übers Fenster gezogen (.jar/.mrpack/.zip) */
body.file-drop-active::after{
content:"Datei loslassen zum Importieren";
position:fixed;inset:0;z-index:9999;
display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.55);
color:#fff;font-size:20px;font-weight:600;
border:3px dashed var(--accent);
pointer-events:none;
}
/* ---------- Leerer Zustand ---------- */
.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:8px}
.empty h2{color:#333;margin:0}
@@ -364,6 +375,7 @@ img.account-av{object-fit:cover;image-rendering:auto}
.info-card{max-width:760px}
.info-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:18px 0 0;padding-top:14px;border-top:1px solid var(--border2)}
.info-status{font-size:12px;color:var(--dim)}
.modal-sep{border-top:1px solid var(--border2)}
.info-meta{margin-top:10px}
.info-meta .settings-kv-row{align-items:center}
.info-meta .settings-kv-label{text-transform:uppercase;letter-spacing:.04em;font-size:11px}

175
src/technicdiscover.js Normal file
View File

@@ -0,0 +1,175 @@
'use strict';
/*
* technicdiscover.js Technic-Modpacks direkt über die Technic-Plattform
* durchsuchen und installieren, ohne vorher manuell eine Datei herunterladen
* zu müssen. Genutzt wird dieselbe öffentliche API wie bei Prism Launcher:
*
* Suche: GET https://api.technicpack.net/search?build=multimc&q=<term>
* Beliebt: GET https://api.technicpack.net/trending?build=multimc
* Pack-Infos: GET https://api.technicpack.net/modpack/<slug>?build=multimc
* -> entweder "url" (ein fertiges ZIP) oder "solder" (Solder-Server)
* Solder: GET <solder>modpack/<slug> Liste der Builds
* GET <solder>modpack/<slug>/<build> Mods dieser Fassung
* { minecraft, forge, mods: [{ name, url, ... }] }
*
* "build=multimc" ist die Kennung, mit der sich auch andere Drittlauncher
* bei der Technic-Plattform melden kein echter Minecraft-Build, sondern
* nur ein Client-Identifier, den die API erwartet.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const forge = require('./forge');
const technicimport = require('./technicimport');
const API_BASE = 'https://api.technicpack.net/';
const API_BUILD = 'multimc';
const UA = 'AeroMC-Launcher/1.0';
async function getJson(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
async function getBuffer(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('HTTP ' + res.status);
return Buffer.from(await res.arrayBuffer());
}
/*
* query leer -> beliebte Packs (trending), sonst Suchtreffer.
* -> [{ slug, name, iconUrl }]
*/
async function search(query) {
const q = String(query || '').trim();
const url = q
? `${API_BASE}search?build=${API_BUILD}&q=${encodeURIComponent(q)}`
: `${API_BASE}trending?build=${API_BUILD}`;
const data = await getJson(url);
const packs = Array.isArray(data.modpacks) ? data.modpacks : [];
return packs
.filter((p) => p && p.slug && p.slug !== 'vanilla')
.map((p) => ({ slug: p.slug, name: p.name || p.slug, iconUrl: p.iconUrl || '' }));
}
/*
* Entpackt einen ZIP-Buffer nach destDir.
* Enthält der ZIP-Inhalt bereits eine erkennbare Ordnerstruktur (mods/,
* config/, …), wird sie 1:1 übernommen. Liegen nur einzelne Dateien direkt
* im ZIP-Wurzelverzeichnis (typisch für ältere, einzeln gepackte Solder-
* Mods), landen sie stattdessen im mods-Ordner.
*/
function extractZipBuffer(buf, destDir) {
const idx = forge.readZipIndex(buf);
const namen = Object.keys(idx);
const hatOrdnerStruktur = namen.some((n) => /^(mods|config|resources|resourcepacks|scripts|structures|bin)\//i.test(n));
for (const name of namen) {
if (name.endsWith('/')) continue; // reines Verzeichnis wird beim Schreiben automatisch angelegt
const relPfad = hatOrdnerStruktur ? name : path.posix.join('mods', name);
const zielPfad = path.join(destDir, ...relPfad.split('/'));
if (!zielPfad.startsWith(destDir + path.sep)) continue; // Pfad-Ausbruch verhindern
fs.mkdirSync(path.dirname(zielPfad), { recursive: true });
fs.writeFileSync(zielPfad, forge.readZipEntry(buf, idx[name]));
}
}
// Solder-Basis-URLs enden mal mit "/api/", mal ohne abschließenden Slash
function solderUrl(basis, pfad) {
const b = String(basis || '').replace(/\/+$/, '');
return `${b}/${pfad}`;
}
async function installSolderBuild(solderBasis, slug, gameDir, onProgress) {
const info = await getJson(solderUrl(solderBasis, 'modpack/' + encodeURIComponent(slug)));
const build = info.recommended || info.latest || (info.builds || [])[0];
if (!build) throw new Error('Keine Fassung (Build) für dieses Modpack gefunden.');
const daten = await getJson(solderUrl(solderBasis, `modpack/${encodeURIComponent(slug)}/${encodeURIComponent(build)}`));
const mods = Array.isArray(daten.mods) ? daten.mods : [];
for (let i = 0; i < mods.length; i++) {
const mod = mods[i];
if (onProgress) onProgress({ current: i + 1, total: mods.length, name: mod.name || '' });
if (!mod || !mod.url) continue;
try {
const buf = await getBuffer(mod.url);
extractZipBuffer(buf, gameDir);
} catch { /* einzelner Mod fehlgeschlagen Rest trotzdem weiter versuchen */ }
}
let loader = 'vanilla';
const loaderVersion = daten.forge || '';
if (loaderVersion) loader = 'forge';
return { version: daten.minecraft || '', loader, loaderVersion };
}
async function installSingleZip(url, gameDir) {
const buf = await getBuffer(url);
// in einen Temp-Ordner entpacken, damit wir wie beim Ordner-Import
// (technicimport.js) Version/Loader aus bin/modpack.jar erkennen können,
// bevor die Dateien an ihren endgültigen Platz kommen
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'technic-dl-'));
try {
extractZipBuffer(buf, tmp);
let base = tmp;
if (!fs.existsSync(path.join(base, 'bin')) && !fs.existsSync(path.join(base, 'mods'))) {
// manche Packs haben einen umschließenden Ordner im ZIP
const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n))
.find((p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } });
if (sub) base = sub;
}
const { version, loader, loaderVersion } = technicimport.detectVersion(base);
fs.cpSync(base, gameDir, { recursive: true });
return { version, loader, loaderVersion };
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
}
}
/*
* Lädt ein Modpack von der Technic-Plattform und legt daraus eine neue
* Instanz an. onProgress(info) wird bei Solder-Packs pro Mod aufgerufen.
* -> { ok, instance } oder { ok: false, message }
*/
async function installFromPlatform(store, slug, onProgress) {
let created = null;
try {
const detail = await getJson(`${API_BASE}modpack/${encodeURIComponent(slug)}?build=${API_BUILD}`);
// die Technic-API antwortet bei unbekanntem Slug mit HTTP 200 und
// {"error": "..."} statt einem Fehlerstatus daher explizit prüfen
if (detail.error) throw new Error(detail.error);
created = store.createOrReplaceImportedInstance({
name: detail.displayName || detail.name || slug,
notes: detail.description || '',
});
const gameDir = store.gameDir(created.id);
fs.mkdirSync(gameDir, { recursive: true });
let ergebnis;
if (detail.solder) {
ergebnis = await installSolderBuild(detail.solder, slug, gameDir, onProgress);
} else if (detail.url) {
ergebnis = await installSingleZip(detail.url, gameDir);
} else {
throw new Error('Für dieses Modpack ist kein Download hinterlegt.');
}
store.updateInstance(created.id, {
minecraft: {
version: ergebnis.version || detail.minecraft || '',
loader: ergebnis.loader || 'vanilla',
loaderVersion: ergebnis.loaderVersion || '',
},
});
return { ok: true, instance: store.getInstance(created.id) };
} catch (err) {
// fehlgeschlagene Installation nicht als leere Karteileiche stehen lassen
if (created) { try { store.deleteInstance(created.id); } catch { /* egal */ } }
return { ok: false, message: 'Installation fehlgeschlagen: ' + err.message };
}
}
module.exports = { search, installFromPlatform };

172
src/technicimport.js Normal file
View File

@@ -0,0 +1,172 @@
'use strict';
/*
* technicimport.js Instanzen aus dem Technic Launcher übernehmen
* -------------------------------------------------------------------
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner Welten,
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
* bei einem klassischen .minecraft.
*
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
* schönen Anzeigenamen "installedPacks" im Wurzelordner listet Packs nur
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft wie bei den
* anderen Importern über die tatsächlich vorhandenen Ordner/Dateien.
*/
const fs = require('fs');
const path = require('path');
const forge = require('./forge');
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
const SKIP_DIRS = new Set(['bin']);
function technicRoot() {
return path.join(process.env.APPDATA || '', '.technic');
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
catch { return null; }
}
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
// oder ein einzelner Pack-Ordner angegeben wurde
function resolveModpacksDir(inputPath) {
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
if (!fs.existsSync(root)) return null;
const direct = path.join(root, 'modpacks');
if (fs.existsSync(direct)) return direct;
if (path.basename(root).toLowerCase() === 'modpacks') return root;
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
}
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
function looksLikePack(dir) {
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
return markers.some((name) => fs.existsSync(path.join(dir, name)));
}
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
// als stabile API dokumentiert ist
function readInstalledPacksMeta(root) {
const data = readJson(path.join(root, 'installedPacks'));
const packs = data && data.installedPacks;
return packs && typeof packs === 'object' ? packs : {};
}
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
// bestmöglich schlägt die Erkennung fehl, bleibt die Instanz einfach
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
function detectVersion(packDir) {
const bin = path.join(packDir, 'bin');
const jarFile = path.join(bin, 'modpack.jar');
if (fs.existsSync(jarFile)) {
try {
const buf = fs.readFileSync(jarFile);
const idx = forge.readZipIndex(buf);
const entry = idx['version.json'];
if (entry) {
// manche Jars speichern JSON mit BOM ohne Entfernen bricht JSON.parse
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
const profile = JSON.parse(text);
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
let loader = 'vanilla';
let loaderVersion = '';
for (const lib of libs) {
const name = String((lib && lib.name) || '');
if (/net\.minecraftforge:forge:/i.test(name)) {
loader = 'forge';
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
loader = 'neoforge';
loaderVersion = name.split(':')[2] || '';
}
}
const id = String(profile.id || profile.inheritsFrom || '');
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
if (version) return { version, loader, loaderVersion };
}
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP weiter unten Fallback */ }
}
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
const legacy = path.join(bin, 'version');
try {
if (fs.existsSync(legacy)) {
const version = fs.readFileSync(legacy, 'utf8').trim();
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
}
} catch { /* egal, bleibt leer */ }
return { version: '', loader: 'vanilla', loaderVersion: '' };
}
function scan(inputPath) {
const modpacksDir = resolveModpacksDir(inputPath);
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
? path.dirname(modpacksDir) : modpacksDir;
const meta = readInstalledPacksMeta(root);
const list = [];
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(modpacksDir, entry.name);
if (!looksLikePack(dir)) continue;
const { version, loader, loaderVersion } = detectVersion(dir);
const info = meta[entry.name] || {};
list.push({
folder: entry.name,
dir,
name: info.displayName || entry.name,
group: '',
notes: '',
version,
loader,
loaderVersion,
gameDir: dir,
hasGameData: true,
});
}
list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
}
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
if (SKIP_DIRS.has(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,
},
});
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, resolveModpacksDir, technicRoot, detectVersion };