Upload via GUI (18 Dateien)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"version": "0.29.0",
|
||||
"version": "0.33.1",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"main": "src/main.js",
|
||||
"author": "AeroMC",
|
||||
|
||||
121
src/icons.js
Normal file
121
src/icons.js
Normal file
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* icons.js – echte Minecraft-Item-Icons
|
||||
* -------------------------------------
|
||||
* Die Original-Texturen werden aus der bereits heruntergeladenen client.jar
|
||||
* des Nutzers extrahiert (also aus seinen eigenen Spieldateien) und lokal
|
||||
* als Icons abgelegt. Es werden keine fremden Assets mitgeliefert.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const forge = require('./forge');
|
||||
|
||||
// Auswahl gut erkennbarer 16x16-Texturen (Pfade im client.jar)
|
||||
const WANTED = [
|
||||
['grass', 'block/grass_block_side.png', 'Grasblock'],
|
||||
['dirt', 'block/dirt.png', 'Erde'],
|
||||
['stone', 'block/cobblestone.png', 'Bruchstein'],
|
||||
['planks', 'block/oak_planks.png', 'Holzbretter'],
|
||||
['tnt', 'block/tnt_side.png', 'TNT'],
|
||||
['obsidian', 'block/obsidian.png', 'Obsidian'],
|
||||
['netherrack', 'block/netherrack.png', 'Netherrack'],
|
||||
['crafting', 'block/crafting_table_front.png', 'Werkbank'],
|
||||
['furnace', 'block/furnace_front.png', 'Ofen'],
|
||||
['bookshelf', 'block/bookshelf.png', 'Bücherregal'],
|
||||
['diamondblock', 'block/diamond_block.png', 'Diamantblock'],
|
||||
['goldblock', 'block/gold_block.png', 'Goldblock'],
|
||||
['emeraldblock', 'block/emerald_block.png', 'Smaragdblock'],
|
||||
['redstoneblock', 'block/redstone_block.png', 'Redstoneblock'],
|
||||
['diamond', 'item/diamond.png', 'Diamant'],
|
||||
['emerald', 'item/emerald.png', 'Smaragd'],
|
||||
['gold', 'item/gold_ingot.png', 'Goldbarren'],
|
||||
['iron', 'item/iron_ingot.png', 'Eisenbarren'],
|
||||
['redstone', 'item/redstone.png', 'Redstone'],
|
||||
['coal', 'item/coal.png', 'Kohle'],
|
||||
['sword', 'item/diamond_sword.png', 'Diamantschwert'],
|
||||
['pickaxe', 'item/diamond_pickaxe.png', 'Diamantspitzhacke'],
|
||||
['axe', 'item/diamond_axe.png', 'Diamantaxt'],
|
||||
['bow', 'item/bow.png', 'Bogen'],
|
||||
['apple', 'item/apple.png', 'Apfel'],
|
||||
['goldenapple', 'item/golden_apple.png', 'Goldener Apfel'],
|
||||
['book', 'item/book.png', 'Buch'],
|
||||
['enchantedbook', 'item/enchanted_book.png', 'Verzaubertes Buch'],
|
||||
['map', 'item/map.png', 'Karte'],
|
||||
['star', 'item/nether_star.png', 'Netherstern'],
|
||||
['endereye', 'item/ender_eye.png', 'Enderauge'],
|
||||
['enderpearl', 'item/ender_pearl.png', 'Enderperle'],
|
||||
['blaze', 'item/blaze_powder.png', 'Lohenstaub'],
|
||||
['xp', 'item/experience_bottle.png', 'Erfahrungsfläschchen'],
|
||||
['cake', 'item/cake.png', 'Kuchen'],
|
||||
['bucket', 'item/water_bucket.png', 'Wassereimer'],
|
||||
['lava', 'item/lava_bucket.png', 'Lavaeimer'],
|
||||
['tnt_item', 'item/gunpowder.png', 'Schwarzpulver'],
|
||||
];
|
||||
|
||||
// sucht die neueste vorhandene client.jar (auch im Datenordner vor der Umbenennung)
|
||||
function findClientJars(sharedDirs) {
|
||||
const jars = [];
|
||||
for (const shared of sharedDirs) {
|
||||
const vdir = path.join(shared, 'versions');
|
||||
if (!fs.existsSync(vdir)) continue;
|
||||
for (const entry of fs.readdirSync(vdir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const jar = path.join(vdir, entry.name, entry.name + '.jar');
|
||||
try {
|
||||
if (fs.existsSync(jar) && fs.statSync(jar).size > 1024 * 1024) {
|
||||
jars.push({ version: entry.name, path: jar, mtime: fs.statSync(jar).mtimeMs });
|
||||
}
|
||||
} catch { /* überspringen */ }
|
||||
}
|
||||
}
|
||||
jars.sort((a, b) => b.mtime - a.mtime);
|
||||
return jars;
|
||||
}
|
||||
|
||||
/*
|
||||
* Extrahiert die Texturen in <outDir>. Gibt die Liste der gefundenen Icons zurück.
|
||||
*/
|
||||
function extractIcons(sharedDirs, outDir) {
|
||||
const jars = findClientJars(sharedDirs);
|
||||
if (!jars.length) return { ok: false, reason: 'no-jar' };
|
||||
|
||||
const jar = jars[0];
|
||||
const buf = fs.readFileSync(jar.path);
|
||||
const zip = forge.readZipIndex(buf);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const found = [];
|
||||
for (const [key, rel, label] of WANTED) {
|
||||
const entry = zip['assets/minecraft/textures/' + rel];
|
||||
if (!entry) continue;
|
||||
try {
|
||||
const png = forge.readZipEntry(buf, entry);
|
||||
// sehr große Dateien überspringen (animierte Texturen sind gestreckt)
|
||||
if (png.length > 64 * 1024) continue;
|
||||
const dest = path.join(outDir, key + '.png');
|
||||
fs.writeFileSync(dest, png);
|
||||
found.push({ key, label, file: dest });
|
||||
} catch { /* Textur überspringen */ }
|
||||
}
|
||||
return { ok: true, version: jar.version, count: found.length, icons: found };
|
||||
}
|
||||
|
||||
// liest die extrahierten Icons als data:-URIs (für die Anzeige)
|
||||
function listIcons(outDir) {
|
||||
if (!fs.existsSync(outDir)) return [];
|
||||
const labels = Object.fromEntries(WANTED.map(([k, , l]) => [k, l]));
|
||||
return fs.readdirSync(outDir)
|
||||
.filter((f) => f.endsWith('.png'))
|
||||
.map((f) => {
|
||||
const key = path.basename(f, '.png');
|
||||
try {
|
||||
const data = fs.readFileSync(path.join(outDir, f)).toString('base64');
|
||||
return { key, label: labels[key] || key, dataUri: 'data:image/png;base64,' + data };
|
||||
} catch { return null; }
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = { extractIcons, listIcons, findClientJars, WANTED };
|
||||
@@ -187,6 +187,7 @@
|
||||
<button class="snav on" data-pane="pane-launcher">Launcher</button>
|
||||
<button class="snav" data-pane="pane-minecraft">Minecraft</button>
|
||||
<button class="snav" data-pane="pane-java">Java</button>
|
||||
<button class="snav" data-pane="pane-console">Konsole</button>
|
||||
<button class="snav" data-pane="pane-commands">Befehle</button>
|
||||
<button class="snav" data-pane="pane-external">Externe Programme</button>
|
||||
<button class="snav" data-pane="pane-proxy">Proxy</button>
|
||||
@@ -212,6 +213,18 @@
|
||||
<small class="hint">Hier werden alle Instanzen als Unterordner (mit ihrem Namen) gespeichert.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Instanzen sortieren</span>
|
||||
<select id="s-sortby">
|
||||
<option value="lastPlayed">Nach letztem Start</option>
|
||||
<option value="name">Nach Name</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-field">
|
||||
<input id="s-iso3d" type="checkbox" /> <span>Block-Symbole räumlich darstellen (3D)</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Skin-Hintergrund <small class="opt">(optional)</small></span>
|
||||
<select id="s-bgskin"><option value="">Aus</option></select>
|
||||
@@ -248,6 +261,15 @@
|
||||
</label>
|
||||
</div>
|
||||
<small class="hint">Gilt für alle Instanzen ohne eigene RAM-Einstellung.</small>
|
||||
|
||||
<h4 style="margin-top:18px">Fenstergröße</h4>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-mc-max" type="checkbox" /> <span>Minecraft maximiert starten</span>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Fensterbreite</span><input id="s-mc-width" type="number" min="320" step="10" /></label>
|
||||
<label class="field"><span>Fensterhöhe</span><input id="s-mc-height" type="number" min="240" step="10" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-java" class="spane hidden">
|
||||
@@ -266,6 +288,23 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="pane-console" class="spane hidden">
|
||||
<h4>Konsole</h4>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-launch" type="checkbox" /> <span>Konsole anzeigen, während das Spiel läuft</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-autoclose" type="checkbox" /> <span>Konsole automatisch schließen, nachdem das Spiel beendet wurde</span>
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-console-crash" type="checkbox" /> <span>Konsole anzeigen, wenn das Spiel abstürzt</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Historie-Limit (Zeilen)</span>
|
||||
<input id="s-console-lines" type="number" min="1000" step="1000" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="pane-commands" class="spane hidden">
|
||||
<h4>Benutzerdefinierte Befehle</h4>
|
||||
<label class="field">
|
||||
@@ -311,6 +350,10 @@
|
||||
|
||||
<div id="pane-info" class="spane hidden">
|
||||
<h4>Über AeroMC</h4>
|
||||
<div class="field">
|
||||
<button id="s-check-update" class="btn">Nach Updates suchen</button>
|
||||
<small id="s-update-status" class="hint"></small>
|
||||
</div>
|
||||
<div class="about-brand">
|
||||
<img src="assets/logo.png" alt="" class="about-logo" />
|
||||
<div>
|
||||
@@ -504,6 +547,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Update ===================== -->
|
||||
<div id="modal-update" class="modal hidden">
|
||||
<div class="modal-card modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>Update verfügbar</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="upd-version">
|
||||
<span id="upd-current" class="upd-old">–</span>
|
||||
<span class="upd-arrow">→</span>
|
||||
<span id="upd-new" class="upd-newv">–</span>
|
||||
</p>
|
||||
<div id="upd-notes" class="upd-notes"></div>
|
||||
<div id="upd-progress" class="upd-progress hidden">
|
||||
<div class="upd-bar"><div id="upd-bar-fill" class="upd-bar-fill"></div></div>
|
||||
<div id="upd-status" class="hint">Wird heruntergeladen …</div>
|
||||
</div>
|
||||
<p class="hint">Der Launcher lädt das Update herunter und startet die Installation automatisch.</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" data-close>Später</button>
|
||||
<button id="upd-install" class="btn btn-primary">Installieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Kontextmenü (Rechtsklick auf Instanz) ===================== -->
|
||||
<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>
|
||||
<button class="ctx-item" data-act="p-groupline">Gruppe ändern</button>
|
||||
<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>
|
||||
<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>
|
||||
<button class="ctx-item" data-act="p-mods">Mods anzeigen</button>
|
||||
<button class="ctx-item" data-act="p-worlds">Welten verwalten</button>
|
||||
<button class="ctx-item" data-act="p-screens">Bildschirmfotos verwalten</button>
|
||||
<div class="ctx-sep"></div>
|
||||
<button class="ctx-item" data-act="p-mcfolder">Minecraft-Ordner</button>
|
||||
<button class="ctx-item" data-act="p-configfolder">Konfigurations-Ordner</button>
|
||||
<button class="ctx-item" data-act="p-instfolder">Instanz-Ordner</button>
|
||||
<div class="ctx-sep"></div>
|
||||
<button class="ctx-item" data-act="p-shortcut">Verknüpfung erstellen</button>
|
||||
<button class="ctx-item" data-act="p-export">Instanz exportieren</button>
|
||||
<button class="ctx-item ctx-danger" data-act="p-delete">Löschen</button>
|
||||
<button class="ctx-item" data-act="p-copy">Instanz kopieren</button>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
|
||||
@@ -411,6 +411,10 @@ async function prepareAndLaunch(opts, onProgress) {
|
||||
extraArgs: opts.extraArgs || '',
|
||||
});
|
||||
|
||||
// Fenstergröße bzw. maximiert starten (Minecraft-Argumente)
|
||||
if (opts.mcMaximized) args.push('--fullscreen');
|
||||
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
||||
|
||||
ensureDir(opts.gameDir);
|
||||
|
||||
// Pre-Launch-Befehl (bricht bei Fehler ab)
|
||||
|
||||
82
src/main.js
82
src/main.js
@@ -16,6 +16,8 @@ const launcher = require('./launcher');
|
||||
const loaders = require('./loaders');
|
||||
const forge = require('./forge');
|
||||
const auth = require('./auth');
|
||||
const updater = require('./updater');
|
||||
const icons = require('./icons');
|
||||
|
||||
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
|
||||
const undici = require('undici');
|
||||
@@ -78,10 +80,10 @@ function windowIcon() {
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
minWidth: 880,
|
||||
minHeight: 560,
|
||||
width: 1200,
|
||||
height: 820,
|
||||
minWidth: 940,
|
||||
minHeight: 640,
|
||||
backgroundColor: '#1b1e24',
|
||||
title: 'AeroMC',
|
||||
icon: windowIcon(),
|
||||
@@ -108,6 +110,24 @@ app.whenReady().then(() => {
|
||||
applyProxy(store.getSettings());
|
||||
createWindow();
|
||||
|
||||
// echte Minecraft-Icons einmalig aus den eigenen Spieldateien holen
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const { shared, out } = iconDirs();
|
||||
if (!fs.existsSync(out) || fs.readdirSync(out).length === 0) icons.extractIcons(shared, out);
|
||||
} catch { /* noch keine Version heruntergeladen */ }
|
||||
}, 1500);
|
||||
|
||||
// automatisch auf Updates prüfen (kurz nach dem Start)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const info = await updater.checkForUpdate(app.getVersion());
|
||||
if (info.available && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update:available', info);
|
||||
}
|
||||
} catch { /* offline o. Ä. – still ignorieren */ }
|
||||
}, 4000);
|
||||
|
||||
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
|
||||
setTimeout(() => checkAuthServices(), 2500);
|
||||
setInterval(() => checkAuthServices(), 120000);
|
||||
@@ -514,6 +534,9 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
|
||||
preLaunchCommand: inst.preLaunchCommand || settings.globalPreLaunch || '',
|
||||
postExitCommand: inst.postExitCommand || settings.globalPostExit || '',
|
||||
wrapperCommand: settings.wrapperCommand || '',
|
||||
mcMaximized: !!settings.mcMaximized,
|
||||
mcWidth: settings.mcWidth || 854,
|
||||
mcHeight: settings.mcHeight || 480,
|
||||
auth: null,
|
||||
};
|
||||
|
||||
@@ -606,6 +629,57 @@ ipcMain.handle('app:openExternal', (_e, url) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – echte Minecraft-Icons (aus den eigenen Spieldateien)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function iconDirs() {
|
||||
const ud = app.getPath('userData');
|
||||
const parent = path.dirname(ud);
|
||||
// auch im Datenordner vor der Umbenennung suchen
|
||||
const candidates = [path.join(ud, 'shared')];
|
||||
for (const alt of ['viper-network', 'Viper-Network', 'aeromc', 'AeroMC']) {
|
||||
const p = path.join(parent, alt, 'shared');
|
||||
if (!candidates.includes(p)) candidates.push(p);
|
||||
}
|
||||
return { shared: candidates, out: path.join(ud, 'icons') };
|
||||
}
|
||||
|
||||
ipcMain.handle('icons:list', () => {
|
||||
const { out } = iconDirs();
|
||||
return icons.listIcons(out);
|
||||
});
|
||||
|
||||
ipcMain.handle('icons:extract', () => {
|
||||
const { shared, out } = iconDirs();
|
||||
const res = icons.extractIcons(shared, out);
|
||||
if (!res.ok) return { ok: false, reason: res.reason };
|
||||
return { ok: true, version: res.version, count: res.count, icons: icons.listIcons(out) };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – Automatische Updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ipcMain.handle('update:check', async () => {
|
||||
try { return await updater.checkForUpdate(app.getVersion()); }
|
||||
catch (err) { return { available: false, error: err.message }; }
|
||||
});
|
||||
|
||||
// Ein Klick: herunterladen, prüfen, Installer starten und Launcher beenden
|
||||
ipcMain.handle('update:install', async (_e, asset) => {
|
||||
try {
|
||||
const file = await updater.downloadAndInstall(asset, (p) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update:progress', p);
|
||||
});
|
||||
updater.runInstaller(file.path);
|
||||
setTimeout(() => app.quit(), 1200); // Installer übernimmt, Launcher schließt sich
|
||||
return { ok: true, path: file.path };
|
||||
} catch (err) {
|
||||
return { ok: false, message: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:info', () => ({
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
|
||||
@@ -86,6 +86,24 @@ contextBridge.exposeInMainWorld('api', {
|
||||
},
|
||||
|
||||
// Dialoge / App
|
||||
// echte Minecraft-Icons
|
||||
iconsList: () => ipcRenderer.invoke('icons:list'),
|
||||
iconsExtract: () => ipcRenderer.invoke('icons:extract'),
|
||||
|
||||
// Updates
|
||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
|
||||
onUpdateAvailable: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:available', l);
|
||||
return () => ipcRenderer.removeListener('update:available', l);
|
||||
},
|
||||
onUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:progress', l);
|
||||
return () => ipcRenderer.removeListener('update:progress', l);
|
||||
},
|
||||
|
||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
||||
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
||||
|
||||
254
src/renderer.js
254
src/renderer.js
@@ -117,7 +117,86 @@ const ICON_LABELS = {
|
||||
sword: 'Schwert', pickaxe: 'Spitzhacke', potion: 'Trank', nether: 'Feuer', end: 'Endauge',
|
||||
redstone: 'Redstone', gold: 'Goldbarren', book: 'Buch', map: 'Karte', star: 'Netherstern',
|
||||
};
|
||||
function iconOf(key, size = 44) { return pixelIconSvg(key, size); }
|
||||
// echte Minecraft-Icons (aus den Spieldateien extrahiert), key -> dataUri
|
||||
let mcIcons = {};
|
||||
async function loadMcIcons() {
|
||||
try {
|
||||
let list = await window.api.iconsList();
|
||||
if (!list.length) {
|
||||
const res = await window.api.iconsExtract();
|
||||
list = (res && res.ok) ? res.icons : [];
|
||||
}
|
||||
mcIcons = Object.fromEntries(list.map((i) => [i.key, i]));
|
||||
await buildIsoCubes(); // räumliche Block-Symbole vorbereiten
|
||||
} catch { mcIcons = {}; }
|
||||
}
|
||||
|
||||
// erzeugt die Würfel-Darstellungen einmalig im Voraus
|
||||
async function buildIsoCubes() {
|
||||
const jobs = Object.values(mcIcons)
|
||||
.filter((i) => BLOCK_KEYS.has(i.key))
|
||||
.map((i) => new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => { isoSources[i.key] = img; resolve(); };
|
||||
img.onerror = () => resolve();
|
||||
img.src = i.dataUri;
|
||||
}));
|
||||
await Promise.all(jobs);
|
||||
for (const key of Object.keys(isoSources)) renderIsoCube(key);
|
||||
}
|
||||
|
||||
// Block-Texturen, die als räumlicher Würfel dargestellt werden
|
||||
const BLOCK_KEYS = new Set(['grass', 'dirt', 'stone', 'planks', 'tnt', 'obsidian', 'netherrack',
|
||||
'crafting', 'furnace', 'bookshelf', 'diamondblock', 'goldblock', 'emeraldblock', 'redstoneblock']);
|
||||
let iso3dEnabled = true;
|
||||
const isoCache = {}; // key -> fertiges Würfelbild
|
||||
const isoSources = {}; // key -> geladenes Textur-Bild
|
||||
|
||||
// zeichnet aus einer 16x16-Textur einen isometrischen Würfel (Canvas -> data:-URI)
|
||||
function renderIsoCube(key) {
|
||||
const img = isoSources[key];
|
||||
if (!img) return null;
|
||||
|
||||
const S = 64, c = document.createElement('canvas');
|
||||
c.width = S; c.height = S;
|
||||
const g = c.getContext('2d');
|
||||
g.imageSmoothingEnabled = false;
|
||||
const w = 26, h = 15, cx = S / 2, top = 8;
|
||||
|
||||
const face = (m, bright) => {
|
||||
g.save();
|
||||
g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
||||
g.drawImage(img, 0, 0, 16, 16);
|
||||
g.restore();
|
||||
if (bright !== 1) { // Fläche abdunkeln/aufhellen
|
||||
g.save();
|
||||
g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
||||
g.globalCompositeOperation = bright < 1 ? 'source-atop' : 'lighter';
|
||||
g.fillStyle = bright < 1 ? `rgba(0,0,0,${(1 - bright).toFixed(2)})` : 'rgba(255,255,255,.12)';
|
||||
g.fillRect(0, 0, 16, 16);
|
||||
g.restore();
|
||||
}
|
||||
};
|
||||
// Oberseite (Raute), linke und rechte Seitenfläche
|
||||
face([w / 16, h / 16, -w / 16, h / 16, cx, top], 1);
|
||||
face([w / 16, h / 16, 0, 30 / 16, cx - w, top + h], 0.72);
|
||||
face([w / 16, -h / 16, 0, 30 / 16, cx, top + h * 2], 0.55);
|
||||
|
||||
const out = c.toDataURL('image/png');
|
||||
isoCache[key] = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
// liefert das Icon-Markup: echte Textur bevorzugt, sonst eigene Pixel-Grafik
|
||||
function iconOf(key, size = 44) {
|
||||
const real = mcIcons[key];
|
||||
if (real) {
|
||||
let src = real.dataUri;
|
||||
if (iso3dEnabled && BLOCK_KEYS.has(key) && isoCache[key]) src = isoCache[key];
|
||||
return `<img src="${src}" width="${size}" height="${size}" style="image-rendering:pixelated;display:block" alt="" />`;
|
||||
}
|
||||
return pixelIconSvg(key, size);
|
||||
}
|
||||
|
||||
const LOADER_LABEL = {
|
||||
vanilla: 'Vanilla', fabric: 'Fabric', forge: 'Forge',
|
||||
@@ -177,6 +256,66 @@ function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// ---------- Kontextmenü (Rechtsklick auf eine Instanz) ----------
|
||||
function openContextMenu(x, y) {
|
||||
const menu = el('ctx-menu');
|
||||
menu.classList.remove('hidden');
|
||||
// erst anzeigen, dann Größe messen und am Fensterrand ausrichten
|
||||
const r = menu.getBoundingClientRect();
|
||||
const px = Math.min(x, window.innerWidth - r.width - 6);
|
||||
const py = Math.min(y, window.innerHeight - r.height - 6);
|
||||
menu.style.left = Math.max(6, px) + 'px';
|
||||
menu.style.top = Math.max(6, py) + 'px';
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
el('ctx-menu').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------- Automatische Updates ----------
|
||||
let pendingUpdate = null;
|
||||
|
||||
function showUpdateDialog(info) {
|
||||
pendingUpdate = info;
|
||||
el('upd-current').textContent = 'v' + info.currentVersion;
|
||||
el('upd-new').textContent = 'v' + info.latestVersion;
|
||||
el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.';
|
||||
el('upd-progress').classList.add('hidden');
|
||||
el('upd-bar-fill').style.width = '0%';
|
||||
el('upd-install').disabled = !info.asset;
|
||||
el('upd-install').textContent = info.asset ? 'Installieren' : 'Kein Installer im Release';
|
||||
show('modal-update');
|
||||
}
|
||||
|
||||
async function startUpdate() {
|
||||
if (!pendingUpdate || !pendingUpdate.asset) return;
|
||||
const btn = el('upd-install');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wird installiert …';
|
||||
el('upd-progress').classList.remove('hidden');
|
||||
el('upd-status').textContent = 'Wird heruntergeladen …';
|
||||
|
||||
const res = await window.api.updateInstall(pendingUpdate.asset);
|
||||
if (res.ok) {
|
||||
el('upd-status').textContent = 'Installation wird gestartet – der Launcher schließt sich.';
|
||||
} else {
|
||||
el('upd-status').textContent = '✖ ' + (res.message || 'Update fehlgeschlagen.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Erneut versuchen';
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateProgress(p) {
|
||||
if (p.phase === 'download') {
|
||||
el('upd-bar-fill').style.width = p.percent + '%';
|
||||
const mb = (n) => (n / 1048576).toFixed(1);
|
||||
el('upd-status').textContent = `Wird heruntergeladen … ${p.percent}% (${mb(p.loaded)} / ${mb(p.total)} MB)`;
|
||||
} else if (p.phase === 'ready') {
|
||||
el('upd-bar-fill').style.width = '100%';
|
||||
el('upd-status').textContent = 'Download fertig – Installation startet …';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Zustand der Anmelde-Dienste in der Statusleiste ----------
|
||||
function showAuthHealth(h) {
|
||||
const box = el('status-auth');
|
||||
@@ -376,6 +515,13 @@ function buildCard(inst) {
|
||||
card.addEventListener('click', () => selectInstance(inst.id));
|
||||
card.addEventListener('dblclick', () => launch(inst.id));
|
||||
|
||||
// Rechtsklick: Instanz auswählen und Kontextmenü öffnen
|
||||
card.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY); // sofort anzeigen
|
||||
selectInstance(inst.id); // Auswahl folgt
|
||||
});
|
||||
|
||||
// Drag & Drop in Gruppen
|
||||
card.draggable = true;
|
||||
card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', inst.id); card.classList.add('dragging'); });
|
||||
@@ -530,14 +676,23 @@ async function updateLoaderVersions(preselect) {
|
||||
}
|
||||
|
||||
// ---------- Instanz-Dialog ----------
|
||||
// alle wählbaren Icons: echte Minecraft-Texturen zuerst, dann die eigenen
|
||||
function availableIcons() {
|
||||
const real = Object.values(mcIcons).map((i) => ({ key: i.key, label: i.label, real: true }));
|
||||
const own = ICON_KEYS
|
||||
.filter((k) => !mcIcons[k])
|
||||
.map((k) => ({ key: k, label: ICON_LABELS[k] || k, real: false }));
|
||||
return real.concat(own);
|
||||
}
|
||||
|
||||
function fillIconSelect(selected) {
|
||||
const sel = el('f-icon');
|
||||
sel.innerHTML = '';
|
||||
for (const key of ICON_KEYS) {
|
||||
for (const it of availableIcons()) {
|
||||
const o = document.createElement('option');
|
||||
o.value = key;
|
||||
o.textContent = ICON_LABELS[key] || key;
|
||||
if (key === selected) o.selected = true;
|
||||
o.value = it.key;
|
||||
o.textContent = it.label;
|
||||
if (it.key === selected) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
renderIconChoices(selected);
|
||||
@@ -548,15 +703,15 @@ function renderIconChoices(selected) {
|
||||
const box = el('f-icon-grid');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
for (const key of ICON_KEYS) {
|
||||
for (const it of availableIcons()) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'icon-choice' + (key === selected ? ' on' : '');
|
||||
b.title = ICON_LABELS[key] || key;
|
||||
b.innerHTML = pixelIconSvg(key, 26);
|
||||
b.className = 'icon-choice' + (it.key === selected ? ' on' : '');
|
||||
b.title = it.label;
|
||||
b.innerHTML = iconOf(it.key, 24);
|
||||
b.addEventListener('click', () => {
|
||||
el('f-icon').value = key;
|
||||
renderIconChoices(key);
|
||||
el('f-icon').value = it.key;
|
||||
renderIconChoices(it.key);
|
||||
});
|
||||
box.appendChild(b);
|
||||
}
|
||||
@@ -725,7 +880,8 @@ async function launch(id, accountId) {
|
||||
if (res.ok) {
|
||||
status('Läuft (PID ' + res.pid + ')');
|
||||
toast('Minecraft gestartet.');
|
||||
openConsole();
|
||||
const s = await window.api.getSettings();
|
||||
if (s.showConsoleOnLaunch) openConsole(); // nur wenn gewünscht
|
||||
} else if (res.needAuth) {
|
||||
status('Bereit'); el('status-right').textContent = '';
|
||||
toast(res.message, false);
|
||||
@@ -748,6 +904,12 @@ function onLaunchEvt(evt) {
|
||||
status('Minecraft beendet (Code ' + evt.code + ')');
|
||||
el('status-right').textContent = '';
|
||||
appendConsole('\n[Prozess beendet, Code ' + evt.code + ']\n');
|
||||
// Konsole je nach Einstellung schließen bzw. bei Absturz zeigen
|
||||
window.api.getSettings().then((s) => {
|
||||
const crashed = evt.code !== 0 && evt.code !== null;
|
||||
if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; }
|
||||
if (s.autoCloseConsole) hide('modal-console');
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const label = labels[evt.phase] || evt.phase;
|
||||
@@ -774,9 +936,10 @@ function appendLineEl(out, line) {
|
||||
div.textContent = line;
|
||||
out.appendChild(div);
|
||||
}
|
||||
let consoleMaxChars = 300000; // aus den Einstellungen abgeleitet (Zeilen x ~80 Zeichen)
|
||||
function appendConsole(text) {
|
||||
consoleBuffer += text;
|
||||
if (consoleBuffer.length > 300000) consoleBuffer = consoleBuffer.slice(-220000);
|
||||
if (consoleBuffer.length > consoleMaxChars) consoleBuffer = consoleBuffer.slice(-Math.round(consoleMaxChars * 0.75));
|
||||
consolePending += text;
|
||||
const lines = consolePending.split('\n');
|
||||
consolePending = lines.pop();
|
||||
@@ -1057,6 +1220,15 @@ async function openSettings() {
|
||||
}
|
||||
sel.value = s.bgSkin || '';
|
||||
el('s-bgskin-pose').value = s.bgSkinPose || 'walking';
|
||||
el('s-sortby').value = s.sortBy || 'lastPlayed';
|
||||
el('s-iso3d').checked = s.iso3dIcons !== false;
|
||||
el('s-console-launch').checked = !!s.showConsoleOnLaunch;
|
||||
el('s-console-autoclose').checked = !!s.autoCloseConsole;
|
||||
el('s-console-crash').checked = s.showConsoleOnCrash !== false;
|
||||
el('s-console-lines').value = s.consoleMaxLines || 100000;
|
||||
el('s-mc-max').checked = !!s.mcMaximized;
|
||||
el('s-mc-width').value = s.mcWidth || 854;
|
||||
el('s-mc-height').value = s.mcHeight || 480;
|
||||
el('s-bgskin-opacity').value = s.bgSkinOpacity ?? 55;
|
||||
el('s-bgskin-val').textContent = String(s.bgSkinOpacity ?? 55);
|
||||
el('s-proxy-enabled').checked = !!s.proxyEnabled;
|
||||
@@ -1113,6 +1285,15 @@ async function saveSettings() {
|
||||
globalPostExit: el('s-global-post').value.trim(),
|
||||
bgSkin: el('s-bgskin').value,
|
||||
bgSkinPose: el('s-bgskin-pose').value,
|
||||
sortBy: el('s-sortby').value,
|
||||
iso3dIcons: el('s-iso3d').checked,
|
||||
showConsoleOnLaunch: el('s-console-launch').checked,
|
||||
autoCloseConsole: el('s-console-autoclose').checked,
|
||||
showConsoleOnCrash: el('s-console-crash').checked,
|
||||
consoleMaxLines: numOrNull(el('s-console-lines').value) || 100000,
|
||||
mcMaximized: el('s-mc-max').checked,
|
||||
mcWidth: numOrNull(el('s-mc-width').value) || 854,
|
||||
mcHeight: numOrNull(el('s-mc-height').value) || 480,
|
||||
bgSkinOpacity: numOrNull(el('s-bgskin-opacity').value) || 55,
|
||||
wrapperCommand: el('s-wrapper').value.trim(),
|
||||
proxyEnabled: el('s-proxy-enabled').checked,
|
||||
@@ -1127,6 +1308,7 @@ async function saveSettings() {
|
||||
};
|
||||
await window.api.saveSettings(patch);
|
||||
applyTheme(patch.theme);
|
||||
iso3dEnabled = patch.iso3dIcons;
|
||||
await applySkinBackground();
|
||||
hide('modal-settings');
|
||||
toast('Einstellungen gespeichert.');
|
||||
@@ -1308,6 +1490,24 @@ function wire() {
|
||||
await applySkinBackground();
|
||||
});
|
||||
|
||||
// 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 (target) target.click();
|
||||
});
|
||||
});
|
||||
// schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#ctx-menu')) closeContextMenu();
|
||||
});
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu();
|
||||
});
|
||||
document.addEventListener('scroll', closeContextMenu, true);
|
||||
window.addEventListener('blur', closeContextMenu);
|
||||
|
||||
// Warnung anklicken -> sofort erneut prüfen
|
||||
el('status-auth').addEventListener('click', async () => {
|
||||
el('status-auth').textContent = 'Prüfe Anmelde-Dienste …';
|
||||
@@ -1432,6 +1632,23 @@ function wire() {
|
||||
}));
|
||||
el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); });
|
||||
|
||||
// Updates
|
||||
el('upd-install').addEventListener('click', startUpdate);
|
||||
el('s-check-update').addEventListener('click', async () => {
|
||||
const st = el('s-update-status');
|
||||
st.textContent = 'Suche nach Updates …';
|
||||
const info = await window.api.updateCheck();
|
||||
if (info.error) { st.textContent = '✖ ' + info.error; return; }
|
||||
if (info.noReleases) { st.textContent = 'Noch keine Veröffentlichung vorhanden.'; return; }
|
||||
if (info.available) {
|
||||
st.textContent = `Version ${info.latestVersion} verfügbar.`;
|
||||
hide('modal-settings');
|
||||
showUpdateDialog(info);
|
||||
} else {
|
||||
st.textContent = `Du hast bereits die neueste Version (v${info.currentVersion}).`;
|
||||
}
|
||||
});
|
||||
|
||||
// Microsoft-Login / Kontoverwaltung
|
||||
el('btn-account').addEventListener('click', openLogin);
|
||||
el('login-add').addEventListener('click', addAccount);
|
||||
@@ -1457,6 +1674,7 @@ function wire() {
|
||||
m.addEventListener('mousedown', (e) => { if (e.target === m) m.classList.add('hidden'); }));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
|
||||
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
|
||||
if (open) open.classList.add('hidden');
|
||||
}
|
||||
@@ -1466,13 +1684,21 @@ function wire() {
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
wire();
|
||||
applyIcons();
|
||||
try { const s = await window.api.getSettings(); applyTheme(s.theme); } catch { applyTheme('dark'); }
|
||||
try {
|
||||
const s = await window.api.getSettings();
|
||||
applyTheme(s.theme);
|
||||
iso3dEnabled = s.iso3dIcons !== false;
|
||||
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
|
||||
} catch { applyTheme('dark'); }
|
||||
await applySkinBackground();
|
||||
window.api.onLaunchProgress(onLaunchEvt);
|
||||
window.api.onAuthCode(onAuthCode);
|
||||
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await applySkinBackground(); });
|
||||
window.api.onAuthHealth(showAuthHealth);
|
||||
window.api.onUpdateAvailable(showUpdateDialog);
|
||||
window.api.onUpdateProgress(onUpdateProgress);
|
||||
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
|
||||
await loadMcIcons(); // echte Minecraft-Icons bereitstellen
|
||||
await refreshAccountButton();
|
||||
await reload();
|
||||
|
||||
|
||||
13
src/store.js
13
src/store.js
@@ -56,6 +56,15 @@ const DEFAULT_SETTINGS = {
|
||||
globalPreLaunch: '',
|
||||
globalPostExit: '',
|
||||
lastSelectedId: '', // zuletzt ausgewählte Instanz (beim Start wieder markieren)
|
||||
showConsoleOnLaunch: false, // Konsole beim Spielstart anzeigen
|
||||
autoCloseConsole: false, // Konsole nach Spielende automatisch schließen
|
||||
showConsoleOnCrash: true, // Konsole anzeigen, wenn das Spiel abstürzt
|
||||
consoleMaxLines: 100000, // Zeilenbegrenzung der Konsole
|
||||
mcMaximized: false, // Minecraft maximiert starten
|
||||
mcWidth: 854,
|
||||
mcHeight: 480,
|
||||
sortBy: 'lastPlayed', // lastPlayed | name
|
||||
iso3dIcons: true, // Block-Symbole räumlich darstellen
|
||||
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
|
||||
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
|
||||
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
|
||||
@@ -204,8 +213,10 @@ function listInstances() {
|
||||
result.push(meta);
|
||||
}
|
||||
}
|
||||
// zuletzt gespielt zuerst, dann alphabetisch
|
||||
// Sortierung laut Einstellung: zuletzt gespielt oder nach Name
|
||||
const byName = getSettings().sortBy === 'name';
|
||||
result.sort((a, b) => {
|
||||
if (byName) return a.name.localeCompare(b.name);
|
||||
if (a.lastPlayed && b.lastPlayed) return b.lastPlayed.localeCompare(a.lastPlayed);
|
||||
if (a.lastPlayed) return -1;
|
||||
if (b.lastPlayed) return 1;
|
||||
|
||||
@@ -66,6 +66,7 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
||||
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc}
|
||||
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
|
||||
:root[data-theme="dark"] .mods-filters select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
|
||||
:root[data-theme="dark"] .upd-notes{background:#1e1e1e;color:var(--text)}
|
||||
:root[data-theme="dark"] .modal-close{color:#aaa}
|
||||
:root[data-theme="dark"] .modal-close:hover{color:#fff}
|
||||
:root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)}
|
||||
@@ -102,15 +103,15 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
||||
}
|
||||
.list{position:relative;z-index:1}
|
||||
.list{padding:4px 8px}
|
||||
.side{background:var(--win);border-left:1px solid var(--border);overflow:auto;padding:12px 8px}
|
||||
.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px}
|
||||
.side-empty{text-align:center;color:var(--dim);padding:24px 8px}
|
||||
.side-ico{width:56px;height:56px;margin:2px auto 6px;display:grid;place-items:center;font-size:40px}
|
||||
.side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px}
|
||||
.side-ico img{width:56px;height:56px;object-fit:cover;border-radius:3px}
|
||||
.side-title{text-align:center;font-weight:600;font-size:14px;word-break:break-word}
|
||||
.side-grp{text-align:center;color:var(--link);font-size:11px;margin-top:2px;cursor:pointer}
|
||||
.side-grp:hover{text-decoration:underline}
|
||||
.hr{height:1px;background:var(--border);margin:8px 2px}
|
||||
.sbtn{display:block;width:100%;text-align:center;padding:6px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)}
|
||||
.hr{height:1px;background:var(--border);margin:6px 2px}
|
||||
.sbtn{display:block;width:100%;text-align:center;padding:5px 4px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)}
|
||||
.sbtn:hover{background:#e2e6ec;border-color:var(--border2)}
|
||||
.sbtn.play{font-weight:600}
|
||||
.sbtn.del:hover{color:var(--danger)}
|
||||
@@ -174,14 +175,16 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
||||
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)}
|
||||
.field textarea{resize:vertical}
|
||||
.field-row{display:flex;gap:12px}.field-row .field{flex:1}
|
||||
.field-icon{max-width:150px}
|
||||
.field-icon{max-width:210px;min-width:0}
|
||||
.hint{font-size:12px}
|
||||
.path-row{display:flex;gap:8px}.path-row input{flex:1}
|
||||
.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
|
||||
.advanced legend{color:var(--dim);font-size:12px;padding:0 6px}
|
||||
.version-row{display:flex;gap:8px}.version-row #f-version-type{max-width:120px}.version-row #f-version{flex:1}
|
||||
.icon-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:3px;margin-top:6px;max-width:100%}
|
||||
.icon-choice{display:flex;align-items:center;justify-content:center;padding:3px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer}
|
||||
.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:3px;margin-top:6px;
|
||||
width:100%;max-width:100%;max-height:132px;overflow-y:auto;overflow-x:hidden;box-sizing:border-box}
|
||||
.icon-choice{display:flex;align-items:center;justify-content:center;padding:2px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;min-width:0;overflow:hidden}
|
||||
.icon-choice img{width:24px;height:24px;image-rendering:pixelated;display:block}
|
||||
.icon-choice:hover{background:rgba(128,128,128,.18)}
|
||||
.icon-choice.on{border-color:var(--accent);background:var(--sel)}
|
||||
.icon-choice svg{display:block}
|
||||
@@ -292,6 +295,33 @@ img.account-av{object-fit:cover;image-rendering:pixelated}
|
||||
.acct-av svg{width:18px;height:18px;color:#4b5563}
|
||||
.acct-head{width:24px;height:26px;object-fit:contain;image-rendering:pixelated;margin-top:-2px;margin-bottom:-2px}
|
||||
|
||||
/* Update-Dialog */
|
||||
.upd-version{display:flex;align-items:center;justify-content:center;gap:12px;margin:4px 0 14px;font-size:15px}
|
||||
.upd-old{color:var(--dim)}
|
||||
.upd-arrow{color:var(--dim)}
|
||||
.upd-newv{font-weight:700;color:var(--accent)}
|
||||
.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px}
|
||||
.upd-progress{margin:10px 0}
|
||||
.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden}
|
||||
.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s}
|
||||
|
||||
/* Kontextmenü (Rechtsklick auf Instanz) */
|
||||
.ctx{
|
||||
position:fixed;z-index:200;min-width:210px;padding:4px;
|
||||
background:var(--win);border:1px solid var(--border);border-radius:4px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.35);
|
||||
}
|
||||
.ctx-item{
|
||||
display:block;width:100%;text-align:left;padding:6px 12px;
|
||||
border:none;border-radius:3px;background:none;color:var(--text);
|
||||
font-family:inherit;font-size:12px;cursor:pointer;white-space:nowrap;
|
||||
}
|
||||
.ctx-item:hover{background:var(--sel);color:var(--text)}
|
||||
.ctx-item:disabled{opacity:.45;cursor:default;background:none}
|
||||
.ctx-strong{font-weight:600}
|
||||
.ctx-danger:hover{background:var(--danger);color:#fff}
|
||||
.ctx-sep{height:1px;background:var(--border);margin:4px 6px}
|
||||
|
||||
/* Toast */
|
||||
.toast{position:fixed;bottom:42px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:4px;box-shadow:0 6px 20px rgba(0,0,0,.4);z-index:100;font-size:13px}
|
||||
.toast.err{background:#a5342a}
|
||||
|
||||
115
src/updater.js
Normal file
115
src/updater.js
Normal file
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* updater.js – automatische Updates über die Gitea-Releases
|
||||
* ---------------------------------------------------------
|
||||
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
|
||||
* bei Bedarf den Windows-Installer herunterladen und starten.
|
||||
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
|
||||
// "1.2.3" -> [1,2,3]; führendes "v" und Zusätze wie "-beta" werden ignoriert
|
||||
function parseVersion(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||
if (!m) return null;
|
||||
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
||||
}
|
||||
|
||||
// >0 wenn a neuer als b
|
||||
function compareVersions(a, b) {
|
||||
const x = parseVersion(a); const y = parseVersion(b);
|
||||
if (!x || !y) return 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (x[i] !== y[i]) return x[i] - y[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Windows-Installer im Release finden (.exe bevorzugt, sonst .zip)
|
||||
function pickAsset(release) {
|
||||
const assets = release.assets || [];
|
||||
return assets.find((a) => /\.exe$/i.test(a.name))
|
||||
|| assets.find((a) => /setup.*\.exe$/i.test(a.name))
|
||||
|| assets.find((a) => /\.zip$/i.test(a.name))
|
||||
|| null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Prüft auf Updates.
|
||||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||||
*/
|
||||
async function checkForUpdate(currentVersion) {
|
||||
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
||||
const list = await res.json();
|
||||
const releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
|
||||
if (!releases.length) {
|
||||
return { available: false, noReleases: true, currentVersion };
|
||||
}
|
||||
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
|
||||
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
||||
const latest = releases[0];
|
||||
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
||||
const asset = pickAsset(latest);
|
||||
return {
|
||||
available,
|
||||
currentVersion,
|
||||
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
|
||||
notes: latest.body || '',
|
||||
published: latest.published_at || latest.created_at || null,
|
||||
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size } : null,
|
||||
url: latest.html_url,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
||||
* onProgress: ({ phase, loaded, total, percent })
|
||||
*/
|
||||
async function downloadAndInstall(asset, onProgress) {
|
||||
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
||||
|
||||
const dir = path.join(os.tmpdir(), 'aeromc-update');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||
|
||||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
|
||||
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
||||
const chunks = [];
|
||||
let loaded = 0;
|
||||
for await (const chunk of res.body) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
loaded += chunk.length;
|
||||
if (onProgress) {
|
||||
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
||||
}
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (total && buf.length !== total) throw new Error('Download unvollständig.');
|
||||
fs.writeFileSync(dest, buf);
|
||||
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
if (onProgress) onProgress({ phase: 'ready', loaded: buf.length, total: buf.length, percent: 100 });
|
||||
|
||||
return { path: dest, bytes: buf.length, sha256 };
|
||||
}
|
||||
|
||||
// Startet den Installer und löst sich vom Launcher (damit er sich selbst ersetzen kann)
|
||||
function runInstaller(installerPath) {
|
||||
const child = spawn(installerPath, [], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { checkForUpdate, downloadAndInstall, runInstaller, compareVersions, parseVersion };
|
||||
Reference in New Issue
Block a user