+
+
+
+
Noch keine Instanzen
@@ -106,6 +107,7 @@ Gruppe (optional) + Untergruppe: mit „/“ trennen, z. B. „Modpacks/Survival“.
@@ -362,6 +364,17 @@
+
+
+
+ Animationen
+ + + Startanimation: Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf (ab dem nächsten Start, nicht im Infobereich). Bewegte Oberfläche: Die Skin-Figur atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden ein, Designs blenden weich über. +Skin-Hintergrund
+
+
+ Wird vorbereitet …
+
+
+
+
+
+
Importiert eine einzelne Instanz aus einem ZIP-Archiv.
Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.
@@ -1828,6 +1851,9 @@
diff --git a/src/kopieren.js b/src/kopieren.js
new file mode 100644
index 0000000..61d08b6
--- /dev/null
+++ b/src/kopieren.js
@@ -0,0 +1,182 @@
+/*
+ * Große Ordner kopieren, ohne den Launcher einzufrieren.
+ *
+ * Früher liefen Importe mit fs.cpSync – synchron im Hauptprozess. Bei
+ * Spielständen mit vielen Gigabyte stand dann der komplette Launcher
+ * minutenlang still (keine Anzeige, kein Abbrechen, „Keine Rückmeldung“).
+ *
+ * Hier wird asynchron gearbeitet: erst Dateien und Größe zählen, dann mit
+ * mehreren Dateien gleichzeitig kopieren. Große Dateien gehen stückweise, damit
+ * der Fortschritt auch bei einer einzelnen 4-GB-Datei weiterläuft. Gesperrte
+ * Einzeldateien werden übersprungen und gemeldet statt alles abzubrechen.
+ */
+const fs = require('fs');
+const fsp = fs.promises;
+const path = require('path');
+const { pipeline } = require('stream/promises');
+
+const GLEICHZEITIG = 8; // Dateien parallel
+const STUECKWEISE_AB = 64 * 1024 * 1024; // ab hier mit Zwischenständen kopieren
+const MAX_TIEFE = 64; // Schutz vor Verknüpfungs-Schleifen
+const MELDE_ALLE_MS = 200;
+
+class Abgebrochen extends Error {
+ constructor() { super('Vom Nutzer abgebrochen.'); this.abgebrochen = true; }
+}
+
+// Ordner-/Dateieintrag auflösen (Verknüpfungen werden wie ihr Ziel behandelt –
+// die Modrinth App verlinkt z. B. Dateien aus ihrem gemeinsamen Speicher)
+async function art(voll, eintrag) {
+ if (!eintrag.isSymbolicLink()) return { ordner: eintrag.isDirectory(), datei: eintrag.isFile() };
+ const st = await fsp.stat(voll).catch(() => null);
+ return st ? { ordner: st.isDirectory(), datei: st.isFile() } : { ordner: false, datei: false };
+}
+
+/** Zählt Dateien und Bytes unter allen Quellen (Dateien oder Ordner). */
+async function zaehle(quellen, job, melde) {
+ const summe = { dateien: 0, bytes: 0 };
+ let zuletzt = 0;
+ async function lauf(voll, tiefe) {
+ if (job && job.abgebrochen) throw new Abgebrochen();
+ const st = await fsp.stat(voll).catch(() => null);
+ if (!st) return;
+ if (st.isFile()) { summe.dateien++; summe.bytes += st.size; }
+ else if (st.isDirectory() && tiefe < MAX_TIEFE) {
+ const eintraege = await fsp.readdir(voll, { withFileTypes: true }).catch(() => []);
+ for (const e of eintraege) {
+ const p = path.join(voll, e.name);
+ const a = await art(p, e);
+ if (a.ordner) await lauf(p, tiefe + 1);
+ else if (a.datei) {
+ const s = await fsp.stat(p).catch(() => null);
+ if (s) { summe.dateien++; summe.bytes += s.size; }
+ }
+ }
+ }
+ if (melde && Date.now() - zuletzt > MELDE_ALLE_MS) { zuletzt = Date.now(); melde({ ...summe }); }
+ }
+ for (const q of quellen) await lauf(q, 0);
+ if (melde) melde({ ...summe });
+ return summe;
+}
+
+/** Freier Platz (Bytes) auf dem Laufwerk von `ordner`, null wenn unbekannt. */
+async function freierPlatz(ordner) {
+ try {
+ let pruefe = ordner;
+ while (!fs.existsSync(pruefe) && path.dirname(pruefe) !== pruefe) pruefe = path.dirname(pruefe);
+ const s = await fsp.statfs(pruefe);
+ return s.bavail * s.bsize;
+ } catch { return null; }
+}
+
+/**
+ * Fortschritt über mehrere Kopiervorgänge hinweg (z. B. alle Instanzen eines
+ * Imports). `melde` bekommt höchstens alle 200 ms einen Zwischenstand.
+ */
+function neuerFortschritt(gesamt, melde) {
+ const f = {
+ gesamtBytes: gesamt.bytes || 0, gesamtDateien: gesamt.dateien || 0,
+ bytes: 0, dateien: 0, datei: '', uebersprungen: [],
+ beginn: Date.now(), zuletzt: 0, verlauf: [],
+ };
+ f.melden = (sofort) => {
+ const jetzt = Date.now();
+ if (!melde || (!sofort && jetzt - f.zuletzt < MELDE_ALLE_MS)) return;
+ f.zuletzt = jetzt;
+ // Tempo über die letzten ~5 Sekunden glätten
+ f.verlauf.push([jetzt, f.bytes]);
+ while (f.verlauf.length > 2 && jetzt - f.verlauf[0][0] > 5000) f.verlauf.shift();
+ const [t0, b0] = f.verlauf[0];
+ const tempo = jetzt > t0 ? ((f.bytes - b0) * 1000) / (jetzt - t0) : 0;
+ // erst nach 1,5 s schätzen – davor gibt es noch kein brauchbares Tempo
+ const eingeschwungen = jetzt - f.beginn > 1500;
+ const rest = eingeschwungen && tempo > 0 && f.gesamtBytes > f.bytes ? Math.round((f.gesamtBytes - f.bytes) / tempo) : null;
+ melde({
+ bytes: f.bytes, gesamtBytes: f.gesamtBytes, dateien: f.dateien, gesamtDateien: f.gesamtDateien,
+ datei: f.datei, tempo: eingeschwungen ? Math.round(tempo) : 0, restSekunden: rest, uebersprungen: f.uebersprungen.length,
+ });
+ };
+ return f;
+}
+
+async function kopiereDatei(von, nach, groesse, fortschritt, job) {
+ if (groesse >= STUECKWEISE_AB) {
+ // große Datei: stückweise, damit der Balken weiterläuft
+ const lesen = fs.createReadStream(von, { highWaterMark: 4 * 1024 * 1024 });
+ lesen.on('data', (stueck) => {
+ if (job && job.abgebrochen) { lesen.destroy(new Abgebrochen()); return; }
+ if (fortschritt) { fortschritt.bytes += stueck.length; fortschritt.melden(); }
+ });
+ await pipeline(lesen, fs.createWriteStream(nach));
+ } else {
+ await fsp.copyFile(von, nach);
+ if (fortschritt) fortschritt.bytes += groesse;
+ }
+}
+
+/**
+ * Kopiert `von` (Datei oder Ordner) nach `nach`. Läuft asynchron, meldet über
+ * `fortschritt` (siehe neuerFortschritt) und bricht ab, sobald job.abgebrochen.
+ * Gesperrte Einzeldateien landen in fortschritt.uebersprungen; volle Platte
+ * (ENOSPC) und Abbruch beenden den ganzen Vorgang mit Fehler.
+ */
+async function kopiere(von, nach, fortschritt, job) {
+ const laufend = new Set();
+ let schwererFehler = null;
+
+ async function platz(aufgabe) {
+ while (laufend.size >= GLEICHZEITIG) await Promise.race(laufend);
+ if (schwererFehler) throw schwererFehler;
+ const p = aufgabe().catch((err) => {
+ if (err && (err.abgebrochen || err.code === 'ENOSPC')) schwererFehler = schwererFehler || err;
+ else if (fortschritt) fortschritt.uebersprungen.push({ datei: err && err.path ? err.path : '?', grund: err && err.code ? err.code : String(err && err.message) });
+ }).finally(() => laufend.delete(p));
+ laufend.add(p);
+ }
+
+ const basis = von;
+ async function datei(q, z) {
+ if (job && job.abgebrochen) throw new Abgebrochen();
+ const st = await fsp.stat(q);
+ if (fortschritt) fortschritt.datei = path.relative(path.dirname(basis), q);
+ await kopiereDatei(q, z, st.size, fortschritt, job);
+ if (fortschritt) { fortschritt.dateien++; fortschritt.melden(); }
+ }
+
+ async function ordner(q, z, tiefe) {
+ if (job && job.abgebrochen) throw new Abgebrochen();
+ if (schwererFehler) throw schwererFehler;
+ await fsp.mkdir(z, { recursive: true });
+ if (tiefe >= MAX_TIEFE) return;
+ const eintraege = await fsp.readdir(q, { withFileTypes: true });
+ for (const e of eintraege) {
+ const qq = path.join(q, e.name);
+ const zz = path.join(z, e.name);
+ const a = await art(qq, e);
+ if (a.ordner) await ordner(qq, zz, tiefe + 1);
+ else if (a.datei) await platz(() => datei(qq, zz));
+ }
+ }
+
+ let fehler = null;
+ try {
+ const st = await fsp.stat(von);
+ if (st.isDirectory()) await ordner(von, nach, 0);
+ else {
+ await fsp.mkdir(path.dirname(nach), { recursive: true });
+ await platz(() => datei(von, nach));
+ }
+ } catch (err) {
+ fehler = err;
+ }
+ // erst zurückkehren, wenn keine Datei mehr geschrieben wird – sonst würde
+ // das Aufräumen nach einem Abbruch mit noch laufenden Kopien kollidieren
+ while (laufend.size) await Promise.allSettled([...laufend]);
+ if (fehler) throw fehler;
+ if (schwererFehler) throw schwererFehler;
+ if (job && job.abgebrochen) throw new Abgebrochen();
+ if (fortschritt) fortschritt.melden(true);
+}
+
+module.exports = { zaehle, kopiere, freierPlatz, neuerFortschritt, Abgebrochen };
diff --git a/src/main.js b/src/main.js
index a374cc2..ed0f566 100644
--- a/src/main.js
+++ b/src/main.js
@@ -18,6 +18,8 @@ let ffmpegPfad = null;
try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ }
const pkg = require('../package.json');
const store = require('./store');
+const startanimation = require('./startanimation');
+const fensteraufbau = require('./fensteraufbau');
const services = require('./services');
const launcher = require('./launcher');
const loaders = require('./loaders');
@@ -28,6 +30,7 @@ const icons = require('./icons');
const bedrock = require('./bedrock');
const backup = require('./backup');
const mmcimport = require('./mmcimport');
+const kopieren = require('./kopieren');
const cfimport = require('./cfimport');
const mrimport = require('./mrimport');
const mcimport = require('./mcimport');
@@ -154,14 +157,18 @@ function broadcast(channel, payload) {
}
}
-function openPopout(modalId, params) {
+// von: Rechteck des angeklickten Knopfs (Bildschirmkoordinaten) – dann wächst
+// das Fenster daraus hervor (Optionen → Darstellung → Animationen)
+function openPopout(modalId, params, von) {
+ const wachsen = !!von && store.getSettings().uiAnimationen !== false;
let win = popoutWindows.get(modalId);
if (win && !win.isDestroyed()) {
- win.show();
- win.focus();
// erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu
// laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren
win.webContents.send('popout:reopen', params || {});
+ if (wachsen && !win.isVisible()) { fensteraufbau.wachsen(win, von); return; }
+ win.show();
+ win.focus();
return;
}
const query = { popout: modalId };
@@ -189,7 +196,7 @@ function openPopout(modalId, params) {
});
popoutWindows.set(modalId, win);
win.loadFile(path.join(__dirname, 'index.html'), { query });
- win.once('ready-to-show', () => win.show());
+ win.once('ready-to-show', () => { if (wachsen) fensteraufbau.wachsen(win, von); else win.show(); });
win.webContents.on('did-fail-load', (_e, code, desc) => {
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
});
@@ -212,7 +219,18 @@ let recordingGuardActive = false;
ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
-ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params));
+ipcMain.on('popout:open', (e, modalId, params, knopf) => {
+ // Herkunft fürs Aufwachsen: angeklickter Knopf, sonst die Mitte des Fensters
+ let von = null;
+ const quelle = BrowserWindow.fromWebContents(e.sender);
+ if (quelle && !quelle.isDestroyed() && quelle.isVisible()) {
+ const cb = quelle.getContentBounds();
+ von = knopf && Number.isFinite(knopf.x) && Number.isFinite(knopf.y)
+ ? { x: Math.round(cb.x + knopf.x), y: Math.round(cb.y + knopf.y), width: Math.max(8, Math.round(knopf.width)), height: Math.max(8, Math.round(knopf.height)) }
+ : { x: Math.round(cb.x + cb.width / 2 - 20), y: Math.round(cb.y + cb.height / 2 - 20), width: 40, height: 40 };
+ }
+ openPopout(modalId, params, von);
+});
// Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster,
// die Popup-Fenster haben ihren eigenen Kopf)
ipcMain.on('window:titleBar', (e, design) => {
@@ -752,7 +770,7 @@ function windowIcon() {
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
}
-function createWindow(startHidden) {
+function createWindow(startHidden, mitStartanimation) {
mainWindow = new BrowserWindow({
width: 1200,
height: 820,
@@ -773,7 +791,7 @@ function createWindow(startHidden) {
},
});
- mainWindow.loadFile(path.join(__dirname, 'index.html'));
+ mainWindow.loadFile(path.join(__dirname, 'index.html'), mitStartanimation ? { query: { startanimation: '1' } } : undefined);
// Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
// Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der
@@ -939,8 +957,13 @@ app.whenReady().then(() => {
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
// von Hand an der Registry gedreht wurde oder nach einem Update)
applyAutostart(autostartAn);
+ // Reste von im Hintergrund gelöschten Instanzen wegräumen (siehe store.entsorgeOrdner)
+ store.raeumePapierkorbAuf();
const startHidden = autostartAn && process.argv.includes('--hidden');
- createWindow(startHidden);
+ // Startanimation: Fenster erst verdeckt anlegen, die Animation zeigt es dann
+ const mitAnimation = !startHidden && store.getSettings().startAnimation !== false;
+ createWindow(startHidden || mitAnimation, mitAnimation);
+ if (mitAnimation) startanimation.spielen(mainWindow, hauptfensterBereit, store.getSettings().theme);
// Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog:
// das vom Renderer per recording:setCaptureTarget gewählte Fenster wird
@@ -2660,18 +2683,48 @@ function aktivesFreundeKonto() {
ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false);
+/*
+ * Der Server kennt den Namen eines Spielers nur, wenn dieser schon einmal über
+ * AeroMC gespielt hat (Herzschlag). Wer vorher eine Anfrage schickt, käme beim
+ * Empfänger nur als UUID an – deshalb fehlende Namen bei Mojang nachschlagen
+ * (öffentliches Profil, 1 Stunde gemerkt).
+ */
+const spielerNamen = new Map(); // uuid -> { name, zeit }
+async function spielerName(uuid) {
+ const id = String(uuid || '').replace(/-/g, '').toLowerCase();
+ if (!/^[0-9a-f]{32}$/.test(id)) return null;
+ const gemerkt = spielerNamen.get(id);
+ if (gemerkt && Date.now() - gemerkt.zeit < 60 * 60 * 1000) return gemerkt.name;
+ try {
+ const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
+ if (!res.ok) return gemerkt ? gemerkt.name : null;
+ const name = (await res.json()).name || null;
+ spielerNamen.set(id, { name, zeit: Date.now() });
+ return name;
+ } catch {
+ return gemerkt ? gemerkt.name : null;
+ }
+}
+async function mitNamen(liste) {
+ const eintraege = Array.isArray(liste) ? liste : [];
+ await Promise.all(eintraege.map(async (e) => {
+ if (e && !e.username && e.uuid) e.username = await spielerName(e.uuid);
+ }));
+ return eintraege;
+}
+
ipcMain.handle('friends:list', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getFriends(konto.uuid);
- return res.ok ? { ok: true, list: res.data || [] } : res;
+ return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
});
ipcMain.handle('friends:requests', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getRequests(konto.uuid);
- return res.ok ? { ok: true, list: res.data || [] } : res;
+ return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
});
ipcMain.handle('friends:add', async (_e, targetUsername) => {
@@ -2685,7 +2738,7 @@ ipcMain.handle('friends:sentRequests', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getSentRequests(konto.uuid);
- return res.ok ? { ok: true, list: res.data || [] } : res;
+ return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
});
ipcMain.handle('friends:cancel', async (_e, otherUuid) => {
@@ -2758,7 +2811,12 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
-ipcMain.handle('instances:duplicate', (_e, id) => store.duplicateInstance(id));
+ipcMain.handle('instances:duplicate', async (_e, id) => {
+ const kopie = store.duplicateInstance(id, true);
+ try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
+ catch { /* Quelle evtl. leer */ }
+ return kopie;
+});
ipcMain.handle('instances:openFolder', (_e, id) => {
const p = store.instancePath(id);
@@ -3997,20 +4055,91 @@ ipcMain.handle('mmc:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; }
});
-ipcMain.handle('mmc:import', (_e, entries, copyData) => {
+// ---------- Instanzen aus anderen Launchern übernehmen ----------
+/*
+ * Gemeinsamer Ablauf für alle Launcher-Importe. Früher wurde synchron kopiert –
+ * bei vielen Gigabyte stand der Launcher dann komplett still. Jetzt:
+ * 1. Größe aller ausgewählten Instanzen ermitteln (asynchron)
+ * 2. prüfen, ob auf dem Laufwerk genug Platz frei ist
+ * 3. Instanz für Instanz anlegen und im Hintergrund kopieren
+ * Fortschritt geht als 'import:fortschritt' an alle Fenster; 'import:abbrechen'
+ * stoppt den Lauf, die gerade halb kopierte Instanz wird wieder entfernt.
+ */
+let laufenderImport = null;
+const GB = 1024 * 1024 * 1024;
+const zuGb = (b) => (b / GB).toLocaleString('de-DE', { maximumFractionDigits: 1 });
+
+ipcMain.handle('import:abbrechen', () => {
+ if (laufenderImport) laufenderImport.abgebrochen = true;
+ return !!laufenderImport;
+});
+
+async function fuehreImportAus(modul, entries, copyData, nachher) {
+ if (laufenderImport) return { ok: false, message: 'Es läuft bereits ein Import – bitte warten oder ihn abbrechen.' };
+ const job = { abgebrochen: false };
+ laufenderImport = job;
+ const liste = entries || [];
const done = [];
const failed = [];
- for (const entry of entries || []) {
- try {
- const imported = mmcimport.importOne(store, entry, copyData !== false);
- done.push(imported);
+ const melde = (daten) => broadcast('import:fortschritt', daten);
+ let fortschritt = null;
+ try {
+ // 1) Was wird kopiert und wie viel?
+ const plaene = liste.map((entry) => ({ entry, quellen: copyData ? modul.kopierQuellen(entry) : [] }));
+ let gesamt = { bytes: 0, dateien: 0 };
+ if (copyData) {
+ melde({ phase: 'zaehlen', dateien: 0, bytes: 0, instanzen: liste.length });
+ try {
+ gesamt = await kopieren.zaehle(plaene.flatMap((p) => p.quellen.map((q) => q.von)), job,
+ (s) => melde({ phase: 'zaehlen', dateien: s.dateien, bytes: s.bytes, instanzen: liste.length }));
+ } catch (err) {
+ if (err.abgebrochen) return { ok: true, imported: [], failed: [], abgebrochen: true, uebersprungen: [] };
+ throw err;
+ }
+ // 2) Reicht der Platz? (etwas Luft für Windows lassen)
+ const frei = await kopieren.freierPlatz(store.getInstancesDir());
+ if (frei !== null && gesamt.bytes > frei - 512 * 1024 * 1024) {
+ return { ok: false, message: `Nicht genug Speicherplatz: Die Instanzen brauchen ${zuGb(gesamt.bytes)} GB, auf dem Laufwerk sind nur ${zuGb(frei)} GB frei. Weniger Instanzen auswählen oder unter Optionen → Allgemein einen anderen Instanz-Ordner wählen.` };
+ }
}
- catch (err) { failed.push({ name: entry.name, message: err.message }); }
- broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length });
+ // 3) Instanz für Instanz – ein gemeinsamer Zähler über alle Instanzen
+ let kopf = { phase: 'kopieren' };
+ fortschritt = kopieren.neuerFortschritt(gesamt, (p) => melde(Object.assign({}, kopf, p)));
+ const f = fortschritt;
+ for (let i = 0; i < plaene.length; i++) {
+ if (job.abgebrochen) break;
+ const { entry, quellen } = plaene[i];
+ kopf = { phase: 'kopieren', instanz: entry.name, nr: i + 1, von: plaene.length };
+ f.melden(true);
+ let angelegt = null;
+ try {
+ angelegt = modul.importOne(store, entry, false); // nur Angaben – kopiert wird hier
+ for (const q of quellen) {
+ await kopieren.kopiere(q.von, path.join(store.gameDir(angelegt.id), q.name), f, job);
+ }
+ angelegt.copied = quellen.length > 0;
+ if (nachher) await nachher(entry, angelegt, kopf);
+ done.push(angelegt);
+ } catch (err) {
+ // halb kopierte Instanz nicht liegen lassen
+ if (angelegt) { try { store.deleteInstance(angelegt.id); } catch { /* Aufräumen ist kein Muss */ } }
+ if (!err.abgebrochen) {
+ failed.push({ name: entry.name, message: err.code === 'ENOSPC' ? 'Speicherplatz voll' : err.message });
+ if (err.code === 'ENOSPC') break;
+ }
+ }
+ }
+ } finally {
+ laufenderImport = null;
}
if (done.length) broadcast('instances:changed');
- return { ok: true, imported: done, failed };
-});
+ return {
+ ok: true, imported: done, failed, abgebrochen: job.abgebrochen,
+ uebersprungen: fortschritt ? fortschritt.uebersprungen.slice(0, 50) : [],
+ };
+}
+
+ipcMain.handle('mmc:import', (_e, entries, copyData) => fuehreImportAus(mmcimport, entries, copyData !== false));
ipcMain.handle('cf:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4025,20 +4154,7 @@ ipcMain.handle('cf:scan', (_e, 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 {
- const imported = cfimport.importOne(store, entry, copyData !== false);
- done.push(imported);
- }
- catch (err) { failed.push({ name: entry.name, message: err.message }); }
- broadcast('cf:progress', { current: done.length + failed.length, total: entries.length });
- }
- if (done.length) broadcast('instances:changed');
- return { ok: true, imported: done, failed };
-});
+ipcMain.handle('cf:import', (_e, entries, copyData) => fuehreImportAus(cfimport, entries, copyData !== false));
// ---------- Modrinth App ----------
ipcMain.handle('mr:pickFolder', async (e) => {
@@ -4100,26 +4216,12 @@ async function verknuepfeModrinthModpack(instId, link) {
return true;
}
-ipcMain.handle('mr:import', async (_e, entries, copyData) => {
- const done = [];
- const failed = [];
- const liste = entries || [];
- for (const entry of liste) {
- try {
- const imported = mrimport.importOne(store, entry, copyData !== false);
- if (entry.modpack && entry.modpack.projectId && entry.modpack.versionId) {
- broadcast('mr:progress', { current: done.length + failed.length, total: liste.length, detail: 'Modpack-Verknüpfung für ' + entry.name });
- try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
- catch { imported.modpack = false; }
- }
- done.push(imported);
- }
- catch (err) { failed.push({ name: entry.name, message: err.message }); }
- broadcast('mr:progress', { current: done.length + failed.length, total: liste.length });
- }
- if (done.length) broadcast('instances:changed');
- return { ok: true, imported: done, failed };
-});
+ipcMain.handle('mr:import', (_e, entries, copyData) => fuehreImportAus(mrimport, entries, copyData !== false, async (entry, imported, kopf) => {
+ if (!(entry.modpack && entry.modpack.projectId && entry.modpack.versionId)) return;
+ broadcast('import:fortschritt', Object.assign({}, kopf, { phase: 'modpack' }));
+ try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
+ catch { imported.modpack = false; }
+}));
ipcMain.handle('mc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4134,20 +4236,7 @@ ipcMain.handle('mc:scan', (_e, 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 {
- const imported = mcimport.importOne(store, entry, copyData !== false);
- done.push(imported);
- }
- catch (err) { failed.push({ name: entry.name, message: err.message }); }
- broadcast('mc:progress', { current: done.length + failed.length, total: entries.length });
- }
- if (done.length) broadcast('instances:changed');
- return { ok: true, imported: done, failed };
-});
+ipcMain.handle('mc:import', (_e, entries, copyData) => fuehreImportAus(mcimport, entries, copyData !== false));
ipcMain.handle('technic:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4162,20 +4251,7 @@ ipcMain.handle('technic:scan', (_e, 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 }); }
- broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
- }
- if (done.length) broadcast('instances:changed');
- return { ok: true, imported: done, failed };
-});
+ipcMain.handle('technic:import', (_e, entries, copyData) => fuehreImportAus(technicimport, entries, copyData !== false));
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
diff --git a/src/mcimport.js b/src/mcimport.js
index 3cbadbe..b5df6ee 100644
--- a/src/mcimport.js
+++ b/src/mcimport.js
@@ -173,16 +173,31 @@ function scan(inputPath) {
return { ok: true, rootDir, count: list.length, instances: list };
}
+function kopierNamen(sourceDir, usesDefaultGameDir) {
+ return fs.readdirSync(sourceDir).filter((name) => {
+ if (/^launcher_profiles.*\.json$/i.test(name)) return false;
+ if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') return false;
+ return !(usesDefaultGameDir && !shouldCopyRootEntry(name));
+ });
+}
+
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;
+ for (const name of kopierNamen(sourceDir, usesDefaultGameDir)) {
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
}
}
+/*
+ * Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
+ * im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
+ * asynchron mit Fortschritt (main.js → fuehreImportAus).
+ */
+function kopierQuellen(entry) {
+ if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
+ return kopierNamen(entry.gameDir, !!entry.usesDefaultGameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
+}
+
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
@@ -214,4 +229,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied };
}
-module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };
\ No newline at end of file
+module.exports = { scan, importOne, kopierQuellen, parseLastVersionId, resolveLauncherRoot };
\ No newline at end of file
diff --git a/src/mmcimport.js b/src/mmcimport.js
index 4643218..88f01ed 100644
--- a/src/mmcimport.js
+++ b/src/mmcimport.js
@@ -1,188 +1,199 @@
-'use strict';
-
-/*
- * mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
- * ------------------------------------------------------
- * Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
- * Ausgelesen werden:
- * instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
- * mmc-pack.json – Minecraft-Version und Mod-Loader
- * .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
- */
-
-const fs = require('fs');
-const path = require('path');
-
-// uid der Loader in mmc-pack.json -> unsere Bezeichnung
-const LOADER_UIDS = {
- 'net.fabricmc.fabric-loader': 'fabric',
- 'org.quiltmc.quilt-loader': 'quilt',
- 'net.minecraftforge': 'forge',
- 'net.neoforged': 'neoforge',
-};
-
-function parseCfg(file) {
- const out = {};
- try {
- for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
- const i = line.indexOf('=');
- if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
- }
- } catch { /* Datei fehlt */ }
- return out;
-}
-
-// Gruppen stehen zentral in instances/instgroups.json
-function readGroups(instancesDir) {
- const map = {};
- try {
- const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
- for (const [group, data] of Object.entries(raw.groups || {})) {
- for (const inst of data.instances || []) map[inst] = group;
- }
- } catch { /* keine Gruppen hinterlegt */ }
- return map;
-}
-
-function readPack(dir) {
- const res = { version: '', loader: 'vanilla', loaderVersion: '' };
- try {
- const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
- for (const c of pack.components || []) {
- if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
- const loader = LOADER_UIDS[c.uid];
- if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
- }
- } catch { /* alte Instanz ohne mmc-pack.json */ }
- return res;
-}
-
-// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
-function resolveInstancesDir(inputPath) {
- if (!inputPath) return null;
- const direct = path.join(inputPath, 'instances');
- if (fs.existsSync(direct)) return direct;
- if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
- return fs.existsSync(inputPath) ? inputPath : null;
-}
-
-/*
- * Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
- * Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
- */
-function readInstance(dir, folderName, group) {
- const cfgFile = path.join(dir, 'instance.cfg');
- if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
-
- const cfg = parseCfg(cfgFile);
- const pack = readPack(dir);
- // MultiMC nutzt .minecraft, Prism minecraft
- const gameDir = ['.minecraft', 'minecraft']
- .map((n) => path.join(dir, n))
- .find((p) => fs.existsSync(p)) || null;
-
- return {
- folder: folderName,
- dir,
- name: cfg.name || folderName,
- group: group || '',
- notes: cfg.notes || '',
- version: pack.version || cfg.IntendedVersion || '',
- loader: pack.loader,
- loaderVersion: pack.loaderVersion,
- javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
- minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
- maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
- gameDir,
- hasGameData: !!gameDir,
- };
-}
-
-/*
- * Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
- */
-function scan(inputPath) {
- const instancesDir = resolveInstancesDir(inputPath);
- if (!instancesDir) return { ok: false, reason: 'not-found' };
-
- const groups = readGroups(instancesDir);
- const list = [];
- for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
- if (inst) list.push(inst);
- }
- list.sort((a, b) => a.name.localeCompare(b.name));
- return { ok: true, instancesDir, count: list.length, instances: list };
-}
-
-/*
- * Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
- * mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
- * Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
- */
-function findInstanceDir(root, tiefe = 2) {
- if (!root || !fs.existsSync(root)) return null;
- if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
- if (tiefe <= 0) return null;
- let eintraege = [];
- try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
- for (const e of eintraege) {
- if (!e.isDirectory()) continue;
- // .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
- if (e.name === '.minecraft' || e.name === 'minecraft') continue;
- const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
- if (treffer) return treffer;
- }
- return null;
-}
-
-/*
- * Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
- * -> { ok, instance } oder { ok: false, reason: 'not-found' }
- */
-function scanSingle(root) {
- const dir = findInstanceDir(root);
- if (!dir) return { ok: false, reason: 'not-found' };
- const inst = readInstance(dir, path.basename(dir), '');
- return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
-}
-
-/*
- * Übernimmt eine Instanz in den Launcher.
- * store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
- */
-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)) {
- try {
- fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
- copied = true;
- } catch { /* Spieldaten konnten nicht kopiert werden */ }
- }
- return { id: created.id, name: created.name, copied };
-}
-
-module.exports = { scan, scanSingle, importOne, resolveInstancesDir, readInstance, findInstanceDir };
+'use strict';
+
+/*
+ * mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
+ * ------------------------------------------------------
+ * Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
+ * Ausgelesen werden:
+ * instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
+ * mmc-pack.json – Minecraft-Version und Mod-Loader
+ * .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+// uid der Loader in mmc-pack.json -> unsere Bezeichnung
+const LOADER_UIDS = {
+ 'net.fabricmc.fabric-loader': 'fabric',
+ 'org.quiltmc.quilt-loader': 'quilt',
+ 'net.minecraftforge': 'forge',
+ 'net.neoforged': 'neoforge',
+};
+
+function parseCfg(file) {
+ const out = {};
+ try {
+ for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
+ const i = line.indexOf('=');
+ if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
+ }
+ } catch { /* Datei fehlt */ }
+ return out;
+}
+
+// Gruppen stehen zentral in instances/instgroups.json
+function readGroups(instancesDir) {
+ const map = {};
+ try {
+ const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
+ for (const [group, data] of Object.entries(raw.groups || {})) {
+ for (const inst of data.instances || []) map[inst] = group;
+ }
+ } catch { /* keine Gruppen hinterlegt */ }
+ return map;
+}
+
+function readPack(dir) {
+ const res = { version: '', loader: 'vanilla', loaderVersion: '' };
+ try {
+ const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
+ for (const c of pack.components || []) {
+ if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
+ const loader = LOADER_UIDS[c.uid];
+ if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
+ }
+ } catch { /* alte Instanz ohne mmc-pack.json */ }
+ return res;
+}
+
+// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
+function resolveInstancesDir(inputPath) {
+ if (!inputPath) return null;
+ const direct = path.join(inputPath, 'instances');
+ if (fs.existsSync(direct)) return direct;
+ if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
+ return fs.existsSync(inputPath) ? inputPath : null;
+}
+
+/*
+ * Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
+ * Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
+ */
+function readInstance(dir, folderName, group) {
+ const cfgFile = path.join(dir, 'instance.cfg');
+ if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
+
+ const cfg = parseCfg(cfgFile);
+ const pack = readPack(dir);
+ // MultiMC nutzt .minecraft, Prism minecraft
+ const gameDir = ['.minecraft', 'minecraft']
+ .map((n) => path.join(dir, n))
+ .find((p) => fs.existsSync(p)) || null;
+
+ return {
+ folder: folderName,
+ dir,
+ name: cfg.name || folderName,
+ group: group || '',
+ notes: cfg.notes || '',
+ version: pack.version || cfg.IntendedVersion || '',
+ loader: pack.loader,
+ loaderVersion: pack.loaderVersion,
+ javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
+ minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
+ maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
+ gameDir,
+ hasGameData: !!gameDir,
+ };
+}
+
+/*
+ * Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
+ */
+function scan(inputPath) {
+ const instancesDir = resolveInstancesDir(inputPath);
+ if (!instancesDir) return { ok: false, reason: 'not-found' };
+
+ const groups = readGroups(instancesDir);
+ const list = [];
+ for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
+ if (inst) list.push(inst);
+ }
+ list.sort((a, b) => a.name.localeCompare(b.name));
+ return { ok: true, instancesDir, count: list.length, instances: list };
+}
+
+/*
+ * Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
+ * mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
+ * Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
+ */
+function findInstanceDir(root, tiefe = 2) {
+ if (!root || !fs.existsSync(root)) return null;
+ if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
+ if (tiefe <= 0) return null;
+ let eintraege = [];
+ try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
+ for (const e of eintraege) {
+ if (!e.isDirectory()) continue;
+ // .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
+ if (e.name === '.minecraft' || e.name === 'minecraft') continue;
+ const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
+ if (treffer) return treffer;
+ }
+ return null;
+}
+
+/*
+ * Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
+ * -> { ok, instance } oder { ok: false, reason: 'not-found' }
+ */
+function scanSingle(root) {
+ const dir = findInstanceDir(root);
+ if (!dir) return { ok: false, reason: 'not-found' };
+ const inst = readInstance(dir, path.basename(dir), '');
+ return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
+}
+
+/*
+ * Übernimmt eine Instanz in den Launcher.
+ * store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
+ */
+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)) {
+ try {
+ fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
+ copied = true;
+ } catch { /* Spieldaten konnten nicht kopiert werden */ }
+ }
+ return { id: created.id, name: created.name, copied };
+}
+
+
+/*
+ * Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
+ * im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
+ * asynchron mit Fortschritt (main.js → fuehreImportAus).
+ */
+function kopierQuellen(entry) {
+ if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
+ return fs.readdirSync(entry.gameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
+}
+
+module.exports = { scan, scanSingle, importOne, kopierQuellen, resolveInstancesDir, readInstance, findInstanceDir };
diff --git a/src/mrimport.js b/src/mrimport.js
index e1339a9..e48595c 100644
--- a/src/mrimport.js
+++ b/src/mrimport.js
@@ -1,437 +1,448 @@
-'use strict';
-
-/*
- * mrimport.js – Instanzen aus der Modrinth App übernehmen
- * --------------------------------------------------------
- * Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
- * Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
- * "profiles\", die Angaben dazu (Name, Version, Loader, Gruppen,
- * Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
- * der App verlegt, steht der neue Ort in settings.custom_dir.
- *
- * Unterstützte Datenbank-Fassungen:
- * - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
- * Gruppen in "instance_group_memberships"/"instance_groups",
- * Starteinstellungen als JSONB in "instance_launch_overrides"
- * - 2024 bis Mitte 2026: eine Tabelle "profiles"
- * - vorher (ohne app.db): je Instanz eine profile.json
- *
- * Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
- * die Datenbank der Modrinth App wird nie verändert.
- */
-
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
-
-let sqlPromise = null;
-function sqlJs() {
- if (!sqlPromise) {
- const initSqlJs = require('sql.js');
- const wasmDir = path.dirname(require.resolve('sql.js'));
- sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
- }
- return sqlPromise;
-}
-
-function appDatenOrdner() {
- const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
- return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
-}
-
-function standardOrdner() {
- return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
-}
-
-function readJson(file) {
- try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
-}
-
-// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
-function ermittleOrte(eingabe) {
- let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
- if (!fs.existsSync(basis)) return null;
- // direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
- if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
- const hoeher = path.dirname(basis);
- if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
- }
- const db = path.join(basis, 'app.db');
- return { basis, db: fs.existsSync(db) ? db : null };
-}
-
-// ---------- Datenbank ----------
-/*
- * Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
- * Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
- * Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
- * dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite),
- * mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
- * -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
- */
-function spieleWalEin(daten, walPfad) {
- let wal;
- try { wal = fs.readFileSync(walPfad); } catch { return null; }
- if (wal.length < 32) return null;
- const magie = wal.readUInt32BE(0);
- if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
- const bigEndian = magie === 0x377f0683;
- const seite = wal.readUInt32BE(8);
- const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
- if (!seite || seite !== dbSeite) return null;
- const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
-
- const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
- let s0 = 0, s1 = 0;
- const pruefe = (buf, start, ende) => {
- for (let i = start; i < ende; i += 8) {
- s0 = (s0 + wort(buf, i) + s1) >>> 0;
- s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
- }
- };
- pruefe(wal, 0, 24);
- if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
-
- const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
- let offen = new Map();
- let seiten = Math.floor(daten.length / seite);
- let rahmen = 0;
- for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
- if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
- pruefe(wal, pos, pos + 8);
- pruefe(wal, pos + 24, pos + 24 + seite);
- if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
- offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
- const nachCommit = wal.readUInt32BE(pos + 4);
- if (nachCommit) { // Transaktion abgeschlossen
- for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
- offen = new Map();
- seiten = nachCommit;
- rahmen++;
- }
- }
- if (!rahmen) return null;
- const neu = Buffer.alloc(seiten * seite);
- daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
- for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
- return { daten: neu, rahmen };
-}
-
-async function oeffneDb(dbPfad) {
- const SQL = await sqlJs();
- let daten = fs.readFileSync(dbPfad);
- let walEingespielt = false;
- if (daten.length > 100 && daten[18] === 2) {
- const mitWal = spieleWalEin(daten, dbPfad + '-wal');
- if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
- // WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
- // Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
- daten[18] = 1; daten[19] = 1;
- }
- const db = new SQL.Database(daten);
- db.walEingespielt = walEingespielt;
- return db;
-}
-
-function zeilen(db, sql, params) {
- const st = db.prepare(sql);
- try {
- if (params) st.bind(params);
- const out = [];
- while (st.step()) out.push(st.getAsObject());
- return out;
- } finally {
- st.free();
- }
-}
-
-function tabellen(db) {
- return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
-}
-
-function spalten(db, tabelle) {
- return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
-}
-
-function jsonFeld(wert) {
- if (wert === null || wert === undefined || wert === '') return null;
- try { return JSON.parse(wert); } catch { return null; }
-}
-
-// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
-function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
- return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
-}
-
-function eigenerOrdner(db, t) {
- if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
- const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
- return r && r.custom_dir ? String(r.custom_dir) : null;
-}
-
-function leseNeu(db, t) {
- const si = spalten(db, 'instances');
- const liste = zeilen(db, `
- SELECT i.id AS id, i.path AS path, i.name AS name,
- ${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
- ${sp(si, 'i.last_played', 'last_played')},
- ${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
- ${sp(si, 'i.recent_time_played', 'recent_time_played', '0')},
- cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
- FROM instances i
- LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
-
- const overrides = new Map();
- if (t.has('instance_launch_overrides')) {
- for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) {
- overrides.set(r.instance_id, jsonFeld(r.o) || {});
- }
- }
- const gruppen = new Map();
- const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
- if (t.has('instance_group_memberships') && t.has('instance_groups')) {
- for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m
- JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name);
- } else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) {
- for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name);
- }
- const links = new Map();
- if (t.has('instance_links')) {
- for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) {
- if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id });
- }
- }
-
- return liste.map((r) => {
- const o = overrides.get(r.id) || {};
- const hooks = o.hooks || {};
- return {
- path: r.path,
- name: r.name,
- iconPath: r.icon_path,
- installiert: !r.install_stage || r.install_stage === 'installed',
- lastPlayed: r.last_played,
- sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
- gameVersion: r.game_version,
- loader: r.loader,
- loaderVersion: r.loader_version,
- gruppe: gruppen.get(r.id) || '',
- javaPath: o.java_path || '',
- maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
- extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
- vorStart: hooks.pre_launch || '',
- nachEnde: hooks.post_exit || '',
- modpack: links.get(r.id) || null,
- };
- });
-}
-
-function leseProfile(db) {
- const s = spalten(db, 'profiles');
- const liste = zeilen(db, `SELECT path, name,
- ${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
- ${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')},
- ${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
- ${sp(s, 'last_played', 'last_played')},
- ${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
- ${sp(s, 'recent_time_played', 'recent_time_played', '0')},
- ${sp(s, 'override_java_path', 'override_java_path')},
- ${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
- ${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
- ${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
- ${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
- ${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
- FROM profiles`);
- return liste.map((r) => {
- const gruppen = jsonFeld(r.groups);
- const args = jsonFeld(r.override_extra_launch_args);
- return {
- path: r.path,
- name: r.name,
- iconPath: r.icon_path,
- installiert: !r.install_stage || r.install_stage === 'installed',
- lastPlayed: r.last_played,
- sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
- gameVersion: r.game_version,
- loader: r.mod_loader,
- loaderVersion: r.mod_loader_version,
- gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
- javaPath: r.override_java_path || '',
- maxMemMb: Number(r.override_mc_memory_max) || null,
- extraArgs: Array.isArray(args) ? args.join(' ') : '',
- vorStart: r.override_hook_pre_launch || '',
- nachEnde: r.override_hook_post_exit || '',
- modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null,
- };
- });
-}
-
-// sehr alte Fassungen: profiles\\profile.json
-function leseProfileJson(profilesDir) {
- const liste = [];
- for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
- if (!e.isDirectory()) continue;
- const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
- if (!p) continue;
- const m = p.metadata || p;
- const lv = m.loader_version;
- liste.push({
- path: e.name,
- name: m.name || e.name,
- iconPath: m.icon || null,
- installiert: !p.install_stage || p.install_stage === 'installed',
- lastPlayed: m.last_played || null,
- sekunden: 0,
- gameVersion: m.game_version,
- loader: m.loader,
- loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
- gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
- javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
- maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
- extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
- vorStart: (p.hooks && p.hooks.pre_launch) || '',
- nachEnde: (p.hooks && p.hooks.post_exit) || '',
- modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
- ? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
- });
- }
- return liste;
-}
-
-// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
-function loaderVersion(loader, wert, mc) {
- let v = String(wert || '').trim();
- if (!v || v === 'stable' || v === 'latest') return '';
- if (loader === 'forge') {
- v = v.replace(/^forge-/i, '');
- if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
- } else if (loader === 'neoforge') {
- v = v.replace(/^neoforge-/i, '');
- } else if (loader === 'fabric' || loader === 'quilt') {
- v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
- if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
- }
- return v;
-}
-
-function zeitstempel(wert) {
- if (!wert) return null;
- const n = Number(wert);
- if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
- const d = new Date(wert);
- return Number.isNaN(d.getTime()) ? null : d.toISOString();
-}
-
-/*
- * Durchsucht die Modrinth App. Leere Eingabe = Standardort.
- * -> { ok, basis, count, instances, hinweis }
- */
-async function scan(eingabe) {
- const orte = ermittleOrte(eingabe);
- if (!orte) return { ok: false, reason: 'not-found' };
-
- let roh = [];
- let profilesDir = path.join(orte.basis, 'profiles');
- let hinweis = '';
- if (orte.db) {
- const db = await oeffneDb(orte.db);
- try {
- const t = tabellen(db);
- const eigener = eigenerOrdner(db, t);
- if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
- if (t.has('instances')) roh = leseNeu(db, t);
- else if (t.has('profiles')) roh = leseProfile(db);
- } finally {
- db.close();
- }
- // Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
- // ist evtl. nicht ganz aktuell
- try {
- const wal = orte.db + '-wal';
- if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
- hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
- }
- } catch { /* egal */ }
- } else if (fs.existsSync(profilesDir)) {
- roh = leseProfileJson(profilesDir);
- } else {
- return { ok: false, reason: 'not-found' };
- }
-
- const instances = [];
- for (const r of roh) {
- const dir = path.join(profilesDir, String(r.path || ''));
- if (!r.path || !fs.existsSync(dir)) continue;
- const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
- const version = String(r.gameVersion || '').trim();
- const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
- instances.push({
- folder: r.path,
- dir,
- gameDir: dir,
- name: String(r.name || r.path),
- group: r.gruppe || '',
- version,
- loader,
- loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
- installiert: r.installiert,
- iconPath: icon,
- lastPlayed: zeitstempel(r.lastPlayed),
- playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
- javaPath: String(r.javaPath || '').trim(),
- maxMemMb: r.maxMemMb || null,
- extraArgs: String(r.extraArgs || '').trim(),
- preLaunchCommand: String(r.vorStart || '').trim(),
- postExitCommand: String(r.nachEnde || '').trim(),
- modpack: r.modpack,
- });
- }
- instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
- return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
-}
-
-// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
-const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
-
-function copyInto(quelle, ziel) {
- fs.mkdirSync(ziel, { recursive: true });
- for (const name of fs.readdirSync(quelle)) {
- if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
- // dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
- fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
- }
-}
-
-function importOne(store, entry, copyData = true) {
- const created = store.createOrReplaceImportedInstance({
- name: entry.name,
- group: entry.group,
- minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
- });
-
- const patch = {};
- if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
- patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
- }
- if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
- if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
- // Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
- const vorher = store.getInstance(created.id) || {};
- if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
- if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
- 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;
- }
- if (entry.iconPath) {
- try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
- }
- return { id: created.id, name: created.name, copied };
-}
-
-module.exports = { scan, importOne, standardOrdner };
+'use strict';
+
+/*
+ * mrimport.js – Instanzen aus der Modrinth App übernehmen
+ * --------------------------------------------------------
+ * Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
+ * Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
+ * "profiles\", die Angaben dazu (Name, Version, Loader, Gruppen,
+ * Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
+ * der App verlegt, steht der neue Ort in settings.custom_dir.
+ *
+ * Unterstützte Datenbank-Fassungen:
+ * - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
+ * Gruppen in "instance_group_memberships"/"instance_groups",
+ * Starteinstellungen als JSONB in "instance_launch_overrides"
+ * - 2024 bis Mitte 2026: eine Tabelle "profiles"
+ * - vorher (ohne app.db): je Instanz eine profile.json
+ *
+ * Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
+ * die Datenbank der Modrinth App wird nie verändert.
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
+
+let sqlPromise = null;
+function sqlJs() {
+ if (!sqlPromise) {
+ const initSqlJs = require('sql.js');
+ const wasmDir = path.dirname(require.resolve('sql.js'));
+ sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
+ }
+ return sqlPromise;
+}
+
+function appDatenOrdner() {
+ const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
+ return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
+}
+
+function standardOrdner() {
+ return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
+}
+
+function readJson(file) {
+ try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
+}
+
+// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
+function ermittleOrte(eingabe) {
+ let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
+ if (!fs.existsSync(basis)) return null;
+ // direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
+ if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
+ const hoeher = path.dirname(basis);
+ if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
+ }
+ const db = path.join(basis, 'app.db');
+ return { basis, db: fs.existsSync(db) ? db : null };
+}
+
+// ---------- Datenbank ----------
+/*
+ * Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
+ * Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
+ * Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
+ * dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite),
+ * mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
+ * -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
+ */
+function spieleWalEin(daten, walPfad) {
+ let wal;
+ try { wal = fs.readFileSync(walPfad); } catch { return null; }
+ if (wal.length < 32) return null;
+ const magie = wal.readUInt32BE(0);
+ if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
+ const bigEndian = magie === 0x377f0683;
+ const seite = wal.readUInt32BE(8);
+ const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
+ if (!seite || seite !== dbSeite) return null;
+ const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
+
+ const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
+ let s0 = 0, s1 = 0;
+ const pruefe = (buf, start, ende) => {
+ for (let i = start; i < ende; i += 8) {
+ s0 = (s0 + wort(buf, i) + s1) >>> 0;
+ s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
+ }
+ };
+ pruefe(wal, 0, 24);
+ if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
+
+ const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
+ let offen = new Map();
+ let seiten = Math.floor(daten.length / seite);
+ let rahmen = 0;
+ for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
+ if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
+ pruefe(wal, pos, pos + 8);
+ pruefe(wal, pos + 24, pos + 24 + seite);
+ if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
+ offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
+ const nachCommit = wal.readUInt32BE(pos + 4);
+ if (nachCommit) { // Transaktion abgeschlossen
+ for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
+ offen = new Map();
+ seiten = nachCommit;
+ rahmen++;
+ }
+ }
+ if (!rahmen) return null;
+ const neu = Buffer.alloc(seiten * seite);
+ daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
+ for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
+ return { daten: neu, rahmen };
+}
+
+async function oeffneDb(dbPfad) {
+ const SQL = await sqlJs();
+ let daten = fs.readFileSync(dbPfad);
+ let walEingespielt = false;
+ if (daten.length > 100 && daten[18] === 2) {
+ const mitWal = spieleWalEin(daten, dbPfad + '-wal');
+ if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
+ // WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
+ // Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
+ daten[18] = 1; daten[19] = 1;
+ }
+ const db = new SQL.Database(daten);
+ db.walEingespielt = walEingespielt;
+ return db;
+}
+
+function zeilen(db, sql, params) {
+ const st = db.prepare(sql);
+ try {
+ if (params) st.bind(params);
+ const out = [];
+ while (st.step()) out.push(st.getAsObject());
+ return out;
+ } finally {
+ st.free();
+ }
+}
+
+function tabellen(db) {
+ return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
+}
+
+function spalten(db, tabelle) {
+ return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
+}
+
+function jsonFeld(wert) {
+ if (wert === null || wert === undefined || wert === '') return null;
+ try { return JSON.parse(wert); } catch { return null; }
+}
+
+// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
+function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
+ return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
+}
+
+function eigenerOrdner(db, t) {
+ if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
+ const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
+ return r && r.custom_dir ? String(r.custom_dir) : null;
+}
+
+function leseNeu(db, t) {
+ const si = spalten(db, 'instances');
+ const liste = zeilen(db, `
+ SELECT i.id AS id, i.path AS path, i.name AS name,
+ ${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
+ ${sp(si, 'i.last_played', 'last_played')},
+ ${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
+ ${sp(si, 'i.recent_time_played', 'recent_time_played', '0')},
+ cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
+ FROM instances i
+ LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
+
+ const overrides = new Map();
+ if (t.has('instance_launch_overrides')) {
+ for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) {
+ overrides.set(r.instance_id, jsonFeld(r.o) || {});
+ }
+ }
+ const gruppen = new Map();
+ const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
+ if (t.has('instance_group_memberships') && t.has('instance_groups')) {
+ for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m
+ JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name);
+ } else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) {
+ for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name);
+ }
+ const links = new Map();
+ if (t.has('instance_links')) {
+ for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) {
+ if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id });
+ }
+ }
+
+ return liste.map((r) => {
+ const o = overrides.get(r.id) || {};
+ const hooks = o.hooks || {};
+ return {
+ path: r.path,
+ name: r.name,
+ iconPath: r.icon_path,
+ installiert: !r.install_stage || r.install_stage === 'installed',
+ lastPlayed: r.last_played,
+ sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
+ gameVersion: r.game_version,
+ loader: r.loader,
+ loaderVersion: r.loader_version,
+ gruppe: gruppen.get(r.id) || '',
+ javaPath: o.java_path || '',
+ maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
+ extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
+ vorStart: hooks.pre_launch || '',
+ nachEnde: hooks.post_exit || '',
+ modpack: links.get(r.id) || null,
+ };
+ });
+}
+
+function leseProfile(db) {
+ const s = spalten(db, 'profiles');
+ const liste = zeilen(db, `SELECT path, name,
+ ${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
+ ${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')},
+ ${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
+ ${sp(s, 'last_played', 'last_played')},
+ ${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
+ ${sp(s, 'recent_time_played', 'recent_time_played', '0')},
+ ${sp(s, 'override_java_path', 'override_java_path')},
+ ${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
+ ${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
+ ${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
+ ${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
+ ${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
+ FROM profiles`);
+ return liste.map((r) => {
+ const gruppen = jsonFeld(r.groups);
+ const args = jsonFeld(r.override_extra_launch_args);
+ return {
+ path: r.path,
+ name: r.name,
+ iconPath: r.icon_path,
+ installiert: !r.install_stage || r.install_stage === 'installed',
+ lastPlayed: r.last_played,
+ sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
+ gameVersion: r.game_version,
+ loader: r.mod_loader,
+ loaderVersion: r.mod_loader_version,
+ gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
+ javaPath: r.override_java_path || '',
+ maxMemMb: Number(r.override_mc_memory_max) || null,
+ extraArgs: Array.isArray(args) ? args.join(' ') : '',
+ vorStart: r.override_hook_pre_launch || '',
+ nachEnde: r.override_hook_post_exit || '',
+ modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null,
+ };
+ });
+}
+
+// sehr alte Fassungen: profiles\\profile.json
+function leseProfileJson(profilesDir) {
+ const liste = [];
+ for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
+ if (!e.isDirectory()) continue;
+ const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
+ if (!p) continue;
+ const m = p.metadata || p;
+ const lv = m.loader_version;
+ liste.push({
+ path: e.name,
+ name: m.name || e.name,
+ iconPath: m.icon || null,
+ installiert: !p.install_stage || p.install_stage === 'installed',
+ lastPlayed: m.last_played || null,
+ sekunden: 0,
+ gameVersion: m.game_version,
+ loader: m.loader,
+ loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
+ gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
+ javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
+ maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
+ extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
+ vorStart: (p.hooks && p.hooks.pre_launch) || '',
+ nachEnde: (p.hooks && p.hooks.post_exit) || '',
+ modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
+ ? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
+ });
+ }
+ return liste;
+}
+
+// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
+function loaderVersion(loader, wert, mc) {
+ let v = String(wert || '').trim();
+ if (!v || v === 'stable' || v === 'latest') return '';
+ if (loader === 'forge') {
+ v = v.replace(/^forge-/i, '');
+ if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
+ } else if (loader === 'neoforge') {
+ v = v.replace(/^neoforge-/i, '');
+ } else if (loader === 'fabric' || loader === 'quilt') {
+ v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
+ if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
+ }
+ return v;
+}
+
+function zeitstempel(wert) {
+ if (!wert) return null;
+ const n = Number(wert);
+ if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
+ const d = new Date(wert);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+/*
+ * Durchsucht die Modrinth App. Leere Eingabe = Standardort.
+ * -> { ok, basis, count, instances, hinweis }
+ */
+async function scan(eingabe) {
+ const orte = ermittleOrte(eingabe);
+ if (!orte) return { ok: false, reason: 'not-found' };
+
+ let roh = [];
+ let profilesDir = path.join(orte.basis, 'profiles');
+ let hinweis = '';
+ if (orte.db) {
+ const db = await oeffneDb(orte.db);
+ try {
+ const t = tabellen(db);
+ const eigener = eigenerOrdner(db, t);
+ if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
+ if (t.has('instances')) roh = leseNeu(db, t);
+ else if (t.has('profiles')) roh = leseProfile(db);
+ } finally {
+ db.close();
+ }
+ // Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
+ // ist evtl. nicht ganz aktuell
+ try {
+ const wal = orte.db + '-wal';
+ if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
+ hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
+ }
+ } catch { /* egal */ }
+ } else if (fs.existsSync(profilesDir)) {
+ roh = leseProfileJson(profilesDir);
+ } else {
+ return { ok: false, reason: 'not-found' };
+ }
+
+ const instances = [];
+ for (const r of roh) {
+ const dir = path.join(profilesDir, String(r.path || ''));
+ if (!r.path || !fs.existsSync(dir)) continue;
+ const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
+ const version = String(r.gameVersion || '').trim();
+ const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
+ instances.push({
+ folder: r.path,
+ dir,
+ gameDir: dir,
+ name: String(r.name || r.path),
+ group: r.gruppe || '',
+ version,
+ loader,
+ loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
+ installiert: r.installiert,
+ iconPath: icon,
+ lastPlayed: zeitstempel(r.lastPlayed),
+ playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
+ javaPath: String(r.javaPath || '').trim(),
+ maxMemMb: r.maxMemMb || null,
+ extraArgs: String(r.extraArgs || '').trim(),
+ preLaunchCommand: String(r.vorStart || '').trim(),
+ postExitCommand: String(r.nachEnde || '').trim(),
+ modpack: r.modpack,
+ });
+ }
+ instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
+ return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
+}
+
+// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
+const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
+
+/*
+ * Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
+ * im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
+ * asynchron mit Fortschritt (main.js → fuehreImportAus).
+ */
+function kopierQuellen(entry) {
+ if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
+ return fs.readdirSync(entry.gameDir).filter((name) => !NICHT_KOPIEREN.has(name.toLowerCase()))
+ .map((name) => ({ von: path.join(entry.gameDir, name), name }));
+}
+
+function copyInto(quelle, ziel) {
+ fs.mkdirSync(ziel, { recursive: true });
+ for (const name of fs.readdirSync(quelle)) {
+ if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
+ // dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
+ fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
+ }
+}
+
+function importOne(store, entry, copyData = true) {
+ const created = store.createOrReplaceImportedInstance({
+ name: entry.name,
+ group: entry.group,
+ minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
+ });
+
+ const patch = {};
+ if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
+ patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
+ }
+ if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
+ if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
+ // Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
+ const vorher = store.getInstance(created.id) || {};
+ if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
+ if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
+ 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;
+ }
+ if (entry.iconPath) {
+ try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
+ }
+ return { id: created.id, name: created.name, copied };
+}
+
+module.exports = { scan, importOne, kopierQuellen, standardOrdner };
diff --git a/src/preload.js b/src/preload.js
index 7bc32ed..445e335 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -16,6 +16,21 @@ window.addEventListener('unhandledrejection', (e) => {
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
});
+// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
+// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
+let letzterKlick = null;
+window.addEventListener('pointerdown', (e) => {
+ const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
+ if (!t || !t.getBoundingClientRect) return;
+ const r = t.getBoundingClientRect();
+ letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
+}, true);
+function frischerKlick() {
+ if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
+ const { x, y, width, height } = letzterKlick;
+ return { x, y, width, height };
+}
+
contextBridge.exposeInMainWorld('api', {
// Einstellungen
getSettings: () => ipcRenderer.invoke('settings:get'),
@@ -196,6 +211,13 @@ contextBridge.exposeInMainWorld('api', {
// Dialoge / App
// MultiMC-Import
+ // gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
+ onImportFortschritt: (cb) => {
+ const l = (_e, d) => cb(d);
+ ipcRenderer.on('import:fortschritt', l);
+ return () => ipcRenderer.removeListener('import:fortschritt', l);
+ },
+ importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
@@ -288,7 +310,7 @@ contextBridge.exposeInMainWorld('api', {
appLicense: () => ipcRenderer.invoke('app:license'),
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
- popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params),
+ popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
popoutHide: () => ipcRenderer.send('popout:hide'),
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
onPopoutReopen: (cb) => {
diff --git a/src/renderer.js b/src/renderer.js
index 3278232..c359a86 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -541,6 +541,31 @@ function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n
// 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch
// bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design.
const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft'];
+// ---------- Bewegte Oberfläche (Optionen → Darstellung → Animationen) ----------
+let uiAnimationen = true;
+const MIT_STARTANIMATION = new URLSearchParams(location.search).get('startanimation') === '1';
+function bewegungErlaubt() {
+ return uiAnimationen && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
+function setzeUiAnimationen(an) {
+ uiAnimationen = an !== false;
+ document.documentElement.classList.toggle('ui-animiert', bewegungErlaubt());
+ if (!bewegungErlaubt()) beendeSkinLive();
+}
+
+// Design weich überblenden statt hart umzuschalten (View Transitions von Chromium)
+function designUeberblenden(theme) {
+ const root = document.documentElement;
+ const vorher = root.getAttribute('data-design') + '/' + root.getAttribute('data-theme');
+ const t = DESIGNS.includes(theme) ? theme : 'dark';
+ const nachher = (['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch') + '/' + (t === 'light' ? 'light' : 'dark');
+ // versteckte Fenster (z. B. Hauptfenster im Infobereich) zeichnen nicht –
+ // dort liefe der Übergang nie an, also gleich umschalten
+ if (vorher === nachher || !bewegungErlaubt() || !document.startViewTransition || document.hidden) { applyTheme(theme); return; }
+ document.startViewTransition(() => applyTheme(theme));
+ setTimeout(() => applyTheme(theme), 600); // Sicherheitsnetz, falls der Übergang hängt
+}
+
function applyTheme(theme) {
const t = DESIGNS.includes(theme) ? theme : 'dark';
const root = document.documentElement;
@@ -561,6 +586,73 @@ let importKind = 'zip';
let importWarteschlange = [];
// Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten
+// ---------- Fortschritt großer Importe ----------
+function zuGroesse(b) {
+ const gb = b / 1073741824;
+ if (gb >= 1) return gb.toLocaleString('de-DE', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' GB';
+ return Math.max(0, Math.round(b / 1048576)).toLocaleString('de-DE') + ' MB';
+}
+function zuDauer(s) {
+ if (s == null || !isFinite(s)) return '';
+ if (s < 60) return 'noch wenige Sekunden';
+ const m = Math.round(s / 60);
+ if (m < 60) return `noch ca. ${m} Min.`;
+ return `noch ca. ${Math.floor(m / 60)} Std. ${m % 60} Min.`;
+}
+function zeigeImportFortschritt(p) {
+ const balken = el('import-lauf-balken');
+ const leiste = balken.parentElement;
+ el('import-lauf').classList.remove('hidden');
+ if (p.phase === 'zaehlen') {
+ el('import-lauf-titel').textContent = 'Größe wird ermittelt …';
+ leiste.classList.add('unbestimmt');
+ el('import-lauf-zeile').textContent = `${p.dateien.toLocaleString('de-DE')} ${p.dateien === 1 ? 'Datei' : 'Dateien'} · ${zuGroesse(p.bytes)} gefunden`;
+ el('import-lauf-datei').textContent = '';
+ return;
+ }
+ leiste.classList.remove('unbestimmt');
+ const titel = p.von > 1 ? `Instanz ${p.nr} von ${p.von}: ${p.instanz}` : `Übernehme ${p.instanz}`;
+ if (p.phase === 'modpack') {
+ el('import-lauf-titel').textContent = `${titel} – Modpack-Verknüpfung …`;
+ return;
+ }
+ el('import-lauf-titel').textContent = titel;
+ const anteil = p.gesamtBytes > 0 ? Math.min(1, p.bytes / p.gesamtBytes) : (p.gesamtDateien ? p.dateien / p.gesamtDateien : 0);
+ balken.style.width = (anteil * 100).toFixed(1) + '%';
+ const teile = [`${zuGroesse(p.bytes)} von ${zuGroesse(p.gesamtBytes)} (${Math.round(anteil * 100)} %)`];
+ if (p.tempo > 0) teile.push(zuGroesse(p.tempo) + '/s');
+ const rest = zuDauer(p.restSekunden);
+ if (rest) teile.push(rest);
+ el('import-lauf-zeile').textContent = teile.join(' · ');
+ el('import-lauf-datei').textContent = p.datei ? `${p.dateien.toLocaleString('de-DE')} / ${p.gesamtDateien.toLocaleString('de-DE')} Dateien · ${p.datei}` : '';
+}
+
+/*
+ * Führt einen Launcher-Import mit Fortschrittsanzeige aus. Gibt das Ergebnis
+ * zurück – oder null, wenn er gar nicht erst lief (z. B. zu wenig Platz).
+ */
+async function importMitFortschritt(aufruf) {
+ el('import-lauf-titel').textContent = 'Wird vorbereitet …';
+ el('import-lauf-zeile').textContent = '';
+ el('import-lauf-datei').textContent = '';
+ el('import-lauf-balken').style.width = '0';
+ el('import-lauf-abbrechen').disabled = false;
+ el('import-lauf').classList.remove('hidden');
+ let res;
+ try { res = await aufruf(); }
+ catch (err) { res = { ok: false, message: err.message }; }
+ el('import-lauf').classList.add('hidden');
+ if (!res || !res.ok) {
+ toast(res && res.message ? res.message : 'Import fehlgeschlagen.', true);
+ return null;
+ }
+ if (res.uebersprungen && res.uebersprungen.length) {
+ // nach der Zusammenfassung zeigen, sonst überschreibt diese den Hinweis
+ setTimeout(() => toast(`${res.uebersprungen.length} Datei${res.uebersprungen.length === 1 ? '' : 'en'} konnte${res.uebersprungen.length === 1 ? '' : 'n'} nicht kopiert werden (z. B. gerade geöffnet) und wurde${res.uebersprungen.length === 1 ? '' : 'n'} übersprungen.`, true), 3000);
+ }
+ return res;
+}
+
function importAbgeschlossen(kind) {
importWarteschlange = importWarteschlange.filter((k) => k !== kind);
if (importWarteschlange.length) {
@@ -864,12 +956,13 @@ async function runMcImport() {
el('mc-progress').textContent = 'Übernehme …';
el('mc-progress').classList.remove('hidden');
- const res = await window.api.mcImport(entries, copy);
+ const res = await importMitFortschritt(() => window.api.mcImport(entries, copy));
+ if (!res) { el('mc-do').disabled = false; el('mc-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mc');
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);
+ toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -934,12 +1027,13 @@ async function runCfImport() {
el('cf-progress').textContent = 'Übernehme …';
el('cf-progress').classList.remove('hidden');
- const res = await window.api.cfImport(entries, copy);
+ const res = await importMitFortschritt(() => window.api.cfImport(entries, copy));
+ if (!res) { el('cf-do').disabled = false; el('cf-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('cf');
const n = res.imported.length;
const f = res.failed.length;
- toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
+ toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -1008,13 +1102,14 @@ async function runMrImport() {
el('mr-progress').textContent = 'Übernehme …';
el('mr-progress').classList.remove('hidden');
- const res = await window.api.mrImport(entries, copy);
+ const res = await importMitFortschritt(() => window.api.mrImport(entries, copy));
+ if (!res) { el('mr-do').disabled = false; el('mr-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mr');
const n = res.imported.length;
const f = res.failed.length;
const ohneLink = res.imported.filter((i) => i.modpack === false).length;
- toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}`
+ toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}`
+ (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -1080,12 +1175,13 @@ async function runMmcImport() {
el('mmc-progress').textContent = 'Übernehme …';
el('mmc-progress').classList.remove('hidden');
- const res = await window.api.mmcImport(entries, copy);
+ const res = await importMitFortschritt(() => window.api.mmcImport(entries, copy));
+ if (!res) { el('mmc-do').disabled = false; el('mmc-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('mmc');
const n = res.imported.length;
const f = res.failed.length;
- toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
+ toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -1206,12 +1302,13 @@ async function runTechnicImport() {
el('technic-progress').textContent = 'Übernehme …';
el('technic-progress').classList.remove('hidden');
- const res = await window.api.technicImport(entries, copy);
+ const res = await importMitFortschritt(() => window.api.technicImport(entries, copy));
+ if (!res) { el('technic-do').disabled = false; el('technic-progress').classList.add('hidden'); return; }
await reload();
importAbgeschlossen('technic');
const n = res.imported.length;
const f = res.failed.length;
- toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
+ toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
@@ -1427,6 +1524,55 @@ function closeContextMenu() {
el('ctx-menu').classList.add('hidden');
}
+// ---------- Konto-Menü (Klick auf den Kontoknopf) ----------
+function schliesseKontoMenue() {
+ el('konto-menu').classList.add('hidden');
+}
+
+async function oeffneKontoMenue() {
+ const menu = el('konto-menu');
+ if (!menu.classList.contains('hidden')) { schliesseKontoMenue(); return; }
+ closeContextMenu();
+ // feste, alphabetische Reihenfolge – sonst springt das aktive Konto nach oben
+ const konten = (await window.api.authAccounts()).slice().sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }));
+ menu.innerHTML = '';
+ const eintrag = (inhalt, aktion, klasse = '') => {
+ const b = document.createElement('button');
+ b.className = 'ctx-item konto-eintrag ' + klasse;
+ b.innerHTML = inhalt;
+ b.addEventListener('click', async () => { schliesseKontoMenue(); await aktion(); });
+ menu.appendChild(b);
+ return b;
+ };
+ for (const k of konten) {
+ const b = eintrag(
+ `${k.active ? '✓' : ''}
${escapeHtml(k.name)}${k.playing ? 'spielt' : ''}`,
+ async () => {
+ if (k.active) return;
+ await window.api.authSetActive(k.id);
+ await refreshAccountButton();
+ await refreshAllAccountLists();
+ await refreshSkinBackground();
+ toast('Aktives Konto: ' + k.name);
+ },
+ k.active ? 'aktiv' : '');
+ b.title = k.active ? 'Wird beim nächsten Start verwendet' : 'Für den nächsten Start verwenden';
+ const img = b.querySelector('img');
+ kontoKopf(k.id).then((src) => { img.src = src; });
+ }
+ if (konten.length) menu.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
+ eintrag(`${svgIcon('plus')}Konto hinzufügen…`, () => openLogin({ addAccount: true }));
+ if (konten.some((k) => k.active)) eintrag(`${svgIcon('user')}Skin ändern…`, () => openSkin());
+ eintrag(`${svgIcon('users')}Konten verwalten…`, () => openLogin());
+
+ // unter dem Knopf, rechtsbündig – aber im Fenster bleiben
+ const r = el('btn-account').getBoundingClientRect();
+ menu.classList.remove('hidden');
+ const breite = menu.offsetWidth;
+ menu.style.left = Math.max(6, Math.min(r.right - breite, innerWidth - breite - 6)) + 'px';
+ menu.style.top = (r.bottom + 4) + 'px';
+}
+
function formatVersionLabel(version) {
return String(version || '')
.replace(/^v/i, '')
@@ -1553,7 +1699,7 @@ async function applySkinBackground(uuidOverride) {
} else if (s.bgSkin) {
uuid = s.bgSkin;
}
- if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; }
+ if (!uuid) { beendeSkinLive(); root.style.setProperty('--bg-skin', 'none'); return; }
root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100));
@@ -1566,6 +1712,11 @@ async function applySkinBackground(uuidOverride) {
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
let umhang = null;
try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ }
+ // 1a) bewegt (Atmen, Kopf folgt der Maus) – sonst wie bisher als Standbild
+ if (await zeigeLebendigeFigur(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang })) {
+ root.style.setProperty('--bg-skin', 'none');
+ return;
+ }
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang });
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
@@ -1586,6 +1737,119 @@ async function applySkinBackground(uuidOverride) {
root.style.setProperty('--bg-skin', 'none');
}
+// ---------- Lebendige Skin-Figur im Hintergrund ----------
+// Statt eines starren Bildes läuft eine echte 3D-Figur in der gewählten Pose:
+// sie atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem
+// Mauszeiger. Pausiert, solange das Fenster minimiert/im Hintergrund ist.
+let skinLive = null;
+
+function skinLiveGroesse() {
+ const r = document.querySelector('.list-area').getBoundingClientRect();
+ // wie der frühere Hintergrund: 52 % Breite × 88 % Höhe, Seitenverhältnis 420:700
+ const w = Math.max(60, Math.min(r.width * 0.52, r.height * 0.88 * 0.6));
+ return { w: Math.round(w), h: Math.round(w / 0.6) };
+}
+
+function beendeSkinLive() {
+ if (!skinLive) return;
+ const { viewer, abmelden } = skinLive;
+ skinLive = null;
+ abmelden();
+ try { viewer.dispose(); } catch { /* egal */ }
+ const c = el('skin-live');
+ if (c) c.classList.add('hidden');
+ const flaeche = document.querySelector('.list-area');
+ if (flaeche) flaeche.classList.remove('skin-lebt');
+}
+
+async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
+ beendeSkinLive();
+ const canvas = el('skin-live');
+ if (POPOUT || !canvas || typeof skinview3d === 'undefined' || !bewegungErlaubt()) return false;
+ const { w, h } = skinLiveGroesse();
+ let viewer;
+ try {
+ viewer = new skinview3d.SkinViewer({ canvas, width: w, height: h, skin: quelle, model: opts.modell === 'slim' ? 'slim' : 'default' });
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(reject, 8000);
+ const check = () => {
+ if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
+ else setTimeout(check, 60);
+ };
+ check();
+ });
+ } catch {
+ try { if (viewer) viewer.dispose(); } catch { /* egal */ }
+ return false;
+ }
+ const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking;
+ viewer.fov = pose.fov;
+ viewer.zoom = pose.zoom;
+ viewer.autoRotate = false;
+ viewer.controls.enabled = false;
+ if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
+
+ // Grundhaltung merken: Pose einmal anwenden und Kopf/Umhang/Höhe festhalten
+ const p = viewer.playerObject;
+ pose.apply(p);
+ const kopfBasis = p.skin.head.rotation.clone();
+ const umhangBasis = p.cape ? p.cape.rotation.x : 0;
+ const hoeheBasis = p.position.y;
+ const koerperBasis = p.rotation.y;
+
+ const ziel = { yaw: 0, pitch: 0 };
+ const kopf = { yaw: 0, pitch: 0 };
+ const maus = (e) => {
+ const r = canvas.getBoundingClientRect();
+ const kx = r.left + r.width * 0.5;
+ const ky = r.top + r.height * 0.22; // Kopf sitzt im oberen Fünftel der Figur
+ ziel.yaw = Math.max(-0.75, Math.min(0.75, (e.clientX - kx) / 520));
+ ziel.pitch = Math.max(-0.4, Math.min(0.45, (e.clientY - ky) / 650));
+ };
+ const weg = () => { ziel.yaw = 0; ziel.pitch = 0; };
+ viewer.animation = new skinview3d.FunctionAnimation((pl, t) => {
+ pose.apply(pl); // Arme/Beine jedes Mal frisch aus der Pose
+ const atem = Math.sin(t * 1.8);
+ pl.position.y = hoeheBasis + atem * 0.18; // Brustkorb hebt und senkt sich
+ pl.rotation.y = koerperBasis + Math.sin(t * 0.55) * 0.05;
+ pl.skin.leftArm.rotation.z += atem * 0.03;
+ pl.skin.rightArm.rotation.z -= atem * 0.03;
+ if (pl.cape) pl.cape.rotation.x = umhangBasis + 0.06 + Math.sin(t * 1.3) * 0.05;
+ kopf.yaw += (ziel.yaw - kopf.yaw) * 0.07; // weich nachführen
+ kopf.pitch += (ziel.pitch - kopf.pitch) * 0.07;
+ // ohne Maus schaut die Figur halb zum Betrachter, sonst dem Zeiger hinterher
+ pl.skin.head.rotation.set(kopfBasis.x + kopf.pitch, kopfBasis.y - koerperBasis * 0.45 + kopf.yaw, kopfBasis.z);
+ });
+
+ const sicht = () => { viewer.renderPaused = document.hidden || !document.hasFocus(); };
+ const ro = new ResizeObserver(() => {
+ const g = skinLiveGroesse();
+ viewer.setSize(g.w, g.h);
+ });
+ ro.observe(document.querySelector('.list-area'));
+ window.addEventListener('mousemove', maus);
+ document.documentElement.addEventListener('mouseleave', weg);
+ document.addEventListener('visibilitychange', sicht);
+ window.addEventListener('blur', sicht);
+ window.addEventListener('focus', sicht);
+ sicht();
+
+ skinLive = {
+ viewer,
+ abmelden: () => {
+ ro.disconnect();
+ window.removeEventListener('mousemove', maus);
+ document.documentElement.removeEventListener('mouseleave', weg);
+ document.removeEventListener('visibilitychange', sicht);
+ window.removeEventListener('blur', sicht);
+ window.removeEventListener('focus', sicht);
+ },
+ };
+ canvas.classList.remove('hidden');
+ document.querySelector('.list-area').classList.add('skin-lebt');
+ return true;
+}
+
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
let skinCycleTimer = null;
let skinCycleIndex = 0;
@@ -1668,6 +1932,15 @@ const SKIN_POSES = {
p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt
p.skin.rightLeg.rotation.set(-1.5, 0, -0.12);
} },
+ kopf: { label: 'Büste', fov: 28, zoom: 2.15, apply: (p) => {
+ p.rotation.set(0.08, -0.5, 0);
+ p.position.y = -9.5; // Kopf in die Bildmitte
+ p.skin.head.rotation.set(0.05, 0.22, 0); // Blick leicht zum Betrachter
+ p.skin.leftArm.rotation.set(0, 0, 0.06);
+ p.skin.rightArm.rotation.set(0, 0, -0.06);
+ p.skin.leftLeg.rotation.set(0, 0, 0);
+ p.skin.rightLeg.rotation.set(0, 0, 0);
+ } },
spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => {
p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht
p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt
@@ -1685,7 +1958,15 @@ const SKIN_POSES = {
* die noch gar nicht hochgeladen ist.
* modell: 'default' (breite Arme) oder 'slim' (Alex)
*/
-async function renderSkin3D(quelle, poseKey, opts = {}) {
+let renderWarteschlange = Promise.resolve();
+function renderSkin3D(quelle, poseKey, opts = {}) {
+ // nacheinander: alle Aufrufe zeichnen auf dieselbe Leinwand (#skin-canvas)
+ const auftrag = renderWarteschlange.then(() => renderSkin3DJetzt(quelle, poseKey, opts));
+ renderWarteschlange = auftrag.catch(() => null);
+ return auftrag;
+}
+
+async function renderSkin3DJetzt(quelle, poseKey, opts = {}) {
if (typeof skinview3d === 'undefined') return null;
const canvas = el('skin-canvas');
const breite = opts.breite || 420;
@@ -1848,7 +2129,7 @@ async function refreshSelectionPanel() {
if (iconData) el('p-icon').innerHTML = `
`;
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';
+ el('p-groupline').textContent = inst.group ? `Gruppe: ${gruppenAnzeige(inst.group)}` : 'Ohne Gruppe';
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
@@ -1924,6 +2205,8 @@ function sucheSchliessen() {
if (document.activeElement === el('search')) el('search').blur();
}
+let instanzenEingeblendet = false;
+
function render() {
const q = el('search').value.trim().toLowerCase();
const grid = el('grid');
@@ -1935,59 +2218,113 @@ function render() {
el('empty').classList.toggle('hidden', instances.length !== 0);
grid.classList.toggle('hidden', instances.length === 0);
- // nach Gruppen bündeln
- const groups = new Map();
- for (const inst of filtered) {
- const g = (inst.group && inst.group.trim()) || '';
- if (!groups.has(g)) groups.set(g, []);
- groups.get(g).push(inst);
+ // nach Gruppen (und Untergruppen, "Modpacks/Survival") bündeln
+ const baum = baueGruppenbaum(filtered);
+ const showHeaders = baum.kinder.size > 0;
+ for (const kind of sortierteKinder(baum)) grid.appendChild(baueGruppenSektion(kind, 0, showHeaders));
+ // Instanzen ohne Gruppe zuletzt
+ if (baum.instanzen.length) {
+ grid.appendChild(baueGruppenSektion({ name: 'Ohne Gruppe', pfad: '', kinder: new Map(), instanzen: baum.instanzen }, 0, showHeaders));
}
- // benannte Gruppen alphabetisch, "ohne Gruppe" zuletzt
- const names = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)));
- const showHeaders = names.some((n) => n !== '');
-
- for (const g of names) {
- const list = groups.get(g);
- const section = document.createElement('div');
- section.className = 'group-section' + (collapsedGroups.has(g) ? ' collapsed' : '');
-
- if (showHeaders) {
- const header = document.createElement('div');
- header.className = 'group-header' + (collapsedGroups.has(g) ? ' collapsed' : '');
- header.innerHTML = `${collapsedGroups.has(g) ? '▸' : '▾'} ${escapeHtml(g || 'Ohne Gruppe')} (${list.length})`;
- header.addEventListener('click', () => {
- if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
- render();
- });
- section.appendChild(header);
+ // Beim ersten Anzeigen kurz nacheinander einblenden – nicht nach der
+ // Startanimation (da ist der Fensteraufbau schon der Auftritt)
+ if (!instanzenEingeblendet && instances.length) {
+ instanzenEingeblendet = true;
+ if (bewegungErlaubt() && !MIT_STARTANIMATION && !POPOUT) {
+ grid.querySelectorAll('.group-header, .card').forEach((k, i) => k.style.setProperty('--i', String(Math.min(i, 24))));
+ grid.classList.add('einblenden');
+ setTimeout(() => grid.classList.remove('einblenden'), 1400);
}
- const gridEl = document.createElement('div');
- gridEl.className = 'group-grid';
- for (const inst of list) gridEl.appendChild(buildCard(inst));
- section.appendChild(gridEl);
-
- // Drop-Ziel: Instanz per Drag&Drop in diese Gruppe verschieben
- section.addEventListener('dragover', (e) => { e.preventDefault(); section.classList.add('drop-hover'); });
- section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
- section.addEventListener('drop', async (e) => {
- e.preventDefault();
- section.classList.remove('drop-hover');
- const dropId = e.dataTransfer.getData('text/plain');
- const dragged = instances.find((i) => i.id === dropId);
- if (dragged && (dragged.group || '') !== g) {
- await window.api.updateInstance(dropId, { group: g });
- await reload();
- toast('Verschoben nach: ' + (g || 'Ohne Gruppe'));
- }
- });
-
- grid.appendChild(section);
}
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
}
+// ---------- Gruppen und Untergruppen ----------
+// Eine Untergruppe steckt im Gruppennamen, getrennt mit "/": "Modpacks/Survival".
+// Ohne "/" bleibt alles wie bisher (eine Ebene).
+function gruppenPfad(g) {
+ return String(g || '').split(/[\/\\]/).map((t) => t.trim()).filter(Boolean).join('/');
+}
+function gruppenAnzeige(g) {
+ return gruppenPfad(g).split('/').join(' › ');
+}
+
+function baueGruppenbaum(liste) {
+ const wurzel = { name: '', pfad: '', kinder: new Map(), instanzen: [] };
+ for (const inst of liste) {
+ let knoten = wurzel;
+ const pfad = gruppenPfad(inst.group);
+ if (pfad) {
+ for (const teil of pfad.split('/')) {
+ if (!knoten.kinder.has(teil)) {
+ knoten.kinder.set(teil, { name: teil, pfad: knoten.pfad ? knoten.pfad + '/' + teil : teil, kinder: new Map(), instanzen: [] });
+ }
+ knoten = knoten.kinder.get(teil);
+ }
+ }
+ knoten.instanzen.push(inst);
+ }
+ return wurzel;
+}
+const anzahlImZweig = (k) => k.instanzen.length + [...k.kinder.values()].reduce((s, c) => s + anzahlImZweig(c), 0);
+const sortierteKinder = (k) => [...k.kinder.values()].sort((a, b) => a.name.localeCompare(b.name, 'de', { numeric: true }));
+
+// Abschnitt für eine Gruppe samt Untergruppen (rekursiv)
+function baueGruppenSektion(knoten, tiefe, showHeaders) {
+ const g = knoten.pfad;
+ const zu = collapsedGroups.has(g);
+ const section = document.createElement('div');
+ section.className = 'group-section' + (tiefe ? ' group-sub' : '') + (zu ? ' collapsed' : '');
+
+ if (showHeaders) {
+ const header = document.createElement('div');
+ header.className = 'group-header' + (zu ? ' collapsed' : '');
+ header.title = g ? gruppenAnzeige(g) : 'Instanzen ohne Gruppe';
+ header.innerHTML = `${zu ? '▸' : '▾'} ${escapeHtml(knoten.name)} (${anzahlImZweig(knoten)})`;
+ header.addEventListener('click', () => {
+ if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
+ render();
+ });
+ section.appendChild(header);
+ }
+ const inhalt = document.createElement('div');
+ inhalt.className = 'group-body';
+ if (knoten.instanzen.length) {
+ const gridEl = document.createElement('div');
+ gridEl.className = 'group-grid';
+ for (const inst of knoten.instanzen) gridEl.appendChild(buildCard(inst));
+ inhalt.appendChild(gridEl);
+ }
+ for (const kind of sortierteKinder(knoten)) inhalt.appendChild(baueGruppenSektion(kind, tiefe + 1, showHeaders));
+ section.appendChild(inhalt);
+
+ // Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
+ // stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
+ section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
+ section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
+ section.addEventListener('drop', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ section.classList.remove('drop-hover');
+ const dropId = e.dataTransfer.getData('text/plain');
+ const dragged = instances.find((i) => i.id === dropId);
+ if (dragged && gruppenPfad(dragged.group) !== g) {
+ await window.api.updateInstance(dropId, { group: g });
+ await reload();
+ toast('Verschoben nach: ' + (g ? gruppenAnzeige(g) : 'Ohne Gruppe'));
+ }
+ });
+ return section;
+}
+
function fillGroupDatalist() {
- const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort();
+ // alle Gruppen samt ihrer übergeordneten Ebenen vorschlagen
+ const alle = new Set();
+ for (const i of instances) {
+ const teile = gruppenPfad(i.group).split('/').filter(Boolean);
+ for (let n = 1; n <= teile.length; n++) alle.add(teile.slice(0, n).join('/'));
+ }
+ const groups = [...alle].sort((a, b) => a.localeCompare(b, 'de', { numeric: true }));
el('group-list').innerHTML = groups.map((g) => ``).join('');
}
@@ -2246,7 +2583,7 @@ async function saveInstance() {
const payload = {
name,
- group: el('f-group').value.trim(),
+ group: gruppenPfad(el('f-group').value),
icon: el('f-icon').value,
notes: el('f-notes').value.trim(),
minecraft: {
@@ -4993,6 +5330,8 @@ async function openSettings(pane) {
sel.disabled = !!s.bgSkinCycle;
el('s-sortby').value = s.sortBy || 'lastPlayed';
el('s-iso3d').checked = s.iso3dIcons !== false;
+ el('s-start-animation').checked = s.startAnimation !== false;
+ el('s-ui-animationen').checked = s.uiAnimationen !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false;
el('s-friends').checked = s.friendsEnabled !== false;
@@ -5127,6 +5466,8 @@ async function saveSettings() {
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
sortBy: el('s-sortby').value,
iso3dIcons: el('s-iso3d').checked,
+ startAnimation: el('s-start-animation').checked,
+ uiAnimationen: el('s-ui-animationen').checked,
bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked,
friendsEnabled: el('s-friends').checked,
@@ -5166,6 +5507,7 @@ async function saveSettings() {
// Theme hier direkt anwenden (für dieses Fenster selbst); alles, was das
// Hauptfenster betrifft (Bedrock-Sichtbarkeit, Skin-Hintergrund,
// Instanzliste), zieht das Hauptfenster über "settings:changed" selbst nach
+ setzeUiAnimationen(patch.uiAnimationen);
applyTheme(patch.theme);
iso3dEnabled = patch.iso3dIcons;
hide('modal-settings');
@@ -5424,6 +5766,22 @@ function promptGlobalRestore() {
}
// ---------- Microsoft-Login / Kontoverwaltung ----------
+// Kopf/Büste eines Kontos selbst in 3D zeichnen – so steht die 2. Ebene
+// (Mütze, Haare, Kragen, Ärmel) wie im Spiel plastisch ab. Fertige Bilder von
+// crafthead zeigen sie nur flach aufgemalt; die dienen nur noch als Rückfall.
+const kontoKoepfe = new Map(); // uuid -> data:-Bild
+async function kontoKopf(uuid) {
+ if (kontoKoepfe.has(uuid)) return kontoKoepfe.get(uuid);
+ const auftrag = (async () => {
+ let modell = 'classic';
+ try { modell = await window.api.skinModelFor(uuid); } catch { /* breite Arme */ }
+ const bild = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, 'kopf', { modell, breite: 128, hoehe: 128 });
+ return bild || `https://crafthead.net/armor/bust/${uuid}?size=64`;
+ })();
+ kontoKoepfe.set(uuid, auftrag);
+ return auftrag;
+}
+
async function refreshAccountButton() {
const acc = await window.api.authStatus();
const av = el('account-av');
@@ -5431,7 +5789,7 @@ async function refreshAccountButton() {
av.innerHTML = '';
const img = document.createElement('img');
img.className = 'acct-head';
- img.src = `https://crafthead.net/bust/${acc.profile.id}?size=64`;
+ img.src = await kontoKopf(acc.profile.id);
img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); });
av.appendChild(img);
el('account-name').textContent = acc.profile.name;
@@ -5462,7 +5820,7 @@ async function renderAccounts(boxId = 'login-accounts') {
const av = document.createElement('img');
av.className = 'account-av';
- av.src = `https://crafthead.net/avatar/${a.id}?size=64`;
+ av.src = await kontoKopf(a.id);
av.addEventListener('error', () => {
const d = document.createElement('div');
d.className = 'account-av'; d.textContent = '🎮';
@@ -5662,7 +6020,9 @@ function wire() {
// schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC
document.addEventListener('click', (e) => {
if (!e.target.closest('#ctx-menu')) closeContextMenu();
+ if (!e.target.closest('#konto-menu') && !e.target.closest('#btn-account')) schliesseKontoMenue();
});
+ window.addEventListener('blur', schliesseKontoMenue);
document.addEventListener('contextmenu', (e) => {
if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu();
});
@@ -5771,6 +6131,12 @@ function wire() {
});
el('technic-list').addEventListener('change', updateTechnicButton);
el('technic-do').addEventListener('click', runTechnicImport);
+ window.api.onImportFortschritt(zeigeImportFortschritt);
+ el('import-lauf-abbrechen').addEventListener('click', async () => {
+ el('import-lauf-abbrechen').disabled = true;
+ el('import-lauf-titel').textContent = 'Wird abgebrochen …';
+ await window.api.importAbbrechen();
+ });
window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden');
@@ -6212,7 +6578,7 @@ function wire() {
// Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an
document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => {
el('s-theme').value = b.dataset.design;
- applyTheme(b.dataset.design);
+ designUeberblenden(b.dataset.design);
}));
el('s-bgskin-opacity').addEventListener('input', (e) => {
el('s-bgskin-val').textContent = e.target.value;
@@ -6298,7 +6664,7 @@ function wire() {
}
// Microsoft-Login / Kontoverwaltung
- el('btn-account').addEventListener('click', openLogin);
+ el('btn-account').addEventListener('click', (e) => { e.stopPropagation(); oeffneKontoMenue(); });
el('login-add').addEventListener('click', addAccount);
el('login-url').addEventListener('click', (e) => {
e.preventDefault();
@@ -6355,6 +6721,7 @@ function wire() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
+ if (!el('konto-menu').classList.contains('hidden')) { schliesseKontoMenue(); return; }
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open && open.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; }
@@ -6533,6 +6900,7 @@ window.addEventListener('DOMContentLoaded', async () => {
applyIcons();
try {
const s = await window.api.getSettings();
+ setzeUiAnimationen(s.uiAnimationen);
applyTheme(s.theme);
iso3dEnabled = s.iso3dIcons !== false;
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
@@ -6615,7 +6983,8 @@ window.addEventListener('DOMContentLoaded', async () => {
// unsichtbar (Sidebar/Symbolleiste sind dort ausgeblendet) und daher nur
// im Hauptfenster sinnvoll
window.api.onSettingsChanged((s) => {
- applyTheme(s.theme);
+ setzeUiAnimationen(s.uiAnimationen);
+ designUeberblenden(s.theme);
iso3dEnabled = s.iso3dIcons !== false;
schnellstartBelegung = s.schnellstart || {};
if (!POPOUT) {
@@ -6629,7 +6998,8 @@ window.addEventListener('DOMContentLoaded', async () => {
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
window.api.onSkinChanged((info) => {
- if (!POPOUT) refreshSkinBackground();
+ kontoKoepfe.clear();
+ if (!POPOUT) { refreshSkinBackground(); refreshAccountButton(); }
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin();
});
diff --git a/src/splash.html b/src/splash.html
new file mode 100644
index 0000000..4148e47
--- /dev/null
+++ b/src/splash.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+ AeroMC
+
+
+
+
+
+
+
diff --git a/src/startanimation.js b/src/startanimation.js
new file mode 100644
index 0000000..f98ebea
--- /dev/null
+++ b/src/startanimation.js
@@ -0,0 +1,76 @@
+/*
+ * Startanimation des Hauptfensters (abschaltbar unter Optionen → Darstellung).
+ *
+ * Ablauf: Ein randloses, durchsichtiges Fenster liegt genau dort, wo der
+ * Launcher erscheinen wird, und zeigt das sich drehende Logo samt Schriftzug.
+ * Ist die Oberfläche fertig (renderer:ready), wird das noch unsichtbare
+ * Hauptfenster abfotografiert und dieses Bild im Startfenster von der Mitte
+ * nach außen aufgezogen. Am Ende übernimmt das echte Fenster an exakt
+ * derselben Stelle – dadurch wirkt es, als baue sich der Launcher auf.
+ */
+const path = require('path');
+const { BrowserWindow } = require('electron');
+
+const MINDESTENS_DREHEN_MS = 1600; // so lange dreht sich das Logo mindestens
+const HOECHSTENS_WARTEN_MS = 8000; // spätestens dann wird der Launcher gezeigt
+
+const warte = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// "renderer:ready" kommt, bevor Instanzliste und Kontoname gezeichnet sind –
+// erst darauf warten, sonst wächst ein halbleeres Fenster auf
+async function warteAufGezeichneteOberflaeche(wc) {
+ const pruefe = (ausdruck) => wc.executeJavaScript(ausdruck, true).catch(() => true);
+ // Liste gezeichnet – oder es gibt (noch) keine Instanzen, dann steht der Leer-Hinweis da
+ for (let i = 0; i < 30 && !(await pruefe("document.querySelectorAll('#grid > *').length > 0 || !document.getElementById('empty').classList.contains('hidden')")); i++) await warte(100);
+ for (let i = 0; i < 12 && !(await pruefe("(document.getElementById('account-name') || {}).textContent !== 'Konten'")); i++) await warte(100);
+ await warte(200);
+}
+
+async function spielen(hauptfenster, oberflaecheBereit, design) {
+ const b = hauptfenster.getContentBounds();
+ const start = new BrowserWindow({
+ x: b.x, y: b.y, width: b.width, height: b.height,
+ frame: false, transparent: true, resizable: false, movable: false,
+ minimizable: false, maximizable: false, fullscreenable: false,
+ skipTaskbar: true, hasShadow: false, focusable: false, show: false,
+ backgroundColor: '#00000000', title: 'AeroMC',
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
+ });
+
+ const launcherZeigen = () => {
+ if (hauptfenster.isDestroyed()) return;
+ hauptfenster.setOpacity(1);
+ hauptfenster.setIgnoreMouseEvents(false);
+ if (!hauptfenster.isVisible()) hauptfenster.show();
+ hauptfenster.focus();
+ };
+
+ try {
+ await start.loadFile(path.join(__dirname, 'splash.html'), { query: { design: design || 'dark' } });
+ start.showInactive();
+ const beginn = Date.now();
+ await Promise.race([oberflaecheBereit, warte(HOECHSTENS_WARTEN_MS)]);
+ const rest = MINDESTENS_DREHEN_MS - (Date.now() - beginn);
+ if (rest > 0) await warte(rest);
+ if (hauptfenster.isDestroyed()) return;
+
+ // Unsichtbar zeigen, damit Chromium die Oberfläche zeichnet, dann abfotografieren
+ hauptfenster.setOpacity(0);
+ hauptfenster.setIgnoreMouseEvents(true); // unsichtbar: Klicks gehen nicht versehentlich hinein
+ hauptfenster.show();
+ await warteAufGezeichneteOberflaeche(hauptfenster.webContents);
+ if (hauptfenster.isDestroyed()) return;
+ const bild = await hauptfenster.webContents.capturePage();
+ if (!bild.isEmpty() && !start.isDestroyed()) {
+ // höchstens 2,5 s – das Hauptfenster darf nie unsichtbar hängen bleiben
+ await Promise.race([start.webContents.executeJavaScript(`aufbauen(${JSON.stringify(bild.toDataURL())})`), warte(2500)]);
+ }
+ } catch {
+ // Animation ist nur Zierde – im Fehlerfall einfach den Launcher zeigen
+ } finally {
+ launcherZeigen();
+ setTimeout(() => { if (!start.isDestroyed()) start.destroy(); }, 80);
+ }
+}
+
+module.exports = { spielen };
diff --git a/src/store.js b/src/store.js
index b59377c..e268100 100644
--- a/src/store.js
+++ b/src/store.js
@@ -88,6 +88,8 @@ const DEFAULT_SETTINGS = {
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)
+ startAnimation: true, // drehendes Logo + Fensteraufbau beim Öffnen des Launchers
+ uiAnimationen: true, // bewegte Oberfläche: lebende Skin-Figur, aufwachsende Fenster, Einblenden, Überblenden
bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
@@ -424,6 +426,35 @@ function createInstance(data) {
return writeInstance(inst);
}
+/*
+ * Große Instanzordner nicht synchron löschen (das fror den Launcher bei vielen
+ * GB minutenlang ein): sofort in einen versteckten Papierkorb im Instanzordner
+ * verschieben – das ist nur eine Umbenennung – und dort im Hintergrund löschen.
+ * Reste (z. B. nach einem Absturz) räumt raeumePapierkorbAuf() beim Start weg.
+ */
+const PAPIERKORB = '.aeromc-papierkorb';
+function entsorgeOrdner(p) {
+ if (!fs.existsSync(p)) return;
+ const korb = path.join(path.dirname(p), PAPIERKORB);
+ const ziel = path.join(korb, path.basename(p) + '-' + Date.now());
+ try {
+ ensureDir(korb);
+ fs.renameSync(p, ziel);
+ } catch {
+ // Umbenennen klappt nicht (z. B. Datei gerade geöffnet): instance.json sofort
+ // weg, damit die Instanz aus der Liste verschwindet – den Rest im Hintergrund
+ try { fs.rmSync(path.join(p, 'instance.json'), { force: true }); } catch { /* egal */ }
+ fs.promises.rm(p, { recursive: true, force: true }).catch(() => { /* bleibt liegen, stört nicht */ });
+ return;
+ }
+ fs.promises.rm(ziel, { recursive: true, force: true }).catch(() => { /* beim nächsten Start erneut */ });
+}
+
+function raeumePapierkorbAuf() {
+ const korb = path.join(getInstancesDir(), PAPIERKORB);
+ if (fs.existsSync(korb)) fs.promises.rm(korb, { recursive: true, force: true }).catch(() => {});
+}
+
function createOrReplaceImportedInstance(data) {
const existing = findInstanceByName(data && data.name);
if (!existing) return createInstance(data);
@@ -435,8 +466,7 @@ function createOrReplaceImportedInstance(data) {
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
inst.launchCount = existing.launchCount || 0;
- const p = instancePath(existing.id);
- if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
+ entsorgeOrdner(instancePath(existing.id));
return writeInstance(inst);
}
@@ -452,12 +482,11 @@ function updateInstance(id, patch) {
}
function deleteInstance(id) {
- const p = instancePath(id);
- if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
+ entsorgeOrdner(instancePath(id));
return true;
}
-function duplicateInstance(id) {
+function duplicateInstance(id, ohneSpieldaten) {
const src = getInstance(id);
if (!src) throw new Error('Instanz nicht gefunden: ' + id);
const copy = JSON.parse(JSON.stringify(src));
@@ -469,9 +498,11 @@ function duplicateInstance(id) {
copy.launchCount = 0;
ensureDir(gameDir(copy.id));
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
- try {
- fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
- } catch { /* Quelle evtl. leer */ }
+ if (!ohneSpieldaten) {
+ try {
+ fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
+ } catch { /* Quelle evtl. leer */ }
+ }
writeJson(instanceFile(copy.id), copy);
return copy;
}
@@ -500,5 +531,6 @@ module.exports = {
createOrReplaceImportedInstance,
updateInstance,
deleteInstance,
+ raeumePapierkorbAuf,
duplicateInstance,
};
diff --git a/src/styles.css b/src/styles.css
index 73ceea6..f004b46 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1,722 +1,760 @@
-/* AeroMC – heller MultiMC-Native-Look */
-:root{
- --win:#f0f0f0; --area:#e2e2e2; --bar:#e9eaed;
- --border:#c4c7cc; --border2:#d6d8dc;
- --text:#1c1c1c; --dim:#6b7280;
- --sel:#dce8f6; --sel-br:#3a6ea5; --link:#2c5aa0; --accent:#3a6ea5;
- --danger:#c0392b; --play:#3a9c3a;
- --font:"Segoe UI",Tahoma,system-ui,sans-serif;
- --mono:Consolas,"Cascadia Mono",monospace;
-}
-*{box-sizing:border-box}
-html,body{margin:0;height:100%;font-family:var(--font);font-size:12px;color:var(--text);background:var(--win)}
-body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:minmax(0,1fr);height:100vh;overflow:hidden}
-.hidden{display:none!important}
-.mono{font-family:var(--mono);font-size:12px}
-.spacer{flex:1}
-.opt,.hint{color:var(--dim)}
-[data-ic] svg,.ic svg{width:16px;height:16px}
-
-/* ===================== DUNKLER MODUS (native, dunkelgrau) ===================== */
-:root[data-theme="dark"]{
- --win:#2d2d30; --area:#242426; --bar:#333337;
- --border:#3f3f46; --border2:#3a3a40;
- --text:#e4e4e6; --dim:#9a9aa2;
- --sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb;
- --danger:#e06c6c; --play:#5cb85c;
- --input:#1e1e1e;
-}
-:root[data-theme="dark"] body{background:var(--win)}
-:root[data-theme="dark"] .search-wrap input,
-:root[data-theme="dark"] .field input,
-:root[data-theme="dark"] .field select,
-:root[data-theme="dark"] .field textarea,
-:root[data-theme="dark"] .mods-search input,
-:root[data-theme="dark"] .path-row input,
-:root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)}
-:root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618}
-:root[data-theme="dark"] .modal-head{background:#333337}
-:root[data-theme="dark"] .modal-body{background:#2d2d30}
-:root[data-theme="dark"] .modal-foot{background:#2a2a2e}
-:root[data-theme="dark"] .advanced{background:#242426}
-:root[data-theme="dark"] .mods-list,
-:root[data-theme="dark"] .account-item,
-:root[data-theme="dark"] .screen-thumb{background:#1e1e1e}
-:root[data-theme="dark"] .mod-icon,
-:root[data-theme="dark"] .account-av,
-:root[data-theme="dark"] .icon-preview{background:#3a3a40}
-:root[data-theme="dark"] .mod-item+.mod-item{border-color:#333}
-:root[data-theme="dark"] .mod-item:hover{background:#2a2f36}
-:root[data-theme="dark"] .btn{background:linear-gradient(#3b3b40,#303034);color:var(--text);border-color:var(--border)}
-:root[data-theme="dark"] .btn:hover{background:linear-gradient(#44444b,#35353b);border-color:#5a5a63}
-:root[data-theme="dark"] .btn:active{background:#2a2a2e}
-:root[data-theme="dark"] .btn-primary{background:linear-gradient(#2a7ac0,#1f66a8);border-color:#1a5a95;color:#fff}
-:root[data-theme="dark"] .btn-primary:hover{background:linear-gradient(#3286cc,#2470b4)}
-:root[data-theme="dark"] .btn-danger{color:#e88;border-color:#6a3a3a}
-:root[data-theme="dark"] .btn-danger:hover{background:#3a2626}
-:root[data-theme="dark"] .tbtn:hover,
-:root[data-theme="dark"] .sbtn:hover,
-:root[data-theme="dark"] .card:hover{background:#37373d;border-color:var(--border)}
-:root[data-theme="dark"] .card.sel{background:#0e639c;border-color:#1177bb}
-:root[data-theme="dark"] .card.sel .card-name{background:#1177bb;color:#fff}
-:root[data-theme="dark"] .st-row2{background:#2a2a2e}
-:root[data-theme="dark"] .st-row,
-:root[data-theme="dark"] .group-header,
-:root[data-theme="dark"] .empty h2,
-:root[data-theme="dark"] .mods-col h4,
-:root[data-theme="dark"] .field>span{color:var(--text)}
-:root[data-theme="dark"] #btn-console svg,:root[data-theme="dark"] #btn-search svg{color:#c8c8cc}
-:root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
-:root[data-theme="dark"] .mods-filters select,
-:root[data-theme="dark"] .mods-search select,
-:root[data-theme="dark"] .mmc-bar select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
-:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)}
-:root[data-theme="dark"] .modal-close{color:#aaa}
-:root[data-theme="dark"] .modal-close:hover{color:#fff}
-:root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)}
-
-/* ---------- Titelleiste (ersetzt die weiße Windows-Leiste) ---------- */
-/* Farben passend zu TITELLEISTE in main.js (dort für die Fensterknöpfe) */
-:root{--titel-bg:#f0f0f0;--titel-fg:#1c1c1c}
-:root[data-theme="dark"]{--titel-bg:#2d2d30;--titel-fg:#e4e4e6}
-.titelleiste{display:flex;align-items:center;gap:8px;height:32px;padding:0 150px 0 10px;background:var(--titel-bg);color:var(--titel-fg);-webkit-app-region:drag;user-select:none;font-size:12px}
-.titel-logo{width:16px;height:16px;flex:none}
-.titel-text{white-space:nowrap}
-body.fenster-inaktiv .titelleiste>*{opacity:.55}
-body.is-popout>.titelleiste{display:none}
-
-/* ---------- Toolbar ---------- */
-.toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;min-width:0;overflow:hidden;background:var(--win);border-bottom:1px solid var(--border)}
-.tbtn{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;padding:6px 10px;border:1px solid transparent;border-radius:3px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
-.tbtn:hover{background:#e2e6ec;border-color:var(--border2)}
-.tbtn:active{background:#d7dde5}
-.tbtn svg{width:18px;height:18px;color:#4b5563}
-/* farbige Toolbar-Icons wie in MultiMC */
-#btn-new svg{color:#3a9c3a}
-#btn-import svg{color:#3d7ab5}
-#btn-open-dir svg{color:#d8a72f}
-#btn-console svg{color:#5a6470}
-#btn-account svg{color:#3a9c3a}
-#btn-settings svg{color:#3d7ab5}
-.tbtn.big{font-size:13px;padding:8px 16px;border:1px solid var(--border);background:linear-gradient(#fdfdfd,#e9e9e9)}
-.tbsep{width:1px;height:24px;background:var(--border);margin:0 5px}
-.tbgrow{flex:1}
-.search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px}
-.search-wrap input:focus{outline:none;border-color:var(--accent)}
-/* eingeklappt nur die Lupe, aufgeklappt nur das Feld */
-.search-wrap{display:flex;align-items:center}
-.search-wrap:not(.offen) input{display:none}
-.search-wrap.offen .tb-lupe{display:none}
-.search-wrap.offen input{animation:suche-auf .16s ease-out}
-@keyframes suche-auf{from{width:0;opacity:0}}
-.tbtn.tb-lupe{padding:6px 8px}
-#btn-search svg{color:#5a6470}
-
-/* ---------- Arbeitsbereich ---------- */
-.workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)}
-.list-area{position:relative;overflow:hidden;min-height:0}
-/* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */
-.list-area::after{
- content:'';position:absolute;right:24px;bottom:8px;width:52%;height:88%;
- background-image:var(--bg-skin,none);background-repeat:no-repeat;
- background-position:right bottom;background-size:contain;
- opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0;
-}
-.list{position:relative;z-index:1;height:100%;min-height:100%;overflow:auto;padding:4px 8px}
-.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px}
-.side-empty{text-align:center;color:var(--dim);padding:24px 8px}
-.side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px}
-.side-ico img{width:56px;height:56px;object-fit:cover;border-radius:3px}
-.side-title{text-align:center;font-weight:600;font-size:14px;word-break:break-word}
-.side-grp{text-align:center;color:var(--link);font-size:11px;margin-top:2px;cursor:pointer}
-.side-grp:hover{text-decoration:underline}
-.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)}
-
-/* ---------- Instanzliste / Gruppen / Kacheln ---------- */
-.group-section{margin-bottom:2px}
-.group-header{display:flex;align-items:center;gap:7px;padding:5px 4px 2px;color:#333;font-weight:600;font-size:12px;cursor:pointer;user-select:none}
-.group-header::after{content:'';flex:1;height:1px;background:var(--border);margin-left:6px}
-.group-header .chev{font-size:9px;color:#888;width:10px;text-align:center}
-.group-header.collapsed .chev{transform:none}
-.group-count{color:var(--dim);font-weight:400}
-.group-grid{display:grid;grid-template-columns:repeat(auto-fill,104px);gap:2px;padding:0 4px 4px;justify-content:start}
-.group-section.collapsed .group-grid{display:none}
-.card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px 4px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative}
-.card:hover{background:#e3ecf7}
-.card.sel{background:var(--sel);border-color:var(--sel-br)}
-.card-icon{width:60px;height:60px;display:grid;place-items:center;font-size:40px}
-.card-icon svg,.side-ico svg{display:block}
-.mc-texture-icon{display:block;object-fit:contain;image-rendering:auto;filter:drop-shadow(0 1px 1px rgba(0,0,0,.18))}
-.card-icon-img{width:60px;height:60px;object-fit:cover;border-radius:3px}
-.card-name{width:100%;max-width:88px;box-sizing:border-box;font-size:12px;text-align:center;line-height:1.25;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.card.sel .card-name{background:var(--sel-br);color:#fff;border-radius:2px}
-.card-badges{display:none}
-.card-play{display:none}
-.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}
-.empty p{max-width:340px;margin:0 0 8px}
-
-/* ---------- Statusleiste (zwei Zeilen) ---------- */
-.statusbar{display:flex;flex-direction:column;min-width:0;background:var(--win);border-top:1px solid var(--border)}
-.st-row{display:flex;justify-content:space-between;padding:3px 10px;font-size:11px;color:#333}
-.st-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444}
-.auth-warn{
- display:inline-flex;align-items:center;gap:6px;
- color:#fff;background:var(--danger);
- padding:1px 10px;border-radius:3px;font-weight:600;cursor:pointer;
- animation:authpulse 2s ease-in-out infinite;
-}
-.auth-warn::before{content:'⚠';font-size:12px}
-.auth-warn:hover{filter:brightness(1.12)}
-@keyframes authpulse{0%,100%{opacity:1}50%{opacity:.72}}
-
-/* ---------- Modals ---------- */
-.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50}
-/* Bestätigungsdialog immer über anderen Dialogen (z. B. Screenshots) */
-#modal-confirm,#modal-prompt{z-index:120}
-.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}
-.modal-sm{width:420px}.modal-lg{width:820px}
-#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)}
-.modal-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#e8e8e8;border-bottom:1px solid var(--border)}
-.modal-head h3{margin:0;font-size:14px;font-weight:600}
-.modal-close{background:none;border:none;color:#666;font-size:15px;cursor:pointer;line-height:1}
-.modal-close:hover{color:#000}
-.modal-body{padding:16px;overflow:auto;background:#f6f6f6}
-.modal-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--border);background:#efefef}
-
-/* Popup-Fenster (Einstellungen/Konsole): Haupt-UI ausblenden, das eine Modal
- füllt das ganze (rahmenlose) Fenster; der eigene Kopfbereich ersetzt die
- native Titelleiste – per -webkit-app-region per Maus verschiebbar */
-body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>footer.statusbar{display:none}
-.popout-host{position:static;inset:auto;background:none;display:block}
-/* !important: schlägt auch die spezifischere #modal-settings-Breitenregel oben */
-.popout-host .modal-card{width:100%!important;max-width:none!important;height:100vh!important;max-height:none!important;border:none;border-radius:0;box-shadow:none;margin:0}
-/* Inhalt soll die restliche Fensterhöhe füllen (scrollen statt Leerraum unter dem Fuß) */
-.popout-host .modal-body{flex:1 1 auto;min-height:0}
-/* Konsole/Logs/Screenshots haben einen einzelnen scrollbaren Kasten mit
- fester Höhe (für den normalen Dialog gedacht) – im Popup-Fenster soll der
- stattdessen die volle verbleibende Höhe ausfüllen statt Leerraum darunter
- zu lassen */
-#modal-console.popout-host .modal-body,
-#modal-logs.popout-host .modal-body,
-#modal-screens.popout-host .modal-body,
-#modal-servers.popout-host .modal-body,
-#modal-mods.popout-host .modal-body,
-#modal-record.popout-host .modal-body{display:flex;flex-direction:column}
-.popout-host .console-out,
-#modal-logs.popout-host .crash-voll,
-.popout-host .mods-list,
-.popout-host .record-stage{flex:1 1 auto;height:auto;max-height:none;min-height:0}
-/* Screenshots/Aufzeichnungen NICHT auf die volle Fensterhöhe zwingen (Grid-
- Zeilen strecken sich sonst nicht mit, es bliebe bei wenigen Einträgen ein
- großer sichtbar leerer Kasten stehen) - nur die feste 460px-Deckelung
- aufheben, damit bei VIELEN Einträgen trotzdem mehr Platz genutzt wird */
-.popout-host .screens-grid{max-height:none}
-/* Mods-Fenster: die aktive Ansicht (Katalog/Installiert) muss selbst ein
- Flex-Container werden, damit ihre eigene Liste (.mods-list) die Regel oben
- nutzen und den restlichen Platz füllen kann */
-#modal-mods.popout-host #m-pane-catalog,
-#modal-mods.popout-host #m-pane-installed{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}
-.popout-host .modal-head{-webkit-app-region:drag;cursor:default}
-.popout-host .modal-head button,.popout-host .modal-head input,.popout-host .modal-head select{-webkit-app-region:no-drag}
-
-/* Formular */
-.field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}
-.field>span{font-size:12px;color:#333}
-.field input,.field select,.field textarea{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
-.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)}
-.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;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
-.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
-.advanced legend{color:var(--dim);font-size:12px;padding:0 6px}
-.version-row{display:flex;gap:8px}.version-row #f-version-type{max-width:120px}.version-row #f-version{flex:1}
-.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:3px;margin-top:6px;
- width:100%;max-width:100%;max-height:132px;overflow-y:auto;overflow-x:hidden;box-sizing:border-box}
-.icon-choice{display:flex;align-items:center;justify-content:center;padding:2px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;min-width:0;overflow:hidden}
-.icon-choice img{width:24px;height:24px;display:block;object-fit:contain;image-rendering:auto}
-.icon-choice:hover{background:rgba(128,128,128,.18)}
-.icon-choice.on{border-color:var(--accent);background:var(--sel)}
-.icon-choice svg{display:block}
-.icon-custom{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap}
-.icon-preview{width:34px;height:34px;border-radius:3px;overflow:hidden;background:#e4e4e4;border:1px solid var(--border)}
-.icon-preview img{width:100%;height:100%;object-fit:cover}
-.appinfo{margin-top:8px;font-size:12px;color:var(--dim);line-height:1.6}
-.about-brand{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;margin:0 0 18px;padding:6px 0 14px;border-bottom:1px solid var(--border2);text-align:center}
-.about-logo{width:56px;height:56px;border-radius:12px}
-.about-name{font-size:18px;font-weight:600}
-
-/* Buttons in Dialogen */
-.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:3px;background:linear-gradient(#fdfdfd,#e9e9e9);color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
-.btn:hover{background:linear-gradient(#ffffff,#e2e2e2);border-color:#9aa0a8}
-.btn:active{background:#dcdcdc}
-.btn:disabled{opacity:.5;cursor:default}
-.btn svg{width:15px;height:15px}
-.btn-primary{background:linear-gradient(#5a9be0,#3a7bc8);border-color:#2f6bb0;color:#fff}
-.btn-primary:hover{background:linear-gradient(#69a8ea,#3f83d2)}
-.btn-danger{color:var(--danger);border-color:#cbb0ac}
-.btn-danger:hover{background:#f7e9e7;border-color:#c0392b}
-.btn-lg{padding:9px 18px;font-size:13px}
-.btn-mini{padding:4px 8px;font-size:11px}
-
-/* Badges (dezent) */
-.badge{font-size:10px;padding:1px 6px;border-radius:2px;background:#e2e2e2;color:#555;border:1px solid var(--border)}
-
-/* Detail-Kopf (Mods/Login) */
-.detail-head{display:flex;align-items:center;gap:12px}
-.detail-icon{font-size:28px}
-.detail-sub{color:var(--dim);font-size:12px;margin-top:2px}
-
-/* Mods */
-.mods-body{min-height:420px}
-.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px}
-.mods-search{display:flex;gap:8px;margin-bottom:10px;align-items:stretch}
-.mods-search select{
- flex:0 0 auto;min-width:140px;max-width:180px;
- padding:7px 10px;border:1px solid var(--border);border-radius:3px;
- background:var(--input,#fff);color:var(--text);
- font-family:inherit;font-size:12px;
-}
-.mods-search select:focus{outline:none;border-color:var(--accent)}
-.mods-filters{display:flex;gap:8px;margin-bottom:12px}
-.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
-.mods-filters select:focus{outline:none;border-color:var(--accent)}
-#m-more{display:block;width:100%;margin-top:8px}
-.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)}
-.mods-search input:focus{outline:none;border-color:var(--accent)}
-.mods-columns{display:grid;grid-template-columns:1fr 1fr;gap:14px}
-.mods-col h4{margin:0 0 8px;font-size:12px;color:#333;font-weight:600;display:flex;align-items:center;gap:6px}
-.mods-tabs{display:flex;gap:2px;margin-bottom:10px;border-bottom:1px solid var(--border)}
-.mtab{padding:7px 14px;border:1px solid transparent;border-bottom:none;border-radius:3px 3px 0 0;background:none;color:var(--dim);cursor:pointer;font-family:inherit;font-size:12px;margin-bottom:-1px}
-.mtab:hover{color:var(--text)}
-.mtab.on{color:var(--text);background:var(--bar);border-color:var(--border);font-weight:600}
-.mods-list{height:340px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:3px;background:#fff;padding:6px}
-.mods-empty{color:var(--dim);text-align:center;padding:24px 8px;font-size:12px}
-.mod-item{display:flex;gap:10px;align-items:center;padding:8px;border-radius:3px;max-width:100%;overflow:hidden}
-.mod-item.clickable{cursor:pointer}
-.mod-item.installing{opacity:.6}
-.mod-item+.mod-item{border-top:1px solid #eee}
-.mod-item:hover{background:#f0f4f8}
-.mod-icon{width:38px;height:38px;border-radius:3px;flex:none;background:#e4e4e4;object-fit:cover;display:flex;align-items:center;justify-content:center;font-size:18px}
-.mod-info{flex:1;min-width:0}
-.mod-name{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.mod-desc{font-size:12px;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
-.mod-meta{font-size:11px;color:var(--dim);margin-top:2px}
-.mod-new{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#2f7d32;color:#fff;vertical-align:middle}
-.mod-off{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#6b6b6b;color:#fff;vertical-align:middle}
-.crash-kern{margin:10px 0;padding:10px 12px;border-left:3px solid #c0392b;background:rgba(192,57,43,.08);border-radius:0 4px 4px 0}
-.crash-zeile{display:flex;gap:8px;margin-bottom:5px;font-size:12px;line-height:1.45}
-.crash-zeile:last-child{margin-bottom:0}
-.crash-titel{flex:0 0 132px;color:var(--dim)}
-.crash-wert{flex:1;word-break:break-word}
-.crash-verlauf{margin:0;font-family:var(--mono);font-size:11px;line-height:1.5;color:var(--dim);white-space:pre-wrap;word-break:break-all}
-.skin-vorschau{display:flex;gap:14px;margin-top:12px}
-.skin-spalte{flex:1;display:flex;flex-direction:column;gap:4px;min-width:0}
-.skin-label{font-size:11px;color:var(--dim)}
-.skin-bild{height:300px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:rgba(0,0,0,.15);padding:6px;text-align:center;overflow:hidden}
-/* Die gerenderte Figur soll das Feld ausfüllen; eine rohe 64x64-Textur wird
- ohne Weichzeichnen vergrößert, damit sie nicht verschwommen wirkt. */
-.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated}
-.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab}
-.skin-bild canvas:active{cursor:grabbing}
-.popout-host .skin-bild{height:240px}
-.skin-umhang{margin-top:12px;padding-top:10px;border-top:1px solid var(--border2)}
-.skin-umhang .field-row{margin-top:6px}
-.skin-umhang .field{margin-bottom:6px}
-.skin-umhang-aero{display:flex;align-items:center;gap:8px;min-height:30px;flex-wrap:wrap}
-.crash-details{margin-top:12px}
-.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)}
-.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
-.mod-aus .mod-name,.mod-aus .mod-meta{opacity:.55}
-.mod-aus .mod-icon{filter:grayscale(1);opacity:.6}
-.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-bar select{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
-.mmc-bar select:focus{outline:none;border-color:var(--accent)}
-.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px}
-.mmc-item+.mmc-item{border-top:1px solid var(--border2)}
-.mmc-item:hover{background:rgba(128,128,128,.10)}
-.mmc-item input{width:16px;height:16px;flex:none}
-.mmc-name{font-weight:600;font-size:13px}
-.mmc-meta{font-size:11px;color:var(--dim)}
-
-/* Bedrock */
-.bd-actions{display:flex;gap:8px;align-items:center;margin-bottom:14px}
-.bd-meta{font-size:11px;color:var(--dim);margin-top:2px}
-
-/* Login / Konten */
-.login-code{text-align:center}
-.login-link{color:var(--link);font-size:15px;display:inline-block;margin-bottom:12px;cursor:pointer;text-decoration:none}
-.login-link:hover{text-decoration:underline}
-.login-usercode{font-family:var(--mono);font-size:28px;font-weight:700;letter-spacing:3px;color:#222;background:#fff;border:1px solid var(--border);border-radius:4px;padding:12px;margin:6px 0 12px}
-.login-error{color:var(--danger)!important;font-weight:600}
-.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:10px}
-.account-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:3px;background:#fff;border:1px solid var(--border)}
-.account-item.active{border-color:var(--accent)}
-.account-av{width:34px;height:34px;border-radius:3px;flex:none;background:#e4e4e4;display:flex;align-items:center;justify-content:center;font-size:18px}
-img.account-av{object-fit:cover;image-rendering:auto}
-.account-main{flex:1;min-width:0}
-.account-name{font-weight:600}
-.account-tag{font-size:11px;color:var(--link)}
-.account-actions{display:flex;gap:6px}
-.account-empty{color:var(--dim);text-align:center;padding:10px}
-
-/* Screenshots */
-.screens-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;max-height:460px;overflow:auto}
-.screen-thumb{border:1px solid var(--border);border-radius:3px;overflow:hidden;background:#fff;cursor:pointer;position:relative}
-.screen-thumb img{width:100%;height:100px;object-fit:cover;display:block}
-.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer}
-.screen-thumb:hover .screen-del{opacity:1}
-.screen-thumb.selected{outline:2px solid var(--sel-br);outline-offset:1px;background:var(--sel)}
-.screen-thumb .screen-check{position:absolute;top:4px;left:4px;width:18px;height:18px;margin:0;cursor:pointer;z-index:2}
-.screens-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
-.screens-toolbar .hint{font-size:11px}
-.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
-.record-thumb-img-wrap{position:relative}
-.record-thumb-img-wrap img{background:#000}
-.record-thumb-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;text-shadow:0 1px 4px rgba(0,0,0,.7);pointer-events:none}
-.record-thumb-size{font-size:10px;color:var(--dim);padding:0 6px 4px}
-
-/* Freunde */
-.tb-badge{display:inline-block;min-width:16px;height:16px;padding:0 4px;margin-left:6px;border-radius:8px;background:#d9453c;color:#fff;font-size:11px;font-weight:600;line-height:16px;text-align:center;box-sizing:border-box}
-#modal-friends .field-row{margin-bottom:14px}
-#modal-friends .field-row input{flex:1}
-/* ---- Spielzeit-Statistik: Kennzahlen + Balken pro Instanz ---- */
-:root{--stat-bar:#3a6ea5}
-:root[data-theme="dark"]{--stat-bar:#3d8fe0}
-.stats-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:6px}
-.stat-tile{border:1px solid var(--border);border-radius:4px;padding:8px 10px;min-width:0}
-.stat-tile .stat-label{font-size:11px;color:var(--dim)}
-.stat-tile .stat-value{font-size:18px;font-weight:600;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.stat-tile .stat-sub{font-size:11px;color:var(--dim);line-height:1.35}
-.stats-list{display:flex;flex-direction:column;gap:2px}
-.stat-row{display:grid;grid-template-columns:minmax(0,38%) 1fr auto;align-items:center;gap:10px;padding:5px 4px;border-radius:3px}
-.stat-row:hover{background:var(--sel)}
-.stat-name{min-width:0}
-.stat-name b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.stat-name span{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
-.stat-track{height:8px}
-.stat-bar{height:8px;background:var(--stat-bar);border-radius:0 4px 4px 0;min-width:2px}
-.stat-value-cell{font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right;min-width:92px}
-.popout-host .stats-list{max-height:none}
-
-/* Clip-Modus-Leiste im Aufzeichnungsfenster */
-.clip-bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:8px 12px;border:1px solid var(--border);border-radius:4px;background:var(--area)}
-.clip-bar.aktiv{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
-.clip-switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;user-select:none}
-.clip-switch input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
-.clip-switch-track{position:relative;width:32px;height:18px;border-radius:9px;background:var(--border);transition:background .15s}
-.clip-switch-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.35);transition:left .15s}
-.clip-switch input:checked + .clip-switch-track{background:var(--accent)}
-.clip-switch input:checked + .clip-switch-track .clip-switch-knob{left:16px}
-.clip-switch input:focus-visible + .clip-switch-track{outline:2px solid var(--accent);outline-offset:2px}
-.clip-length{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
-.clip-length select{padding:3px 6px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
-.clip-length select:disabled{opacity:.6}
-.clip-status{flex:1;min-width:200px;font-size:12px;color:var(--dim)}
-.clip-bar.aktiv .clip-status{color:var(--text)}
-.clip-bar.aktiv .clip-status::before{content:"";display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:var(--danger);vertical-align:1px;animation:rec-pulse 1.6s infinite}
-
-/* ---- Absturz-Assistent ---- */
-.crash-hilfe{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
-.crash-hilfe-titel{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em}
-.crash-fund{display:flex;gap:12px;align-items:flex-start;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:4px;background:var(--area)}
-.crash-fund-text{flex:1;min-width:0}
-.crash-fund-text b{display:block;font-size:13px;margin-bottom:3px}
-.crash-fund-text span{font-size:12px;color:var(--dim);line-height:1.45}
-.crash-fund .btn{flex:0 0 auto;align-self:center}
-.crash-fund.erledigt{border-left-color:var(--play)}
-.crash-unklar{padding:10px 12px;border:1px dashed var(--border);border-radius:4px;font-size:12px;color:var(--dim)}
-
-/* ---- Instanz teilen ---- */
-.share-code-box{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border:1px solid var(--accent);border-radius:4px;background:var(--area);margin:10px 0}
-.share-code{font-family:var(--mono);font-size:24px;font-weight:600;letter-spacing:.12em;user-select:all}
-.share-warn{color:var(--danger)}
-.share-code-input{text-transform:uppercase;letter-spacing:.1em;font-family:var(--mono)}
-.share-preview{margin-top:12px;padding:12px 14px;border:1px solid var(--border);border-radius:4px;background:var(--area)}
-.share-preview b{font-size:14px}
-.share-preview .hint{margin:4px 0 0}
-
-/* ---- Kurznachrichten ---- */
-.friends-chat{display:flex;flex-direction:column;gap:8px;height:100%;min-height:340px}
-.chat-head{display:flex;align-items:center;gap:10px}
-.chat-head b{font-size:13px}
-.chat-verlauf{flex:1;min-height:200px;max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;padding:8px;border:1px solid var(--border);border-radius:4px;background:var(--area)}
-.popout-host .chat-verlauf{max-height:none}
-.chat-leer{margin:auto;font-size:12px;color:var(--dim);text-align:center}
-.chat-blase{max-width:78%;padding:6px 10px;border-radius:10px;font-size:12.5px;line-height:1.4;word-break:break-word;background:var(--win);border:1px solid var(--border);align-self:flex-start}
-.chat-blase.ich{align-self:flex-end;background:var(--accent);border-color:var(--accent);color:#fff}
-.chat-zeit{display:block;font-size:10px;opacity:.65;margin-top:2px}
-.chat-schnell{display:flex;flex-wrap:wrap;gap:6px}
-.chat-btn{position:relative}
-.chat-badge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:#e5484d;color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center}
-.report-liste{margin:6px 0 10px;padding-left:20px;font-size:12px;line-height:1.6}
-
-/* ---- Auswahl-Dialog ---- */
-.wahl-liste{display:flex;flex-direction:column;gap:4px;max-height:320px;overflow-y:auto}
-.popout-host .wahl-liste{max-height:none}
-.wahl-option{display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:4px;background:var(--area);color:var(--text);font-family:inherit;text-align:left;cursor:pointer}
-.wahl-option:hover{border-color:var(--accent)}
-.wahl-option.an{border-color:var(--accent);background:var(--sel)}
-.wahl-option:disabled{opacity:.5;cursor:default}
-.wahl-bild{width:26px;height:26px;flex:0 0 auto}
-.wahl-label{display:flex;flex-direction:column;min-width:0}
-.wahl-label b{font-size:13px;font-weight:600}
-.wahl-label small{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-
-/* ---- Schnellstart-Taste auf der Kachel ---- */
-.card{position:relative}
-.card-taste{position:absolute;top:4px;right:4px;padding:1px 5px;border-radius:3px;background:var(--accent);color:#fff;font-size:10px;font-weight:600;line-height:14px}
-
-/* ---- Bilder im Chat ---- */
-.chat-bild{display:block;max-width:100%;max-height:180px;border-radius:6px;margin-bottom:3px;cursor:zoom-in}
-.screen-thumb .screen-teilen{right:32px;background:var(--accent)}
-
-.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px}
-.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px}
-.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))}
-.friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto}
-.friend-dot.online{background:#3bb54a}
-.friend-avatar{width:28px;height:28px;border-radius:4px;flex:0 0 auto;background:#1e1e1e;object-fit:contain}
-.friend-info{flex:1;min-width:0}
-.friend-name{font-size:13px}
-.friend-status{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.friends-empty{color:var(--dim);font-size:12px;padding:8px 4px}
-
-/* Spielaufzeichnung */
-.record-search{display:flex;flex-direction:column;align-items:center;gap:10px;padding:48px 0;color:var(--dim)}
-.record-stage{display:flex;flex-direction:column;gap:10px}
-.record-video{width:100%;flex:1 1 auto;min-height:360px;background:#000;border-radius:4px;object-fit:contain}
-.record-toolbar{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
-.record-live-badge{display:flex;align-items:center;gap:8px;color:var(--danger);font-size:13px;font-weight:600}
-#record-timer{font-variant-numeric:tabular-nums}
-.rec-dot{width:10px;height:10px;border-radius:50%;background:var(--danger);animation:rec-pulse 1s infinite}
-@keyframes rec-pulse{0%,100%{opacity:1}50%{opacity:.25}}
-.record-audio-bar{display:flex;align-items:center;gap:10px;padding:2px 2px}
-.record-mic-list{display:flex;flex-direction:column;gap:8px;margin-top:8px}
-.record-source-row{display:flex;align-items:center;gap:10px;margin-bottom:8px}
-.record-source-row .checkbox-field{margin-bottom:0;flex:1;min-width:0;font-size:13px;gap:6px}
-.record-vol{width:110px}
-
-/* Konsole (dunkel wie ein Log) */
-.console-out{height:440px;overflow:auto;margin:0;background:#1e1e1e;border:1px solid var(--border);border-radius:3px;padding:10px;font-family:var(--mono);font-size:12px;color:#d4d4d4;white-space:pre-wrap;word-break:break-word;line-height:1.5}
-.log-line{white-space:pre-wrap}
-.log-error{color:#f48771}.log-warn{color:#dcdcaa}.log-info{color:#569cd6}
-
-/* Einstellungen */
-.settings-body{display:flex;padding:0;min-height:0;background:var(--area)}
-.settings-nav{width:190px;flex:none;background:linear-gradient(180deg,var(--bar),rgba(128,128,128,.08));border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:4px}
-.settings-nav-label{padding:2px 10px 8px;color:var(--dim);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
-.snav{text-align:left;padding:10px 12px;border:1px solid transparent;border-radius:8px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;transition:background .15s,border-color .15s,transform .15s}
-.snav:hover{background:rgba(128,128,128,.12)}
-.snav.on{background:var(--sel);border-color:var(--sel-br);color:var(--text);box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)}
-.settings-content{flex:1;padding:20px 22px;overflow:auto}
-.settings-hero{margin-bottom:18px}
-.settings-hero h4{margin:0 0 6px;font-size:20px;font-weight:700}
-.settings-hero p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
-.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start}
-.settings-grid-stack{grid-template-columns:repeat(2,minmax(0,1fr))}
-.settings-card{background:rgba(255,255,255,.7);border:1px solid var(--border);border-radius:12px;padding:16px 16px 12px;box-shadow:0 1px 0 rgba(255,255,255,.5)}
-.settings-card h5{margin:0 0 14px;font-size:13px;font-weight:700;color:var(--text)}
-.settings-card-wide{grid-column:span 2}
-.settings-card .field:last-child,.settings-card .checkbox-field:last-of-type{margin-bottom:0}
-.settings-kv{display:grid;gap:10px}
-.settings-kv-row{display:flex;justify-content:space-between;gap:14px;padding-bottom:10px;border-bottom:1px solid var(--border2)}
-.settings-kv-row:last-child{padding-bottom:0;border-bottom:none}
-.settings-kv-label{color:var(--dim);font-size:12px}
-.settings-kv-value{text-align:right;font-weight:600;font-size:12px;word-break:break-word}
-.appinfo{margin-top:8px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px;font-size:12px;line-height:1.6}
-.appinfo-item{padding:10px 12px;border:1px solid var(--border2);border-radius:10px;background:rgba(128,128,128,.06)}
-.appinfo-item.full{grid-column:span 2}
-.appinfo-label{display:block;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
-.appinfo-value{display:block;margin-top:4px;font-weight:600;word-break:break-word}
-.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}
-.info-meta .settings-kv-value{text-align:left;font-size:13px;font-weight:600}
-.info-meta-link{color:var(--link);text-decoration:none}
-.info-meta-link:hover{text-decoration:underline}
-.spane h4{margin:0 0 14px;font-size:14px;font-weight:600}
-.status-ok{color:#2e8b2e!important;font-weight:600}
-.status-bad{color:var(--danger)!important;font-weight:600}
-:root[data-theme="dark"] .status-ok{color:#5cd65c!important}
-.checkbox-field{display:flex;align-items:center;gap:8px;margin-bottom:14px;cursor:pointer;font-size:13px}
-.checkbox-field input{width:16px;height:16px}
-.field-port{max-width:110px;flex:none}
-
-:root[data-theme="dark"] .settings-body{background:#252528}
-:root[data-theme="dark"] .settings-nav{background:linear-gradient(180deg,#2f2f34,#242428)}
-:root[data-theme="dark"] .settings-card{background:rgba(30,30,30,.88);border-color:#3b3b42;box-shadow:none}
-:root[data-theme="dark"] .settings-kv-row{border-bottom-color:#36363c}
-:root[data-theme="dark"] .about-brand{border-bottom-color:#36363c}
-:root[data-theme="dark"] .appinfo-item{background:rgba(255,255,255,.03);border-color:#36363c}
-:root[data-theme="dark"] .info-meta-link{color:#7fc7ff}
-
-@media (max-width: 900px){
- #modal-settings .modal-card{max-width:calc(100vw - 20px);max-height:calc(100vh - 20px)}
- .settings-body{flex-direction:column;min-height:0}
- .settings-nav{width:auto;border-right:none;border-bottom:1px solid var(--border);padding:10px;flex-direction:row;flex-wrap:wrap}
- .settings-nav-label{width:100%;padding:0 4px 6px}
- .snav{flex:1 1 150px}
- .settings-content{padding:16px}
- .settings-grid,.settings-grid-stack{grid-template-columns:1fr}
- .settings-card-wide{grid-column:auto}
- .appinfo{grid-template-columns:1fr}
- .appinfo-item.full{grid-column:auto}
- .field-row{flex-direction:column;gap:0}
- .path-row{flex-direction:column}
-}
-
-/* Popup-Fenster sind eigenständige, meist schmalere Fenster -- die
- Zeilenumbruch-Regel oben ist für die (deutlich breitere) Haupt-App gedacht
- und soll hier nicht greifen (sonst z. B. Version/Icon im Instanz-Fenster
- untereinander statt nebeneinander) */
-.is-popout .field-row{flex-direction:row}
-.is-popout .path-row{flex-direction:row}
-
-/* Konto-Button mit 3D-Skin-Kopf */
-.acct-btn{gap:8px}
-.acct-av{display:inline-flex;align-items:center;justify-content:center}
-.acct-av svg{width:18px;height:18px;color:#4b5563}
-.acct-head{width:24px;height:26px;object-fit:contain;image-rendering:auto;margin-top:-2px;margin-bottom:-2px}
-
-/* Update-Dialog */
-.upd-version{display:flex;align-items:center;justify-content:center;gap:12px;margin:4px 0 14px;font-size:15px}
-.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{
- position:fixed;z-index:200;min-width:210px;padding:4px;
- background:var(--win);border:1px solid var(--border);border-radius:4px;
- box-shadow:0 8px 24px rgba(0,0,0,.35);
-}
-.ctx-item{
- display:block;width:100%;text-align:left;padding:6px 12px;
- border:none;border-radius:3px;background:none;color:var(--text);
- font-family:inherit;font-size:12px;cursor:pointer;white-space:nowrap;
-}
-.ctx-item:hover{background:var(--sel);color:var(--text)}
-.ctx-item:disabled{opacity:.45;cursor:default;background:none}
-.ctx-strong{font-weight:600}
-.ctx-danger:hover{background:var(--danger);color:#fff}
-.ctx-sep{height:1px;background:var(--border);margin:4px 6px}
-
-/* Toast */
-.toast{position:fixed;bottom:42px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:4px;box-shadow:0 6px 20px rgba(0,0,0,.4);z-index:100;font-size:13px}
-.toast.err{background:#a5342a}
-
-/* Scrollbars (hell) */
-::-webkit-scrollbar{width:14px;height:14px}
-::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px}
-::-webkit-scrollbar-thumb:hover{background:#a8a8a8}
-
-/* ===================== Skin-Editor ===================== */
-#modal-skineditor .se-karte{width:1100px;max-width:calc(100vw - 32px);height:760px;max-height:calc(100vh - 40px)}
-.se-modus{display:inline-flex;border:1px solid var(--border);border-radius:4px;overflow:hidden;background:var(--bar)}
-.se-modus-btn{padding:5px 14px;border:none;background:none;color:var(--dim);font:inherit;font-size:12px;cursor:pointer}
-.se-modus-btn+.se-modus-btn{border-left:1px solid var(--border)}
-.se-modus-btn:hover{color:var(--text)}
-.se-modus-btn.on{background:var(--accent);color:#fff;font-weight:600}
-.se-body{display:grid;grid-template-columns:200px minmax(0,1fr) 270px;gap:14px;flex:1 1 auto;min-height:0;overflow:hidden;padding:12px 14px}
-.se-werkzeuge{display:flex;flex-direction:column;gap:14px;overflow:auto;min-height:0;padding-right:2px}
-.se-gruppe{display:flex;flex-direction:column;gap:6px}
-.se-titel{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dim)}
-.se-tools{display:grid;grid-template-columns:1fr 1fr;gap:4px}
-.se-tool{display:flex;flex-direction:column;align-items:center;gap:3px;padding:7px 4px;border:1px solid var(--border);border-radius:4px;background:var(--bar);color:var(--text);font:inherit;font-size:11px;cursor:pointer}
-.se-tool svg{width:18px;height:18px}
-.se-tool:hover:not(:disabled){border-color:var(--sel-br)}
-.se-tool.on{background:var(--sel);border-color:var(--sel-br);font-weight:600}
-.se-tool:disabled{opacity:.4;cursor:default}
-.se-farbzeile{display:flex;gap:6px;align-items:center}
-.se-farbzeile input[type=color]{width:42px;height:30px;padding:0 2px;border:1px solid var(--border);border-radius:4px;background:var(--bar);cursor:pointer}
-.se-farbzeile input[type=text]{flex:1;min-width:0;height:30px;padding:0 8px;border:1px solid var(--border);border-radius:4px;font-family:var(--mono);font-size:12px;background:#fff;color:var(--text)}
-:root[data-theme="dark"] .se-farbzeile input[type=text]{background:#1e1e1e}
-.se-zuletzt{display:grid;grid-template-columns:repeat(8,1fr);gap:3px}
-.se-farbfeld{aspect-ratio:1;border:1px solid rgba(0,0,0,.25);border-radius:3px;cursor:pointer;padding:0}
-.se-farbfeld:hover{transform:scale(1.12)}
-.se-farbfeld.on{outline:2px solid var(--sel-br);outline-offset:1px}
-.se-ebenen{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--border);border-radius:4px;overflow:hidden}
-.se-ebene{padding:6px 4px;border:none;background:var(--bar);color:var(--dim);font:inherit;font-size:12px;cursor:pointer}
-.se-ebene+.se-ebene{border-left:1px solid var(--border)}
-.se-ebene.on{background:var(--sel);color:var(--text);font-weight:600}
-.se-check{display:flex;align-items:center;gap:6px;cursor:pointer}
-.se-check input{margin:0}
-.se-check:has(input:disabled){opacity:.5;cursor:default}
-.se-feld{display:flex;flex-direction:column;gap:3px}
-.se-feld select{height:28px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font:inherit}
-:root[data-theme="dark"] .se-feld select{background:#1e1e1e}
-.se-undo{display:grid;grid-template-columns:1fr 1fr;gap:4px}
-.se-undo .btn{justify-content:center}
-.se-gruppe>.btn-mini{align-self:flex-start}
-.se-mitte{display:flex;flex-direction:column;gap:6px;min-width:0;min-height:0}
-.se-buehne{flex:1 1 auto;min-height:0;position:relative;border:1px solid var(--border);border-radius:6px;background:var(--area);overflow:hidden}
-.se-info{min-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.se-seite{display:flex;flex-direction:column;gap:6px;min-height:0}
-.se-mini{position:relative;height:320px;border:1px solid var(--border);border-radius:6px;background:var(--area);overflow:hidden}
-.se-tipp{margin:4px 0 0;line-height:1.45}
-.se-ansicht{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
-.se-ansicht canvas{display:block}
-.se-2d canvas{cursor:crosshair;border-radius:2px}
-.se-3d canvas{cursor:grab}
-.se-3d canvas:active{cursor:grabbing}
-.se-2d[data-werkzeug="pipette"] canvas{cursor:copy}
-.se-3d-leer{position:absolute}
-.se-ablage{display:none}
-.se-u2d canvas{cursor:crosshair}
-.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
-.se-fuss{align-items:center}
-.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+/* AeroMC – heller MultiMC-Native-Look */
+:root{
+ --win:#f0f0f0; --area:#e2e2e2; --bar:#e9eaed;
+ --border:#c4c7cc; --border2:#d6d8dc;
+ --text:#1c1c1c; --dim:#6b7280;
+ --sel:#dce8f6; --sel-br:#3a6ea5; --link:#2c5aa0; --accent:#3a6ea5;
+ --danger:#c0392b; --play:#3a9c3a;
+ --font:"Segoe UI",Tahoma,system-ui,sans-serif;
+ --mono:Consolas,"Cascadia Mono",monospace;
+}
+*{box-sizing:border-box}
+html,body{margin:0;height:100%;font-family:var(--font);font-size:12px;color:var(--text);background:var(--win)}
+body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:minmax(0,1fr);height:100vh;overflow:hidden}
+.hidden{display:none!important}
+.mono{font-family:var(--mono);font-size:12px}
+.spacer{flex:1}
+.opt,.hint{color:var(--dim)}
+[data-ic] svg,.ic svg{width:16px;height:16px}
+
+/* ===================== DUNKLER MODUS (native, dunkelgrau) ===================== */
+:root[data-theme="dark"]{
+ --win:#2d2d30; --area:#242426; --bar:#333337;
+ --border:#3f3f46; --border2:#3a3a40;
+ --text:#e4e4e6; --dim:#9a9aa2;
+ --sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb;
+ --danger:#e06c6c; --play:#5cb85c;
+ --input:#1e1e1e;
+}
+:root[data-theme="dark"] body{background:var(--win)}
+:root[data-theme="dark"] .search-wrap input,
+:root[data-theme="dark"] .field input,
+:root[data-theme="dark"] .field select,
+:root[data-theme="dark"] .field textarea,
+:root[data-theme="dark"] .mods-search input,
+:root[data-theme="dark"] .path-row input,
+:root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)}
+:root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618}
+:root[data-theme="dark"] .modal-head{background:#333337}
+:root[data-theme="dark"] .modal-body{background:#2d2d30}
+:root[data-theme="dark"] .modal-foot{background:#2a2a2e}
+:root[data-theme="dark"] .advanced{background:#242426}
+:root[data-theme="dark"] .mods-list,
+:root[data-theme="dark"] .account-item,
+:root[data-theme="dark"] .screen-thumb{background:#1e1e1e}
+:root[data-theme="dark"] .mod-icon,
+:root[data-theme="dark"] .account-av,
+:root[data-theme="dark"] .icon-preview{background:#3a3a40}
+:root[data-theme="dark"] .mod-item+.mod-item{border-color:#333}
+:root[data-theme="dark"] .mod-item:hover{background:#2a2f36}
+:root[data-theme="dark"] .btn{background:linear-gradient(#3b3b40,#303034);color:var(--text);border-color:var(--border)}
+:root[data-theme="dark"] .btn:hover{background:linear-gradient(#44444b,#35353b);border-color:#5a5a63}
+:root[data-theme="dark"] .btn:active{background:#2a2a2e}
+:root[data-theme="dark"] .btn-primary{background:linear-gradient(#2a7ac0,#1f66a8);border-color:#1a5a95;color:#fff}
+:root[data-theme="dark"] .btn-primary:hover{background:linear-gradient(#3286cc,#2470b4)}
+:root[data-theme="dark"] .btn-danger{color:#e88;border-color:#6a3a3a}
+:root[data-theme="dark"] .btn-danger:hover{background:#3a2626}
+:root[data-theme="dark"] .tbtn:hover,
+:root[data-theme="dark"] .sbtn:hover,
+:root[data-theme="dark"] .card:hover{background:#37373d;border-color:var(--border)}
+:root[data-theme="dark"] .card.sel{background:#0e639c;border-color:#1177bb}
+:root[data-theme="dark"] .card.sel .card-name{background:#1177bb;color:#fff}
+:root[data-theme="dark"] .st-row2{background:#2a2a2e}
+:root[data-theme="dark"] .st-row,
+:root[data-theme="dark"] .group-header,
+:root[data-theme="dark"] .empty h2,
+:root[data-theme="dark"] .mods-col h4,
+:root[data-theme="dark"] .field>span{color:var(--text)}
+:root[data-theme="dark"] #btn-console svg,:root[data-theme="dark"] #btn-search svg{color:#c8c8cc}
+:root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
+:root[data-theme="dark"] .mods-filters select,
+:root[data-theme="dark"] .mods-search select,
+:root[data-theme="dark"] .mmc-bar select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
+:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)}
+:root[data-theme="dark"] .modal-close{color:#aaa}
+:root[data-theme="dark"] .modal-close:hover{color:#fff}
+:root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)}
+
+/* ---------- Titelleiste (ersetzt die weiße Windows-Leiste) ---------- */
+/* Farben passend zu TITELLEISTE in main.js (dort für die Fensterknöpfe) */
+:root{--titel-bg:#f0f0f0;--titel-fg:#1c1c1c}
+:root[data-theme="dark"]{--titel-bg:#2d2d30;--titel-fg:#e4e4e6}
+.titelleiste{display:flex;align-items:center;gap:8px;height:32px;padding:0 150px 0 10px;background:var(--titel-bg);color:var(--titel-fg);-webkit-app-region:drag;user-select:none;font-size:12px}
+.titel-logo{width:16px;height:16px;flex:none}
+.titel-text{white-space:nowrap}
+body.fenster-inaktiv .titelleiste>*{opacity:.55}
+body.is-popout>.titelleiste{display:none}
+
+/* ---------- Toolbar ---------- */
+.toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;min-width:0;overflow:hidden;background:var(--win);border-bottom:1px solid var(--border)}
+.tbtn{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;padding:6px 10px;border:1px solid transparent;border-radius:3px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
+.tbtn:hover{background:#e2e6ec;border-color:var(--border2)}
+.tbtn:active{background:#d7dde5}
+.tbtn svg{width:18px;height:18px;color:#4b5563}
+/* farbige Toolbar-Icons wie in MultiMC */
+#btn-new svg{color:#3a9c3a}
+#btn-import svg{color:#3d7ab5}
+#btn-open-dir svg{color:#d8a72f}
+#btn-console svg{color:#5a6470}
+#btn-account svg{color:#3a9c3a}
+#btn-settings svg{color:#3d7ab5}
+.tbtn.big{font-size:13px;padding:8px 16px;border:1px solid var(--border);background:linear-gradient(#fdfdfd,#e9e9e9)}
+.tbsep{width:1px;height:24px;background:var(--border);margin:0 5px}
+.tbgrow{flex:1}
+.search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px}
+.search-wrap input:focus{outline:none;border-color:var(--accent)}
+/* eingeklappt nur die Lupe, aufgeklappt nur das Feld */
+.search-wrap{display:flex;align-items:center}
+.search-wrap:not(.offen) input{display:none}
+.search-wrap.offen .tb-lupe{display:none}
+.search-wrap.offen input{animation:suche-auf .16s ease-out}
+@keyframes suche-auf{from{width:0;opacity:0}}
+.tbtn.tb-lupe{padding:6px 8px}
+#btn-search svg{color:#5a6470}
+
+/* ---------- Arbeitsbereich ---------- */
+.workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)}
+.list-area{position:relative;overflow:hidden;min-height:0}
+/* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */
+.list-area::after{
+ content:'';position:absolute;right:24px;bottom:8px;width:52%;height:88%;
+ background-image:var(--bg-skin,none);background-repeat:no-repeat;
+ background-position:right bottom;background-size:contain;
+ opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0;
+}
+.list{position:relative;z-index:1;height:100%;min-height:100%;overflow:auto;padding:4px 8px}
+/* lebende Skin-Figur ersetzt das Standbild (gleiche Stelle und Größe) */
+.skin-live{position:absolute;right:24px;bottom:8px;opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0}
+.list-area.skin-lebt::after{display:none}
+
+/* ---------- Bewegte Oberfläche (nur mit Klasse ui-animiert) ---------- */
+/* Instanzen beim ersten Anzeigen nacheinander einblenden */
+.ui-animiert .list.einblenden .card,
+.ui-animiert .list.einblenden .group-header{animation:karte-rein .42s cubic-bezier(.2,.8,.2,1) both;animation-delay:calc(var(--i,0) * 28ms)}
+@keyframes karte-rein{from{opacity:0;transform:translateY(12px) scale(.94)}}
+/* Block-Symbol hebt sich beim Darüberfahren und dreht sich ein Stück */
+.ui-animiert .card-icon,.ui-animiert .card-icon-img{transition:transform .22s cubic-bezier(.2,.8,.2,1),filter .22s}
+.ui-animiert .card:hover .card-icon,.ui-animiert .card:hover .card-icon-img{transform:translateY(-4px) rotate(-7deg) scale(1.06);filter:drop-shadow(0 6px 6px rgba(0,0,0,.28))}
+/* Designwechsel weich überblenden */
+::view-transition-old(root),::view-transition-new(root){animation-duration:.45s;animation-timing-function:ease}
+.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px}
+.side-empty{text-align:center;color:var(--dim);padding:24px 8px}
+.side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px}
+.side-ico img{width:56px;height:56px;object-fit:cover;border-radius:3px}
+.side-title{text-align:center;font-weight:600;font-size:14px;word-break:break-word}
+.side-grp{text-align:center;color:var(--link);font-size:11px;margin-top:2px;cursor:pointer}
+.side-grp:hover{text-decoration:underline}
+.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)}
+
+/* ---------- Instanzliste / Gruppen / Kacheln ---------- */
+.group-section{margin-bottom:2px}
+.group-header{display:flex;align-items:center;gap:7px;padding:5px 4px 2px;color:#333;font-weight:600;font-size:12px;cursor:pointer;user-select:none}
+.group-header::after{content:'';flex:1;height:1px;background:var(--border);margin-left:6px}
+.group-header .chev{font-size:9px;color:#888;width:10px;text-align:center}
+.group-header.collapsed .chev{transform:none}
+.group-count{color:var(--dim);font-weight:400}
+.group-grid{display:grid;grid-template-columns:repeat(auto-fill,104px);gap:2px;padding:0 4px 4px;justify-content:start}
+.group-section.collapsed .group-grid{display:none}
+/* Untergruppen: eingerückt, mit feiner Linie links */
+.group-section.collapsed > .group-body{display:none}
+.group-sub{margin-left:10px;padding-left:8px;border-left:1px solid var(--border)}
+.group-sub > .group-header{font-size:11.5px;padding-top:4px}
+.card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px 4px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative}
+.card:hover{background:#e3ecf7}
+.card.sel{background:var(--sel);border-color:var(--sel-br)}
+.card-icon{width:60px;height:60px;display:grid;place-items:center;font-size:40px}
+.card-icon svg,.side-ico svg{display:block}
+.mc-texture-icon{display:block;object-fit:contain;image-rendering:auto;filter:drop-shadow(0 1px 1px rgba(0,0,0,.18))}
+.card-icon-img{width:60px;height:60px;object-fit:cover;border-radius:3px}
+.card-name{width:100%;max-width:88px;box-sizing:border-box;font-size:12px;text-align:center;line-height:1.25;padding:1px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.card.sel .card-name{background:var(--sel-br);color:#fff;border-radius:2px}
+.card-badges{display:none}
+.card-play{display:none}
+.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}
+.empty p{max-width:340px;margin:0 0 8px}
+
+/* ---------- Statusleiste (zwei Zeilen) ---------- */
+.statusbar{display:flex;flex-direction:column;min-width:0;background:var(--win);border-top:1px solid var(--border)}
+.st-row{display:flex;justify-content:space-between;padding:3px 10px;font-size:11px;color:#333}
+.st-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444}
+.auth-warn{
+ display:inline-flex;align-items:center;gap:6px;
+ color:#fff;background:var(--danger);
+ padding:1px 10px;border-radius:3px;font-weight:600;cursor:pointer;
+ animation:authpulse 2s ease-in-out infinite;
+}
+.auth-warn::before{content:'⚠';font-size:12px}
+.auth-warn:hover{filter:brightness(1.12)}
+@keyframes authpulse{0%,100%{opacity:1}50%{opacity:.72}}
+
+/* ---------- Modals ---------- */
+.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50}
+/* Bestätigungsdialog immer über anderen Dialogen (z. B. Screenshots) */
+#modal-confirm,#modal-prompt{z-index:120}
+.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}
+.modal-sm{width:420px}.modal-lg{width:820px}
+#modal-settings .modal-card{width:960px;max-width:calc(100vw - 32px)}
+.modal-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#e8e8e8;border-bottom:1px solid var(--border)}
+.modal-head h3{margin:0;font-size:14px;font-weight:600}
+.modal-close{background:none;border:none;color:#666;font-size:15px;cursor:pointer;line-height:1}
+.modal-close:hover{color:#000}
+.modal-body{padding:16px;overflow:auto;background:#f6f6f6}
+.modal-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--border);background:#efefef}
+
+/* Popup-Fenster (Einstellungen/Konsole): Haupt-UI ausblenden, das eine Modal
+ füllt das ganze (rahmenlose) Fenster; der eigene Kopfbereich ersetzt die
+ native Titelleiste – per -webkit-app-region per Maus verschiebbar */
+body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>footer.statusbar{display:none}
+.popout-host{position:static;inset:auto;background:none;display:block}
+/* !important: schlägt auch die spezifischere #modal-settings-Breitenregel oben */
+.popout-host .modal-card{width:100%!important;max-width:none!important;height:100vh!important;max-height:none!important;border:none;border-radius:0;box-shadow:none;margin:0}
+/* Inhalt soll die restliche Fensterhöhe füllen (scrollen statt Leerraum unter dem Fuß) */
+.popout-host .modal-body{flex:1 1 auto;min-height:0}
+/* Konsole/Logs/Screenshots haben einen einzelnen scrollbaren Kasten mit
+ fester Höhe (für den normalen Dialog gedacht) – im Popup-Fenster soll der
+ stattdessen die volle verbleibende Höhe ausfüllen statt Leerraum darunter
+ zu lassen */
+#modal-console.popout-host .modal-body,
+#modal-logs.popout-host .modal-body,
+#modal-screens.popout-host .modal-body,
+#modal-servers.popout-host .modal-body,
+#modal-mods.popout-host .modal-body,
+#modal-record.popout-host .modal-body{display:flex;flex-direction:column}
+.popout-host .console-out,
+#modal-logs.popout-host .crash-voll,
+.popout-host .mods-list,
+.popout-host .record-stage{flex:1 1 auto;height:auto;max-height:none;min-height:0}
+/* Screenshots/Aufzeichnungen NICHT auf die volle Fensterhöhe zwingen (Grid-
+ Zeilen strecken sich sonst nicht mit, es bliebe bei wenigen Einträgen ein
+ großer sichtbar leerer Kasten stehen) - nur die feste 460px-Deckelung
+ aufheben, damit bei VIELEN Einträgen trotzdem mehr Platz genutzt wird */
+.popout-host .screens-grid{max-height:none}
+/* Mods-Fenster: die aktive Ansicht (Katalog/Installiert) muss selbst ein
+ Flex-Container werden, damit ihre eigene Liste (.mods-list) die Regel oben
+ nutzen und den restlichen Platz füllen kann */
+#modal-mods.popout-host #m-pane-catalog,
+#modal-mods.popout-host #m-pane-installed{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}
+.popout-host .modal-head{-webkit-app-region:drag;cursor:default}
+.popout-host .modal-head button,.popout-host .modal-head input,.popout-host .modal-head select{-webkit-app-region:no-drag}
+
+/* Formular */
+.field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}
+.field>span{font-size:12px;color:#333}
+.field input,.field select,.field textarea{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
+.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)}
+.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;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
+.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
+.advanced legend{color:var(--dim);font-size:12px;padding:0 6px}
+.version-row{display:flex;gap:8px}.version-row #f-version-type{max-width:120px}.version-row #f-version{flex:1}
+.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:3px;margin-top:6px;
+ width:100%;max-width:100%;max-height:132px;overflow-y:auto;overflow-x:hidden;box-sizing:border-box}
+.icon-choice{display:flex;align-items:center;justify-content:center;padding:2px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;min-width:0;overflow:hidden}
+.icon-choice img{width:24px;height:24px;display:block;object-fit:contain;image-rendering:auto}
+.icon-choice:hover{background:rgba(128,128,128,.18)}
+.icon-choice.on{border-color:var(--accent);background:var(--sel)}
+.icon-choice svg{display:block}
+.icon-custom{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap}
+.icon-preview{width:34px;height:34px;border-radius:3px;overflow:hidden;background:#e4e4e4;border:1px solid var(--border)}
+.icon-preview img{width:100%;height:100%;object-fit:cover}
+.appinfo{margin-top:8px;font-size:12px;color:var(--dim);line-height:1.6}
+.about-brand{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;margin:0 0 18px;padding:6px 0 14px;border-bottom:1px solid var(--border2);text-align:center}
+.about-logo{width:56px;height:56px;border-radius:12px}
+.about-name{font-size:18px;font-weight:600}
+
+/* Buttons in Dialogen */
+.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:3px;background:linear-gradient(#fdfdfd,#e9e9e9);color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
+.btn:hover{background:linear-gradient(#ffffff,#e2e2e2);border-color:#9aa0a8}
+.btn:active{background:#dcdcdc}
+.btn:disabled{opacity:.5;cursor:default}
+.btn svg{width:15px;height:15px}
+.btn-primary{background:linear-gradient(#5a9be0,#3a7bc8);border-color:#2f6bb0;color:#fff}
+.btn-primary:hover{background:linear-gradient(#69a8ea,#3f83d2)}
+.btn-danger{color:var(--danger);border-color:#cbb0ac}
+.btn-danger:hover{background:#f7e9e7;border-color:#c0392b}
+.btn-lg{padding:9px 18px;font-size:13px}
+.btn-mini{padding:4px 8px;font-size:11px}
+
+/* Badges (dezent) */
+.badge{font-size:10px;padding:1px 6px;border-radius:2px;background:#e2e2e2;color:#555;border:1px solid var(--border)}
+
+/* Detail-Kopf (Mods/Login) */
+.detail-head{display:flex;align-items:center;gap:12px}
+.detail-icon{font-size:28px}
+.detail-sub{color:var(--dim);font-size:12px;margin-top:2px}
+
+/* Mods */
+.mods-body{min-height:420px}
+.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px}
+.mods-search{display:flex;gap:8px;margin-bottom:10px;align-items:stretch}
+.mods-search select{
+ flex:0 0 auto;min-width:140px;max-width:180px;
+ padding:7px 10px;border:1px solid var(--border);border-radius:3px;
+ background:var(--input,#fff);color:var(--text);
+ font-family:inherit;font-size:12px;
+}
+.mods-search select:focus{outline:none;border-color:var(--accent)}
+.mods-filters{display:flex;gap:8px;margin-bottom:12px}
+.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
+.mods-filters select:focus{outline:none;border-color:var(--accent)}
+#m-more{display:block;width:100%;margin-top:8px}
+.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)}
+.mods-search input:focus{outline:none;border-color:var(--accent)}
+.mods-columns{display:grid;grid-template-columns:1fr 1fr;gap:14px}
+.mods-col h4{margin:0 0 8px;font-size:12px;color:#333;font-weight:600;display:flex;align-items:center;gap:6px}
+.mods-tabs{display:flex;gap:2px;margin-bottom:10px;border-bottom:1px solid var(--border)}
+.mtab{padding:7px 14px;border:1px solid transparent;border-bottom:none;border-radius:3px 3px 0 0;background:none;color:var(--dim);cursor:pointer;font-family:inherit;font-size:12px;margin-bottom:-1px}
+.mtab:hover{color:var(--text)}
+.mtab.on{color:var(--text);background:var(--bar);border-color:var(--border);font-weight:600}
+.mods-list{height:340px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:3px;background:#fff;padding:6px}
+.mods-empty{color:var(--dim);text-align:center;padding:24px 8px;font-size:12px}
+.mod-item{display:flex;gap:10px;align-items:center;padding:8px;border-radius:3px;max-width:100%;overflow:hidden}
+.mod-item.clickable{cursor:pointer}
+.mod-item.installing{opacity:.6}
+.mod-item+.mod-item{border-top:1px solid #eee}
+.mod-item:hover{background:#f0f4f8}
+.mod-icon{width:38px;height:38px;border-radius:3px;flex:none;background:#e4e4e4;object-fit:cover;display:flex;align-items:center;justify-content:center;font-size:18px}
+.mod-info{flex:1;min-width:0}
+.mod-name{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.mod-desc{font-size:12px;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.mod-meta{font-size:11px;color:var(--dim);margin-top:2px}
+.mod-new{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#2f7d32;color:#fff;vertical-align:middle}
+.mod-off{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#6b6b6b;color:#fff;vertical-align:middle}
+.crash-kern{margin:10px 0;padding:10px 12px;border-left:3px solid #c0392b;background:rgba(192,57,43,.08);border-radius:0 4px 4px 0}
+.crash-zeile{display:flex;gap:8px;margin-bottom:5px;font-size:12px;line-height:1.45}
+.crash-zeile:last-child{margin-bottom:0}
+.crash-titel{flex:0 0 132px;color:var(--dim)}
+.crash-wert{flex:1;word-break:break-word}
+.crash-verlauf{margin:0;font-family:var(--mono);font-size:11px;line-height:1.5;color:var(--dim);white-space:pre-wrap;word-break:break-all}
+.skin-vorschau{display:flex;gap:14px;margin-top:12px}
+.skin-spalte{flex:1;display:flex;flex-direction:column;gap:4px;min-width:0}
+.skin-label{font-size:11px;color:var(--dim)}
+.skin-bild{height:300px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:rgba(0,0,0,.15);padding:6px;text-align:center;overflow:hidden}
+/* Die gerenderte Figur soll das Feld ausfüllen; eine rohe 64x64-Textur wird
+ ohne Weichzeichnen vergrößert, damit sie nicht verschwommen wirkt. */
+.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated}
+.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab}
+.skin-bild canvas:active{cursor:grabbing}
+.popout-host .skin-bild{height:240px}
+.skin-umhang{margin-top:12px;padding-top:10px;border-top:1px solid var(--border2)}
+.skin-umhang .field-row{margin-top:6px}
+.skin-umhang .field{margin-bottom:6px}
+.skin-umhang-aero{display:flex;align-items:center;gap:8px;min-height:30px;flex-wrap:wrap}
+.crash-details{margin-top:12px}
+.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)}
+.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
+.mod-aus .mod-name,.mod-aus .mod-meta{opacity:.55}
+.mod-aus .mod-icon{filter:grayscale(1);opacity:.6}
+.mod-action{flex:none}
+
+/* Import */
+.import-pane{min-height:180px}
+/* Fortschritt großer Importe (viele GB) */
+.import-lauf{margin:0 0 12px;padding:10px 12px;border:1px solid var(--border);border-radius:6px;background:var(--input,rgba(0,0,0,.15))}
+.import-lauf-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
+.import-lauf-kopf b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.import-balken{height:8px;border-radius:999px;background:rgba(127,127,127,.25);overflow:hidden}
+.import-balken i{display:block;height:100%;width:0;border-radius:inherit;background:var(--accent);transition:width .25s ease}
+.import-balken.unbestimmt i{width:30%;animation:import-schieben 1.2s ease-in-out infinite}
+@keyframes import-schieben{0%{transform:translateX(-100%)}100%{transform:translateX(340%)}}
+.import-lauf-zeile{margin-top:7px;font-size:12px}
+.import-lauf-datei{margin-top:3px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.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-bar select{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
+.mmc-bar select:focus{outline:none;border-color:var(--accent)}
+.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px}
+.mmc-item+.mmc-item{border-top:1px solid var(--border2)}
+.mmc-item:hover{background:rgba(128,128,128,.10)}
+.mmc-item input{width:16px;height:16px;flex:none}
+.mmc-name{font-weight:600;font-size:13px}
+.mmc-meta{font-size:11px;color:var(--dim)}
+
+/* Bedrock */
+.bd-actions{display:flex;gap:8px;align-items:center;margin-bottom:14px}
+.bd-meta{font-size:11px;color:var(--dim);margin-top:2px}
+
+/* Login / Konten */
+.login-code{text-align:center}
+.login-link{color:var(--link);font-size:15px;display:inline-block;margin-bottom:12px;cursor:pointer;text-decoration:none}
+.login-link:hover{text-decoration:underline}
+.login-usercode{font-family:var(--mono);font-size:28px;font-weight:700;letter-spacing:3px;color:#222;background:#fff;border:1px solid var(--border);border-radius:4px;padding:12px;margin:6px 0 12px}
+.login-error{color:var(--danger)!important;font-weight:600}
+.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:10px}
+.account-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:3px;background:#fff;border:1px solid var(--border)}
+.account-item.active{border-color:var(--accent)}
+.account-av{width:34px;height:34px;border-radius:3px;flex:none;background:#e4e4e4;display:flex;align-items:center;justify-content:center;font-size:18px}
+img.account-av{object-fit:cover;image-rendering:auto}
+.account-main{flex:1;min-width:0}
+.account-name{font-weight:600}
+.account-tag{font-size:11px;color:var(--link)}
+.account-actions{display:flex;gap:6px}
+.account-empty{color:var(--dim);text-align:center;padding:10px}
+
+/* Screenshots */
+.screens-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;max-height:460px;overflow:auto}
+.screen-thumb{border:1px solid var(--border);border-radius:3px;overflow:hidden;background:#fff;cursor:pointer;position:relative}
+.screen-thumb img{width:100%;height:100px;object-fit:cover;display:block}
+.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer}
+.screen-thumb:hover .screen-del{opacity:1}
+.screen-thumb.selected{outline:2px solid var(--sel-br);outline-offset:1px;background:var(--sel)}
+.screen-thumb .screen-check{position:absolute;top:4px;left:4px;width:18px;height:18px;margin:0;cursor:pointer;z-index:2}
+.screens-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
+.screens-toolbar .hint{font-size:11px}
+.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
+.record-thumb-img-wrap{position:relative}
+.record-thumb-img-wrap img{background:#000}
+.record-thumb-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;text-shadow:0 1px 4px rgba(0,0,0,.7);pointer-events:none}
+.record-thumb-size{font-size:10px;color:var(--dim);padding:0 6px 4px}
+
+/* Freunde */
+.tb-badge{display:inline-block;min-width:16px;height:16px;padding:0 4px;margin-left:6px;border-radius:8px;background:#d9453c;color:#fff;font-size:11px;font-weight:600;line-height:16px;text-align:center;box-sizing:border-box}
+#modal-friends .field-row{margin-bottom:14px}
+#modal-friends .field-row input{flex:1}
+/* ---- Spielzeit-Statistik: Kennzahlen + Balken pro Instanz ---- */
+:root{--stat-bar:#3a6ea5}
+:root[data-theme="dark"]{--stat-bar:#3d8fe0}
+.stats-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:6px}
+.stat-tile{border:1px solid var(--border);border-radius:4px;padding:8px 10px;min-width:0}
+.stat-tile .stat-label{font-size:11px;color:var(--dim)}
+.stat-tile .stat-value{font-size:18px;font-weight:600;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.stat-tile .stat-sub{font-size:11px;color:var(--dim);line-height:1.35}
+.stats-list{display:flex;flex-direction:column;gap:2px}
+.stat-row{display:grid;grid-template-columns:minmax(0,38%) 1fr auto;align-items:center;gap:10px;padding:5px 4px;border-radius:3px}
+.stat-row:hover{background:var(--sel)}
+.stat-name{min-width:0}
+.stat-name b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.stat-name span{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
+.stat-track{height:8px}
+.stat-bar{height:8px;background:var(--stat-bar);border-radius:0 4px 4px 0;min-width:2px}
+.stat-value-cell{font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right;min-width:92px}
+.popout-host .stats-list{max-height:none}
+
+/* Clip-Modus-Leiste im Aufzeichnungsfenster */
+.clip-bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:8px 12px;border:1px solid var(--border);border-radius:4px;background:var(--area)}
+.clip-bar.aktiv{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
+.clip-switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:600;user-select:none}
+.clip-switch input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
+.clip-switch-track{position:relative;width:32px;height:18px;border-radius:9px;background:var(--border);transition:background .15s}
+.clip-switch-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.35);transition:left .15s}
+.clip-switch input:checked + .clip-switch-track{background:var(--accent)}
+.clip-switch input:checked + .clip-switch-track .clip-switch-knob{left:16px}
+.clip-switch input:focus-visible + .clip-switch-track{outline:2px solid var(--accent);outline-offset:2px}
+.clip-length{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
+.clip-length select{padding:3px 6px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
+.clip-length select:disabled{opacity:.6}
+.clip-status{flex:1;min-width:200px;font-size:12px;color:var(--dim)}
+.clip-bar.aktiv .clip-status{color:var(--text)}
+.clip-bar.aktiv .clip-status::before{content:"";display:inline-block;width:8px;height:8px;margin-right:7px;border-radius:50%;background:var(--danger);vertical-align:1px;animation:rec-pulse 1.6s infinite}
+
+/* ---- Absturz-Assistent ---- */
+.crash-hilfe{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
+.crash-hilfe-titel{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em}
+.crash-fund{display:flex;gap:12px;align-items:flex-start;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:4px;background:var(--area)}
+.crash-fund-text{flex:1;min-width:0}
+.crash-fund-text b{display:block;font-size:13px;margin-bottom:3px}
+.crash-fund-text span{font-size:12px;color:var(--dim);line-height:1.45}
+.crash-fund .btn{flex:0 0 auto;align-self:center}
+.crash-fund.erledigt{border-left-color:var(--play)}
+.crash-unklar{padding:10px 12px;border:1px dashed var(--border);border-radius:4px;font-size:12px;color:var(--dim)}
+
+/* ---- Instanz teilen ---- */
+.share-code-box{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border:1px solid var(--accent);border-radius:4px;background:var(--area);margin:10px 0}
+.share-code{font-family:var(--mono);font-size:24px;font-weight:600;letter-spacing:.12em;user-select:all}
+.share-warn{color:var(--danger)}
+.share-code-input{text-transform:uppercase;letter-spacing:.1em;font-family:var(--mono)}
+.share-preview{margin-top:12px;padding:12px 14px;border:1px solid var(--border);border-radius:4px;background:var(--area)}
+.share-preview b{font-size:14px}
+.share-preview .hint{margin:4px 0 0}
+
+/* ---- Kurznachrichten ---- */
+.friends-chat{display:flex;flex-direction:column;gap:8px;height:100%;min-height:340px}
+.chat-head{display:flex;align-items:center;gap:10px}
+.chat-head b{font-size:13px}
+.chat-verlauf{flex:1;min-height:200px;max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;padding:8px;border:1px solid var(--border);border-radius:4px;background:var(--area)}
+.popout-host .chat-verlauf{max-height:none}
+.chat-leer{margin:auto;font-size:12px;color:var(--dim);text-align:center}
+.chat-blase{max-width:78%;padding:6px 10px;border-radius:10px;font-size:12.5px;line-height:1.4;word-break:break-word;background:var(--win);border:1px solid var(--border);align-self:flex-start}
+.chat-blase.ich{align-self:flex-end;background:var(--accent);border-color:var(--accent);color:#fff}
+.chat-zeit{display:block;font-size:10px;opacity:.65;margin-top:2px}
+.chat-schnell{display:flex;flex-wrap:wrap;gap:6px}
+.chat-btn{position:relative}
+.chat-badge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:#e5484d;color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center}
+.report-liste{margin:6px 0 10px;padding-left:20px;font-size:12px;line-height:1.6}
+
+/* ---- Auswahl-Dialog ---- */
+.wahl-liste{display:flex;flex-direction:column;gap:4px;max-height:320px;overflow-y:auto}
+.popout-host .wahl-liste{max-height:none}
+.wahl-option{display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:4px;background:var(--area);color:var(--text);font-family:inherit;text-align:left;cursor:pointer}
+.wahl-option:hover{border-color:var(--accent)}
+.wahl-option.an{border-color:var(--accent);background:var(--sel)}
+.wahl-option:disabled{opacity:.5;cursor:default}
+.wahl-bild{width:26px;height:26px;flex:0 0 auto}
+.wahl-label{display:flex;flex-direction:column;min-width:0}
+.wahl-label b{font-size:13px;font-weight:600}
+.wahl-label small{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+/* ---- Schnellstart-Taste auf der Kachel ---- */
+.card{position:relative}
+.card-taste{position:absolute;top:4px;right:4px;padding:1px 5px;border-radius:3px;background:var(--accent);color:#fff;font-size:10px;font-weight:600;line-height:14px}
+
+/* ---- Bilder im Chat ---- */
+.chat-bild{display:block;max-width:100%;max-height:180px;border-radius:6px;margin-bottom:3px;cursor:zoom-in}
+.screen-thumb .screen-teilen{right:32px;background:var(--accent)}
+
+.friends-section-title{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.03em;margin:10px 0 6px}
+.friend-row{display:flex;align-items:center;gap:10px;padding:6px 4px;border-radius:3px}
+.friend-row:hover{background:var(--sel,rgba(0,0,0,.04))}
+.friend-dot{width:9px;height:9px;border-radius:50%;background:#8a8a8a;flex:0 0 auto}
+.friend-dot.online{background:#3bb54a}
+.friend-avatar{width:28px;height:28px;border-radius:4px;flex:0 0 auto;background:#1e1e1e;object-fit:contain}
+.friend-info{flex:1;min-width:0}
+.friend-name{font-size:13px}
+.friend-status{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.friends-empty{color:var(--dim);font-size:12px;padding:8px 4px}
+
+/* Spielaufzeichnung */
+.record-search{display:flex;flex-direction:column;align-items:center;gap:10px;padding:48px 0;color:var(--dim)}
+.record-stage{display:flex;flex-direction:column;gap:10px}
+.record-video{width:100%;flex:1 1 auto;min-height:360px;background:#000;border-radius:4px;object-fit:contain}
+.record-toolbar{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
+.record-live-badge{display:flex;align-items:center;gap:8px;color:var(--danger);font-size:13px;font-weight:600}
+#record-timer{font-variant-numeric:tabular-nums}
+.rec-dot{width:10px;height:10px;border-radius:50%;background:var(--danger);animation:rec-pulse 1s infinite}
+@keyframes rec-pulse{0%,100%{opacity:1}50%{opacity:.25}}
+.record-audio-bar{display:flex;align-items:center;gap:10px;padding:2px 2px}
+.record-mic-list{display:flex;flex-direction:column;gap:8px;margin-top:8px}
+.record-source-row{display:flex;align-items:center;gap:10px;margin-bottom:8px}
+.record-source-row .checkbox-field{margin-bottom:0;flex:1;min-width:0;font-size:13px;gap:6px}
+.record-vol{width:110px}
+
+/* Konsole (dunkel wie ein Log) */
+.console-out{height:440px;overflow:auto;margin:0;background:#1e1e1e;border:1px solid var(--border);border-radius:3px;padding:10px;font-family:var(--mono);font-size:12px;color:#d4d4d4;white-space:pre-wrap;word-break:break-word;line-height:1.5}
+.log-line{white-space:pre-wrap}
+.log-error{color:#f48771}.log-warn{color:#dcdcaa}.log-info{color:#569cd6}
+
+/* Einstellungen */
+.settings-body{display:flex;padding:0;min-height:0;background:var(--area)}
+.settings-nav{width:190px;flex:none;background:linear-gradient(180deg,var(--bar),rgba(128,128,128,.08));border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:4px}
+.settings-nav-label{padding:2px 10px 8px;color:var(--dim);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
+.snav{text-align:left;padding:10px 12px;border:1px solid transparent;border-radius:8px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;transition:background .15s,border-color .15s,transform .15s}
+.snav:hover{background:rgba(128,128,128,.12)}
+.snav.on{background:var(--sel);border-color:var(--sel-br);color:var(--text);box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)}
+.settings-content{flex:1;padding:20px 22px;overflow:auto}
+.settings-hero{margin-bottom:18px}
+.settings-hero h4{margin:0 0 6px;font-size:20px;font-weight:700}
+.settings-hero p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
+.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start}
+.settings-grid-stack{grid-template-columns:repeat(2,minmax(0,1fr))}
+.settings-card{background:rgba(255,255,255,.7);border:1px solid var(--border);border-radius:12px;padding:16px 16px 12px;box-shadow:0 1px 0 rgba(255,255,255,.5)}
+.settings-card h5{margin:0 0 14px;font-size:13px;font-weight:700;color:var(--text)}
+.settings-card-wide{grid-column:span 2}
+.settings-card .field:last-child,.settings-card .checkbox-field:last-of-type{margin-bottom:0}
+.settings-kv{display:grid;gap:10px}
+.settings-kv-row{display:flex;justify-content:space-between;gap:14px;padding-bottom:10px;border-bottom:1px solid var(--border2)}
+.settings-kv-row:last-child{padding-bottom:0;border-bottom:none}
+.settings-kv-label{color:var(--dim);font-size:12px}
+.settings-kv-value{text-align:right;font-weight:600;font-size:12px;word-break:break-word}
+.appinfo{margin-top:8px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px;font-size:12px;line-height:1.6}
+.appinfo-item{padding:10px 12px;border:1px solid var(--border2);border-radius:10px;background:rgba(128,128,128,.06)}
+.appinfo-item.full{grid-column:span 2}
+.appinfo-label{display:block;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
+.appinfo-value{display:block;margin-top:4px;font-weight:600;word-break:break-word}
+.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}
+.info-meta .settings-kv-value{text-align:left;font-size:13px;font-weight:600}
+.info-meta-link{color:var(--link);text-decoration:none}
+.info-meta-link:hover{text-decoration:underline}
+.spane h4{margin:0 0 14px;font-size:14px;font-weight:600}
+.status-ok{color:#2e8b2e!important;font-weight:600}
+.status-bad{color:var(--danger)!important;font-weight:600}
+:root[data-theme="dark"] .status-ok{color:#5cd65c!important}
+.checkbox-field{display:flex;align-items:center;gap:8px;margin-bottom:14px;cursor:pointer;font-size:13px}
+.checkbox-field input{width:16px;height:16px}
+.field-port{max-width:110px;flex:none}
+
+:root[data-theme="dark"] .settings-body{background:#252528}
+:root[data-theme="dark"] .settings-nav{background:linear-gradient(180deg,#2f2f34,#242428)}
+:root[data-theme="dark"] .settings-card{background:rgba(30,30,30,.88);border-color:#3b3b42;box-shadow:none}
+:root[data-theme="dark"] .settings-kv-row{border-bottom-color:#36363c}
+:root[data-theme="dark"] .about-brand{border-bottom-color:#36363c}
+:root[data-theme="dark"] .appinfo-item{background:rgba(255,255,255,.03);border-color:#36363c}
+:root[data-theme="dark"] .info-meta-link{color:#7fc7ff}
+
+@media (max-width: 900px){
+ #modal-settings .modal-card{max-width:calc(100vw - 20px);max-height:calc(100vh - 20px)}
+ .settings-body{flex-direction:column;min-height:0}
+ .settings-nav{width:auto;border-right:none;border-bottom:1px solid var(--border);padding:10px;flex-direction:row;flex-wrap:wrap}
+ .settings-nav-label{width:100%;padding:0 4px 6px}
+ .snav{flex:1 1 150px}
+ .settings-content{padding:16px}
+ .settings-grid,.settings-grid-stack{grid-template-columns:1fr}
+ .settings-card-wide{grid-column:auto}
+ .appinfo{grid-template-columns:1fr}
+ .appinfo-item.full{grid-column:auto}
+ .field-row{flex-direction:column;gap:0}
+ .path-row{flex-direction:column}
+}
+
+/* Popup-Fenster sind eigenständige, meist schmalere Fenster -- die
+ Zeilenumbruch-Regel oben ist für die (deutlich breitere) Haupt-App gedacht
+ und soll hier nicht greifen (sonst z. B. Version/Icon im Instanz-Fenster
+ untereinander statt nebeneinander) */
+.is-popout .field-row{flex-direction:row}
+.is-popout .path-row{flex-direction:row}
+
+/* Konto-Button mit 3D-Skin-Kopf */
+.acct-btn{gap:8px}
+.acct-av{display:inline-flex;align-items:center;justify-content:center}
+.acct-av svg{width:18px;height:18px;color:#4b5563}
+.acct-head{width:24px;height:26px;object-fit:contain;image-rendering:auto;margin-top:-2px;margin-bottom:-2px}
+
+/* Update-Dialog */
+.upd-version{display:flex;align-items:center;justify-content:center;gap:12px;margin:4px 0 14px;font-size:15px}
+.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{
+ position:fixed;z-index:200;min-width:210px;padding:4px;
+ background:var(--win);border:1px solid var(--border);border-radius:4px;
+ box-shadow:0 8px 24px rgba(0,0,0,.35);
+}
+.ctx-item{
+ display:block;width:100%;text-align:left;padding:6px 12px;
+ border:none;border-radius:3px;background:none;color:var(--text);
+ font-family:inherit;font-size:12px;cursor:pointer;white-space:nowrap;
+}
+.ctx-item:hover{background:var(--sel);color:var(--text)}
+.ctx-item:disabled{opacity:.45;cursor:default;background:none}
+.ctx-strong{font-weight:600}
+.ctx-danger:hover{background:var(--danger);color:#fff}
+.ctx-sep{height:1px;background:var(--border);margin:4px 6px}
+/* Konto-Menü oben rechts (wie in MultiMC) */
+.acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px}
+.konto-menu{min-width:220px}
+.konto-eintrag{display:flex;align-items:center;gap:9px}
+.konto-eintrag img{width:22px;height:24px;object-fit:contain;flex:none}
+.konto-eintrag .konto-name{flex:1;overflow:hidden;text-overflow:ellipsis}
+.konto-eintrag .konto-haken{width:14px;text-align:center;color:var(--accent);font-weight:700}
+.konto-eintrag .konto-spielt{font-size:10px;color:var(--dim)}
+.konto-eintrag.aktiv .konto-name{font-weight:600}
+.konto-menu .ctx-item svg{width:16px;height:16px;flex:none;color:var(--dim)}
+
+/* Toast */
+.toast{position:fixed;bottom:42px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:4px;box-shadow:0 6px 20px rgba(0,0,0,.4);z-index:100;font-size:13px}
+.toast.err{background:#a5342a}
+
+/* Scrollbars (hell) */
+::-webkit-scrollbar{width:14px;height:14px}
+::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px}
+::-webkit-scrollbar-thumb:hover{background:#a8a8a8}
+
+/* ===================== Skin-Editor ===================== */
+#modal-skineditor .se-karte{width:1100px;max-width:calc(100vw - 32px);height:760px;max-height:calc(100vh - 40px)}
+.se-modus{display:inline-flex;border:1px solid var(--border);border-radius:4px;overflow:hidden;background:var(--bar)}
+.se-modus-btn{padding:5px 14px;border:none;background:none;color:var(--dim);font:inherit;font-size:12px;cursor:pointer}
+.se-modus-btn+.se-modus-btn{border-left:1px solid var(--border)}
+.se-modus-btn:hover{color:var(--text)}
+.se-modus-btn.on{background:var(--accent);color:#fff;font-weight:600}
+.se-body{display:grid;grid-template-columns:200px minmax(0,1fr) 270px;gap:14px;flex:1 1 auto;min-height:0;overflow:hidden;padding:12px 14px}
+.se-werkzeuge{display:flex;flex-direction:column;gap:14px;overflow:auto;min-height:0;padding-right:2px}
+.se-gruppe{display:flex;flex-direction:column;gap:6px}
+.se-titel{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dim)}
+.se-tools{display:grid;grid-template-columns:1fr 1fr;gap:4px}
+.se-tool{display:flex;flex-direction:column;align-items:center;gap:3px;padding:7px 4px;border:1px solid var(--border);border-radius:4px;background:var(--bar);color:var(--text);font:inherit;font-size:11px;cursor:pointer}
+.se-tool svg{width:18px;height:18px}
+.se-tool:hover:not(:disabled){border-color:var(--sel-br)}
+.se-tool.on{background:var(--sel);border-color:var(--sel-br);font-weight:600}
+.se-tool:disabled{opacity:.4;cursor:default}
+.se-farbzeile{display:flex;gap:6px;align-items:center}
+.se-farbzeile input[type=color]{width:42px;height:30px;padding:0 2px;border:1px solid var(--border);border-radius:4px;background:var(--bar);cursor:pointer}
+.se-farbzeile input[type=text]{flex:1;min-width:0;height:30px;padding:0 8px;border:1px solid var(--border);border-radius:4px;font-family:var(--mono);font-size:12px;background:#fff;color:var(--text)}
+:root[data-theme="dark"] .se-farbzeile input[type=text]{background:#1e1e1e}
+.se-zuletzt{display:grid;grid-template-columns:repeat(8,1fr);gap:3px}
+.se-farbfeld{aspect-ratio:1;border:1px solid rgba(0,0,0,.25);border-radius:3px;cursor:pointer;padding:0}
+.se-farbfeld:hover{transform:scale(1.12)}
+.se-farbfeld.on{outline:2px solid var(--sel-br);outline-offset:1px}
+.se-ebenen{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--border);border-radius:4px;overflow:hidden}
+.se-ebene{padding:6px 4px;border:none;background:var(--bar);color:var(--dim);font:inherit;font-size:12px;cursor:pointer}
+.se-ebene+.se-ebene{border-left:1px solid var(--border)}
+.se-ebene.on{background:var(--sel);color:var(--text);font-weight:600}
+.se-check{display:flex;align-items:center;gap:6px;cursor:pointer}
+.se-check input{margin:0}
+.se-check:has(input:disabled){opacity:.5;cursor:default}
+.se-feld{display:flex;flex-direction:column;gap:3px}
+.se-feld select{height:28px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font:inherit}
+:root[data-theme="dark"] .se-feld select{background:#1e1e1e}
+.se-undo{display:grid;grid-template-columns:1fr 1fr;gap:4px}
+.se-undo .btn{justify-content:center}
+.se-gruppe>.btn-mini{align-self:flex-start}
+.se-mitte{display:flex;flex-direction:column;gap:6px;min-width:0;min-height:0}
+.se-buehne{flex:1 1 auto;min-height:0;position:relative;border:1px solid var(--border);border-radius:6px;background:var(--area);overflow:hidden}
+.se-info{min-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.se-seite{display:flex;flex-direction:column;gap:6px;min-height:0}
+.se-mini{position:relative;height:320px;border:1px solid var(--border);border-radius:6px;background:var(--area);overflow:hidden}
+.se-tipp{margin:4px 0 0;line-height:1.45}
+.se-ansicht{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
+.se-ansicht canvas{display:block}
+.se-2d canvas{cursor:crosshair;border-radius:2px}
+.se-3d canvas{cursor:grab}
+.se-3d canvas:active{cursor:grabbing}
+.se-2d[data-werkzeug="pipette"] canvas{cursor:copy}
+.se-3d-leer{position:absolute}
+.se-ablage{display:none}
+.se-u2d canvas{cursor:crosshair}
+.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
+.se-fuss{align-items:center}
+.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
diff --git a/src/technicimport.js b/src/technicimport.js
index f8b687d..b177e7a 100644
--- a/src/technicimport.js
+++ b/src/technicimport.js
@@ -1,172 +1,183 @@
-'use strict';
-
-/*
- * technicimport.js – Instanzen aus dem Technic Launcher übernehmen
- * -------------------------------------------------------------------
- * Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
- * unter "/modpacks/" 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 };
+'use strict';
+
+/*
+ * technicimport.js – Instanzen aus dem Technic Launcher übernehmen
+ * -------------------------------------------------------------------
+ * Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
+ * unter "/modpacks/" 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 });
+ }
+}
+
+/*
+ * Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
+ * im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
+ * asynchron mit Fortschritt (main.js → fuehreImportAus).
+ */
+function kopierQuellen(entry) {
+ if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
+ return fs.readdirSync(entry.gameDir).filter((name) => !SKIP_DIRS.has(name))
+ .map((name) => ({ von: path.join(entry.gameDir, name), name }));
+}
+
+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, kopierQuellen, resolveModpacksDir, technicRoot, detectVersion };
+
+
+ 
AeroMC
+