Upload via GUI (93 Dateien)
This commit is contained in:
+581
-20
@@ -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]}`;
|
||||
|
||||
Reference in New Issue
Block a user