Upload via GUI (138 Dateien)

This commit is contained in:
2026-10-03 14:20:38 +00:00
parent e83c951256
commit 972a3e73da
13 changed files with 3889 additions and 2615 deletions
+2 -2
View File
@@ -73,7 +73,7 @@
:root:is([data-design="aero"],[data-design="lila"]) .tbtn:active{background:var(--surface2)}
:root:is([data-design="aero"],[data-design="lila"]) .tbtn svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-new svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-import svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-open-dir svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-console svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-account svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-settings svg,
:root:is([data-design="aero"],[data-design="lila"]) #btn-search svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-console svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-account svg,:root:is([data-design="aero"],[data-design="lila"]) #btn-settings svg,
:root:is([data-design="aero"],[data-design="lila"]) .acct-av svg{color:var(--cyan-soft)}
:root:is([data-design="aero"],[data-design="lila"]) .tbsep{background:var(--border)}
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px;padding:5px 12px;width:150px}
@@ -270,7 +270,7 @@
:root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto}
:root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff}
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
:root[data-design="minecraft"] .tbtn svg,:root[data-design="minecraft"] #btn-console svg,:root[data-design="minecraft"] .acct-av svg{color:#e6e6e6}
:root[data-design="minecraft"] #btn-search svg,:root[data-design="minecraft"] .tbtn svg,:root[data-design="minecraft"] #btn-console svg,:root[data-design="minecraft"] .acct-av svg{color:#e6e6e6}
:root[data-design="minecraft"] #btn-new svg,:root[data-design="minecraft"] #btn-account svg{color:#7fd858}
:root[data-design="minecraft"] #btn-import svg{color:#6db8ff}
:root[data-design="minecraft"] #btn-open-dir svg{color:#f2c94c}
+25
View File
@@ -200,7 +200,32 @@ async function sendReport(daten) {
}
}
// ---------- AeroMC-Umhänge ----------
// Hochladen/Entfernen nur angemeldet; Abrufen ist öffentlich (auch für den Mod)
function uploadCape(uuid, image) {
return anfrage('/cape', { method: 'POST', body: { uuid, image } });
}
function removeCape(uuid) {
return anfrage('/cape/remove', { method: 'POST', body: { uuid } });
}
async function getCapes(uuids) {
try {
const liste = [...new Set((uuids || []).map((u) => String(u).replace(/-/g, '').toLowerCase()))].filter(Boolean);
if (!liste.length) return { ok: true, capes: {} };
const { res, daten } = await roheAnfrage('/capes', { method: 'GET', body: { uuids: liste.join(',') } });
// ältere Plugin-Fassung ohne Umhänge
if (res.status === 404) return { ok: false, fehlt: true, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` };
return { ok: true, capes: (daten && daten.capes) || {} };
} catch (err) {
return { ok: false, message: 'Umhang-Server nicht erreichbar: ' + err.message };
}
}
module.exports = {
BASIS_URL, uploadCape, removeCape, getCapes,
heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend,
sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer,
sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport,
+62 -4
View File
@@ -27,8 +27,9 @@
<button id="btn-stats" class="tbtn" data-ic="chart" title="Spielzeit-Statistik">Statistik</button>
<button id="btn-friends" class="tbtn" data-ic="users" title="Freunde (ingame: Strg+Alt+F zeigt, wer online ist)">Freunde<span id="friends-badge" class="tb-badge hidden"></span></button>
<span class="tbgrow"></span>
<div class="search-wrap">
<input id="search" type="text" placeholder="Suchen… (Strg+F)" autocomplete="off" title="Strg+F fokussiert die Suche" />
<div id="search-wrap" class="search-wrap">
<button id="btn-search" class="tbtn tb-lupe" data-ic="search" title="Instanzen suchen (Strg+F)"></button>
<input id="search" type="text" placeholder="Suchen… (Esc schließt)" autocomplete="off" title="Instanzen nach Name, Version oder Gruppe filtern" />
</div>
<button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button>
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
@@ -445,6 +446,14 @@
</label>
<small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small>
</section>
<section class="settings-card">
<h5>Umhänge</h5>
<label class="checkbox-field">
<input id="s-aeromc-capes" type="checkbox" /> <span>AeroMC-Umhänge im Spiel anzeigen</span>
</label>
<small class="hint">Zeigt deinen eigenen Umhang und die anderer AeroMC-Spieler – in jeder Minecraft-Version und mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small>
</section>
</div>
</div>
@@ -1249,6 +1258,26 @@
</select>
</label>
<p id="skin-status" class="hint"></p>
<div class="skin-umhang">
<div class="skin-label">Umhang</div>
<div class="field-row">
<label class="field">
<span>Offizieller Umhang <small class="opt">(im Spiel für alle sichtbar)</small></span>
<select id="skin-cape-offiziell" title="Nur Umhänge, die dein Konto bei Mojang besitzt">
<option value="">Kein Umhang</option>
</select>
</label>
<div class="field">
<span>AeroMC-Umhang <small class="opt">(eigener, auch HD)</small></span>
<div class="skin-umhang-aero">
<span id="skin-cape-aero" class="hint">–</span>
<button id="skin-cape-edit" class="btn btn-mini" title="Im Skin-Editor einen eigenen Umhang gestalten">Gestalten…</button>
<button id="skin-cape-remove" class="btn btn-mini btn-danger hidden">Entfernen</button>
</div>
</div>
</div>
<small id="skin-cape-status" class="hint">Den AeroMC-Umhang sehen im Spiel alle, die AeroMC nutzen oder den Mod „AeroMC Umhänge“ (Fabric/Quilt) installiert haben – dort hat er Vorrang vor dem offiziellen.</small>
</div>
</div>
<div class="modal-foot">
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
@@ -1268,6 +1297,7 @@
<div class="se-modus">
<button class="se-modus-btn on" data-se-modus="2d" title="Auf der aufgeklappten Vorlage malen">2D-Vorlage</button>
<button class="se-modus-btn" data-se-modus="3d" title="Direkt auf der drehbaren Figur malen">3D-Figur</button>
<button class="se-modus-btn" data-se-modus="umhang" title="Eigenen Umhang (auch HD) gestalten">Umhang</button>
</div>
<button class="modal-close" data-close>✕</button>
</div>
@@ -1290,7 +1320,7 @@
</div>
<div id="se-zuletzt" class="se-zuletzt"></div>
</div>
<div class="se-gruppe">
<div id="se-gruppe-ebene" class="se-gruppe">
<div class="se-titel">Ebene</div>
<div class="se-ebenen">
<button class="se-ebene on" data-se-schicht="0" title="Die eigentliche Figur (L wechselt)">Grundschicht</button>
@@ -1300,6 +1330,30 @@
<label class="se-check"><input id="se-durch" type="checkbox" checked /> Grundschicht durchscheinen</label>
<button id="se-leeren" class="btn btn-mini">2. Schicht leeren</button>
</div>
<div id="se-gruppe-umhang" class="se-gruppe hidden">
<div class="se-titel">Umhang</div>
<label class="se-feld"><span>Größe</span>
<select id="se-u-groesse" title="Größere Umhänge (HD) erlauben feinere Motive">
<option value="64">Normal (64×32)</option>
<option value="128">HD 128×64</option>
<option value="256">HD 256×128</option>
<option value="512">HD 512×256</option>
<option value="1024">HD 1024×512</option>
</select>
</label>
<label class="se-feld"><span>Pinselgröße</span>
<select id="se-u-pinsel">
<option value="1">1 Pixel</option>
<option value="2">2 Pixel</option>
<option value="4">4 Pixel</option>
<option value="8">8 Pixel</option>
<option value="16">16 Pixel</option>
</select>
</label>
<button id="se-u-bild" class="btn btn-mini" title="Ein beliebiges Bild (PNG, JPG …) auf die Außenseite setzen">Bild einsetzen…</button>
<label class="se-check" title="Dieselbe Datei trägt auch die Elytra"><input id="se-u-elytra" type="checkbox" /> Als Elytra zeigen</label>
<button id="se-u-einpassen" class="btn btn-mini" title="Zoom zurücksetzen (Taste 0)">Ansicht einpassen</button>
</div>
<div class="se-gruppe">
<div class="se-titel">Optionen</div>
<label class="se-check" title="Malt gleichzeitig auf der anderen Körperseite (M)"><input id="se-spiegeln" type="checkbox" /> Spiegeln (links ↔ rechts)</label>
@@ -1319,6 +1373,10 @@
<div id="se-buehne" class="se-buehne">
<div id="se-2d" class="se-ansicht se-2d"><canvas id="se-2d-canvas"></canvas></div>
</div>
<!-- gerade nicht gezeigte Ansichten (je nach Modus) -->
<div id="se-ablage" class="se-ablage">
<div id="se-u2d" class="se-ansicht se-u2d"><canvas id="se-u2d-canvas"></canvas></div>
</div>
<div id="se-info" class="se-info hint"></div>
</section>
<aside class="se-seite">
@@ -1326,7 +1384,7 @@
<div id="se-mini" class="se-mini">
<div id="se-3d" class="se-ansicht se-3d"><canvas id="se-3d-canvas"></canvas><span id="se-3d-leer" class="hint se-3d-leer">wird geladen …</span></div>
</div>
<p class="hint se-tipp">Beide Ansichten sind bemalbar. Die <b>2. Schicht</b> liegt wie eine Hülle über der Figur – lässt man Pixel darin frei, wirken Haare, Kapuzen oder Ärmel plastisch.</p>
<p id="se-tipp" class="hint se-tipp">Beide Ansichten sind bemalbar. Die <b>2. Schicht</b> liegt wie eine Hülle über der Figur – lässt man Pixel darin frei, wirken Haare, Kapuzen oder Ärmel plastisch.</p>
</aside>
</div>
<div class="modal-foot se-fuss">
+3
View File
@@ -344,6 +344,8 @@ function buildArgs(vj, ctx) {
// RAM
jvm = ['-Xms' + ctx.minMemMb + 'M', '-Xmx' + ctx.maxMemMb + 'M', ...jvm];
if (ctx.extraArgs) jvm = [...ctx.extraArgs.split(' ').filter(Boolean), ...jvm];
// AeroMC-Umhang-Agent (zeigt eigene Umhänge im Spiel, siehe agent/)
if (ctx.javaAgent) jvm = ['-javaagent:' + ctx.javaAgent, ...jvm];
return [...jvm, vj.mainClass, ...game];
}
@@ -528,6 +530,7 @@ async function prepareAndLaunch(opts, onProgress) {
minMemMb: opts.minMemMb || 2048,
maxMemMb: opts.maxMemMb || 4096,
extraArgs: opts.extraArgs || '',
javaAgent: opts.javaAgent || '',
});
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
+171
View File
@@ -1864,6 +1864,176 @@ ipcMain.handle('skin:currentData', async () => {
}
});
// ---------- Umhänge (offiziell von Mojang + eigene AeroMC-Umhänge) ----------
// Bilder immer als data:-Adresse in die Oberfläche (fremde Server schicken
// keine CORS-Kopfzeilen – die 3D-Ansicht dürfte sie sonst nicht laden)
async function bildAlsDataUri(url) {
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const daten = Buffer.from(await res.arrayBuffer());
if (daten.length > 6 * 1024 * 1024) throw new Error('Bild zu groß');
return 'data:image/png;base64,' + daten.toString('base64');
}
// PNG mit Seitenverhältnis 2:1 (64×32 … 1024×512) – wie der Server es verlangt
function umhangPngAusBase64(base64) {
const daten = Buffer.from(String(base64 || ''), 'base64');
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
const breite = daten.readUInt32BE(16);
const hoehe = daten.readUInt32BE(20);
if (![64, 128, 256, 512, 1024].includes(breite) || breite !== 2 * hoehe) return null;
return daten;
}
// Umhang eines beliebigen Spielers fürs Anzeigen: eigener AeroMC-Umhang hat
// Vorrang, sonst der offizielle aus Mojangs Profil. 5 Minuten gemerkt.
const umhangCache = new Map(); // uuid -> { dataUri, quelle, zeit }
async function umhangFuer(uuid) {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return null;
const gemerkt = umhangCache.get(id);
if (gemerkt && Date.now() - gemerkt.zeit < 5 * 60 * 1000) return gemerkt;
let ergebnis = { dataUri: null, quelle: null, zeit: Date.now() };
try {
const aero = await friends.getCapes([id]);
const eintrag = aero.ok && aero.capes && aero.capes[id];
if (eintrag && eintrag.url) ergebnis = { dataUri: await bildAlsDataUri(eintrag.url), quelle: 'aeromc', zeit: Date.now() };
} catch { /* weiter mit Mojang */ }
if (!ergebnis.dataUri) {
try {
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (res.ok) {
const profil = await res.json();
const eig = (profil.properties || []).find((p) => p.name === 'textures');
const tex = eig ? JSON.parse(Buffer.from(eig.value, 'base64').toString('utf8')) : {};
const cape = tex.textures && tex.textures.CAPE;
if (cape && cape.url) ergebnis = { dataUri: await bildAlsDataUri(skins.aufHttps(cape.url)), quelle: 'mojang', zeit: Date.now() };
}
} catch { /* kein Umhang */ }
}
umhangCache.set(id, ergebnis);
return ergebnis;
}
ipcMain.handle('cape:forUuid', async (_e, uuid) => {
const r = await umhangFuer(uuid);
return r && r.dataUri ? { dataUri: r.dataUri, quelle: r.quelle } : null;
});
// Übersicht fürs Skin-Fenster: offizielle Umhänge + eigener AeroMC-Umhang
ipcMain.handle('cape:info', async () => {
const konto = aktivesFreundeKonto();
const token = await aktivesToken();
if (!konto || !token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const ergebnis = { ok: true, name: konto.username, offiziell: [], aeromc: null, aeromcFehler: '' };
try {
const p = await skins.holeProfil(token);
if (p.ok) ergebnis.offiziell = p.capes || [];
} catch { /* ohne offizielle Liste weiter */ }
const aero = await friends.getCapes([konto.uuid]);
if (aero.ok) ergebnis.aeromc = aero.capes[String(konto.uuid).replace(/-/g, '').toLowerCase()] || null;
else { ergebnis.aeromcFehler = aero.message; ergebnis.aeromcFehlt = !!aero.fehlt; }
return ergebnis;
});
ipcMain.handle('cape:setOfficial', async (_e, capeId) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
const res = await skins.setzeUmhang(token, capeId || null);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res;
} catch (err) { return { ok: false, message: 'Umhang konnte nicht gesetzt werden: ' + err.message }; }
});
// Der Umhang-Agent muss als echte Datei vorliegen (Java liest nicht aus app.asar):
// einmal nach userData kopieren, Dateiname mit Größe, damit ein Update ihn ersetzt.
// Rückgabe: "pfad=serveradresse" für -javaagent, oder '' wenn es nicht klappt.
function umhangAgent() {
try {
const quelle = path.join(__dirname, 'assets', 'agent', 'aeromc-capes.jar');
const groesse = fs.statSync(quelle).size;
const ordner = path.join(app.getPath('userData'), 'agent');
const ziel = path.join(ordner, `aeromc-capes-${groesse}.jar`);
if (!fs.existsSync(ziel) || fs.statSync(ziel).size !== groesse) {
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(ziel, fs.readFileSync(quelle));
for (const alt of fs.readdirSync(ordner)) {
if (alt !== path.basename(ziel) && /^aeromc-capes-\d+\.jar$/.test(alt)) { try { fs.unlinkSync(path.join(ordner, alt)); } catch { /* noch in Benutzung */ } }
}
}
return `${ziel}=${friends.BASIS_URL}`;
} catch (err) {
console.error('Umhang-Agent nicht verfügbar:', err.message);
return '';
}
}
// verständliche Meldung, wenn der Server das neue Plugin noch nicht hat
function umhangFehler(res) {
if (res && res.code === 'rest_no_route') return { ok: false, message: 'Der Server kennt noch keine Umhänge (Plugin „AeroMC Freunde“ 1.5.0 nötig).' };
return res;
}
ipcMain.handle('cape:upload', async (_e, base64) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
if (!umhangPngAusBase64(base64)) return { ok: false, message: 'Ungültiger Umhang (PNG mit 64×32 bis 1024×512 Pixeln nötig).' };
const res = await friends.uploadCape(konto.uuid, base64);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res.ok ? { ok: true, cape: res.data && res.data.cape } : umhangFehler(res);
});
ipcMain.handle('cape:remove', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
const res = await friends.removeCape(konto.uuid);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res.ok ? res : umhangFehler(res);
});
// für den Editor: eigener Umhang, sonst der getragene offizielle
ipcMain.handle('cape:currentData', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
umhangCache.delete(String(konto.uuid).replace(/-/g, '').toLowerCase());
const r = await umhangFuer(konto.uuid);
if (!r || !r.dataUri) return { ok: false, keiner: true, message: 'Dein Konto hat noch keinen Umhang.' };
return { ok: true, dataUri: r.dataUri, quelle: r.quelle };
});
// Bild wählen: art 'umhang' = fertige Umhang-Datei (PNG), 'bild' = beliebiges Motiv zum Einsetzen
ipcMain.handle('cape:chooseFile', async (e, art) => {
const motiv = art === 'bild';
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: motiv ? 'Bild für den Umhang wählen' : 'Umhang-Datei öffnen',
properties: ['openFile'],
filters: motiv
? [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] }]
: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
const pfad = res.filePaths[0];
try {
const st = fs.statSync(pfad);
if (st.size > 15 * 1024 * 1024) return { ok: false, message: 'Die Datei ist zu groß (über 15 MB).' };
const endung = path.extname(pfad).slice(1).toLowerCase().replace('jpg', 'jpeg');
return { ok: true, pfad, dataUri: `data:image/${endung || 'png'};base64,` + fs.readFileSync(pfad).toString('base64') };
} catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
});
ipcMain.handle('cape:saveData', async (e, base64) => {
const daten = umhangPngAusBase64(base64);
if (!daten) return { ok: false, message: 'Ungültige Umhang-Daten.' };
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Umhang speichern',
defaultPath: path.join(app.getPath('pictures'), 'umhang.png'),
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; }
catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('skin:readData', (_e, dateiPfad) => {
const geprueft = skins.pruefeDatei(dateiPfad);
if (!geprueft.ok) return geprueft;
@@ -3602,6 +3772,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (settings.autoModUpdates) modUpdates = await modUpdatesVorDemStart(id).catch(() => null);
const opts = {
javaAgent: settings.aeromcCapes === false ? '' : umhangAgent(),
versionId: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla',
loaderVersion: inst.minecraft.loaderVersion || '',
+8
View File
@@ -159,6 +159,14 @@ contextBridge.exposeInMainWorld('api', {
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
capeInfo: () => ipcRenderer.invoke('cape:info'),
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
capeRemove: () => ipcRenderer.invoke('cape:remove'),
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
authLogin: () => ipcRenderer.invoke('auth:login'),
authLogout: () => ipcRenderer.invoke('auth:logout'),
+105 -9
View File
@@ -1564,7 +1564,9 @@ async function applySkinBackground(uuidOverride) {
// schmaler Alex-Skin mit breiten Armen falsch aus (grauer Streifen am Arm)
let modell = s.bgSkinModel === 'slim' || s.bgSkinModel === 'classic' ? s.bgSkinModel : '';
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell });
let umhang = null;
try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ }
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang });
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
// 2) Rückfall: externe Render-Dienste
@@ -1711,6 +1713,7 @@ async function renderSkin3D(quelle, poseKey, opts = {}) {
viewer.autoRotate = false;
viewer.animation = null;
pose.apply(viewer.playerObject);
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
viewer.render();
const data = canvas.toDataURL('image/png');
@@ -1740,6 +1743,7 @@ const ICONS_SVG = {
export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4.2-4.2"/>',
};
function svgIcon(name) {
const p = ICONS_SVG[name];
@@ -1909,6 +1913,17 @@ function buildCard(inst) {
return card;
}
// Suchfeld in der Werkzeugleiste: standardmäßig nur eine Lupe
function sucheOeffnen() {
el('search-wrap').classList.add('offen');
el('search').focus();
el('search').select();
}
function sucheSchliessen() {
el('search-wrap').classList.remove('offen');
if (document.activeElement === el('search')) el('search').blur();
}
function render() {
const q = el('search').value.trim().toLowerCase();
const grid = el('grid');
@@ -2607,7 +2622,7 @@ function schliesseSkinViewer(bildId) {
* Mausrad zoomt -- kommt von skinview3d selbst). Klappt das Rendern nicht,
* wird stattdessen ein Hinweistext gezeigt.
*/
async function zeigeSkinFigur(bildId, leerId, textur, leerText) {
async function zeigeSkinFigur(bildId, leerId, textur, leerText, umhang) {
schliesseSkinViewer(bildId);
const canvas = el(bildId);
const leer = el(leerId);
@@ -2644,6 +2659,7 @@ async function zeigeSkinFigur(bildId, leerId, textur, leerText) {
viewer.autoRotate = false;
viewer.animation = null;
pose.apply(viewer.playerObject);
if (umhang) { try { await viewer.loadCape(umhang); } catch { /* ohne Umhang weiter */ } }
viewer.controls.enableRotate = true;
viewer.controls.enableZoom = true;
viewer.controls.enablePan = false;
@@ -2679,14 +2695,78 @@ async function openSkin() {
// Armform zuerst setzen – davon hängt ab, wie die Figur gerendert wird
if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic';
skinTexturAktuell = p.skinUrl || '';
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell,
'Standard-Skin (keine eigene Datei hinterlegt)');
'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
}
// ---------- Umhänge im Skin-Fenster ----------
let skinUmhang = null; // data:-Adresse des angezeigten Umhangs (AeroMC vor offiziell)
async function ladeUmhangInfo() {
const sel = el('skin-cape-offiziell');
const info = await window.api.capeInfo();
sel.innerHTML = '<option value="">Kein Umhang</option>';
if (!info.ok) {
el('skin-cape-aero').textContent = info.message || '–';
el('skin-cape-remove').classList.add('hidden');
skinUmhang = null;
return;
}
for (const c of info.offiziell) {
const o = document.createElement('option');
o.value = c.id;
o.textContent = c.name;
if (c.aktiv) o.selected = true;
sel.appendChild(o);
}
sel.disabled = !info.offiziell.length;
sel.title = info.offiziell.length ? 'Nur Umhänge, die dein Konto bei Mojang besitzt' : 'Dein Konto besitzt keinen offiziellen Umhang';
if (info.aeromc) {
el('skin-cape-aero').textContent = `Hochgeladen (${info.aeromc.width}×${info.aeromc.height})`;
el('skin-cape-remove').classList.remove('hidden');
el('skin-cape-edit').textContent = 'Bearbeiten…';
} else {
el('skin-cape-aero').textContent = info.aeromcFehlt ? 'Server noch nicht bereit' : info.aeromcFehler ? 'Umhang-Server nicht erreichbar' : 'Noch keiner';
el('skin-cape-aero').title = info.aeromcFehler || '';
el('skin-cape-remove').classList.add('hidden');
el('skin-cape-edit').textContent = 'Gestalten…';
}
const konto = await window.api.authStatus();
const r = konto && konto.profile ? await window.api.capeForUuid(konto.profile.id) : null;
skinUmhang = r ? r.dataUri : null;
}
async function waehleOffiziellenUmhang() {
const sel = el('skin-cape-offiziell');
sel.disabled = true;
el('skin-cape-status').textContent = 'Umhang wird gesetzt …';
const res = await window.api.capeSetOfficial(sel.value || null);
sel.disabled = false;
if (res.ok) {
el('skin-cape-status').textContent = sel.value ? '✔ Offizieller Umhang gesetzt.' : '✔ Kein offizieller Umhang mehr.';
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
} else {
el('skin-cape-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
}
}
function entferneAeroUmhang() {
askConfirm('AeroMC-Umhang entfernen?', 'Dein eigener Umhang wird vom AeroMC-Umhang-Server gelöscht. Hast du ihn nicht als PNG gespeichert, ist er danach weg.', 'Entfernen', async () => {
const res = await window.api.capeRemove();
el('skin-cape-status').textContent = res.ok ? '✔ AeroMC-Umhang entfernt.' : '✖ ' + (res.message || 'Fehlgeschlagen.');
if (res.ok) {
await ladeUmhangInfo();
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, 'Standard-Skin (keine eigene Datei hinterlegt)', skinUmhang);
}
});
}
// Armform gewechselt -> beide Figuren neu zeichnen
async function skinVarianteGewechselt() {
if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, '');
if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, '');
if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, '', skinUmhang);
if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, '', skinUmhang);
}
async function waehleSkinDatei() {
@@ -2697,7 +2777,7 @@ async function waehleSkinDatei() {
gewaehlterSkin = res.pfad;
el('skin-pfad').value = res.pfad;
skinTexturNeu = res.dataUri || '';
await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar');
await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar', skinUmhang);
el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel – sieht gut aus.`;
el('skin-apply').disabled = false;
}
@@ -4918,6 +4998,7 @@ async function openSettings(pane) {
el('s-friends').checked = s.friendsEnabled !== false;
el('s-friends-server').checked = s.friendsShareServer !== false;
el('s-auto-mod-updates').checked = !!s.autoModUpdates;
el('s-aeromc-capes').checked = s.aeromcCapes !== false;
el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled;
@@ -5051,6 +5132,7 @@ async function saveSettings() {
friendsEnabled: el('s-friends').checked,
friendsShareServer: el('s-friends-server').checked,
autoModUpdates: el('s-auto-mod-updates').checked,
aeromcCapes: el('s-aeromc-capes').checked,
autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked,
@@ -5527,6 +5609,18 @@ function wire() {
// ohne Wrapper käme das Klick-Ereignis als Bereichsname an
el('btn-settings').addEventListener('click', () => openSettings());
el('search').addEventListener('input', render);
el('btn-search').addEventListener('click', () => sucheOeffnen());
el('search').addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
// Esc gehört hier der Suche, nicht dem globalen Dialog-Schließen
e.stopPropagation();
el('search').value = '';
render();
sucheSchliessen();
});
// leer verlassen = wieder zur Lupe; mit Suchbegriff bleibt das Feld offen,
// damit man sieht, dass die Liste gerade gefiltert ist
el('search').addEventListener('blur', () => { if (!el('search').value.trim()) sucheSchliessen(); });
el('btn-open-dir').addEventListener('click', () => {
if (currentDetailId) window.api.openInstanceFolder(currentDetailId);
@@ -5925,6 +6019,9 @@ function wire() {
el('login-skin').addEventListener('click', openSkin);
el('skin-pick').addEventListener('click', waehleSkinDatei);
el('skin-variante').addEventListener('change', skinVarianteGewechselt);
el('skin-cape-offiziell').addEventListener('change', waehleOffiziellenUmhang);
el('skin-cape-edit').addEventListener('click', () => window.openSkinEditor({ modus: 'umhang', quelle: 'aktuell' }));
el('skin-cape-remove').addEventListener('click', entferneAeroUmhang);
el('skin-apply').addEventListener('click', setzeSkin);
// gewählte Datei (sonst der aktuelle Skin des Kontos) im Editor öffnen
el('skin-editor').addEventListener('click', () => window.openSkinEditor(gewaehlterSkin
@@ -6274,8 +6371,7 @@ function wire() {
// der eine eigene Suche hat (Mods, Modpack-Katalog, Import …)
if (strg && e.key.toLowerCase() === 'f' && !modalOffen) {
e.preventDefault();
el('search').focus();
el('search').select();
sucheOeffnen();
return;
}
// Strg+1 … Strg+5: Schnellstart-Instanz starten (nur im Hauptfenster)
@@ -6535,7 +6631,7 @@ window.addEventListener('DOMContentLoaded', async () => {
window.api.onSkinChanged((info) => {
if (!POPOUT) refreshSkinBackground();
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
if (POPOUT === 'skin' && info.quelle === 'editor' && !el('modal-skin').classList.contains('hidden')) openSkin();
if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin();
});
window.api.onInstancesChanged(() => { if (!POPOUT) reload(); });
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
+681 -132
View File
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -115,7 +115,30 @@ async function holeProfil(accessToken) {
id: daten.id,
skinUrl: aktiv ? aufHttps(aktiv.url) : '',
variante: aktiv ? String(aktiv.variant || '').toLowerCase() : '',
// offizielle Umhänge, die dieses Konto besitzt (Migrator, MINECON …)
capes: (daten.capes || []).map((c) => ({
id: c.id,
name: c.alias || 'Umhang',
url: aufHttps(c.url),
aktiv: c.state === 'ACTIVE',
})),
};
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps };
/*
* Offiziellen Umhang wählen (nur einer, den das Konto besitzt) bzw. mit
* capeId = null keinen tragen. Gilt im Spiel für alle Spieler.
*/
async function setzeUmhang(accessToken, capeId) {
const res = await fetch(API + '/minecraft/profile/capes/active', capeId
? {
method: 'PUT',
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, 'Content-Type': 'application/json' },
body: JSON.stringify({ capeId }),
}
: { method: 'DELETE', headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA } });
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
return { ok: true };
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang };
+2 -1
View File
@@ -78,7 +78,8 @@ 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
autoModUpdates: false,
aeromcCapes: true, // AeroMC-Umhänge im Spiel anzeigen (Umhang-Agent beim Start) // 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
+17 -1
View File
@@ -65,7 +65,7 @@ body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:mi
:root[data-theme="dark"] .empty h2,
:root[data-theme="dark"] .mods-col h4,
:root[data-theme="dark"] .field>span{color:var(--text)}
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc}
:root[data-theme="dark"] #btn-console svg,:root[data-theme="dark"] #btn-search svg{color:#c8c8cc}
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
:root[data-theme="dark"] .mods-filters select,
:root[data-theme="dark"] .mods-search select,
@@ -103,6 +103,14 @@ body.is-popout>.titelleiste{display:none}
.tbgrow{flex:1}
.search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px}
.search-wrap input:focus{outline:none;border-color:var(--accent)}
/* eingeklappt nur die Lupe, aufgeklappt nur das Feld */
.search-wrap{display:flex;align-items:center}
.search-wrap:not(.offen) input{display:none}
.search-wrap.offen .tb-lupe{display:none}
.search-wrap.offen input{animation:suche-auf .16s ease-out}
@keyframes suche-auf{from{width:0;opacity:0}}
.tbtn.tb-lupe{padding:6px 8px}
#btn-search svg{color:#5a6470}
/* ---------- Arbeitsbereich ---------- */
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 172px;overflow:hidden;min-width:0;background:var(--area)}
@@ -337,6 +345,11 @@ body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>foote
.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated}
.skin-bild canvas{max-width:100%;max-height:100%;cursor:grab}
.skin-bild canvas:active{cursor:grabbing}
.popout-host .skin-bild{height:240px}
.skin-umhang{margin-top:12px;padding-top:10px;border-top:1px solid var(--border2)}
.skin-umhang .field-row{margin-top:6px}
.skin-umhang .field{margin-bottom:6px}
.skin-umhang-aero{display:flex;align-items:center;gap:8px;min-height:30px;flex-wrap:wrap}
.crash-details{margin-top:12px}
.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)}
.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
@@ -702,5 +715,8 @@ img.account-av{object-fit:cover;image-rendering:auto}
.se-3d canvas:active{cursor:grabbing}
.se-2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-3d-leer{position:absolute}
.se-ablage{display:none}
.se-u2d canvas{cursor:crosshair}
.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
.se-fuss{align-items:center}
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -1,8 +1,8 @@
<?php
/**
* Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.4.0
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.5.0
* Author: M_Viper
*/
@@ -10,7 +10,7 @@ if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei
}
define('AEROMC_FRIENDS_VERSION', '1.4.0');
define('AEROMC_FRIENDS_VERSION', '1.5.0');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
@@ -18,6 +18,7 @@ define('AEROMC_FRIENDS_SESSIONS_TABLE', 'aeromc_sessions');
define('AEROMC_FRIENDS_SHARES_TABLE', 'aeromc_shares');
define('AEROMC_FRIENDS_MESSAGES_TABLE', 'aeromc_messages');
define('AEROMC_FRIENDS_REPORTS_TABLE', 'aeromc_reports');
define('AEROMC_FRIENDS_CAPES_TABLE', 'aeromc_capes');
// Übergangsmodus: false = Anfragen ohne Anmeldung werden noch angenommen, aber
// nur für Spieler, die sich noch NIE über die neue Anmeldung gemeldet haben
@@ -143,6 +144,22 @@ function aeromc_friends_install() {
) $charset_collate;";
dbDelta($sql_reports);
// eigene Umhänge (Bild liegt unter wp-content/uploads/aeromc-umhaenge)
$capes_table = $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
$sql_capes = "CREATE TABLE $capes_table (
uuid varchar(32) NOT NULL,
username varchar(32) NOT NULL,
datei varchar(64) NOT NULL DEFAULT '',
breite smallint(5) unsigned NOT NULL DEFAULT 0,
hoehe smallint(5) unsigned NOT NULL DEFAULT 0,
hash varchar(40) NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'aktiv',
updated_at datetime NOT NULL,
PRIMARY KEY (uuid),
KEY status (status)
) $charset_collate;";
dbDelta($sql_capes);
update_option('aeromc_friends_db_version', AEROMC_FRIENDS_VERSION);
}
register_activation_hook(__FILE__, 'aeromc_friends_install');
@@ -157,6 +174,7 @@ add_action('plugins_loaded', function () {
});
require_once plugin_dir_path(__FILE__) . 'includes/routes.php';
require_once plugin_dir_path(__FILE__) . 'includes/capes.php';
if (is_admin()) {
require_once plugin_dir_path(__FILE__) . 'includes/admin.php';
}
@@ -0,0 +1,306 @@
<?php
/**
* AeroMC-Umhänge: eigene (auch HD-)Umhänge, die Spieler im Launcher gestalten.
*
* POST /aeromc/v1/cape { uuid, image } Umhang hochladen/ersetzen (Anmeldung Pflicht)
* POST /aeromc/v1/cape/remove { uuid } eigenen Umhang entfernen (Anmeldung Pflicht)
* GET /aeromc/v1/cape?uuid= Umhang eines Spielers (öffentlich)
* GET /aeromc/v1/capes?uuids=a,b,c mehrere auf einmal, für Launcher und Mod (öffentlich)
*
* Bilder werden streng geprüft (echtes PNG, Seitenverhältnis 2:1, 64×32 bis
* 1024×512) und – falls GD verfügbar ist – neu kodiert, damit keine fremden
* Daten im Bild mitreisen. Der Dateiname enthält einen Teil der Prüfsumme, so
* ändert sich die Adresse bei jedem neuen Umhang (kein veralteter Zwischenspeicher).
* Admins können Umhänge unter "AeroMC Fehler → Umhänge" löschen oder sperren.
*/
if (!defined('ABSPATH')) {
exit;
}
define('AEROMC_FRIENDS_CAPE_DIR', 'aeromc-umhaenge');
define('AEROMC_FRIENDS_CAPE_MAX_BYTES', 3000000);
define('AEROMC_FRIENDS_CAPE_MIN_GAP', 15); // Sekunden zwischen zwei Uploads
define('AEROMC_FRIENDS_CAPE_MAX_UUIDS', 100); // je Sammelabfrage
function aeromc_friends_capes_table() {
global $wpdb;
return $wpdb->prefix . AEROMC_FRIENDS_CAPES_TABLE;
}
function aeromc_friends_cape_dir() {
$uploads = wp_upload_dir();
return [
'pfad' => trailingslashit($uploads['basedir']) . AEROMC_FRIENDS_CAPE_DIR,
'url' => trailingslashit($uploads['baseurl']) . AEROMC_FRIENDS_CAPE_DIR,
];
}
/** Datei eines Umhangs löschen (nur Namen in unserem Schema). */
function aeromc_friends_cape_delete_file($datei) {
if (!$datei || !preg_match('/^[a-f0-9]{32}-[a-f0-9]{8}\.png$/', (string) $datei)) {
return;
}
$ort = aeromc_friends_cape_dir();
$voll = $ort['pfad'] . '/' . $datei;
if (file_exists($voll)) {
@unlink($voll);
}
}
/** Zeile -> öffentliche Angaben */
function aeromc_friends_cape_info($r) {
$ort = aeromc_friends_cape_dir();
return [
'url' => $ort['url'] . '/' . $r->datei,
'width' => (int) $r->breite,
'height' => (int) $r->hoehe,
'hash' => $r->hash,
'updated' => $r->updated_at,
];
}
/**
* Prüft ein Umhang-Bild und gibt [daten, breite, hoehe] zurück oder WP_Error.
*/
function aeromc_friends_cape_check_image($base64) {
$daten = base64_decode(preg_replace('/^data:image\/png;base64,/', '', (string) $base64), true);
if ($daten === false || strlen($daten) < 60) {
return new WP_Error('aeromc_invalid', 'Ungültiges Bild.', ['status' => 400]);
}
if (strlen($daten) > AEROMC_FRIENDS_CAPE_MAX_BYTES) {
return new WP_Error('aeromc_too_big', 'Der Umhang ist zu groß (Datei).', ['status' => 413]);
}
if (strncmp($daten, "\x89PNG\r\n\x1A\n", 8) !== 0 || substr($daten, 12, 4) !== 'IHDR') {
return new WP_Error('aeromc_invalid', 'Nur PNG-Bilder.', ['status' => 400]);
}
$kopf = unpack('Nbreite/Nhoehe', substr($daten, 16, 8));
$breite = (int) $kopf['breite'];
$hoehe = (int) $kopf['hoehe'];
if (!in_array($breite, [64, 128, 256, 512, 1024], true) || $breite !== 2 * $hoehe) {
return new WP_Error('aeromc_invalid', 'Ein Umhang muss 64×32, 128×64, 256×128, 512×256 oder 1024×512 Pixel groß sein.', ['status' => 400]);
}
// neu kodieren: entfernt alles außer den Bildpunkten
if (function_exists('imagecreatefromstring')) {
$bild = @imagecreatefromstring($daten);
if (!$bild || imagesx($bild) !== $breite || imagesy($bild) !== $hoehe) {
return new WP_Error('aeromc_invalid', 'Das Bild ist beschädigt.', ['status' => 400]);
}
imagealphablending($bild, false);
imagesavealpha($bild, true);
ob_start();
imagepng($bild, null, 9);
$daten = ob_get_clean();
imagedestroy($bild);
}
return [$daten, $breite, $hoehe];
}
add_action('rest_api_init', function () {
register_rest_route('aeromc/v1', '/cape', [
[
'methods' => 'POST',
'callback' => 'aeromc_friends_route_cape_upload',
'permission_callback' => '__return_true',
],
[
'methods' => 'GET',
'callback' => 'aeromc_friends_route_cape_get',
'permission_callback' => '__return_true',
],
]);
register_rest_route('aeromc/v1', '/cape/remove', [
'methods' => 'POST',
'callback' => 'aeromc_friends_route_cape_remove',
'permission_callback' => '__return_true',
]);
register_rest_route('aeromc/v1', '/capes', [
'methods' => 'GET',
'callback' => 'aeromc_friends_route_capes',
'permission_callback' => '__return_true',
]);
});
/** Anmeldung ist hier immer Pflicht – auch im Übergangsmodus (öffentlich sichtbare Bilder). */
function aeromc_friends_cape_auth($request, $uuid) {
$sitzung = aeromc_friends_check_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
if (!$sitzung) {
return new WP_Error('aeromc_auth', 'Anmeldung erforderlich.', ['status' => 401]);
}
return $sitzung;
}
function aeromc_friends_route_cape_upload($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$sitzung = aeromc_friends_cape_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
$table = aeromc_friends_capes_table();
$alt = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid));
if ($alt && $alt->status === 'gesperrt') {
return new WP_Error('aeromc_blocked', 'Dein Umhang wurde vom Betreiber gesperrt.', ['status' => 403]);
}
if ($alt && time() - strtotime($alt->updated_at . ' UTC') < AEROMC_FRIENDS_CAPE_MIN_GAP) {
return new WP_Error('aeromc_too_fast', 'Bitte einen Moment warten, bevor du den Umhang erneut hochlädst.', ['status' => 429]);
}
$geprueft = aeromc_friends_cape_check_image($request->get_param('image'));
if (is_wp_error($geprueft)) {
return $geprueft;
}
list($daten, $breite, $hoehe) = $geprueft;
$hash = sha1($daten);
$ort = aeromc_friends_cape_dir();
if (!wp_mkdir_p($ort['pfad'])) {
return new WP_Error('aeromc_error', 'Umhang konnte nicht gespeichert werden.', ['status' => 500]);
}
if (!file_exists($ort['pfad'] . '/index.html')) {
file_put_contents($ort['pfad'] . '/index.html', '');
}
$datei = $uuid . '-' . substr($hash, 0, 8) . '.png';
if (file_put_contents($ort['pfad'] . '/' . $datei, $daten) === false) {
return new WP_Error('aeromc_error', 'Umhang konnte nicht gespeichert werden.', ['status' => 500]);
}
if ($alt && $alt->datei !== $datei) {
aeromc_friends_cape_delete_file($alt->datei);
}
$zeile = [
'uuid' => $uuid,
'username' => $sitzung->username,
'datei' => $datei,
'breite' => $breite,
'hoehe' => $hoehe,
'hash' => $hash,
'status' => 'aktiv',
'updated_at' => current_time('mysql', true),
];
$wpdb->replace($table, $zeile);
return ['ok' => true, 'cape' => aeromc_friends_cape_info((object) $zeile)];
}
function aeromc_friends_route_cape_remove($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$sitzung = aeromc_friends_cape_auth($request, $uuid);
if (is_wp_error($sitzung)) {
return $sitzung;
}
$table = aeromc_friends_capes_table();
$alt = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid));
if ($alt) {
aeromc_friends_cape_delete_file($alt->datei);
if ($alt->status === 'gesperrt') {
// Sperre bleibt bestehen, nur das Bild ist weg
$wpdb->update($table, ['datei' => '', 'hash' => ''], ['uuid' => $uuid]);
} else {
$wpdb->delete($table, ['uuid' => $uuid]);
}
}
return ['ok' => true];
}
function aeromc_friends_route_cape_get($request) {
global $wpdb;
$uuid = aeromc_friends_normalize_uuid($request->get_param('uuid'));
if (!$uuid) {
return new WP_Error('aeromc_invalid', 'Ungültige uuid.', ['status' => 400]);
}
$table = aeromc_friends_capes_table();
$r = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s AND status = 'aktiv'", $uuid));
return ['ok' => true, 'cape' => $r ? aeromc_friends_cape_info($r) : null];
}
function aeromc_friends_route_capes($request) {
global $wpdb;
$liste = array_filter(array_map('aeromc_friends_normalize_uuid', explode(',', (string) $request->get_param('uuids'))));
$liste = array_slice(array_values(array_unique($liste)), 0, AEROMC_FRIENDS_CAPE_MAX_UUIDS);
$ergebnis = [];
if ($liste) {
$table = aeromc_friends_capes_table();
$platz = implode(',', array_fill(0, count($liste), '%s'));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE status = 'aktiv' AND uuid IN ($platz)", $liste
));
foreach ($rows as $r) {
$ergebnis[$r->uuid] = aeromc_friends_cape_info($r);
}
}
$antwort = new WP_REST_Response(['ok' => true, 'capes' => (object) $ergebnis]);
// kurz zwischenspeichern lassen – der Mod fragt beim Betreten eines Servers viele auf einmal ab
$antwort->header('Cache-Control', 'public, max-age=60');
return $antwort;
}
// ---------- Admin: Umhänge prüfen, löschen, sperren ----------
if (is_admin()) {
add_action('admin_menu', function () {
add_submenu_page('aeromc-reports', 'AeroMC Umhänge', 'Umhänge', 'manage_options', 'aeromc-capes', 'aeromc_friends_capes_page');
}, 20);
}
function aeromc_friends_capes_page() {
global $wpdb;
if (!current_user_can('manage_options')) {
return;
}
$table = aeromc_friends_capes_table();
if (isset($_POST['aeromc_cape'], $_POST['aeromc_aktion']) && check_admin_referer('aeromc_capes')) {
$uuid = aeromc_friends_normalize_uuid($_POST['aeromc_cape']);
$r = $uuid ? $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE uuid = %s", $uuid)) : null;
if ($r) {
$aktion = (string) $_POST['aeromc_aktion'];
if ($aktion === 'loeschen') {
aeromc_friends_cape_delete_file($r->datei);
$wpdb->delete($table, ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>Umhang von ' . esc_html($r->username) . ' gelöscht.</p></div>';
} elseif ($aktion === 'sperren') {
aeromc_friends_cape_delete_file($r->datei);
$wpdb->update($table, ['status' => 'gesperrt', 'datei' => '', 'hash' => ''], ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>' . esc_html($r->username) . ' gesperrt – der Umhang ist entfernt, neue Uploads werden abgelehnt.</p></div>';
} elseif ($aktion === 'entsperren') {
$wpdb->delete($table, ['uuid' => $uuid]);
echo '<div class="notice notice-success"><p>Sperre für ' . esc_html($r->username) . ' aufgehoben.</p></div>';
}
}
}
$ort = aeromc_friends_cape_dir();
$rows = $wpdb->get_results("SELECT * FROM $table ORDER BY updated_at DESC LIMIT 300");
echo '<div class="wrap"><h1>AeroMC Umhänge</h1>';
echo '<p>Eigene Umhänge, die Spieler im AeroMC-Skin-Editor hochgeladen haben. „Sperren“ entfernt den Umhang und verhindert neue Uploads dieses Spielers.</p>';
if (!$rows) {
echo '<p>Noch keine Umhänge hochgeladen.</p></div>';
return;
}
echo '<table class="widefat striped"><thead><tr><th style="width:150px">Umhang</th><th>Spieler</th><th>Größe</th><th>Geändert (UTC)</th><th>Status</th><th></th></tr></thead><tbody>';
foreach ($rows as $r) {
$bild = $r->datei
? '<img src="' . esc_url($ort['url'] . '/' . $r->datei) . '" alt="" style="width:128px;height:64px;image-rendering:pixelated;background:#ddd;border:1px solid #ccc" />'
: '–';
echo '<tr><td>' . $bild . '</td><td>' . esc_html($r->username) . '<br /><small>' . esc_html($r->uuid) . '</small></td>'
. '<td>' . esc_html($r->breite . '×' . $r->hoehe) . '</td><td>' . esc_html($r->updated_at) . '</td><td>' . esc_html($r->status) . '</td><td>';
echo '<form method="post" style="display:flex;gap:6px">';
wp_nonce_field('aeromc_capes');
echo '<input type="hidden" name="aeromc_cape" value="' . esc_attr($r->uuid) . '" />';
if ($r->status === 'gesperrt') {
echo '<button class="button" name="aeromc_aktion" value="entsperren">Sperre aufheben</button>';
} else {
echo '<button class="button" name="aeromc_aktion" value="loeschen" onclick="return confirm(\'Umhang löschen?\')">Löschen</button>';
echo '<button class="button button-link-delete" name="aeromc_aktion" value="sperren" onclick="return confirm(\'Spieler sperren? Der Umhang wird entfernt und neue Uploads abgelehnt.\')">Sperren</button>';
}
echo '</form></td></tr>';
}
echo '</tbody></table></div>';
}