Upload via GUI (93 Dateien)

This commit is contained in:
2026-10-02 19:39:47 +00:00
parent 0595b7ab1a
commit 41c63ef0ed
9 changed files with 2486 additions and 887 deletions
+218
View File
@@ -0,0 +1,218 @@
'use strict';
/*
* crashhelp.js – Absturz-Assistent
* ---------------------------------
* Liest Absturzbericht, latest.log und die letzte Konsolenausgabe eines Starts
* und erkennt die häufigsten Ursachen. Jeder Fund bekommt eine verständliche
* Erklärung und – wo möglich – eine Aktion, die der Launcher mit einem Klick
* ausführen kann:
*
* { typ: 'mod-installieren', projekt, name } fehlende Abhängigkeit von Modrinth holen
* { typ: 'mod-deaktivieren', dateiname, name } Mod abschalten (umbenennen, nichts gelöscht)
* { typ: 'ram', mb } Arbeitsspeicher der Instanz ändern
* { typ: 'java-auto' } passendes Java automatisch wählen lassen
* { typ: 'mods-ordner' } Mods-Ordner öffnen
*
* Bewusst nur Muster, die eindeutig sind – lieber keinen Fund als einen falschen.
*/
// Mod-IDs, die keine installierbaren Mods sind
const KEINE_MODS = new Set(['minecraft', 'java', 'fabricloader', 'fabric-loader', 'forge', 'neoforge', 'quilt_loader', 'quilt-loader', 'fml', 'mixinextras']);
// Mod-ID -> Modrinth-Projekt, wo sich beides unterscheidet
const PROJEKT_FUER_ID = {
fabric: 'fabric-api',
'fabric-api-base': 'fabric-api',
quilted_fabric_api: 'qsl',
cloth_config: 'cloth-config',
'cloth-config2': 'cloth-config',
architectury: 'architectury-api',
geckolib3: 'geckolib',
};
// Java-Hauptversion aus der "class file version" (52 = Java 8, 61 = 17, 65 = 21 …)
const javaAusKlassenversion = (v) => Number(v) - 44;
function projektFuer(modId) {
const id = String(modId || '').toLowerCase();
return PROJEKT_FUER_ID[id] || id.replace(/_/g, '-');
}
// installierte Mod zu einer ID/einem Namen finden (für "abschalten")
function findeMod(mods, modId, name) {
const id = String(modId || '').toLowerCase();
const kurz = id.replace(/[-_]/g, '');
const n = String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
return (mods || []).find((m) => {
if (m.enabled === false) return false;
const datei = String(m.filename || '').toLowerCase();
const titel = String(m.title || '').toLowerCase().replace(/[^a-z0-9]/g, '');
return (id && (datei.startsWith(id) || datei.replace(/[-_]/g, '').startsWith(kurz)))
|| (n && titel && (titel === n || datei.replace(/[^a-z0-9]/g, '').startsWith(n)));
}) || null;
}
/*
* text: zusammengeführter Text aus Bericht, Log und Konsole
* ctx: { loader, version, maxMemMb, gesamtMb, mods: [{ filename, title, projectId, enabled }] }
* -> [{ schluessel, titel, text, aktion? }]
*/
function analysiere(text, ctx = {}) {
const t = String(text || '');
const funde = [];
const schonDa = new Set();
const fund = (schluessel, titel, erklaerung, aktion) => {
if (schonDa.has(schluessel)) return;
schonDa.add(schluessel);
funde.push({ schluessel, titel, text: erklaerung, aktion: aktion || null });
};
const mods = ctx.mods || [];
// --- Fehlende Abhängigkeiten (Fabric/Quilt) ---
// "Mod 'Mod Menu' (modmenu) 7.2.2 requires any version of fabric-api, which is missing!"
// neuere Fassung: "... requires any version of mod 'Fabric API' (fabric-api), which is missing!"
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version ([^\n,]+?)|any version) of (?:mod '([^']+)' \(([\w.-]+)\)|([\w.-]+)), which is missing/gi)) {
const [, name, , version, fehltName, fehltIdNeu, fehltIdAlt] = m;
const fehlt = fehltIdNeu || fehltIdAlt;
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
const anzeige = fehltName || fehlt;
fund(`fehlt:${fehlt}`, `„${name}“ braucht ${anzeige}`,
`Der Mod „${name}“ funktioniert nur zusammen mit „${anzeige}“${version ? ` (Version ${version.trim()})` : ''} – und der fehlt in dieser Instanz.`,
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: anzeige });
}
// Lösungsvorschlag von Fabric: "- Install fabric-api, any version."
for (const m of t.matchAll(/^\s*-\s*Install ([\w.-]+), (?:any version|version [^\n.]+)/gim)) {
const fehlt = m[1];
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
fund(`fehlt:${fehlt}`, `Es fehlt „${fehlt}“`,
`Ein installierter Mod braucht „${fehlt}“, der in dieser Instanz nicht installiert ist.`,
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
}
// --- Fehlende Abhängigkeiten (Forge/NeoForge) ---
// "Mod ID: 'architectury', Requested by: 'rei', Expected range: '[9.1.12,)', Actual version: '[MISSING]'"
for (const m of t.matchAll(/Mod ID: '([\w.-]+)', Requested by: '([\w.-]+)', Expected range: '([^']*)', Actual version: '([^']*)'/gi)) {
const [, fehlt, von, bereich, ist] = m;
if (fehlt.toLowerCase() === 'minecraft') {
const mod = findeMod(mods, von);
fund(`mcversion:${von}`, `„${von}“ passt nicht zu dieser Minecraft-Version`,
`Der Mod „${von}“ ist für Minecraft ${bereich} gemacht, deine Instanz hat ${ctx.version || ist}.`,
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || von } : null);
continue;
}
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
if (/MISSING/i.test(ist)) {
fund(`fehlt:${fehlt}`, `„${von}“ braucht ${fehlt}`,
`Der Mod „${von}“ funktioniert nur zusammen mit „${fehlt}“ – und der fehlt in dieser Instanz.`,
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
} else {
fund(`alt:${fehlt}`, `„${fehlt}“ ist zu alt oder zu neu`,
`„${von}“ braucht „${fehlt}“ in der Version ${bereich}, installiert ist ${ist}. Aktualisiere „${fehlt}“ im Inhalts-Dialog („Nach Updates suchen“).`);
}
}
// --- Mod für eine andere Minecraft-Version (Fabric) ---
// "Mod 'X' (x) 1.0 requires version 1.20.4 of minecraft, but only the wrong version is present: 1.21!"
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version |any version of )?([^\n]*?) of (?:mod )?'?minecraft'?/gi)) {
const [, name, id, fuer] = m;
const mod = findeMod(mods, id, name);
fund(`mcversion:${id}`, `„${name}“ passt nicht zu dieser Minecraft-Version`,
`„${name}“ ist für Minecraft ${fuer.replace(/^version\s+/i, '').trim() || 'eine andere Version'} gemacht, deine Instanz hat ${ctx.version || 'eine andere'}. Abschalten oder eine passende Version des Mods installieren.`,
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
}
// Lösungsvorschlag: "- Remove mod 'X' (x)."
for (const m of t.matchAll(/^\s*-\s*Remove mod '([^']+)' \(([\w.-]+)\)/gim)) {
const [, name, id] = m;
const mod = findeMod(mods, id, name);
fund(`entfernen:${id}`, `„${name}“ verursacht den Fehler`,
`Der Mod-Loader schlägt vor, „${name}“ zu entfernen – er verträgt sich nicht mit den anderen Mods oder dieser Version.`,
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
}
// "Mod 'A' (a) is incompatible with any version of mod 'B' (b)"
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? is incompatible with [^\n]*?mod '([^']+)' \(([\w.-]+)\)/gi)) {
const [, a, idA, b] = m;
const mod = findeMod(mods, idA, a);
fund(`inkompatibel:${idA}`, `„${a}“ und „${b}“ vertragen sich nicht`,
`Diese beiden Mods können nicht zusammen installiert sein. Schalte einen der beiden ab.`,
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: a } : null);
}
// --- Doppelte Mods ---
if (/Duplicate mods found|Found duplicate mods|is provided by multiple mods|Mod ID ['"]?[\w.-]+['"]? is (?:provided|present) multiple times/i.test(t)) {
fund('doppelt', 'Ein Mod ist doppelt installiert',
'Mindestens ein Mod liegt zweimal im Mods-Ordner (z. B. zwei Versionen desselben Mods). Lösche die ältere Datei.',
{ typ: 'mods-ordner' });
}
// --- Falsche Java-Version ---
const klasse = t.match(/UnsupportedClassVersionError[\s\S]{0,300}?class file version (\d+)\.\d+/i);
const javaPflicht = t.match(/requires (?:at least )?Java (\d+)|Java (\d+) (?:or (?:newer|later|above)) is required|Unsupported Java detected \((\d+)/i);
if (klasse || javaPflicht) {
const noetig = klasse ? javaAusKlassenversion(klasse[1]) : Number(javaPflicht[1] || javaPflicht[2] || 0);
fund('java', noetig ? `Diese Instanz braucht Java ${noetig}` : 'Falsche Java-Version',
`Minecraft oder ein Mod wurde für ${noetig ? `Java ${noetig}` : 'eine andere Java-Version'} gebaut, gestartet wurde mit einer älteren. AeroMC kann das passende Java automatisch wählen und bei Bedarf herunterladen.`,
{ typ: 'java-auto' });
}
// --- Zu wenig Arbeitsspeicher ---
if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) {
const jetzt = Number(ctx.maxMemMb) || 2048;
const grenze = Math.max(2048, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.6));
const neu = Math.min(grenze, jetzt + 2048);
fund('ram', 'Minecraft hatte zu wenig Arbeitsspeicher',
`Der Instanz waren ${Math.round(jetzt / 102.4) / 10} GB zugewiesen – zu wenig für diese Mods oder Welt.`
+ (neu > jetzt ? ` Empfohlen: ${Math.round(neu / 102.4) / 10} GB.` : ' Mehr lässt dein PC kaum zu – weniger Mods oder eine kleinere Sichtweite helfen.'),
neu > jetzt ? { typ: 'ram', mb: neu } : null);
}
if (/Could not reserve enough space for (?:\d+KB )?object heap|Invalid maximum heap size|Initial heap size set to a larger value than the maximum/i.test(t)) {
const jetzt = Number(ctx.maxMemMb) || 4096;
const neu = Math.max(2048, Math.min(jetzt - 1024, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.5)));
fund('ram-zuviel', 'Zu viel Arbeitsspeicher eingestellt',
`Java konnte die eingestellten ${Math.round(jetzt / 102.4) / 10} GB nicht reservieren – entweder ist gerade nicht genug frei oder es läuft ein 32-Bit-Java.`,
neu < jetzt ? { typ: 'ram', mb: neu } : { typ: 'java-auto' });
}
// --- Mixin-Fehler: ein bestimmter Mod passt nicht ---
for (const m of t.matchAll(/Mixin (?:apply|transformation)[^\n]*? (?:for|from) mod ([\w.-]+) failed|from mod ([\w.-]+) failed injection check|mixins?\.json:[^\n]*? from mod ([\w.-]+)/gi)) {
const id = m[1] || m[2] || m[3];
if (!id || KEINE_MODS.has(id.toLowerCase())) continue;
const mod = findeMod(mods, id);
fund(`mixin:${id}`, `„${mod ? (mod.title || id) : id}“ passt nicht zu dieser Version`,
`Der Mod „${id}“ greift tief in Minecraft ein und ist mit dieser Minecraft-Version oder einem anderen Mod nicht kompatibel. Schalte ihn ab oder installiere eine passende Version.`,
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || id } : null);
}
// --- Grafiktreiber ---
if (/Pixel format not accelerated|WGL: The driver does not appear to support OpenGL|GLFW error 65542|OpenGL 3\.2 (?:is )?not supported|Couldn't set pixel format|No OpenGL context found/i.test(t)) {
fund('treiber', 'Problem mit dem Grafiktreiber',
'Minecraft konnte OpenGL nicht starten. Meist hilft es, den Grafiktreiber direkt vom Hersteller (NVIDIA, AMD oder Intel) zu aktualisieren. Bei Laptops: in den Windows-Grafikeinstellungen Java auf „Hohe Leistung“ stellen.');
}
// --- Bekannte Unverträglichkeiten anhand der installierten Mods ---
const aktive = mods.filter((m) => m.enabled !== false);
const hat = (re) => aktive.find((m) => re.test(String(m.filename || '') + ' ' + String(m.title || '')));
const optifine = hat(/optifine|optifabric/i);
const sodium = hat(/sodium|embeddium|rubidium/i);
if (optifine && sodium) {
fund('optifine-sodium', 'OptiFine und Sodium zusammen installiert',
'OptiFine und Sodium (bzw. Embeddium/Rubidium) machen dasselbe und vertragen sich nicht. Schalte OptiFine ab – für Shader gibt es Iris als Ergänzung zu Sodium.',
{ typ: 'mod-deaktivieren', dateiname: optifine.filename, name: optifine.title || 'OptiFine' });
}
// --- Forge nennt einen Verdächtigen, aber sonst nichts Eindeutiges ---
if (!funde.length) {
const verdacht = t.match(/^Suspected Mods?:\s*([^\n]+)$/im);
if (verdacht && !/^\s*NONE\s*$/i.test(verdacht[1])) {
const name = verdacht[1].split(/[,(]/)[0].trim();
const mod = findeMod(mods, '', name);
fund('verdacht', `Vermutlich verursacht von „${name}“`,
`Laut Absturzbericht steckt wahrscheinlich „${verdacht[1].trim()}“ dahinter. Schalte den Mod testweise ab und starte erneut.`,
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || name } : null);
}
}
return funde;
}
module.exports = { analysiere, projektFuer };
+129 -7
View File
@@ -4,8 +4,14 @@
* friends.js – Anbindung an das AeroMC-Freundesystem (WordPress-Plugin)
* -----------------------------------------------------------------------------
* Schlanke Fetch-Wrapper gegen die REST-Routen des "AeroMC Freunde"-Plugins
* (siehe wordpress-plugin/aeromc-friends/). Keine eigene Anmeldung - jeder
* Aufruf trägt einfach die Minecraft-UUID des aktiven Kontos.
* (siehe wordpress-plugin/aeromc-friends/). Jeder Aufruf trägt die
* Minecraft-UUID des Kontos und – sobald angemeldet – ein Sitzungs-Token.
*
* Anmeldung wie bei einem Minecraft-Server, ohne eigenes Passwort: Aufgabe
* vom Plugin holen, mit dem Minecraft-Zugang des Kontos bei Mojang "beitreten",
* Plugin prüft bei Mojang nach und gibt ein Token aus. Läuft im Hintergrund
* vor der ersten Anfrage eines Kontos; kennt das Plugin die Anmeldung noch
* nicht (ältere Fassung), geht es ohne Token weiter wie bisher.
*
* Genau wie bei discordpresence.js: jeder Fehler (Plugin nicht installiert,
* kein Internet, Seite down) wird abgefangen und nach oben als { ok:false,
@@ -14,19 +20,84 @@
*/
const BASIS_URL = 'https://viper-network.de/wp-json/aeromc/v1';
const MOJANG_JOIN = 'https://sessionserver.mojang.com/session/minecraft/join';
async function anfrage(pfad, { method = 'GET', body } = {}) {
try {
// liefert für eine Konto-UUID { accessToken, name } (vom Hauptprozess gesetzt)
let zugangsHelfer = null;
const sitzungen = new Map(); // uuid -> Token
const laufendeAnmeldung = new Map(); // uuid -> Promise (nicht doppelt anmelden)
const fehlversuch = new Map(); // uuid -> Zeitpunkt, um nicht ständig neu zu versuchen
const NEUVERSUCH_MS = 5 * 60 * 1000;
function setzeZugangsHelfer(fn) { zugangsHelfer = fn; }
async function roheAnfrage(pfad, { method = 'GET', body, token } = {}) {
const url = method === 'GET' && body
? `${BASIS_URL}${pfad}?${new URLSearchParams(body).toString()}`
: `${BASIS_URL}${pfad}`;
const headers = { 'Content-Type': 'application/json' };
if (token) headers['X-AeroMC-Token'] = token;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
headers,
body: method === 'GET' ? undefined : JSON.stringify(body || {}),
});
const daten = await res.json().catch(() => null);
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` };
return { res, daten };
}
// Mojang-Sitzungsprüfung durchlaufen und ein Token holen; null bei Fehlschlag
async function anmelden(uuid) {
if (!zugangsHelfer) return null;
const letzter = fehlversuch.get(uuid);
if (letzter && Date.now() - letzter < NEUVERSUCH_MS) return null;
if (laufendeAnmeldung.has(uuid)) return laufendeAnmeldung.get(uuid);
const ablauf = (async () => {
try {
const zugang = await zugangsHelfer(uuid);
if (!zugang || !zugang.accessToken || !zugang.name) throw new Error('kein Minecraft-Zugang');
const aufgabe = await roheAnfrage('/auth/challenge', { method: 'POST', body: { uuid, username: zugang.name } });
if (!aufgabe.res.ok || !aufgabe.daten || !aufgabe.daten.serverId) throw new Error('keine Aufgabe');
const join = await fetch(MOJANG_JOIN, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessToken: zugang.accessToken,
selectedProfile: String(uuid).replace(/-/g, ''),
serverId: aufgabe.daten.serverId,
}),
});
if (!join.ok) throw new Error('Mojang ' + join.status);
const bestaetigt = await roheAnfrage('/auth/verify', { method: 'POST', body: { uuid } });
if (!bestaetigt.res.ok || !bestaetigt.daten || !bestaetigt.daten.token) throw new Error('nicht bestätigt');
sitzungen.set(uuid, bestaetigt.daten.token);
fehlversuch.delete(uuid);
return bestaetigt.daten.token;
} catch {
fehlversuch.set(uuid, Date.now());
return null;
} finally {
laufendeAnmeldung.delete(uuid);
}
})();
laufendeAnmeldung.set(uuid, ablauf);
return ablauf;
}
async function anfrage(pfad, { method = 'GET', body } = {}) {
try {
const uuid = body && body.uuid;
let token = uuid ? sitzungen.get(uuid) : null;
if (uuid && !token) token = await anmelden(uuid);
let { res, daten } = await roheAnfrage(pfad, { method, body, token });
// Sitzung abgelaufen oder Anmeldung jetzt Pflicht -> einmal neu anmelden
if (res.status === 401 && daten && daten.code === 'aeromc_auth' && uuid) {
sitzungen.delete(uuid);
fehlversuch.delete(uuid);
token = await anmelden(uuid);
if (token) ({ res, daten } = await roheAnfrage(pfad, { method, body, token }));
}
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}`, code: daten && daten.code };
return { ok: true, data: daten };
} catch (err) {
return { ok: false, message: 'Freundesystem nicht erreichbar: ' + err.message };
@@ -79,7 +150,58 @@ function dismissInvite(uuid, id) {
return anfrage('/friends/invites/dismiss', { method: 'POST', body: { uuid, id } });
}
// Instanz per Code teilen: Beschreibung (Version, Loader, Mod-Liste) hinterlegen -> { code, days }
function createShare(uuid, manifest) {
return anfrage('/share', { method: 'POST', body: { uuid, manifest } });
}
// geteilte Instanz abrufen (ohne Anmeldung – wer den Code hat, darf sie sehen)
async function getShare(code) {
try {
const { res, daten } = await roheAnfrage('/share', { method: 'GET', body: { code } });
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` };
return { ok: true, data: daten };
} catch (err) {
return { ok: false, message: 'Server nicht erreichbar: ' + err.message };
}
}
// ---- Kurznachrichten ----
// image: optional base64-JPEG (geteilter Screenshot)
function sendMessage(uuid, targetUuid, text, image) {
const body = { uuid, targetUuid, text: text || '' };
if (image) body.image = image;
return anfrage('/friends/message', { method: 'POST', body });
}
// Unterhaltung mit einem Freund (älteste zuerst)
function getMessages(uuid, withUuid) {
return anfrage('/friends/messages', { method: 'GET', body: { uuid, with: withUuid } });
}
// ungelesene Nachrichten an mich
function getUnreadMessages(uuid) {
return anfrage('/friends/messages/unread', { method: 'GET', body: { uuid } });
}
function markMessagesRead(uuid, fromUuid) {
return anfrage('/friends/messages/read', { method: 'POST', body: { uuid, fromUuid } });
}
// Fehlerbericht aus dem Absturz-Assistenten; mit uuid wird angemeldet gesendet, sonst anonym
async function sendReport(daten) {
if (daten && daten.uuid) return anfrage('/report', { method: 'POST', body: daten });
try {
const { res, daten: antwort } = await roheAnfrage('/report', { method: 'POST', body: daten });
if (!res.ok) return { ok: false, message: (antwort && antwort.message) || `Fehler ${res.status}` };
return { ok: true, data: antwort };
} catch (err) {
return { ok: false, message: 'Server nicht erreichbar: ' + err.message };
}
}
module.exports = {
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
sendInvite, getInvites, dismissInvite,
sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer,
sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport,
};
+130 -1
View File
@@ -64,6 +64,7 @@
<div class="hr"></div>
<button id="p-shortcut" class="sbtn">Verknüpfung erstellen</button>
<button id="p-export" class="sbtn">Instanz exportieren</button>
<button id="p-share" class="sbtn" title="Code erzeugen, mit dem Freunde diese Instanz samt Mods nachbauen können">Instanz teilen</button>
<button id="p-delete" class="sbtn del" title="Entf">Löschen</button>
<button id="p-copy" class="sbtn">Instanz kopieren</button>
</div>
@@ -583,6 +584,14 @@
<small id="s-autobackup-last" class="hint">Noch keine automatische Sicherung.</small>
</section>
<section class="settings-card">
<h5>Welten</h5>
<label class="checkbox-field">
<input id="s-auto-world" type="checkbox" /> <span>Nach jeder Spielsitzung die zuletzt gespielte Welt sichern</span>
</label>
<small class="hint">Läuft im Hintergrund, sobald Minecraft beendet ist. Pro Welt bleiben die letzten 5 Stände erhalten – unter Dokumente\AeroMC Launcher\Welten-Sicherungen\&lt;Instanz&gt;\Automatisch.</small>
</section>
<section class="settings-card settings-card-wide">
<h5>Wiederherstellen</h5>
<small class="hint">Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.</small>
@@ -1011,6 +1020,32 @@
</div>
</div>
<!-- ===================== Dialog: Instanz teilen ===================== -->
<div id="modal-share" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3 id="share-title">Instanz teilen</h3>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<p id="share-status" class="hint">Code wird erstellt …</p>
<div id="share-result" class="hidden">
<p class="hint" style="margin-top:0">Gib diesen Code an deine Freunde weiter. Sie lösen ihn unter <b>Importieren → Per Code</b> ein.</p>
<div class="share-code-box">
<span id="share-code" class="share-code">XXXX-XXXX</span>
<button id="share-copy" class="btn btn-mini">Kopieren</button>
</div>
<p id="share-info" class="hint"></p>
<p id="share-warn" class="hint share-warn hidden"></p>
</div>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Spielzeit-Statistik ===================== -->
<div id="modal-stats" class="modal hidden">
<div class="modal-card">
@@ -1048,6 +1083,25 @@
<div id="friends-requests-box"></div>
<div id="friends-list-box"></div>
</div>
<!-- Unterhaltung mit einem Freund -->
<div id="friends-chat" class="friends-chat hidden">
<div class="chat-head">
<button id="chat-back" class="btn btn-mini" title="Zurück zur Freundesliste">← Zurück</button>
<img id="chat-avatar" class="friend-avatar" alt="" />
<b id="chat-name"></b>
</div>
<div id="chat-verlauf" class="chat-verlauf"></div>
<div class="chat-schnell">
<button class="btn btn-mini chat-vorlage">Bin in 10 Min. online</button>
<button class="btn btn-mini chat-vorlage">Komm online!</button>
<button class="btn btn-mini chat-vorlage">Bin gleich zurück</button>
<button class="btn btn-mini chat-vorlage">Gute Nacht 👋</button>
</div>
<div class="path-row">
<input id="chat-text" type="text" maxlength="200" placeholder="Nachricht …" autocomplete="off" />
<button id="chat-send" class="btn btn-primary">Senden</button>
</div>
</div>
</div>
<div class="modal-foot">
<div class="spacer"></div>
@@ -1201,8 +1255,9 @@
</div>
<div class="modal-body">
<p id="crash-sub" class="hint">–</p>
<div id="crash-hilfe" class="crash-hilfe"></div>
<div id="crash-kern" class="crash-kern"></div>
<details class="crash-details">
<details id="crash-details" class="crash-details">
<summary>Vollständigen Bericht anzeigen</summary>
<pre id="crash-voll" class="crash-voll"></pre>
</details>
@@ -1210,13 +1265,47 @@
<div class="modal-foot">
<button id="crash-folder" class="btn" data-ic="folder">Ordner öffnen</button>
<button id="crash-console" class="btn">Konsole</button>
<button id="crash-send" class="btn" title="Bericht und Mod-Liste an den Entwickler schicken">An M_Viper senden</button>
<div class="spacer"></div>
<button id="crash-retry" class="btn btn-primary hidden">Erneut starten</button>
<button class="btn" data-close>Schließen</button>
<button id="crash-copy" class="btn btn-primary">Bericht kopieren</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Fehlerbericht senden ===================== -->
<div id="modal-report" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3>Fehlerbericht an M_Viper senden</h3>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<p class="hint" style="margin-top:0">Damit hilfst du, Fehler in AeroMC zu finden und zu beheben. Gesendet wird:</p>
<ul class="report-liste">
<li>der Absturzbericht bzw. das Ende des Spielprotokolls</li>
<li>die Liste deiner Mods dieser Instanz und ihre Versionen</li>
<li>Minecraft-Version, Loader, Launcher-Version, Windows-Version und RAM</li>
<li>was der Absturz-Assistent erkannt hat</li>
</ul>
<p class="hint">Dein Windows-Benutzername und Anmeldedaten werden vorher entfernt. Ohne Häkchen unten wird anonym gesendet – dann sind auch dein Minecraft-Name und deine UUID unkenntlich gemacht.</p>
<label class="field">
<span>Was hast du gemacht, als es passiert ist? <small class="opt">(optional)</small></span>
<textarea id="report-kommentar" rows="3" maxlength="2000" placeholder="z. B. „Beim Betreten des Nethers abgestürzt“"></textarea>
</label>
<label class="checkbox-field">
<input id="report-name" type="checkbox" /> <span>Meinen Minecraft-Namen mitsenden (für Rückfragen)</span>
</label>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button>
<button id="report-do" class="btn btn-primary">Senden</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Lizenz ===================== -->
<div id="modal-license" class="modal hidden">
<div class="modal-card modal-lg">
@@ -1277,6 +1366,7 @@
<button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button>
<button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
<button id="import-tab-technic" class="mtab" data-kind="technic">Technic</button>
<button id="import-tab-code" class="mtab" data-kind="code">Per Code</button>
</div>
<div id="import-pane-zip" class="import-pane">
@@ -1394,6 +1484,19 @@
</div>
</div>
<div id="import-pane-code" class="import-pane hidden">
<p class="import-copy">Ein Freund hat dir eine Instanz geteilt? Gib hier seinen Code ein – AeroMC legt die Instanz an und lädt alle Mods, Ressourcenpakete und Shader in denselben Versionen selbst von Modrinth bzw. CurseForge.</p>
<label class="field">
<span>Code</span>
<div class="path-row">
<input id="code-input" class="share-code-input" type="text" placeholder="z. B. K7QD-M2XP" maxlength="12" autocomplete="off" spellcheck="false" />
<button id="code-check" class="btn btn-primary">Prüfen</button>
</div>
</label>
<div id="code-status" class="hint" style="margin-top:8px"></div>
<div id="code-preview" class="share-preview hidden"></div>
</div>
<div id="import-pane-technic" class="import-pane hidden">
<p class="import-copy">Modpack direkt von der Technic-Plattform installieren oder eine bereits vorhandene Technic-Installation importieren.</p>
@@ -1438,6 +1541,7 @@
<div id="mmc-progress" class="hint hidden"></div>
<div id="cf-progress" class="hint hidden"></div>
<div id="technic-progress" class="hint hidden"></div>
<div id="code-progress" class="hint hidden"></div>
<div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button>
<button id="import-zip-do" class="btn btn-primary">ZIP wählen…</button>
@@ -1446,6 +1550,7 @@
<button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="technic-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="code-do" class="btn btn-primary hidden" disabled>Instanz anlegen</button>
</div>
</div>
</div>
@@ -1494,6 +1599,29 @@
</div>
<!-- ===================== Kontextmenü (Rechtsklick auf Instanz) ===================== -->
<!-- Auswahl-Dialog: z. B. Ziel-Instanz, Freund oder Schnellstart-Taste (siehe waehleAus) -->
<div id="modal-wahl" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3 id="wahl-titel">Auswählen</h3>
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<p id="wahl-text" class="hint" style="margin-top:0"></p>
<div id="wahl-liste" class="wahl-liste"></div>
<label id="wahl-eingabe-feld" class="field hidden" style="margin:12px 0 0">
<span id="wahl-eingabe-label">Text</span>
<input id="wahl-eingabe" type="text" maxlength="200" autocomplete="off" />
</label>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button>
<button id="wahl-ok" class="btn btn-primary" disabled>OK</button>
</div>
</div>
</div>
<div id="ctx-menu" class="ctx hidden">
<button class="ctx-item" data-act="p-edit">Symbol ändern</button>
<button class="ctx-item" data-act="p-edit">Umbenennen</button>
@@ -1501,6 +1629,7 @@
<div class="ctx-sep"></div>
<button class="ctx-item ctx-strong" data-act="p-play">Starten</button>
<button class="ctx-item" data-act="p-offline">Offline starten</button>
<button class="ctx-item" data-act="ctx-schnellstart">Schnellstart-Taste …</button>
<div class="ctx-sep"></div>
<button class="ctx-item" data-act="p-edit">Instanz bearbeiten</button>
<button class="ctx-item" data-act="p-notes">Notizen bearbeiten</button>
+581 -20
View File
@@ -39,6 +39,7 @@ const servers = require('./servers');
const mcping = require('./mcping');
const discordpresence = require('./discordpresence');
const friends = require('./friends');
const crashhelp = require('./crashhelp');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici');
@@ -240,6 +241,10 @@ const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss a
// (sonst sieht z. B. M_Viper eine Meldung über sich selbst, weil zufällig sein
// Fenster zuerst gefunden wurde, obwohl Electric__Max der Freund war, der geprüft hat)
const kontoInstanz = new Map(); // kontoId -> instanceId des gerade laufenden Spiels (für "Einladen": eigener Server)
// Ende der Konsolenausgabe des letzten Starts je Instanz – für den Absturz-Assistenten,
// weil frühe Fehler (falsches Java, zu viel RAM) in keiner Datei landen
const letzteAusgabe = new Map();
const AUSGABE_MAX = 200000;
// Herzschläge aller laufenden Spiele – um eine geänderte Sichtbarkeit sofort zu melden
const laufendeHerzschlaege = new Set();
ipcMain.handle('recording:getPid', (_e, id) => laufendePid.get(id) || null);
@@ -560,6 +565,62 @@ ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
// Nur eine Instanz des Launchers. Ein zweiter Aufruf (Doppelklick auf eine
// Verknüpfung) reicht seine Argumente an das laufende Fenster weiter.
// Rechtsklick-Menü für Textfelder und markierten Text – Electron bringt von sich
// aus keins mit, ohne das ginge z. B. "Einfügen" eines Teilen-Codes nur per Strg+V.
// Gilt für Haupt- und alle Popup-Fenster.
app.on('web-contents-created', (_e, contents) => {
contents.on('context-menu', (_ev, params) => {
const auswahl = String(params.selectionText || '').trim();
if (!params.isEditable && !auswahl) return;
const f = params.editFlags || {};
const vorlage = params.isEditable
? [
{ label: 'Ausschneiden', role: 'cut', enabled: !!f.canCut },
{ label: 'Kopieren', role: 'copy', enabled: !!f.canCopy },
{ label: 'Einfügen', role: 'paste', enabled: !!f.canPaste },
{ type: 'separator' },
{ label: 'Alles auswählen', role: 'selectAll', enabled: !!f.canSelectAll },
]
: [{ label: 'Kopieren', role: 'copy', enabled: !!f.canCopy }];
const fenster = BrowserWindow.fromWebContents(contents);
Menu.buildFromTemplate(vorlage).popup(fenster ? { window: fenster } : {});
});
});
// Eigene App-Kennung für Windows (gleich wie appId in package.json und die
// Startmenü-Verknüpfung des Installers). Ohne das zeigt Windows bei
// Benachrichtigungen "electron.app.AeroMC" ohne Logo an.
const APP_KENNUNG = 'com.aeromc.launcher';
if (process.platform === 'win32') app.setAppUserModelId(APP_KENNUNG);
/*
* Name und Logo dieser App-Kennung bei Windows hinterlegen
* (HKCU\Software\Classes\AppUserModelId\<Kennung>). Damit stimmt die Anzeige
* auch ohne Startmenü-Verknüpfung, z. B. bei der Portable-Fassung. Das Logo
* liegt im Paket (app.asar), das Windows nicht lesen kann – deshalb eine Kopie
* im Datenordner.
*/
let benachrichtigungsLogo = null;
function registriereAppBeiWindows() {
if (process.platform !== 'win32') return;
try {
const ziel = path.join(app.getPath('userData'), 'aeromc-logo.png');
const quelle = path.join(__dirname, 'assets', 'logo.png');
if (fs.existsSync(quelle)) {
const bild = nativeImage.createFromPath(quelle);
if (!bild.isEmpty()) {
fs.writeFileSync(ziel, bild.resize({ width: 128, height: 128, quality: 'best' }).toPNG());
benachrichtigungsLogo = ziel;
}
}
const schluessel = `HKCU\\Software\\Classes\\AppUserModelId\\${APP_KENNUNG}`;
execFile('reg', ['add', schluessel, '/v', 'DisplayName', '/t', 'REG_SZ', '/d', 'AeroMC', '/f'], { windowsHide: true }, () => {});
if (benachrichtigungsLogo) {
execFile('reg', ['add', schluessel, '/v', 'IconUri', '/t', 'REG_SZ', '/d', benachrichtigungsLogo, '/f'], { windowsHide: true }, () => {});
}
} catch { /* rein kosmetisch */ }
}
const hatEinzelSperre = app.requestSingleInstanceLock();
if (!hatEinzelSperre) app.quit();
@@ -712,6 +773,15 @@ function createWindow(startHidden) {
ipcMain.once('renderer:ready', () => oeffneVorbereitetenImport());
}
// Freundesystem-Anmeldung: Minecraft-Zugang eines Kontos für Mojangs Sitzungsprüfung
friends.setzeZugangsHelfer(async (uuid) => {
const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
const konto = await auth.getValidAccountById(clientId, uuid).catch(() => null);
return konto && konto.profile && konto.mcAccessToken
? { accessToken: konto.mcAccessToken, name: konto.profile.name }
: null;
});
/*
* Das Setup kann optional andere Launcher übernehmen lassen (installer/Installer.cs):
* es legt dazu pending-import.json mit den angekreuzten Launchern ab. Beim ersten
@@ -755,15 +825,47 @@ function createTray() {
const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty();
tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 }));
tray.setToolTip('AeroMC');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Öffnen', click: () => showMainWindow() },
{ type: 'separator' },
{ label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } },
]));
// Menü bei jedem Rechtsklick neu bauen – so sind Favoriten und zuletzt
// gespielte Instanzen immer aktuell
tray.on('right-click', () => tray.popUpContextMenu(trayMenue()));
tray.on('click', () => showMainWindow());
} catch { /* z. B. wenn kein Icon vorhanden ist – Autostart läuft dann ohne Tray-Symbol weiter */ }
}
// Instanzen direkt aus dem Infobereich starten, ohne das Fenster zu öffnen:
// erst die Schnellstart-Favoriten (Strg+1 … 5), dann die zuletzt gespielten
function trayMenue() {
const alle = store.listInstances();
const nachId = new Map(alle.map((i) => [i.id, i]));
const schnell = store.getSettings().schnellstart || {};
const favoriten = Object.keys(schnell).sort()
.map((taste) => ({ taste, inst: nachId.get(schnell[taste]) }))
.filter((f) => f.inst);
const favIds = new Set(favoriten.map((f) => f.inst.id));
const zuletzt = alle.filter((i) => i.lastPlayed && !favIds.has(i.id))
.sort((a, b) => String(b.lastPlayed).localeCompare(String(a.lastPlayed)))
.slice(0, 5);
const starte = (inst) => () => starteInstanzImFenster(inst.id);
const eintrag = (inst, zusatz) => ({
label: `${inst.name} · ${inst.minecraft.version || '?'}${zusatz ? ' ' + zusatz : ''}`,
click: starte(inst),
});
const vorlage = [{ label: 'AeroMC öffnen', click: () => showMainWindow() }, { type: 'separator' }];
if (favoriten.length) {
vorlage.push({ label: 'Schnellstart', enabled: false });
favoriten.forEach((f) => vorlage.push(eintrag(f.inst, `Strg+${f.taste}`)));
}
if (zuletzt.length) {
if (favoriten.length) vorlage.push({ type: 'separator' });
vorlage.push({ label: 'Zuletzt gespielt', enabled: false });
zuletzt.forEach((i) => vorlage.push(eintrag(i)));
}
if (!favoriten.length && !zuletzt.length) vorlage.push({ label: 'Noch keine Instanz gespielt', enabled: false });
vorlage.push({ type: 'separator' }, { label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } });
return Menu.buildFromTemplate(vorlage);
}
function destroyTray() {
if (tray) { tray.destroy(); tray = null; }
}
@@ -782,12 +884,13 @@ function applyAutostart(enabled) {
: [];
app.setLoginItemSettings({ openAtLogin: !!enabled, path: verknuepfungsZiel(), args });
} catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ }
if (enabled) createTray();
else destroyTray();
// Symbol im Infobereich gibt es immer (Schnellstart per Rechtsklick)
createTray();
}
app.whenReady().then(() => {
if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst
registriereAppBeiWindows();
store.init(app.getPath('userData'));
services.setCacheDir(app.getPath('userData')); // Java-Suche zwischenspeichern
// selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen
@@ -846,6 +949,8 @@ app.whenReady().then(() => {
// Einladungen ("Komm auf meinen Server") öfter prüfen – sie gelten nur ein paar Minuten
setTimeout(() => { pruefeEinladungen().catch(() => {}); }, 8000);
setInterval(() => { pruefeEinladungen().catch(() => {}); }, 15000);
setTimeout(() => { pruefeNachrichten().catch(() => {}); }, 9000);
setInterval(() => { pruefeNachrichten().catch(() => {}); }, 15000);
// Zahl offener Freundschaftsanfragen für den "Freunde"-Knopf
setTimeout(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 3000);
setInterval(() => { aktualisiereAnfragenZahl().catch(() => {}); }, 60000);
@@ -1027,6 +1132,7 @@ async function installiereInhalt(id, project, art) {
} else {
res = await services.modrinthInstall({
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir, art: art || 'mod',
versionId: isDep ? '' : (proj.versionId || ''),
});
if (!res.ok) {
if (!isDep && !firstError) firstError = res.message;
@@ -1272,6 +1378,164 @@ ipcMain.handle('crash:latest', (_e, id, seit) => {
return crashreports.lies(store.gameDir(id), Number(seit) || 0);
});
// ---- Absturz-Assistent ----
// Wertet Absturzbericht, latest.log und Konsolenausgabe des letzten Starts aus
// (siehe crashhelp.js) und liefert verständliche Funde mit Lösungs-Aktion.
function absturzDaten(id, seit) {
const inst = store.getInstance(id);
if (!inst) return null;
const gameDir = store.gameDir(id);
const seitMs = Number(seit) || 0;
const bericht = crashreports.lies(gameDir, seitMs);
// latest.log nur, wenn es zu diesem Start gehört (sonst ist es vom letzten Mal)
let log = '';
try {
const logDatei = path.join(gameDir, 'logs', 'latest.log');
if (fs.existsSync(logDatei) && (!seitMs || fs.statSync(logDatei).mtimeMs >= seitMs)) {
const r = crashreports.liesLog(gameDir, 'latest.log', 4000);
if (r.ok) log = r.text;
}
} catch { /* egal */ }
const settings = store.getSettings();
const text = [bericht.ok ? bericht.text : '', log, letzteAusgabe.get(id) || ''].join('\n');
const funde = crashhelp.analysiere(text, {
loader: inst.minecraft.loader,
version: inst.minecraft.version,
maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb,
gesamtMb: gesamtSpeicherMb(),
mods: store.listMods(id, 'mod'),
});
return { inst, funde, bericht: bericht.ok ? bericht : null, log, ausgabe: letzteAusgabe.get(id) || '' };
}
ipcMain.handle('crash:analyze', (_e, id, seit) => {
const daten = absturzDaten(id, seit);
if (!daten) return { ok: false };
return { ok: true, funde: daten.funde, bericht: daten.bericht };
});
// ---- Fehlerbericht an M_Viper (nur auf ausdrücklichen Wunsch) ----
// Vor dem Senden werden persönliche Angaben entfernt: Windows-Benutzerordner in
// Pfaden, Zugangs-Tokens und – wenn anonym – Minecraft-Name und UUID.
function regexSicher(t) { return String(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function bereinigeBericht(text, konto) {
let t = String(text || '');
const heim = os.homedir();
for (const variante of [heim, heim.replace(/\\/g, '/'), heim.replace(/\\/g, '\\\\')]) {
t = t.replace(new RegExp(regexSicher(variante), 'gi'), '%USERPROFILE%');
}
const benutzer = os.userInfo().username;
if (benutzer && benutzer.length > 2) t = t.replace(new RegExp('(\\\\|/)' + regexSicher(benutzer) + '(\\\\|/)', 'gi'), '$1%USERNAME%$2');
// Zugangsdaten nie mitschicken
t = t.replace(/eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/g, '***');
t = t.replace(/((?:accessToken|auth_access_token|auth_session|session)["'\s:=]+(?:token:)?)[^\s"',]+/gi, '$1***');
if (konto) {
if (konto.name) t = t.replace(new RegExp(regexSicher(konto.name), 'g'), '<Spieler>');
if (konto.uuid) {
const ohne = String(konto.uuid).replace(/-/g, '');
const mit = ohne.replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5');
t = t.replace(new RegExp(regexSicher(ohne) + '|' + regexSicher(mit), 'gi'), '<UUID>');
}
}
return t;
}
ipcMain.handle('crash:report', async (_e, id, seit, wahl) => {
const daten = absturzDaten(id, seit);
if (!daten) return { ok: false, message: 'Instanz nicht gefunden.' };
const mitName = !!(wahl && wahl.mitName);
const aktiv = aktivesFreundeKonto();
// anonym: Name/UUID aus dem Text entfernen; mit Name: nur Pfade/Tokens bereinigen
const anonymKonto = mitName ? null : (aktiv ? { name: aktiv.username, uuid: aktiv.uuid } : null);
const ende = (text, zeilen) => String(text || '').split(/\r?\n/).slice(-zeilen).join('\n');
let bericht = '';
if (daten.bericht) bericht += '--- Absturzbericht ' + (daten.bericht.datei || '') + ' ---\n' + daten.bericht.text + '\n\n';
if (daten.log) bericht += '--- latest.log (Ende) ---\n' + ende(daten.log, daten.bericht ? 150 : 400) + '\n\n';
if (daten.ausgabe) bericht += '--- Konsolenausgabe (Ende) ---\n' + ende(daten.ausgabe, 300) + '\n';
bericht = bereinigeBericht(bericht, anonymKonto).slice(-190000);
const inst = daten.inst;
const nutzlast = {
launcherVersion: app.getVersion(),
mcVersion: inst.minecraft.version || '',
loader: inst.minecraft.loader || 'vanilla',
loaderVersion: inst.minecraft.loaderVersion || '',
system: `Windows ${os.release()} · ${Math.round(os.totalmem() / 1073741824)} GB RAM · Instanz-RAM ${inst.java.maxMemMb || store.getSettings().defaultMaxMemMb || '?'} MB`,
funde: daten.funde.map((f) => ({ titel: f.titel, text: f.text })),
mods: store.listMods(id, 'mod').map((m) => ({
datei: m.filename, name: m.title || '', version: m.versionNumber || '', aktiv: m.enabled !== false,
})),
bericht,
kommentar: bereinigeBericht(String((wahl && wahl.kommentar) || '').slice(0, 2000), anonymKonto),
};
if (mitName && aktiv) { nutzlast.uuid = aktiv.uuid; nutzlast.username = aktiv.username; }
const res = await friends.sendReport(nutzlast);
return res.ok ? { ok: true, id: res.data && res.data.id } : res;
});
// Lösungs-Aktion eines Fundes ausführen
ipcMain.handle('crash:fix', async (_e, id, aktion) => {
const inst = store.getInstance(id);
const a = aktion || {};
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
try {
switch (a.typ) {
case 'mod-installieren': {
// Mod-IDs und Modrinth-Namen weichen manchmal ab -> ein paar Schreibweisen probieren
const kandidaten = [...new Set([a.projekt, crashhelp.projektFuer(a.name),
String(a.name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-')].filter((k) => k && /^[\w-]{1,64}$/.test(k)))];
let letzter = null;
for (const projekt of kandidaten) {
const res = await installiereInhalt(id, { projectId: projekt, title: a.name || projekt }, 'mod').catch((err) => ({ ok: false, message: err.message }));
if (res.ok) return { ok: true, text: `„${a.name || projekt}“ installiert.` };
letzter = res.message;
}
return { ok: false, message: `„${a.name}“ wurde auf Modrinth nicht gefunden${letzter ? ` (${letzter})` : ''}. Bitte im Inhalts-Dialog von Hand suchen.` };
}
case 'mod-deaktivieren': {
const res = store.setModEnabled(id, String(a.dateiname || ''), false, 'mod');
return res.ok ? { ok: true, text: `„${a.name || a.dateiname}“ abgeschaltet.` } : { ok: false, message: 'Konnte den Mod nicht abschalten.' };
}
case 'ram': {
const mb = Math.round(Number(a.mb) || 0);
if (mb < 1024) return { ok: false, message: 'Ungültige Speichergröße.' };
const minMb = inst.java.minMemMb && inst.java.minMemMb > mb ? Math.min(mb, 1024) : inst.java.minMemMb;
store.updateInstance(id, { java: { maxMemMb: mb, minMemMb: minMb || null } });
broadcast('instances:changed');
return { ok: true, text: `Arbeitsspeicher auf ${Math.round(mb / 102.4) / 10} GB gestellt.` };
}
case 'java-auto': {
store.updateInstance(id, { java: { path: '' } });
store.saveSettings({ autoJava: true });
broadcast('instances:changed');
return { ok: true, text: 'Beim nächsten Start wählt AeroMC das passende Java und lädt es bei Bedarf herunter.' };
}
case 'mods-ordner': {
const dir = store.inhaltsDir(id, 'mod');
fs.mkdirSync(dir, { recursive: true });
shell.openPath(dir);
return { ok: true, text: 'Mods-Ordner geöffnet.' };
}
default:
return { ok: false, message: 'Unbekannte Aktion.' };
}
} catch (err) {
return { ok: false, message: err.message };
}
});
// nach einer Lösung gleich erneut starten (Start läuft im Hauptfenster)
ipcMain.handle('crash:relaunch', (_e, id) => {
if (!store.getInstance(id)) return false;
showMainWindow();
starteInstanzImFenster(id);
return true;
});
// ---- Serverliste (servers.dat) ----
ipcMain.handle('servers:list', (_e, id) => {
@@ -1454,7 +1718,9 @@ ipcMain.handle('auth:setActive', (_e, id) => {
// schon, wurde von hier nur nie ausgelöst.
broadcast('accounts:changed');
// Anfragen-Zahl am "Freunde"-Knopf gilt für das aktive Konto
ungeleseneNachrichten = [];
aktualisiereAnfragenZahl().catch(() => {});
pruefeNachrichten().catch(() => {});
return res;
});
@@ -1589,10 +1855,12 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
const zeilen = await Promise.all((Array.isArray(text) ? text : [text]).map(async (z) => {
if (typeof z === 'string') return { text: z, kopf: null };
return { text: z.text, kopf: await spielerKopf(z.uuid) };
return { text: z.text, kopf: await spielerKopf(z.uuid), umbruch: !!z.umbruch };
}));
const breite = 320;
const hoehe = 44 + zeilen.reduce((summe, z) => summe + (z.kopf ? 24 : 18), 0);
// Zeilen mit umbruch (z. B. Nachrichten) dürfen mehrzeilig werden – Höhe grob schätzen
const zeilenHoehe = (z) => (z.umbruch ? Math.max(z.kopf ? 24 : 18, Math.ceil(String(z.text).length / 38) * 17) : (z.kopf ? 24 : 18));
const hoehe = 44 + zeilen.reduce((summe, z) => summe + zeilenHoehe(z), 0);
const stapel = toastsProFenster.get(pid) || 0;
toastsProFenster.set(pid, stapel + 1);
const overlayFenster = new BrowserWindow({
@@ -1621,9 +1889,9 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
<div style="font-family:Segoe UI,sans-serif;background:rgba(20,20,25,0.88);color:#fff;border-radius:8px;
padding:10px 14px;box-shadow:0 4px 16px rgba(0,0,0,0.4);box-sizing:border-box;">
<div style="font-weight:600;font-size:13px;">${esc(titel)}</div>
${zeilen.map((z) => `<div style="display:flex;align-items:center;gap:7px;font-size:12px;margin-top:${z.kopf ? 4 : 2}px;">
${zeilen.map((z) => `<div style="display:flex;align-items:${z.umbruch ? 'flex-start' : 'center'};gap:7px;font-size:12px;margin-top:${z.kopf ? 4 : 2}px;">
${z.kopf ? `<img src="${z.kopf}" style="width:20px;height:20px;flex:0 0 auto;" />` : ''}
<span style="opacity:0.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(z.text)}</span>
<span style="opacity:0.85;${z.umbruch ? 'white-space:normal;word-break:break-word;line-height:1.35;' : 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'}">${esc(z.text)}</span>
</div>`).join('')}
</div>
</body></html>`)}`;
@@ -1815,18 +2083,228 @@ ipcMain.handle('friends:dismissInvite', async (_e, inviteId) => {
return friends.dismissInvite(konto.uuid, inviteId);
});
// ---------- Offene Freundschaftsanfragen (Zahl am "Freunde"-Knopf) ----------
let letzteAnfragenZahl = 0;
// ---------- Instanz per Code teilen ----------
// Geteilt wird nur die Beschreibung: Minecraft-Version, Loader und die Liste der
// Mods/Ressourcenpakete/Shader mit ihrer genauen Modrinth/CurseForge-Version.
// Wer den Code einlöst, lädt alles selbst von dort – keine Dateien über den
// eigenen Server, keine Urheberrechtsprobleme. Von Hand hinzugefügte Dateien
// (ohne Herkunft) lassen sich so nicht übertragen und werden genannt.
const TEILBARE_ARTEN = ['mod', 'resourcepack', 'shader'];
const TEILBARE_LOADER = new Set(['vanilla', 'fabric', 'quilt', 'forge', 'neoforge']);
function teilBeschreibung(id) {
const inst = store.getInstance(id);
if (!inst) return null;
const inhalte = [];
const nichtUebertragbar = [];
for (const art of TEILBARE_ARTEN) {
for (const m of store.listMods(id, art)) {
if (m.fehlt) continue;
if (!m.projectId) { nichtUebertragbar.push(m.filename); continue; }
inhalte.push({
art,
projectId: m.projectId,
versionId: m.versionId || '',
title: m.title || m.projectId,
aktiv: m.enabled !== false,
});
}
}
return {
format: 1,
name: inst.name,
icon: inst.icon || '',
minecraft: {
version: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla',
loaderVersion: inst.minecraft.loaderVersion || '',
},
inhalte,
nichtUebertragbar,
};
}
ipcMain.handle('share:create', async (_e, id) => {
const beschreibung = teilBeschreibung(id);
if (!beschreibung) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!beschreibung.minecraft.version) return { ok: false, message: 'Die Instanz hat noch keine Minecraft-Version.' };
if (!TEILBARE_LOADER.has(beschreibung.minecraft.loader)) {
return { ok: false, message: 'OptiFine-Instanzen lassen sich nicht teilen – OptiFine muss jeder selbst installieren.' };
}
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Bitte zuerst mit einem Microsoft-Konto anmelden.' };
const res = await friends.createShare(konto.uuid, beschreibung);
if (!res.ok) return res;
return {
ok: true,
code: res.data.code,
tage: res.data.days || 30,
anzahl: beschreibung.inhalte.length,
nichtUebertragbar: beschreibung.nichtUebertragbar,
};
});
ipcMain.handle('share:fetch', async (_e, code) => {
const res = await friends.getShare(code);
if (!res.ok) return res;
const m = res.data && res.data.manifest;
if (!m || !m.minecraft || !Array.isArray(m.inhalte)) return { ok: false, message: 'Die geteilte Instanz ist beschädigt.' };
return { ok: true, data: res.data };
});
ipcMain.handle('share:install', async (_e, code) => {
const abruf = await friends.getShare(code);
if (!abruf.ok) return abruf;
const m = abruf.data && abruf.data.manifest;
const version = m && m.minecraft && String(m.minecraft.version || '');
const loader = m && m.minecraft && String(m.minecraft.loader || 'vanilla');
if (!version || !/^[\w.\- ]{1,32}$/.test(version) || !TEILBARE_LOADER.has(loader) || !Array.isArray(m.inhalte)) {
return { ok: false, message: 'Die geteilte Instanz ist beschädigt.' };
}
// eindeutiger Name, damit nichts Vorhandenes ersetzt wird
const basis = String(m.name || 'Geteilte Instanz').replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 60) || 'Geteilte Instanz';
const vorhanden = new Set(store.listInstances().map((i) => String(i.name).toLowerCase()));
let name = basis;
for (let n = 2; vorhanden.has(name.toLowerCase()); n++) name = `${basis} (${n})`;
const von = abruf.data.fromUsername || 'einem Freund';
const inst = store.createInstance({
name,
icon: typeof m.icon === 'string' ? m.icon : undefined,
notes: `Geteilt von ${von} (Code ${abruf.data.code})`,
minecraft: { version, loader, loaderVersion: String(m.minecraft.loaderVersion || '') },
});
broadcast('instances:changed');
const inhalte = m.inhalte.filter((it) => it && TEILBARE_ARTEN.includes(it.art)
&& /^[\w-]{1,64}$/.test(String(it.projectId || '')));
const installiert = [];
const fehlgeschlagen = [];
for (let i = 0; i < inhalte.length; i++) {
const it = inhalte[i];
const titel = String(it.title || it.projectId).slice(0, 80);
broadcast('share:progress', { aktuell: i + 1, gesamt: inhalte.length, titel });
// schon als Abhängigkeit eines früheren Eintrags mitgekommen?
if (store.listMods(inst.id, it.art).some((x) => x.projectId === it.projectId)) { installiert.push(titel); continue; }
const res = await installiereInhalt(inst.id, {
projectId: it.projectId,
title: titel,
versionId: typeof it.versionId === 'string' ? it.versionId : '',
}, it.art).catch((err) => ({ ok: false, message: err.message }));
if (res.ok) {
installiert.push(titel);
// war beim Teilenden abgeschaltet -> hier auch
if (it.aktiv === false && res.entry) store.setModEnabled(inst.id, res.entry.filename, false, it.art);
} else {
fehlgeschlagen.push({ titel, grund: res.message || 'unbekannt' });
}
}
broadcast('instances:changed');
return {
ok: true,
instanz: { id: inst.id, name: inst.name },
installiert: installiert.length,
fehlgeschlagen,
nichtUebertragbar: Array.isArray(m.nichtUebertragbar) ? m.nichtUebertragbar.slice(0, 50) : [],
von,
};
});
// ---------- Kurznachrichten ----------
// Ungelesene Nachrichten werden zusammen mit den Einladungen abgefragt. Neue
// erscheinen im Launcher als Hinweis und – wer gerade spielt – als Meldung im Spiel.
const gezeigteNachrichten = new Set();
let ungeleseneNachrichten = []; // des aktiven Kontos (für Zähler und Liste)
async function pruefeNachrichten() {
if (store.getSettings().friendsEnabled === false) return;
const aktiv = aktivesFreundeKonto();
const konten = new Set([...kontoPid.keys()]);
if (aktiv) konten.add(aktiv.uuid);
const spitznamen = store.getSettings().friendNicknames || {};
for (const kontoUuid of konten) {
const res = await friends.getUnreadMessages(kontoUuid);
if (!res.ok) continue;
const liste = res.data || [];
const neu = liste.filter((n) => !gezeigteNachrichten.has(n.id));
neu.forEach((n) => gezeigteNachrichten.add(n.id));
if (aktiv && aktiv.uuid === kontoUuid) {
const vorher = ungeleseneNachrichten.length;
ungeleseneNachrichten = liste;
if (neu.length > 2) broadcast('friends:message', { sammel: neu.length });
else neu.forEach((n) => broadcast('friends:message', n));
if (neu.length || vorher !== liste.length) aktualisiereAnfragenZahl().catch(() => {});
}
const pid = kontoPid.get(kontoUuid);
if (pid) {
for (const n of neu.slice(-3)) {
const name = spitznamen[n.fromUuid] || n.fromUsername || 'Ein Freund';
const inhalt = n.imageUrl ? `📷 Screenshot${n.text ? ' – ' + n.text : ''}` : n.text;
await zeigeIngameOverlay('Nachricht', [{ uuid: n.fromUuid, text: `${name}: ${inhalt}`, umbruch: true }], pid, 9000);
}
}
}
}
ipcMain.handle('friends:messages', async (_e, otherUuid) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getMessages(konto.uuid, otherUuid);
if (!res.ok) return res;
// geöffnet = gelesen
if (ungeleseneNachrichten.some((n) => n.fromUuid === otherUuid)) {
await friends.markMessagesRead(konto.uuid, otherUuid).catch(() => {});
ungeleseneNachrichten = ungeleseneNachrichten.filter((n) => n.fromUuid !== otherUuid);
aktualisiereAnfragenZahl().catch(() => {});
}
return { ok: true, list: res.data || [], ich: konto.uuid };
});
// Screenshot verkleinern (höchstens 1600 px breit), als JPEG an einen Freund schicken
ipcMain.handle('friends:sendScreenshot', async (_e, otherUuid, instanceId, dateiname, text) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const datei = safeSub(instanceId, 'screenshots', String(dateiname || ''));
if (!datei || !fs.existsSync(datei)) return { ok: false, message: 'Screenshot nicht gefunden.' };
let bild = nativeImage.createFromPath(datei);
if (bild.isEmpty()) return { ok: false, message: 'Bild konnte nicht gelesen werden.' };
const { width } = bild.getSize();
if (width > 1600) bild = bild.resize({ width: 1600, quality: 'best' });
let jpeg = bild.toJPEG(85);
if (jpeg.length > 2000000) jpeg = bild.toJPEG(65);
const res = await friends.sendMessage(konto.uuid, otherUuid, String(text || '').trim().slice(0, 200), jpeg.toString('base64'));
return res;
});
ipcMain.handle('friends:sendMessage', async (_e, otherUuid, text) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const t = String(text || '').trim().slice(0, 200);
if (!t) return { ok: false, message: 'Leere Nachricht.' };
return friends.sendMessage(konto.uuid, otherUuid, t);
});
// ungelesene Nachrichten je Freund (für die Zahl am Sprechblasen-Knopf)
ipcMain.handle('friends:unreadByFriend', () => {
const zaehler = {};
for (const n of ungeleseneNachrichten) zaehler[n.fromUuid] = (zaehler[n.fromUuid] || 0) + 1;
return zaehler;
});
// ---------- Offene Anfragen + ungelesene Nachrichten (Zahl am "Freunde"-Knopf) ----------
let letzteAnfragenZahl = { anfragen: 0, nachrichten: 0 };
async function aktualisiereAnfragenZahl() {
let zahl = 0;
let anfragen = 0;
const acc = auth.currentAccount();
if (store.getSettings().friendsEnabled !== false && acc && acc.profile) {
const res = await friends.getRequests(acc.profile.id);
if (!res.ok) return; // nicht erreichbar -> letzten bekannten Stand behalten
zahl = (res.data || []).length;
anfragen = (res.data || []).length;
}
letzteAnfragenZahl = zahl;
broadcast('friends:requestCount', zahl);
const aktiv = aktivesFreundeKonto();
const nachrichten = aktiv && acc && acc.profile && acc.profile.id === aktiv.uuid ? ungeleseneNachrichten.length : 0;
letzteAnfragenZahl = { anfragen, nachrichten };
broadcast('friends:requestCount', letzteAnfragenZahl);
}
ipcMain.handle('friends:requestCount', () => letzteAnfragenZahl);
@@ -2085,6 +2563,22 @@ ipcMain.handle('instances:export', async (_e, id) => {
* Backup-System, landet in einem eigenen Unterordner der Dokumente.
*/
ipcMain.handle('world:backup', async (_e, id, worldName) => {
const res = await sichereWelt(id, worldName, false);
if (res.ok) {
const inst = store.getInstance(id);
notify('Welt gesichert', `${worldName} (${inst ? inst.name : ''}) wurde gesichert.`);
}
return res;
});
// automatische Sicherungen: so viele Stände je Welt bleiben erhalten
const AUTO_WELT_STAENDE = 5;
/*
* Eine Welt als ZIP sichern. automatisch = true: Unterordner "Automatisch",
* es bleiben nur die letzten AUTO_WELT_STAENDE Stände dieser Welt.
*/
async function sichereWelt(id, worldName, automatisch) {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const worldDir = path.join(store.gameDir(id), 'saves', String(worldName || ''));
@@ -2093,8 +2587,9 @@ ipcMain.handle('world:backup', async (_e, id, worldName) => {
}
try {
const zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen',
let zielOrdner = path.join(app.getPath('documents'), 'AeroMC Launcher', 'Welten-Sicherungen',
(inst.name || 'Instanz').replace(/[^\w.-]+/g, '_'));
if (automatisch) zielOrdner = path.join(zielOrdner, 'Automatisch');
fs.mkdirSync(zielOrdner, { recursive: true });
const zeitstempel = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
const safeWorld = String(worldName).replace(/[^\w.-]+/g, '_');
@@ -2103,11 +2598,69 @@ ipcMain.handle('world:backup', async (_e, id, worldName) => {
await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force",
{ VN_SRC: worldDir, VN_DST: zielDatei });
notify('Welt gesichert', `${worldName} (${inst.name}) wurde gesichert.`);
if (automatisch) {
// ältere automatische Stände dieser Welt wegräumen
const praefix = safeWorld + '_';
const staende = fs.readdirSync(zielOrdner)
.filter((n) => n.startsWith(praefix) && n.endsWith('.zip') && /^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}/.test(n.slice(praefix.length)))
.sort();
for (const alt of staende.slice(0, Math.max(0, staende.length - AUTO_WELT_STAENDE))) {
try { fs.unlinkSync(path.join(zielOrdner, alt)); } catch { /* egal */ }
}
}
return { ok: true, path: zielDatei };
} catch (err) {
return { ok: false, message: 'Sicherung fehlgeschlagen: ' + err.message };
}
}
/*
* Nach einer Spielsitzung: die Welt sichern, die während der Sitzung zuletzt
* gespeichert wurde (level.dat neuer als der Spielstart). Läuft im Hintergrund.
*/
async function sichereZuletztGespielteWelt(id, sitzungStart) {
const savesDir = path.join(store.gameDir(id), 'saves');
let beste = null;
try {
for (const e of fs.readdirSync(savesDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const level = path.join(savesDir, e.name, 'level.dat');
try {
const zeit = fs.statSync(level).mtimeMs;
if (zeit >= sitzungStart - 5000 && (!beste || zeit > beste.zeit)) beste = { name: e.name, zeit };
} catch { /* keine Welt */ }
}
} catch { return; }
if (!beste) return; // nur Mehrspieler gespielt
const res = await sichereWelt(id, beste.name, true);
if (res.ok) broadcast('ui:toast', { text: `Welt „${beste.name}“ automatisch gesichert (die letzten ${AUTO_WELT_STAENDE} Stände bleiben erhalten).` });
}
// Welt in eine andere Instanz kopieren
ipcMain.handle('world:copyTo', async (_e, id, worldName, zielId) => {
const quelle = store.getInstance(id);
const ziel = store.getInstance(zielId);
if (!quelle || !ziel) return { ok: false, message: 'Instanz nicht gefunden.' };
if (id === zielId) return { ok: false, message: 'Quelle und Ziel sind dieselbe Instanz.' };
const savesQuelle = path.join(store.gameDir(id), 'saves');
const worldDir = path.join(savesQuelle, String(worldName || ''));
if (path.dirname(worldDir) !== savesQuelle || !fs.existsSync(worldDir)) return { ok: false, message: 'Welt nicht gefunden.' };
if (laufendePid.has(id)) return { ok: false, message: 'Bitte erst Minecraft in dieser Instanz beenden – sonst wird die Welt mitten beim Speichern kopiert.' };
const savesZiel = path.join(store.gameDir(zielId), 'saves');
fs.mkdirSync(savesZiel, { recursive: true });
let name = String(worldName);
for (let n = 2; fs.existsSync(path.join(savesZiel, name)); n++) name = `${worldName} (${n})`;
try {
await fs.promises.cp(worldDir, path.join(savesZiel, name), {
recursive: true,
filter: (src) => path.basename(src) !== 'session.lock',
});
return { ok: true, name, ziel: ziel.name };
} catch (err) {
return { ok: false, message: 'Kopieren fehlgeschlagen: ' + err.message };
}
});
// ---- Modpack-Import (Modrinth .mrpack) ----
@@ -2740,7 +3293,7 @@ function notify(title, body) {
if (store.getSettings().desktopNotifications === false) return;
if (BrowserWindow.getAllWindows().some((w) => !w.isDestroyed() && w.isFocused())) return;
if (!Notification.isSupported()) return;
const n = new Notification({ title, body, silent: false });
const n = new Notification({ title, body, silent: false, icon: benachrichtigungsLogo || undefined });
n.on('click', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
@@ -2970,6 +3523,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
let freundeHerzschlag = null;
let herzschlagJetzt = null; // sofort melden, z. B. sobald ein Server erkannt wurde
letzteAusgabe.set(id, '');
try {
let launchStart = 0;
const res = await launcher.prepareAndLaunch(opts, (evt) => {
@@ -3016,12 +3570,19 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
aktualisiereFreundeTastenkuerzel();
if (launchStart) {
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
const sitzungStart = launchStart;
launchStart = 0; // Spielzeit nur einmal gutschreiben
if (store.getSettings().autoWorldBackup !== false) {
// kurz warten, bis Minecraft die Weltdateien sicher geschlossen hat
setTimeout(() => { sichereZuletztGespielteWelt(id, sitzungStart).catch(() => {}); }, 3000);
}
const cur = store.getInstance(id);
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
}
}
if (evt.phase === 'log') {
const bisher = (letzteAusgabe.get(id) || '') + evt.line;
letzteAusgabe.set(id, bisher.length > AUSGABE_MAX ? bisher.slice(-AUSGABE_MAX) : bisher);
const m = VERBINDUNGS_ZEILE.exec(evt.line);
if (m) {
const server = m[2] === '25565' ? m[1] : `${m[1]}:${m[2]}`;
+22
View File
@@ -45,6 +45,17 @@ contextBridge.exposeInMainWorld('api', {
systemMemory: () => ipcRenderer.invoke('system:memory'),
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
crashAnalyze: (id, seit) => ipcRenderer.invoke('crash:analyze', id, seit),
crashFix: (id, aktion) => ipcRenderer.invoke('crash:fix', id, aktion),
crashRelaunch: (id) => ipcRenderer.invoke('crash:relaunch', id),
shareCreate: (id) => ipcRenderer.invoke('share:create', id),
shareFetch: (code) => ipcRenderer.invoke('share:fetch', code),
shareInstall: (code) => ipcRenderer.invoke('share:install', code),
onShareProgress: (cb) => {
const l = (_e, p) => cb(p);
ipcRenderer.on('share:progress', l);
return () => ipcRenderer.removeListener('share:progress', l);
},
onAutoBackup: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('backup:auto', l);
@@ -85,6 +96,8 @@ contextBridge.exposeInMainWorld('api', {
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
worldCopyTo: (id, worldName, zielId) => ipcRenderer.invoke('world:copyTo', id, worldName, zielId),
friendsSendScreenshot: (otherUuid, instanceId, datei, text) => ipcRenderer.invoke('friends:sendScreenshot', otherUuid, instanceId, datei, text),
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
@@ -299,6 +312,15 @@ contextBridge.exposeInMainWorld('api', {
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
friendsMessages: (otherUuid) => ipcRenderer.invoke('friends:messages', otherUuid),
friendsSendMessage: (otherUuid, text) => ipcRenderer.invoke('friends:sendMessage', otherUuid, text),
friendsUnreadByFriend: () => ipcRenderer.invoke('friends:unreadByFriend'),
onFriendsMessage: (cb) => {
const l = (_e, n) => cb(n);
ipcRenderer.on('friends:message', l);
return () => ipcRenderer.removeListener('friends:message', l);
},
crashReport: (id, seit, wahl) => ipcRenderer.invoke('crash:report', id, seit, wahl),
clipSetActive: (active, instanceId) => ipcRenderer.invoke('clip:setActive', active, instanceId),
clipSave: (data) => ipcRenderer.invoke('clip:save', data),
onClipHotkey: (cb) => {
+487 -20
View File
@@ -568,8 +568,8 @@ async function ensureDefaultMcScan() {
}
function setImportKind(kind) {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].forEach((entryKind) => {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic', 'code'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic', 'code'].forEach((entryKind) => {
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
});
@@ -579,6 +579,8 @@ function setImportKind(kind) {
el('cf-do').classList.toggle('hidden', importKind !== 'cf');
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
el('technic-do').classList.toggle('hidden', importKind !== 'technic');
el('code-do').classList.toggle('hidden', importKind !== 'code');
el('code-progress').classList.toggle('hidden', importKind !== 'code' || !el('code-progress').textContent);
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
@@ -1225,6 +1227,94 @@ async function renderBedrockList() {
}
}
// ---------- Auswahl-Dialog ----------
// waehleAus({ titel, text, optionen: [{ wert, label, sub, bild, aus }], textfeld, okLabel })
// -> Promise<{ wert, text } | null>
let wahlAufloesen = null;
let wahlGewaehlt = null;
function waehleAus({ titel, text, optionen, textfeld, okLabel }) {
if (wahlAufloesen) wahlAufloesen(null);
return new Promise((resolve) => {
wahlAufloesen = resolve;
wahlGewaehlt = null;
el('wahl-titel').textContent = titel;
el('wahl-text').textContent = text || '';
el('wahl-text').classList.toggle('hidden', !text);
const liste = el('wahl-liste');
liste.innerHTML = '';
for (const o of optionen) {
const b = document.createElement('button');
b.className = 'wahl-option';
b.disabled = !!o.aus;
b.innerHTML = `${o.bild ? `<img class="wahl-bild" src="${escapeHtml(o.bild)}" alt="" />` : ''}`
+ `<span class="wahl-label"><b>${escapeHtml(o.label)}</b>${o.sub ? `<small>${escapeHtml(o.sub)}</small>` : ''}</span>`;
const bild = b.querySelector('img');
if (bild) bild.addEventListener('error', () => bild.remove());
b.addEventListener('click', () => {
wahlGewaehlt = o.wert;
liste.querySelectorAll('.wahl-option').forEach((x) => x.classList.toggle('an', x === b));
el('wahl-ok').disabled = false;
});
b.addEventListener('dblclick', () => { wahlGewaehlt = o.wert; schliesseWahl(true); });
liste.appendChild(b);
}
el('wahl-eingabe-feld').classList.toggle('hidden', !textfeld);
el('wahl-eingabe').value = '';
if (textfeld) {
el('wahl-eingabe-label').textContent = textfeld.label || 'Text';
el('wahl-eingabe').placeholder = textfeld.placeholder || '';
}
el('wahl-ok').textContent = okLabel || 'OK';
el('wahl-ok').disabled = true;
show('modal-wahl');
});
}
function schliesseWahl(bestaetigt) {
const aufloesen = wahlAufloesen;
wahlAufloesen = null;
hide('modal-wahl');
if (aufloesen) aufloesen(bestaetigt && wahlGewaehlt !== null ? { wert: wahlGewaehlt, text: el('wahl-eingabe').value.trim() } : null);
}
// ---------- Schnellstart-Tasten (Strg+1 … Strg+5) ----------
let schnellstartBelegung = {};
function schnellstartTasteVon(id) {
return Object.keys(schnellstartBelegung).find((t) => schnellstartBelegung[t] === id) || null;
}
async function setzeSchnellstart(id) {
const inst = instances.find((i) => i.id === id);
if (!inst) return;
const s = await window.api.getSettings();
const belegung = Object.assign({}, s.schnellstart || {});
const aktuell = Object.keys(belegung).find((t) => belegung[t] === id);
const optionen = ['1', '2', '3', '4', '5'].map((t) => {
const belegt = belegung[t] && belegung[t] !== id ? instances.find((i) => i.id === belegung[t]) : null;
return {
wert: t,
label: `Strg+${t}`,
sub: belegung[t] === id ? 'aktuell diese Instanz' : (belegt ? `ersetzt „${belegt.name}“` : 'frei'),
};
});
if (aktuell) optionen.push({ wert: '0', label: 'Keine Taste', sub: 'Schnellstart-Taste entfernen' });
const wahl = await waehleAus({
titel: `Schnellstart für „${inst.name}“`,
text: 'Mit der Taste startest du diese Instanz direkt aus dem Launcher-Fenster. Im Infobereich (Rechtsklick auf das Symbol neben der Uhr) steht sie ganz oben.',
optionen,
okLabel: 'Übernehmen',
});
if (!wahl) return;
for (const t of Object.keys(belegung)) if (belegung[t] === id) delete belegung[t];
if (wahl.wert !== '0') belegung[wahl.wert] = id;
await window.api.saveSettings({ schnellstart: belegung });
schnellstartBelegung = belegung;
render();
toast(wahl.wert === '0' ? 'Schnellstart-Taste entfernt.' : `„${inst.name}“ startet jetzt mit Strg+${wahl.wert}.`);
}
// ---------- Kontextmenü (Rechtsklick auf eine Instanz) ----------
function openContextMenu(x, y) {
const menu = el('ctx-menu');
@@ -1688,12 +1778,15 @@ function buildCard(inst) {
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
let tip = inst.name + '\n' + (inst.minecraft.version || 'ohne Version') + loaderTxt;
const taste = schnellstartTasteVon(inst.id);
if (taste) tip += `\nSchnellstart: Strg+${taste}`;
if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim();
card.title = tip;
card.innerHTML = `
${iconHtml}
<div class="card-name">${escapeHtml(inst.name)}</div>`;
<div class="card-name">${escapeHtml(inst.name)}</div>
${taste ? `<span class="card-taste">Strg+${taste}</span>` : ''}`;
// einfacher Klick = auswählen (MultiMC), Doppelklick = starten
card.addEventListener('click', (e) => {
@@ -2597,23 +2690,98 @@ async function ladeLog(name) {
let letzterStartMs = 0;
let aktuellerCrash = null;
let aktuellerCrashId = null;
let aktuellerCrashSeit = 0;
async function zeigeAbsturzbericht(instanzId) {
const CRASH_AKTION_LABEL = {
'mod-installieren': (a) => `„${a.name}“ installieren`,
'mod-deaktivieren': (a) => `„${a.name}“ abschalten`,
ram: (a) => `RAM auf ${Math.round(a.mb / 102.4) / 10} GB stellen`,
'java-auto': () => 'Passendes Java wählen',
'mods-ordner': () => 'Mods-Ordner öffnen',
};
// Funde des Absturz-Assistenten als Karten mit Lösungs-Knopf
function zeigeAbsturzHilfe(id, funde) {
const box = el('crash-hilfe');
box.innerHTML = '';
if (!funde.length) {
box.innerHTML = '<div class="crash-unklar">AeroMC konnte die Ursache nicht eindeutig erkennen. Die Angaben unten helfen beim Suchen – oder beim Nachfragen, z. B. im Forum des betroffenen Mods.</div>';
return;
}
const titel = document.createElement('div');
titel.className = 'crash-hilfe-titel';
titel.textContent = funde.length === 1 ? 'Das ist wahrscheinlich passiert' : `Das ist wahrscheinlich passiert (${funde.length} Punkte)`;
box.appendChild(titel);
for (const f of funde) {
const karte = document.createElement('div');
karte.className = 'crash-fund';
karte.innerHTML = `<div class="crash-fund-text"><b>${escapeHtml(f.titel)}</b><span>${escapeHtml(f.text)}</span></div>`;
const label = f.aktion && CRASH_AKTION_LABEL[f.aktion.typ];
if (label) {
const knopf = document.createElement('button');
knopf.className = 'btn btn-mini btn-primary';
knopf.textContent = label(f.aktion);
knopf.addEventListener('click', async () => {
knopf.disabled = true;
const vorher = knopf.textContent;
knopf.textContent = 'Einen Moment …';
const r = await window.api.crashFix(id, f.aktion).catch((err) => ({ ok: false, message: err.message }));
if (r.ok) {
knopf.textContent = '✓ Erledigt';
karte.classList.add('erledigt');
toast(r.text || 'Erledigt.');
if (f.aktion.typ !== 'mods-ordner') el('crash-retry').classList.remove('hidden');
else knopf.disabled = false;
} else {
knopf.textContent = vorher;
knopf.disabled = false;
toast(r.message || 'Das hat nicht geklappt.', true);
}
});
karte.appendChild(knopf);
}
box.appendChild(karte);
}
}
async function zeigeAbsturzbericht(instanzId, seit) {
const id = instanzId || currentDetailId;
if (!id) return;
if (POPOUT !== 'crash') { window.api.popoutOpen('crash', { instanceId: id }); return; }
const startMs = seit !== undefined ? seit : letzterStartMs;
if (POPOUT !== 'crash') { window.api.popoutOpen('crash', { instanceId: id, seit: startMs }); return; }
// Minecraft schreibt den Bericht erst kurz nach dem Ende
await new Promise((r) => setTimeout(r, 900));
let res;
try { res = await window.api.crashLatest(id, letzterStartMs); }
// Absturz-Assistent: Bericht + Spielprotokoll + Konsolenausgabe auswerten
let analyse;
try { analyse = await window.api.crashAnalyze(id, startMs); }
catch { return; }
if (!res || !res.ok) return; // kein Bericht -> Konsole muss reichen
if (!analyse || !analyse.ok) return;
const res = analyse.bericht;
// weder Bericht noch erkannte Ursache -> die Konsole muss reichen
if (!res && !analyse.funde.length) return;
aktuellerCrash = res;
aktuellerCrash = res || { text: '' };
aktuellerCrashId = id;
aktuellerCrashSeit = startMs;
el('crash-send').disabled = false;
el('crash-send').textContent = 'An M_Viper senden';
const inst = await window.api.getInstance(id);
el('crash-sub').textContent = `${inst ? inst.name + ' · ' : ''}${res.datei} · `
+ new Date(res.zeitpunkt).toLocaleString('de-DE');
el('crash-sub').textContent = res
? `${inst ? inst.name + ' · ' : ''}${res.datei} · ` + new Date(res.zeitpunkt).toLocaleString('de-DE')
: `${inst ? inst.name + ' · ' : ''}ausgewertet aus dem Spielprotokoll`;
zeigeAbsturzHilfe(id, analyse.funde);
el('crash-retry').classList.add('hidden');
el('crash-details').classList.toggle('hidden', !res);
el('crash-kern').classList.toggle('hidden', !res);
el('crash-copy').classList.toggle('hidden', !res);
if (!res) {
el('crash-kern').innerHTML = '';
el('crash-voll').textContent = '';
show('modal-crash');
return;
}
const zeile = (titel, wert) => wert
? `<div class="crash-zeile"><div class="crash-titel">${titel}</div><div class="crash-wert">${escapeHtml(wert)}</div></div>`
@@ -3562,6 +3730,7 @@ async function openScreens() {
thumb.innerHTML =
`<input type="checkbox" class="screen-check" title="Auswählen" />
<img alt="" /><div class="screen-name">${escapeHtml(s.name)}</div>
<button class="screen-del screen-teilen" title="An einen Freund schicken">📤</button>
<button class="screen-del" title="Löschen">🗑</button>`;
const img = thumb.querySelector('img');
window.api.screenshotData(currentDetailId, s.name).then((d) => { if (d) img.src = d; });
@@ -3583,7 +3752,11 @@ async function openScreens() {
e.stopPropagation();
window.api.openScreenshot(currentDetailId, s.name);
});
thumb.querySelector('.screen-del').addEventListener('click', (e) => {
thumb.querySelector('.screen-teilen').addEventListener('click', (e) => {
e.stopPropagation();
schickeScreenshot(s.name);
});
thumb.querySelector('.screen-del:not(.screen-teilen)').addEventListener('click', (e) => {
e.stopPropagation();
askConfirm('Screenshot löschen', `„${s.name}" löschen?`, '🗑 Löschen', async () => {
await window.api.deleteContent(currentDetailId, 'screenshots', s.name);
@@ -3595,6 +3768,35 @@ async function openScreens() {
updateScreensSelectionUi();
}
// Screenshot an einen Freund schicken – er sieht ihn im Chat
async function schickeScreenshot(dateiname) {
const res = await window.api.friendsList().catch((err) => ({ ok: false, message: err.message }));
if (!res.ok) { toast(res.message || 'Freundesliste nicht erreichbar.', true); return; }
if (!res.list.length) { toast('Du hast noch keine Freunde im Freundesystem.', true); return; }
const s = await window.api.getSettings();
const spitznamen = s.friendNicknames || {};
const optionen = [...res.list]
.sort((a, b) => (a.online === b.online ? String(a.username).localeCompare(String(b.username)) : (a.online ? -1 : 1)))
.map((f) => ({
wert: f.uuid,
label: spitznamen[f.uuid] || f.username || f.uuid,
sub: f.online ? 'online' : 'offline – sieht es beim nächsten Mal',
bild: `https://crafthead.net/cube/${f.uuid}/64`,
}));
const wahl = await waehleAus({
titel: 'Screenshot schicken',
text: `„${dateiname}“ an wen?`,
optionen,
textfeld: { label: 'Nachricht dazu (optional)', placeholder: 'z. B. „Schau mal, mein neues Haus!“' },
okLabel: 'Schicken',
});
if (!wahl) return;
toast('Screenshot wird geschickt …');
const r = await window.api.friendsSendScreenshot(wahl.wert, currentDetailId, dateiname, wahl.text).catch((err) => ({ ok: false, message: err.message }));
if (r.ok) toast('Screenshot verschickt.');
else toast(r.message || 'Verschicken fehlgeschlagen.', true);
}
async function deleteSelectedScreenshots() {
const names = [...screenSelected];
if (!names.length || !currentDetailId) return;
@@ -3725,6 +3927,80 @@ async function deleteSelectedRecords() {
// ---------- Freunde ----------
let friendsAutoRefresh = null;
// ---------- Instanz per Code teilen ----------
async function teileInstanz() {
if (!currentDetailId) return;
const inst = await window.api.getInstance(currentDetailId);
el('share-title').textContent = inst ? `„${inst.name}“ teilen` : 'Instanz teilen';
el('share-status').textContent = 'Code wird erstellt …';
el('share-status').classList.remove('hidden');
el('share-result').classList.add('hidden');
show('modal-share');
const res = await window.api.shareCreate(currentDetailId).catch((err) => ({ ok: false, message: err.message }));
if (!res.ok) { el('share-status').textContent = '✖ ' + (res.message || 'Teilen fehlgeschlagen.'); return; }
el('share-status').classList.add('hidden');
el('share-result').classList.remove('hidden');
el('share-code').textContent = res.code;
el('share-info').textContent = res.anzahl
? `${res.anzahl} Mod${res.anzahl === 1 ? '' : 's'}, Ressourcenpakete und Shader werden in ihren genauen Versionen übertragen, dazu Minecraft-Version und Loader. Der Code ist ${res.tage} Tage gültig.`
: `Übertragen werden Minecraft-Version und Loader. Der Code ist ${res.tage} Tage gültig.`;
const nicht = res.nichtUebertragbar || [];
el('share-warn').classList.toggle('hidden', !nicht.length);
if (nicht.length) {
el('share-warn').textContent = `Nicht dabei, weil von Hand hinzugefügt: ${nicht.slice(0, 5).join(', ')}${nicht.length > 5 ? ` und ${nicht.length - 5} weitere` : ''}.`;
}
}
let codeVorschau = null;
async function pruefeTeilCode() {
const code = el('code-input').value.trim();
codeVorschau = null;
el('code-do').disabled = true;
el('code-preview').classList.add('hidden');
if (!code) { el('code-status').textContent = 'Bitte einen Code eingeben.'; return; }
el('code-status').textContent = 'Suche …';
const res = await window.api.shareFetch(code).catch((err) => ({ ok: false, message: err.message }));
if (!res.ok) { el('code-status').textContent = '✖ ' + (res.message || 'Code nicht gefunden.'); return; }
codeVorschau = res.data;
const m = res.data.manifest;
const zaehle = (art) => m.inhalte.filter((it) => it && it.art === art).length;
const teile = [[zaehle('mod'), 'Mod', 'Mods'], [zaehle('resourcepack'), 'Ressourcenpaket', 'Ressourcenpakete'], [zaehle('shader'), 'Shader', 'Shader']]
.filter(([n]) => n).map(([n, e, mz]) => `${n} ${n === 1 ? e : mz}`);
const loader = m.minecraft.loader && m.minecraft.loader !== 'vanilla' ? (LOADER_LABEL[m.minecraft.loader] || m.minecraft.loader) : 'Vanilla';
const cf = m.inhalte.some((it) => String(it.projectId || '').startsWith('cf-'));
const nicht = Array.isArray(m.nichtUebertragbar) ? m.nichtUebertragbar.length : 0;
el('code-preview').innerHTML = `<b>${escapeHtml(m.name || 'Geteilte Instanz')}</b>
<p class="hint">Minecraft ${escapeHtml(m.minecraft.version)} · ${escapeHtml(loader)}${res.data.fromUsername ? ' · geteilt von ' + escapeHtml(res.data.fromUsername) : ''}</p>
<p class="hint">${teile.length ? escapeHtml(teile.join(', ')) : 'Keine Mods – nur Version und Loader'}</p>
${cf ? '<p class="hint">Enthält Inhalte von CurseForge – dafür braucht es einen CurseForge-Zugang (Optionen → Netzwerk).</p>' : ''}
${nicht ? `<p class="hint">${nicht} Datei${nicht === 1 ? '' : 'en'} hatte der Teilende von Hand hinzugefügt – die sind nicht dabei.</p>` : ''}`;
el('code-preview').classList.remove('hidden');
el('code-status').textContent = '';
el('code-do').disabled = false;
}
async function installiereTeilCode() {
if (!codeVorschau) return;
el('code-do').disabled = true;
el('code-check').disabled = true;
el('code-progress').textContent = 'Instanz wird angelegt …';
el('code-progress').classList.remove('hidden');
const res = await window.api.shareInstall(codeVorschau.code).catch((err) => ({ ok: false, message: err.message }));
el('code-check').disabled = false;
el('code-progress').textContent = '';
el('code-progress').classList.add('hidden');
if (!res.ok) { toast(res.message || 'Anlegen fehlgeschlagen.', true); el('code-do').disabled = false; return; }
await reload();
hide('modal-import');
const fehl = res.fehlgeschlagen || [];
toast(`„${res.instanz.name}“ angelegt – ${res.installiert} Inhalt${res.installiert === 1 ? '' : 'e'} installiert`
+ (fehl.length ? ` · nicht verfügbar: ${fehl.slice(0, 3).map((f) => f.titel).join(', ')}${fehl.length > 3 ? ' …' : ''}` : '') + '.', fehl.length > 0);
codeVorschau = null;
el('code-input').value = '';
el('code-preview').classList.add('hidden');
}
// ---------- Spielzeit-Statistik ----------
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
@@ -3780,10 +4056,14 @@ async function ladeStatistik() {
async function openFriends() {
if (POPOUT !== 'friends') { window.api.popoutOpen('friends'); return; }
// beim (Wieder-)Öffnen immer mit der Liste beginnen
chatPartner = null;
el('friends-chat').classList.add('hidden');
el('friends-content').classList.remove('hidden');
show('modal-friends');
await ladeFreunde();
clearInterval(friendsAutoRefresh);
friendsAutoRefresh = setInterval(ladeFreunde, 30000);
friendsAutoRefresh = setInterval(() => (chatPartner ? ladeChat() : ladeFreunde()), chatPartner ? 10000 : 30000);
}
async function ladeFreunde() {
@@ -3792,13 +4072,14 @@ async function ladeFreunde() {
el('friends-content').classList.toggle('hidden', !aktiv);
if (!aktiv) return;
const [anfragenRes, gesendetRes, freundeRes, einstellungen, einladungenRes, meinServer] = await Promise.all([
const [anfragenRes, gesendetRes, freundeRes, einstellungen, einladungenRes, meinServer, ungelesen] = await Promise.all([
window.api.friendsRequests(),
window.api.friendsSentRequests(),
window.api.friendsList(),
window.api.getSettings(),
window.api.friendsInvites(),
window.api.friendsMyServer(),
window.api.friendsUnreadByFriend().catch(() => ({})),
]);
const spitznamen = einstellungen.friendNicknames || {};
@@ -3899,10 +4180,12 @@ async function ladeFreunde() {
<div class="friend-info"><div class="friend-name">${escapeHtml(anzeigeName)}</div><div class="friend-status">${escapeHtml(status)}</div></div>
${kannBeitreten ? '<button class="btn btn-mini btn-primary" data-act="join" title="Passende Instanz starten und diesem Server beitreten">Beitreten</button>' : ''}
${kannEinladen ? `<button class="btn btn-mini" data-act="invite" title="Auf ${escapeHtml(meinServer.server)} einladen">Einladen</button>` : ''}
<button class="btn btn-mini chat-btn" data-act="chat" title="Nachricht schreiben">💬${ungelesen[f.uuid] ? `<span class="chat-badge">${ungelesen[f.uuid]}</span>` : ''}</button>
<button class="btn btn-mini" data-act="nick" title="Spitzname">✎</button>
<button class="btn btn-mini btn-danger" data-act="remove" title="Entfernen">🗑</button>`;
row.querySelector('.friend-avatar').addEventListener('error', function () { this.remove(); });
if (kannBeitreten) row.querySelector('[data-act="join"]').addEventListener('click', () => freundBeitreten(f.server, f.version));
row.querySelector('[data-act="chat"]').addEventListener('click', () => oeffneChat(f.uuid, anzeigeName));
if (kannEinladen) {
row.querySelector('[data-act="invite"]').addEventListener('click', async (ev) => {
const btn = ev.currentTarget;
@@ -3968,6 +4251,71 @@ async function freundBeitreten(server, version, einladungId) {
toast(res.message || 'Beitreten fehlgeschlagen.', true);
}
// ---------- Kurznachrichten ----------
let chatPartner = null; // { uuid, name } – solange die Unterhaltung offen ist
function oeffneChat(uuid, name) {
chatPartner = { uuid, name };
el('friends-content').classList.add('hidden');
el('friends-chat').classList.remove('hidden');
el('chat-name').textContent = name;
el('chat-avatar').src = `https://crafthead.net/cube/${encodeURIComponent(uuid)}/64`;
el('chat-verlauf').innerHTML = '<div class="chat-leer">Lade …</div>';
el('chat-text').value = '';
ladeChat();
el('chat-text').focus();
clearInterval(friendsAutoRefresh);
friendsAutoRefresh = setInterval(ladeChat, 10000);
}
function schliesseChat() {
chatPartner = null;
el('friends-chat').classList.add('hidden');
el('friends-content').classList.remove('hidden');
ladeFreunde();
clearInterval(friendsAutoRefresh);
friendsAutoRefresh = setInterval(ladeFreunde, 30000);
}
async function ladeChat() {
if (!chatPartner) return;
const partner = chatPartner;
const res = await window.api.friendsMessages(partner.uuid).catch((err) => ({ ok: false, message: err.message }));
if (chatPartner !== partner) return; // inzwischen gewechselt
const box = el('chat-verlauf');
if (!res.ok) { box.innerHTML = `<div class="chat-leer">${escapeHtml(res.message || 'Nicht erreichbar.')}</div>`; return; }
const unten = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
if (!res.list.length) {
box.innerHTML = `<div class="chat-leer">Noch keine Nachrichten. Schreib ${escapeHtml(partner.name)} etwas – er sieht es im Launcher oder als Meldung im Spiel.</div>`;
return;
}
box.innerHTML = res.list.map((n) => {
const zeit = new Date(String(n.createdAt).replace(' ', 'T') + 'Z');
const zeitText = isNaN(zeit) ? '' : zeit.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
const bild = n.imageUrl && /^https:\/\//.test(n.imageUrl)
? `<img class="chat-bild" src="${escapeHtml(n.imageUrl)}" alt="Screenshot" data-url="${escapeHtml(n.imageUrl)}" title="Groß öffnen" />` : '';
return `<div class="chat-blase${n.fromUuid === res.ich ? ' ich' : ''}">${bild}${escapeHtml(n.text)}<span class="chat-zeit">${zeitText}</span></div>`;
}).join('');
box.querySelectorAll('.chat-bild').forEach((img) => {
img.addEventListener('click', () => window.api.openExternal(img.dataset.url));
img.addEventListener('load', () => { if (unten) box.scrollTop = box.scrollHeight; });
});
if (unten || box.dataset.erstesMal !== partner.uuid) box.scrollTop = box.scrollHeight;
box.dataset.erstesMal = partner.uuid;
}
async function sendeChat(text) {
const t = String(text || '').trim();
if (!chatPartner || !t) return;
el('chat-send').disabled = true;
const res = await window.api.friendsSendMessage(chatPartner.uuid, t).catch((err) => ({ ok: false, message: err.message }));
el('chat-send').disabled = false;
if (!res.ok) { toast(res.message || 'Nachricht konnte nicht gesendet werden.', true); return; }
el('chat-text').value = '';
await ladeChat();
el('chat-verlauf').scrollTop = el('chat-verlauf').scrollHeight;
}
async function sendeFreundschaftsanfrage() {
const input = el('friends-add-input');
const name = input.value.trim();
@@ -3982,6 +4330,36 @@ async function sendeFreundschaftsanfrage() {
}
}
// Welt in eine andere Instanz kopieren – Ziel aus allen anderen Instanzen wählen
async function kopiereWelt(weltName) {
const quelle = await window.api.getInstance(currentDetailId);
const alle = (await window.api.listInstances()).filter((i) => i.id !== currentDetailId);
if (!alle.length) { toast('Es gibt keine andere Instanz, in die kopiert werden könnte.', true); return; }
const vergleiche = (a, b) => String(a).localeCompare(String(b), undefined, { numeric: true });
const optionen = alle
.sort((a, b) => String(b.lastPlayed || '').localeCompare(String(a.lastPlayed || '')))
.map((i) => {
const v = i.minecraft.version || '?';
const aelter = quelle && quelle.minecraft.version && i.minecraft.version && vergleiche(i.minecraft.version, quelle.minecraft.version) < 0;
return {
wert: i.id,
label: i.name,
sub: `Minecraft ${v}${aelter ? ' – älter als die Welt, sie könnte dort Probleme machen' : ''}`,
};
});
const wahl = await waehleAus({
titel: `„${weltName}“ kopieren`,
text: 'In welche Instanz soll die Welt kopiert werden? Das Original bleibt unverändert.',
optionen,
okLabel: 'Kopieren',
});
if (!wahl) return;
toast('Welt wird kopiert …');
const res = await window.api.worldCopyTo(currentDetailId, weltName, wahl.wert).catch((err) => ({ ok: false, message: err.message }));
if (res.ok) toast(`„${weltName}“ nach „${res.ziel}“ kopiert${res.name !== weltName ? ` (als „${res.name}“)` : ''}.`);
else toast(res.message || 'Kopieren fehlgeschlagen.', true);
}
function renderContentList(boxId, items, sub, emoji) {
const box = el(boxId);
box.innerHTML = '';
@@ -4010,6 +4388,13 @@ function renderContentList(boxId, items, sub, emoji) {
else toast(res.message || 'Sicherung fehlgeschlagen.', true);
});
aktionen.appendChild(sichern);
const kopieren = document.createElement('button');
kopieren.className = 'btn btn-mini';
kopieren.textContent = 'Kopieren nach …';
kopieren.title = 'Diese Welt in eine andere Instanz übernehmen';
kopieren.addEventListener('click', () => kopiereWelt(it.name));
aktionen.appendChild(kopieren);
}
const rm = document.createElement('button');
rm.className = 'btn btn-danger btn-mini';
@@ -4437,6 +4822,7 @@ async function openSettings(pane) {
el('s-autobackup-every').value = s.autoBackupEvery || 'daily';
el('s-autobackup-keep').value = s.autoBackupKeep || 5;
el('s-autobackup-launch').checked = !!s.autoBackupBeforeLaunch;
el('s-auto-world').checked = s.autoWorldBackup !== false;
el('s-autobackup-last').textContent = s.autoBackupLast
? 'Zuletzt automatisch gesichert: ' + new Date(s.autoBackupLast).toLocaleString('de-DE')
: 'Noch keine automatische Sicherung.';
@@ -4568,6 +4954,7 @@ async function saveSettings() {
autoBackupEvery: el('s-autobackup-every').value,
autoBackupKeep: Math.min(50, Math.max(1, numOrNull(el('s-autobackup-keep').value) || 5)),
autoBackupBeforeLaunch: el('s-autobackup-launch').checked,
autoWorldBackup: el('s-auto-world').checked,
showConsoleOnLaunch: el('s-console-launch').checked,
autoCloseConsole: el('s-console-autoclose').checked,
showConsoleOnCrash: el('s-console-crash').checked,
@@ -5065,11 +5452,16 @@ function wire() {
// Kontextmenü: Einträge lösen die Aktionen der rechten Leiste aus
document.querySelectorAll('#ctx-menu .ctx-item').forEach((item) => {
item.addEventListener('click', () => {
const target = el(item.dataset.act);
closeContextMenu();
if (item.dataset.act === 'ctx-schnellstart') { if (currentDetailId) setzeSchnellstart(currentDetailId); return; }
const target = el(item.dataset.act);
if (target) target.click();
});
});
// Auswahl-Dialog
el('wahl-ok').addEventListener('click', () => schliesseWahl(true));
el('wahl-eingabe').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !el('wahl-ok').disabled) schliesseWahl(true); });
el('modal-wahl').querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => schliesseWahl(false)));
// schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC
document.addEventListener('click', (e) => {
if (!e.target.closest('#ctx-menu')) closeContextMenu();
@@ -5220,7 +5612,26 @@ function wire() {
// Freunde
el('btn-friends').addEventListener('click', openFriends);
el('btn-stats').addEventListener('click', openStats);
el('p-share').addEventListener('click', teileInstanz);
el('share-copy').addEventListener('click', async () => {
try { await navigator.clipboard.writeText(el('share-code').textContent); toast('Code kopiert.'); }
catch { toast('Kopieren fehlgeschlagen.', true); }
});
el('code-check').addEventListener('click', pruefeTeilCode);
el('code-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') pruefeTeilCode(); });
el('code-do').addEventListener('click', installiereTeilCode);
window.api.onShareProgress((p) => {
if (!el('code-progress')) return;
el('code-progress').textContent = `Installiere ${p.aktuell}/${p.gesamt}: ${p.titel}`;
el('code-progress').classList.toggle('hidden', importKind !== 'code');
});
el('friends-add-btn').addEventListener('click', sendeFreundschaftsanfrage);
el('chat-back').addEventListener('click', schliesseChat);
el('chat-send').addEventListener('click', () => sendeChat(el('chat-text').value));
el('chat-text').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendeChat(el('chat-text').value); });
document.querySelectorAll('.chat-vorlage').forEach((b) => b.addEventListener('click', () => sendeChat(b.textContent)));
el('chat-avatar').addEventListener('error', function () { this.style.visibility = 'hidden'; });
el('chat-avatar').addEventListener('load', function () { this.style.visibility = ''; });
el('friends-add-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendeFreundschaftsanfrage();
});
@@ -5518,6 +5929,38 @@ function wire() {
if (!ok) toast('Ordner mit den Berichten nicht gefunden.', true);
});
el('crash-console').addEventListener('click', () => { hide('modal-crash'); openConsole(); });
el('crash-send').addEventListener('click', () => {
el('report-kommentar').value = '';
el('report-name').checked = false;
el('report-do').disabled = false;
el('report-do').textContent = 'Senden';
show('modal-report');
el('report-kommentar').focus();
});
el('report-do').addEventListener('click', async () => {
if (!aktuellerCrashId) return;
el('report-do').disabled = true;
el('report-do').textContent = 'Wird gesendet …';
const res = await window.api.crashReport(aktuellerCrashId, aktuellerCrashSeit, {
kommentar: el('report-kommentar').value,
mitName: el('report-name').checked,
}).catch((err) => ({ ok: false, message: err.message }));
if (res.ok) {
hide('modal-report');
toast(`Danke! Dein Bericht${res.id ? ` (#${res.id})` : ''} ist bei M_Viper angekommen.`);
el('crash-send').disabled = true;
el('crash-send').textContent = '✓ Gesendet';
} else {
el('report-do').disabled = false;
el('report-do').textContent = 'Senden';
toast(res.message || 'Senden fehlgeschlagen.', true);
}
});
el('crash-retry').addEventListener('click', async () => {
if (!aktuellerCrashId) return;
await window.api.crashRelaunch(aktuellerCrashId);
hide('modal-crash');
});
el('m-add-file').addEventListener('click', async () => {
const res = await window.api.modAddFile(currentDetailId, modArt);
@@ -5702,6 +6145,15 @@ function wire() {
el('search').select();
return;
}
// Strg+1 … Strg+5: Schnellstart-Instanz starten (nur im Hauptfenster)
if (!POPOUT && strg && !e.shiftKey && !e.altKey && /^[1-5]$/.test(e.key) && !modalOffen && !inEingabe) {
e.preventDefault();
const id = schnellstartBelegung[e.key];
const inst = id && instances.find((i) => i.id === id);
if (inst) { selectInstance(inst.id); launch(inst.id); }
else toast(`Strg+${e.key} ist noch keiner Instanz zugewiesen – Rechtsklick auf eine Instanz → „Schnellstart-Taste …“.`);
return;
}
// Strg+N: neue Instanz anlegen
if (strg && e.key.toLowerCase() === 'n' && !modalOffen) {
e.preventDefault();
@@ -5782,7 +6234,7 @@ const POPOUT_OPENERS = {
settings: (p) => openSettings(p.pane || new URLSearchParams(location.search).get('pane') || undefined),
console: () => openConsole(),
mods: () => openMods(),
crash: () => zeigeAbsturzbericht(currentDetailId),
crash: (p) => zeigeAbsturzbericht(currentDetailId, Number(p.seit) || 0),
logs: () => openLogs(),
content: () => openContent(),
screens: () => openScreens(),
@@ -5855,22 +6307,34 @@ window.addEventListener('DOMContentLoaded', async () => {
iso3dEnabled = s.iso3dIcons !== false;
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
el('btn-friends').classList.toggle('hidden', s.friendsEnabled === false);
schnellstartBelegung = s.schnellstart || {};
} catch { applyTheme('dark'); }
// Zahl offener Freundschaftsanfragen am "Freunde"-Knopf (nur Hauptfenster
// zeigt die Werkzeugleiste)
if (!POPOUT) {
const zeigeAnfragenZahl = (zahl) => {
const zeigeAnfragenZahl = (info) => {
const anfragen = typeof info === 'number' ? info : ((info && info.anfragen) || 0);
const nachrichten = typeof info === 'number' ? 0 : ((info && info.nachrichten) || 0);
const zahl = anfragen + nachrichten;
const badge = el('friends-badge');
badge.textContent = zahl > 99 ? '99+' : String(zahl);
badge.classList.toggle('hidden', !zahl);
el('btn-friends').title = zahl
? `Freunde – ${zahl} offene Anfrage${zahl === 1 ? '' : 'n'} (ingame: Strg+Alt+F zeigt, wer online ist)`
: 'Freunde (ingame: Strg+Alt+F zeigt, wer online ist)';
const teile = [];
if (anfragen) teile.push(`${anfragen} offene Anfrage${anfragen === 1 ? '' : 'n'}`);
if (nachrichten) teile.push(`${nachrichten} ungelesene Nachricht${nachrichten === 1 ? '' : 'en'}`);
el('btn-friends').title = (teile.length ? `Freunde – ${teile.join(', ')}` : 'Freunde') + ' (ingame: Strg+Alt+F zeigt, wer online ist)';
};
window.api.onFriendsRequestCount(zeigeAnfragenZahl);
window.api.friendsRequestCount().then(zeigeAnfragenZahl).catch(() => {});
// Meldungen aus dem Hauptprozess, deren Fenster schon zu ist (z. B. Performance-Paket)
window.api.onUiToast((t) => toast(t.text, !!t.error));
// neue Kurznachricht (im Spiel kommt zusätzlich eine Meldung)
window.api.onFriendsMessage((n) => {
if (n.sammel) { toast(`💬 ${n.sammel} neue Nachrichten – im Freunde-Fenster lesen.`); return; }
toast(n.imageUrl
? `📷 ${n.fromUsername || 'Ein Freund'} hat dir einen Screenshot geschickt${n.text ? ': ' + n.text : ''}`
: `💬 ${n.fromUsername || 'Ein Freund'}: ${n.text}`);
});
// Einladung "Komm auf meinen Server" (im Spiel kommt zusätzlich ein Toast)
window.api.onFriendsInvite((inv) => {
toast(`${inv.fromUsername || 'Ein Freund'} lädt dich auf ${inv.server} ein – im Freunde-Fenster beitreten.`);
@@ -5878,6 +6342,8 @@ window.addEventListener('DOMContentLoaded', async () => {
}
// offenes Freunde-Fenster bei neuer Einladung gleich aktualisieren
if (POPOUT === 'friends') window.api.onFriendsInvite(() => ladeFreunde());
// neue Nachricht: offene Unterhaltung oder Liste (Zähler) aktualisieren
if (POPOUT === 'friends') window.api.onFriendsMessage(() => (chatPartner ? ladeChat() : ladeFreunde()));
await refreshSkinBackground();
window.api.onLaunchProgress(onLaunchEvt);
// Servername live in den Titel nachtragen, falls das Aufzeichnungs-Fenster
@@ -5921,6 +6387,7 @@ window.addEventListener('DOMContentLoaded', async () => {
window.api.onSettingsChanged((s) => {
applyTheme(s.theme);
iso3dEnabled = s.iso3dIcons !== false;
schnellstartBelegung = s.schnellstart || {};
if (!POPOUT) {
applyBedrockVisibility();
refreshSkinBackground();
+25 -3
View File
@@ -283,8 +283,30 @@ async function modrinthSearch({ query = '', gameVersion = '', loader = '', categ
}
// findet die passende Datei-Version für Spielversion + Loader
async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod' }) {
async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod', versionId = '' }) {
const typ = inhaltsArt(art);
// bestimmte Version gewünscht (z. B. geteilte Instanz) – sonst neueste passende
if (versionId) {
try {
const res = await fetch(MODRINTH + '/version/' + encodeURIComponent(versionId), { headers: { 'User-Agent': UA } });
if (res.ok) {
const v = await res.json();
const passt = !gameVersion || (v.game_versions || []).includes(gameVersion);
const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0];
if (passt && file && v.project_id === projectId) {
return {
versionId: v.id,
versionNumber: v.version_number,
filename: file.filename,
url: file.url,
sha1: file.hashes && file.hashes.sha1,
size: file.size,
dependencies: v.dependencies || [],
};
}
}
} catch { /* dann eben die neueste passende */ }
}
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
// Nur Mods werden nach Loader eingeordnet; bei Ressourcenpaketen und
@@ -351,9 +373,9 @@ async function downloadFile(url, destPath, extraHeaders) {
}
// lädt eine Mod in <modsDir>; gibt Metadaten für die Instanz zurück
async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir, art = 'mod', versionId = '' }) {
const typ = inhaltsArt(art);
const file = await modrinthResolveFile({ projectId, gameVersion, loader, art });
const file = await modrinthResolveFile({ projectId, gameVersion, loader, art, versionId });
if (!file) {
return { ok: false, reason: 'no-match',
message: typ.loaderNoetig
+3 -1
View File
@@ -78,7 +78,9 @@ const DEFAULT_SETTINGS = {
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
friendsEnabled: true,
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
autoModUpdates: false, // vor jedem Start Mods über Modrinth aktualisieren // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
autoModUpdates: false, // vor jedem Start Mods über Modrinth aktualisieren
schnellstart: {}, // Strg+1 … Strg+5 -> Instanz-ID ({ "1": "Survival", … })
autoWorldBackup: true, // nach jeder Spielsitzung die zuletzt gespielte Welt sichern (letzte 5 Stände) // Freundesystem: eigene Online-Meldung + auffindbar für Anfragen
friendNicknames: {}, // rein lokal: uuid -> eigener Spitzname für einen Freund
autostart: false, // mit Windows starten, im Infobereich statt als Fenster
discordClientId: '', // optional, nur per Hand in settings.json; leer => eingebaute AeroMC-App-ID
+56
View File
@@ -427,6 +427,61 @@ img.account-av{object-fit:cover;image-rendering:auto}
.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))}
@@ -531,6 +586,7 @@ img.account-av{object-fit:cover;image-rendering:auto}
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}