Files
AeroMc-Launcher/src/renderer.js
T

4433 lines
180 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* renderer.js UI-Logik für AeroMC */
// ---------- Instanz-Icons: eigene Pixel-Grafiken im Minecraft-Stil ----------
// Jedes Icon ist ein 16x16-Raster; jedes Zeichen verweist auf eine Farbe der Palette.
const PIX = {
grass: { p: { a: '#7cbd56', b: '#63a343', c: '#8b6b4a', d: '#6f5238' }, r: [
'aaaaaaaaaaaaaaaa', 'abaabaaabaabaaab', 'aaaaaaaaaaaaaaaa', 'babaabaabaaabaab',
'cccccccccccccccc', 'cdcccdcccccdccdc', 'ccccccccdcccccdc', 'cccdccccccccdccc',
'dcccccdcccccdccc', 'ccccdccccdccccdc', 'cdccccccccccccdc', 'ccccccdcccccdccc',
'ccdcccccccdccccc', 'cccccccdccccccdc', 'cdccccccccccdccc', 'cccccccccccccccc',
] },
creeper: { p: { a: '#7cbd56', b: '#5f9c42', k: '#0d0f0d' }, r: [
'abbaabbaabbaabba', 'baabbaabbaabbaab', 'abbaabbaabbaabba', 'aakkaaaaaaaakkaa',
'aakkaaaaaaaakkaa', 'aaaaaakkkkaaaaaa', 'aaaaakkkkkkaaaaa', 'aaaaakkkkkkaaaaa',
'aaaaakkaakkaaaaa', 'aaaaakkaakkaaaaa', 'abbaabbaabbaabba', 'baabbaabbaabbaab',
'abbaabbaabbaabba', 'baabbaabbaabbaab', 'abbaabbaabbaabba', 'baabbaabbaabbaab',
] },
diamond: { p: { a: '#5ce4e0', b: '#3ec6c9', c: '#2aa3ab', w: '#c9fbff', t: null }, r: [
'tttttttttttttttt', 'tttttttttttttttt', 'tttttaaaaaattttt', 'ttttawwwaaaatttt',
'tttawwbbbaaaattt', 'ttawwbbbbbaaaatt', 'tawwbbbbbbbaaaat', 'abbbbbbbbbbbccca',
'abbbbbbbbbbbccca', 'tabbbbbbbbbcccat', 'ttabbbbbbbcccatt', 'tttabbbbbcccattt',
'ttttabbbcccatttt', 'tttttabcccattttt', 'ttttttaccatttttt', 'tttttttaattttttt',
] },
tnt: { p: { r: '#c0392b', w: '#f2f2f2', k: '#2b2b2b', d: '#8e2a20' }, r: [
'rrrrrrrrrrrrrrrr', 'rdrrrdrrrrdrrrdr', 'wwwwwwwwwwwwwwww', 'wkkwkkwkkwkkwkkw',
'wkwkwkwkwkwkwkwk', 'wwwwwwwwwwwwwwww', 'rrrrrrrrrrrrrrrr', 'rdrrrrrdrrrrrdrr',
'rrrrdrrrrrdrrrrr', 'rrdrrrrrdrrrrrdr', 'rrrrrdrrrrrdrrrr', 'rdrrrrrrdrrrrrrr',
'rrrrdrrrrrrrdrrr', 'rrdrrrrdrrrrrrdr', 'rrrrrrrrrrrrrrrr', 'rrrrrrrrrrrrrrrr',
] },
chest: { p: { a: '#a97142', b: '#8a5a33', c: '#6b4423', k: '#3a2716', y: '#e0c060' }, r: [
'cccccccccccccccc', 'cabababababababc', 'cbababababababac', 'cabababababababc',
'ckkkkkkkkkkkkkkc', 'cabababababababc', 'cababyyyyyybabac', 'cababykkkkybabac',
'cababyyyyyybabac', 'cabababababababc', 'cabababababababc', 'cabababababababc',
'cabababababababc', 'cabababababababc', 'cabababababababc', 'cccccccccccccccc',
] },
sword: { p: { s: '#d8dde6', l: '#f4f7fb', d: '#9aa3b0', h: '#8a5a33', g: '#e0c060', t: null }, r: [
'tttttttttttttsst', 'ttttttttttttslls', 'tttttttttttslldt', 'ttttttttttslldtt',
'tttttttttslldttt', 'ttttttttslldtttt', 'tttttttslldttttt', 'ttttttslldtttttt',
'tttttslldttttttt', 'ttttslldtttttttt', 'tttgllgdtttttttt', 'tthhghhttttttttt',
'ttthhhtttttttttt', 'tttthhtttttttttt', 'ttttthtttttttttt', 'tttttttttttttttt',
] },
pickaxe: { p: { s: '#c9ced6', l: '#eef2f7', d: '#8e959f', h: '#8a5a33', b: '#6b4423', t: null }, r: [
'tttssssttssssttt', 'ttslllsddslllstt', 'tsllddsttsddllst', 'tsldttttttttdlst',
'ttdttttsstttttdt', 'tttttthhtttttttt', 'ttttttbhtttttttt', 'ttttttthbttttttt',
'tttttttbhttttttt', 'ttttttttbhtttttt', 'ttttttttthbttttt', 'ttttttttttbhtttt',
'tttttttttttbhttt', 'ttttttttttttbhtt', 'tttttttttttttbht', 'tttttttttttttttt',
] },
potion: { p: { g: '#cfd8e3', l: '#eef3fa', v: '#c0392b', d: '#8e2a20', k: '#5a4632', t: null }, r: [
'ttttttkkkktttttt', 'tttttkllkktttttt', 'tttttkllkttttttt', 'tttttkllkttttttt',
'ttttkglllgkttttt', 'tttkgllllllgkttt', 'ttkglvvvvvvlgktt', 'tkglvvvvvvvvlgkt',
'tkglvvvvvvvvlgkt', 'tkglvvvvvvvvlgkt', 'tkgdvvvvvvvvdgkt', 'tkgdvvvvvvvvdgkt',
'ttkgdvvvvvvdgktt', 'tttkgdvvvvdgkttt', 'ttttkkgggggkkttt', 'tttttkkkkkkkkttt',
] },
nether: { p: { a: '#e07b39', b: '#c9542a', c: '#8f2f18', y: '#f7c948', t: null }, r: [
'tttttttyyttttttt', 'ttttttyaaytttttt', 'tttttyaaaayttttt', 'ttttyaaaaaaytttt',
'tttyaabbaaaayttt', 'ttyaabbbbaaayttt', 'tyaabbbbbbaaaytt', 'yaabbbcccbbbaayt',
'yabbbcccccbbbayt', 'yabbcccccccbbayt', 'yabbcccccccbbayt', 'tyabcccccccbaytt',
'ttyabcccccbayttt', 'tttyabcccbaytttt', 'ttttyaabbayttttt', 'tttttyyyyytttttt',
] },
end: { p: { a: '#2f6f66', b: '#3f8f83', c: '#1f4a45', w: '#bff5ea', t: null }, r: [
'tttttbbbbbbttttt', 'tttbbaaaaaabbttt', 'ttbaaaawwaaaabtt', 'tbaaawwwwwwaaabt',
'baaawwbbbbwwaaab', 'baawwbbaabbwwaab', 'baawbbaaaabbwaab', 'bawbbaaaaaabbwab',
'bawbbaaaaaabbwab', 'baawbbaaaabbwaab', 'baawwbbaabbwwaab', 'baaawwbbbbwwaaab',
'tbaaawwwwwwaaabt', 'ttbaaaawwaaaabtt', 'tttbbaaaaaabbttt', 'tttttbbbbbbttttt',
] },
redstone: { p: { a: '#c0392b', b: '#8e2a20', c: '#5c1a13', g: '#3a3a3a', t: null }, r: [
'tttttttggttttttt', 'ttttttgaagtttttt', 'tttttgaaaagttttt', 'ttttgaabbaagtttt',
'tttgaabbbbaagttt', 'ttgaabbbbbbaagtt', 'tgaabbbccbbbaagt', 'gaabbbcccccbbaag',
'gaabbbcccccbbaag', 'tgaabbbccbbbaagt', 'ttgaabbbbbbaagtt', 'tttgaabbbbaagttt',
'ttttgaabbaagtttt', 'tttttgaaaagttttt', 'ttttttgaagtttttt', 'tttttttggttttttt',
] },
gold: { p: { a: '#f7d774', b: '#e0b53c', c: '#b8891f', t: null }, r: [
'tttttttttttttttt', 'tttttttttttttttt', 'tttaaaaaaaaaattt', 'ttaaaaaaaaaaaatt',
'taaabbbbbbbbaaat', 'taabbbbbbbbbbaat', 'taabbbbbbbbbbaat', 'tabbbbbbbbbbbbat',
'tabbbbbbbbbbbbat', 'tabbbcccccbbbbat', 'taccccccccccccat', 'taccccccccccccat',
'ttcccccccccccctt', 'tttccccccccccttt', 'tttttttttttttttt', 'tttttttttttttttt',
] },
book: { p: { a: '#8a5a33', b: '#6b4423', w: '#f0e6d2', y: '#e0c060', t: null }, r: [
'tbbbbbbbbbbbbbbt', 'bawwwwwwwwwwwwab', 'bawwwwwwwwwwwwab', 'bawwbbbwwbbbwwab',
'bawwwwwwwwwwwwab', 'bawwbbbbwbbbbwab', 'bawwwwwwwwwwwwab', 'bawwbbbwwbbbwwab',
'bawwwwwwwwwwwwab', 'bawwbbbbwbbbbwab', 'bawwwwwwwwwwwwab', 'bawwwwyywwwwwwab',
'bawwwwyywwwwwwab', 'bawwwwwwwwwwwwab', 'bawwwwwwwwwwwwab', 'tbbbbbbbbbbbbbbt',
] },
map: { p: { a: '#e8dcc0', b: '#cdbb95', c: '#8a5a33', g: '#7cbd56', u: '#4a90d9' }, r: [
'cccccccccccccccc', 'caaaaaaaaaaaaaac', 'cabbaaaaaaaaaabc', 'caaauuuaaggaaaac',
'caaauuuaagggaaac', 'caaaauaaaggaaaac', 'caaaaaaaaaaaaaac', 'caagggaaaaauuaac',
'caaggggaaaauuaac', 'caagggaaaaauuaac', 'caaaaaaaaaaaaaac', 'caaaaaabbaaaaaac',
'caaaaabbbbaaaaac', 'caaaaaabbaaaaaac', 'caaaaaaaaaaaaaac', 'cccccccccccccccc',
] },
star: { p: { a: '#ffffff', b: '#dfe6f2', c: '#9fb0cc', t: null }, r: [
'tttttttaattttttt', 'tttttttaattttttt', 'ttttttbaabtttttt', 'ttttttbaabtttttt',
'tttttbbaabbttttt', 'ttattbbaabbttatt', 'tabaabbaabbaabat', 'taaaaaaaaaaaaaat',
'taaaaaaaaaaaaaat', 'tabaabbaabbaabat', 'ttcttbbaabbttctt', 'tttttbbaabbttttt',
'ttttttbaabtttttt', 'ttttttbaabtttttt', 'tttttttaattttttt', 'tttttttaattttttt',
] },
};
// baut aus dem Raster ein SVG
function pixelIconRects(key, scale = 1, offsetX = 0, offsetY = 0) {
const def = PIX[key] || PIX.grass;
let rects = '';
def.r.forEach((row, y) => {
for (let x = 0; x < 16 && x < row.length; x++) {
const col = def.p[row[x]];
if (!col || col === '#00000000') continue;
rects += `<rect x="${offsetX + (x * scale)}" y="${offsetY + (y * scale)}" width="${scale}" height="${scale}" fill="${col}"/>`;
}
});
return rects;
}
function pixelIconSvg(key, size) {
const rects = pixelIconRects(key);
return `<svg viewBox="0 0 16 16" width="${size}" height="${size}">${rects}</svg>`;
}
const HD_ICON_THEMES = {
grass: { glyph: 'loop', bg: '#46b96c', shade: '#1f5b33', glow: '#95f08a' },
dirt: { glyph: 'cube', bg: '#a96d43', shade: '#58311a', glow: '#d9a56e' },
stone: { glyph: 'cube', bg: '#98a3b3', shade: '#495364', glow: '#dfe5ef' },
planks: { glyph: 'cube', bg: '#cf9b61', shade: '#704521', glow: '#f2cb97' },
tnt: { glyph: 'crate', bg: '#db4d45', shade: '#6d1d18', glow: '#fff2d2' },
obsidian: { glyph: 'cube', bg: '#6f5cd7', shade: '#25184d', glow: '#b8abff' },
netherrack: { glyph: 'cube', bg: '#c14f4f', shade: '#5d1717', glow: '#ff9a7b' },
crafting: { glyph: 'grid', bg: '#bf8750', shade: '#613a20', glow: '#f5d5a4' },
furnace: { glyph: 'forge', bg: '#7e8997', shade: '#38414c', glow: '#ffbd67' },
bookshelf: { glyph: 'books', bg: '#b77a4d', shade: '#5b331f', glow: '#ffd966' },
diamondblock: { glyph: 'cube', bg: '#42d0d7', shade: '#14606d', glow: '#dfffff' },
goldblock: { glyph: 'cube', bg: '#f0bf3c', shade: '#8b5a09', glow: '#fff1a1' },
emeraldblock: { glyph: 'cube', bg: '#2ec97b', shade: '#0b5a35', glow: '#b4ffd7' },
redstoneblock: { glyph: 'cube', bg: '#e14d51', shade: '#75191d', glow: '#ffb1b4' },
diamond: { glyph: 'gem', bg: '#47dce0', shade: '#126b79', glow: '#defcff' },
emerald: { glyph: 'gem', bg: '#2fd485', shade: '#0d683c', glow: '#d8ffe8' },
gold: { glyph: 'ingot', bg: '#f2c44e', shade: '#8d6208', glow: '#fff3b4' },
iron: { glyph: 'ingot', bg: '#b9c3d1', shade: '#57606d', glow: '#f5f8fc' },
redstone: { glyph: 'spark', bg: '#ea5956', shade: '#7e1717', glow: '#ffc8c6' },
coal: { glyph: 'coal', bg: '#59606b', shade: '#1f242b', glow: '#cfd5df' },
sword: { glyph: 'sword', bg: '#5d9ff4', shade: '#1f477c', glow: '#eef6ff' },
pickaxe: { glyph: 'pickaxe', bg: '#57a8de', shade: '#1f5276', glow: '#ebf8ff' },
axe: { glyph: 'axe', bg: '#6997df', shade: '#27456c', glow: '#eef4ff' },
bow: { glyph: 'bow', bg: '#b97a58', shade: '#5c3522', glow: '#ffe1cb' },
apple: { glyph: 'apple', bg: '#ea5353', shade: '#7e1717', glow: '#ffd3d3' },
goldenapple: { glyph: 'apple', bg: '#f0c44f', shade: '#8c6208', glow: '#fff1b2' },
book: { glyph: 'book', bg: '#7a5ad3', shade: '#33205f', glow: '#ece4ff' },
enchantedbook: { glyph: 'bookSpark', bg: '#6f63e7', shade: '#272266', glow: '#d9fcff' },
map: { glyph: 'map', bg: '#5aa67d', shade: '#1d563f', glow: '#f0f1c9' },
star: { glyph: 'star', bg: '#5f7ce0', shade: '#22357c', glow: '#ffffff' },
endereye: { glyph: 'eye', bg: '#3da984', shade: '#124a40', glow: '#ccfff0' },
enderpearl: { glyph: 'pearl', bg: '#4cc0c7', shade: '#155b62', glow: '#e6ffff' },
blaze: { glyph: 'flame', bg: '#ef8b38', shade: '#7c3a06', glow: '#ffe3a3' },
xp: { glyph: 'bottle', bg: '#3ea977', shade: '#15503a', glow: '#dcffe0' },
cake: { glyph: 'cake', bg: '#f08cab', shade: '#8a3453', glow: '#fff6dc' },
bucket: { glyph: 'bucket', bg: '#5b94dc', shade: '#1f4a79', glow: '#d8f1ff' },
lava: { glyph: 'bucket', bg: '#ec7134', shade: '#862d08', glow: '#ffd4ab' },
tnt_item: { glyph: 'powder', bg: '#9ba2af', shade: '#404753', glow: '#fff4ce' },
creeper: { glyph: 'creeper', bg: '#67c35b', shade: '#234f1c', glow: '#d7ffd0' },
chest: { glyph: 'chest', bg: '#c4884f', shade: '#653a1d', glow: '#f5d28f' },
potion: { glyph: 'potion', bg: '#8d63d9', shade: '#3b2466', glow: '#ffd7f5' },
nether: { glyph: 'flame', bg: '#d65a3b', shade: '#6b2111', glow: '#ffd1a0' },
end: { glyph: 'eye', bg: '#3f8d7d', shade: '#153f39', glow: '#d6fff4' },
};
const LEGACY_REAL_ICON_MAP = {
chest: 'chest',
creeper: 'creeper',
potion: 'potion',
nether: 'blaze',
end: 'endereye',
tnt_item: 'tnt_item',
};
function hdIconTheme(key) {
return HD_ICON_THEMES[key] || { glyph: 'letter', bg: '#5b6474', shade: '#252b34', glow: '#e8edf7' };
}
function hdIconGlyph(key, theme) {
switch (theme.glyph) {
case 'cube':
return `
<path d="M32 18 44 24 32 30 20 24Z"/>
<path d="M20 24v14l12 7 12-7V24"/>
<path d="M32 30v15"/>
`;
case 'loop':
return `
<path d="M22 33c0-4 3-7 7-7 5 0 7 6 11 6 3 0 6-3 6-7s-3-7-7-7c-5 0-7 6-11 6-3 0-6-3-6-7s3-7 7-7"/>
`;
case 'crate':
return `
<rect x="19" y="20" width="26" height="24" rx="5"/>
<path d="M19 28h26M32 20v24M24 24h16M24 40h16"/>
`;
case 'grid':
return `
<rect x="18" y="18" width="28" height="28" rx="7"/>
<path d="M27 18v28M37 18v28M18 27h28M18 37h28"/>
`;
case 'forge':
return `
<rect x="18" y="18" width="28" height="28" rx="7"/>
<path d="M25 27h14v9H25zM24 40h16"/>
`;
case 'books':
return `
<rect x="18" y="17" width="9" height="30" rx="3"/>
<rect x="28" y="15" width="8" height="32" rx="3"/>
<rect x="37" y="19" width="9" height="28" rx="3"/>
`;
case 'gem':
return `
<path d="M32 17 45 29 32 46 19 29Z"/>
<path d="M24 22h16M20 29h24M32 17v29"/>
`;
case 'ingot':
return `
<path d="M22 26h20l5 12H17Z"/>
<path d="M26 21h12l5 5H21Z"/>
`;
case 'spark':
return `
<path d="M32 17 35 26 45 28 37 34 39 44 32 39 25 44 27 34 19 28 29 26Z"/>
`;
case 'coal':
return `
<path d="M24 19h16l7 11-7 15H24l-7-15Z"/>
<path d="M24 19 32 30 40 19"/>
`;
case 'sword':
return `
<path d="M37 18 46 27 35 38 26 29Z"/>
<path d="M28 31 18 41M24 45l-4 4M27 42l-7 2"/>
`;
case 'pickaxe':
return `
<path d="M19 26c7-7 16-9 26-6"/>
<path d="M33 20 22 45"/>
`;
case 'axe':
return `
<path d="M25 19c8 0 15 5 17 13-8 2-14-1-18-8"/>
<path d="M31 19 22 46"/>
`;
case 'bow':
return `
<path d="M24 18c9 8 9 20 0 28"/>
<path d="M39 18v28M27 22 39 32 27 42"/>
`;
case 'apple':
return `
<path d="M32 23c7 0 13 5 13 12s-6 13-13 13-13-6-13-13 6-12 13-12Z"/>
<path d="M32 19c0-3 2-5 5-6M36 18c3 0 5 1 7 3"/>
`;
case 'book':
return `
<path d="M20 19h12c3 0 5 2 5 5v21H24c-2 0-4 1-4 2Z"/>
<path d="M44 19H32c-3 0-5 2-5 5v21h13c2 0 4 1 4 2Z"/>
`;
case 'bookSpark':
return `
<path d="M20 19h12c3 0 5 2 5 5v21H24c-2 0-4 1-4 2Z"/>
<path d="M44 19H32c-3 0-5 2-5 5v21h13c2 0 4 1 4 2Z"/>
<path d="M45 17 47 22 52 24 47 26 45 31 43 26 38 24 43 22Z"/>
`;
case 'map':
return `
<path d="M18 20 28 16 38 20 46 17v27l-8 3-10-4-10 4Z"/>
<path d="M28 16v27M38 20v27"/>
`;
case 'star':
return `
<path d="M32 17 36 26 46 27 38 34 40 44 32 39 24 44 26 34 18 27 28 26Z"/>
`;
case 'eye':
return `
<path d="M16 32c5-8 10-12 16-12s11 4 16 12c-5 8-10 12-16 12s-11-4-16-12Z"/>
<circle cx="32" cy="32" r="5"/>
`;
case 'pearl':
return `
<circle cx="32" cy="32" r="13"/>
<path d="M26 27c2-2 4-3 7-3"/>
`;
case 'flame':
return `
<path d="M34 17c5 8 9 12 9 19 0 7-4 13-11 13s-11-5-11-12c0-7 5-11 10-17 0 4 1 6 4 10 2-6 1-9-1-13Z"/>
`;
case 'bottle':
return `
<path d="M28 18h8v6l4 6v12c0 5-3 8-8 8s-8-3-8-8V30l4-6Z"/>
<path d="M25 39h14"/>
`;
case 'cake':
return `
<path d="M18 30h28v14H18Z"/>
<path d="M18 30c3-4 7-6 14-6s11 2 14 6M26 24v6M32 23v7M38 24v6"/>
`;
case 'bucket':
return `
<path d="M22 22h20l-3 22H25Z"/>
<path d="M26 22c0-4 2-7 6-7s6 3 6 7"/>
`;
case 'powder':
return `
<circle cx="24" cy="34" r="4"/>
<circle cx="33" cy="28" r="5"/>
<circle cx="41" cy="36" r="3"/>
`;
case 'creeper':
return `
<rect x="21" y="18" width="22" height="28" rx="6"/>
<rect x="25" y="24" width="4" height="5" rx="1" fill="currentColor" stroke="none"/>
<rect x="35" y="24" width="4" height="5" rx="1" fill="currentColor" stroke="none"/>
<path d="M26 40h12l-2-7h-8Z" fill="currentColor" stroke="none"/>
`;
case 'chest':
return `
<rect x="18" y="22" width="28" height="22" rx="5"/>
<path d="M18 29h28M32 29v8"/>
`;
case 'potion':
return `
<path d="M28 17h8v5l4 6v13c0 6-3 9-8 9s-8-3-8-9V28l4-6Z"/>
<path d="M25 39h14"/>
`;
case 'letter':
return `<text x="32" y="40" text-anchor="middle" font-size="22" font-weight="700" font-family="Segoe UI, sans-serif" fill="currentColor" stroke="none">${escapeHtml((key || '?').slice(0, 1).toUpperCase())}</text>`;
default:
return pixelIconSvg(key, 44);
}
}
function hdIconSvg(key, size = 44) {
const theme = hdIconTheme(key);
const gid = `g-${String(key || 'icon').replace(/[^a-z0-9_-]/gi, '-')}-${size}`;
return `
<svg viewBox="0 0 64 64" width="${size}" height="${size}" aria-hidden="true">
<defs>
<clipPath id="${gid}-clip">
<rect x="8" y="8" width="48" height="48" rx="12"/>
</clipPath>
<filter id="${gid}-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="rgba(0,0,0,.28)"/>
</filter>
</defs>
<g filter="url(#${gid}-shadow)">
<g clip-path="url(#${gid}-clip)">${pixelIconRects('grass', 3, 8, 8)}</g>
<rect x="8" y="8" width="48" height="48" rx="12" fill="rgba(18,28,16,.10)"/>
<rect x="8" y="8" width="48" height="48" rx="12" fill="none" stroke="rgba(255,255,255,.18)" stroke-width="1.5"/>
</g>
<g fill="none" stroke="${theme.glow}" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">
${hdIconGlyph(key, theme)}
</g>
</svg>`;
}
const ICON_KEYS = Object.keys(PIX);
const ICON_LABELS = {
grass: 'Grasblock', creeper: 'Creeper', diamond: 'Diamant', tnt: 'TNT', chest: 'Truhe',
sword: 'Schwert', pickaxe: 'Spitzhacke', potion: 'Trank', nether: 'Feuer', end: 'Endauge',
redstone: 'Redstone', gold: 'Goldbarren', book: 'Buch', map: 'Karte', star: 'Netherstern',
};
// echte Minecraft-Icons (aus den Spieldateien extrahiert), key -> dataUri
let mcIcons = {};
async function loadMcIcons() {
try {
let list = await window.api.iconsList();
const needRefresh = !list.length || !['potion', 'chest', 'creeper', 'paper', 'slimeball', 'cookie'].every((key) => list.some((i) => i.key === key));
if (needRefresh) {
const res = await window.api.iconsExtract();
if (res && res.ok && Array.isArray(res.icons) && res.icons.length) list = res.icons;
}
mcIcons = Object.fromEntries(list.map((i) => [i.key, i]));
await buildIsoCubes(); // räumliche Block-Symbole vorbereiten
} catch { mcIcons = {}; }
}
function resolveRealIconKey(key) {
if (mcIcons[key]) return key;
const mapped = LEGACY_REAL_ICON_MAP[key];
if (mapped && mcIcons[mapped]) return mapped;
return '';
}
function realItemIconSvg(key, size = 44) {
const realKey = resolveRealIconKey(key);
if (!realKey) return '';
const real = mcIcons[realKey];
if (!real) return '';
const theme = hdIconTheme(realKey);
const src = iso3dEnabled && BLOCK_KEYS.has(realKey) && isoCache[realKey] ? isoCache[realKey] : real.dataUri;
const plate = BLOCK_KEYS.has(realKey)
? `<rect x="8" y="8" width="48" height="48" rx="15" fill="${theme.bg}" opacity=".16"/>`
: `<circle cx="32" cy="32" r="22" fill="${theme.bg}" opacity=".14"/>`;
return `
<svg class="mc-item-icon" viewBox="0 0 64 64" width="${size}" height="${size}" aria-hidden="true">
${plate}
<image href="${src}" x="10" y="10" width="44" height="44" preserveAspectRatio="xMidYMid meet"/>
</svg>`;
}
// 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 = 96, c = document.createElement('canvas');
c.width = S; c.height = S;
const g = c.getContext('2d');
g.imageSmoothingEnabled = true;
if ('imageSmoothingQuality' in g) g.imageSmoothingQuality = 'high';
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 als skalierbares HD-SVG; eigene Bilder der Instanz bleiben separat erhalten
function iconOf(key, size = 44) {
const real = realItemIconSvg(key, size);
if (real) return real;
if (PIX[key]) return pixelIconSvg(key, size);
return hdIconSvg(key, size);
}
const LOADER_LABEL = {
vanilla: 'Vanilla', fabric: 'Fabric', forge: 'Forge',
neoforge: 'NeoForge', quilt: 'Quilt', optifine: 'OptiFine',
};
// Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren
// bei größeren Modpacks spürbar GC-bedingte Ruckler. Wirkt erst ab genug RAM
// sinnvoll, schadet aber auch bei wenig RAM nicht.
const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 '
+ '-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch '
+ '-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M '
+ '-XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 '
+ '-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 '
+ '-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem '
+ '-XX:MaxTenuringThreshold=1';
// ---------- Zustand ----------
let instances = [];
let currentDetailId = null;
let selectedInstanceIds = new Set();
let editingId = null;
let confirmAction = null;
let mcVersionData = null; // gecachte Mojang-Versionsliste
let currentModsInstalled = new Set(); // projectIds im offenen Mod-Dialog
let pendingImagePath = null; // im Dialog gewähltes eigenes Bild (noch nicht gespeichert)
let pendingClearImage = false; // eigenes Bild soll entfernt werden
let loginVerificationUri = null; // aktueller Device-Code-Link
const collapsedGroups = new Set(); // eingeklappte Gruppen
let consoleBuffer = ''; // gesammelte Log-Ausgabe (roh, für Kopieren)
let consolePending = ''; // noch nicht abgeschlossene Zeile
// ---------- DOM-Helfer ----------
const el = (id) => document.getElementById(id);
function show(id) { el(id).classList.remove('hidden'); }
function hide(id) { el(id).classList.add('hidden'); }
function toast(msg, isError = false) {
const t = el('toast');
t.textContent = msg;
t.classList.toggle('err', isError);
t.classList.remove('hidden');
clearTimeout(toast._t);
toast._t = setTimeout(() => t.classList.add('hidden'), 2800);
}
function fmtDate(iso) {
if (!iso) return '';
try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; }
}
function status(text) { el('status-left').textContent = text; }
function fmtDuration(s) {
s = s || 0;
if (s < 60) return s + ' Sek.';
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return h ? `${h} Std. ${m} Min.` : `${m} Min.`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
}
// ---------- Instanzen aus MultiMC übernehmen ----------
let mmcFound = [];
let cfFound = [];
let mcFound = [];
let technicFound = [];
let importKind = 'zip';
async function ensureDefaultMcScan() {
if (importKind !== 'mc') return;
if (el('mc-path').value.trim()) return;
if (mcFound.length) return;
await scanMc();
}
function setImportKind(kind) {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc', 'technic'].forEach((entryKind) => {
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
});
el('import-zip-do').classList.toggle('hidden', importKind !== 'zip');
el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack');
el('mc-do').classList.toggle('hidden', importKind !== 'mc');
el('cf-do').classList.toggle('hidden', importKind !== 'cf');
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
el('technic-do').classList.toggle('hidden', importKind !== 'technic');
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
el('technic-progress').classList.toggle('hidden', importKind !== 'technic' || !el('technic-progress').textContent);
}
function openImportDialog(kind = 'zip') {
mcFound = [];
el('mc-result').classList.add('hidden');
el('mc-do').disabled = true;
el('mc-progress').textContent = '';
el('mc-progress').classList.add('hidden');
el('mc-status').textContent = 'Leer lassen nutzt automatisch den Standardpfad unter Windows.';
el('cf-result').classList.add('hidden');
el('cf-do').disabled = true;
el('cf-progress').textContent = '';
el('cf-progress').classList.add('hidden');
el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances';
el('mmc-result').classList.add('hidden');
el('mmc-do').disabled = true;
el('mmc-progress').textContent = '';
el('mmc-progress').classList.add('hidden');
el('mmc-status').textContent = 'Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.';
setImportKind(kind);
show('modal-import');
if (importKind === 'mc') {
el('mc-path').focus();
ensureDefaultMcScan();
}
if (importKind === 'cf') el('cf-path').focus();
if (importKind === 'mmc') el('mmc-path').focus();
}
async function runZipImport() {
el('import-zip-do').disabled = true;
try {
const res = await window.api.importInstance();
if (res.canceled) return;
if (res.ok) {
await reload();
hide('modal-import');
toast('Importiert: ' + res.instance.name);
return;
}
toast(res.message || 'Import fehlgeschlagen.', true);
} finally {
el('import-zip-do').disabled = false;
}
}
async function runModpackImport() {
el('import-modpack-do').disabled = true;
status('Importiere Modpack …');
try {
const res = await window.api.importModpack();
status('Bereit'); el('status-right').textContent = '';
if (res.canceled) return;
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`);
// CurseForge gibt nicht jede Datei heraus ehrlich benennen statt verschweigen
if (res.fehlend) toast(`${res.fehlend} Datei(en) konnten nicht geladen werden und fehlen in der Instanz.`, true);
return;
}
toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
if (res.needsKey) { hide('modal-import'); openSettings('pane-network'); }
} finally {
status('Bereit'); el('status-right').textContent = '';
el('import-modpack-do').disabled = false;
}
}
function fmtDownloads(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
}
async function searchModpacks() {
const query = el('mp-query').value.trim();
const gameVersion = el('mp-mc').value.trim();
const sort = el('mp-sort').value || 'relevance';
const source = (el('mp-source') && el('mp-source').value) || 'modrinth';
const box = el('mp-results');
const st = el('mp-status');
st.textContent = 'Suche …';
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
try {
const res = await window.api.modpackSearch({
query, gameVersion, sort, limit: 30, offset: 0, art: 'modpack', source,
});
if (res.error) {
st.textContent = 'Fehler: ' + res.error;
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
if (res.needKey) {
const wrap = document.createElement('div');
wrap.className = 'mods-empty';
const btn = document.createElement('button');
btn.className = 'btn btn-mini';
btn.textContent = 'Zu den Netzwerk-Einstellungen';
btn.addEventListener('click', () => { hide('modal-import'); openSettings('pane-network'); });
wrap.appendChild(btn);
box.appendChild(wrap);
}
return;
}
const hits = res.hits || [];
st.textContent = hits.length
? (res.total + ' Treffer' + (gameVersion ? ' für ' + gameVersion : ''))
: 'Keine Treffer.';
box.innerHTML = '';
if (!hits.length) {
box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>';
return;
}
for (const h of hits) {
const item = document.createElement('div');
item.className = 'mod-item';
const meta = [
h.author ? ('von ' + h.author) : '',
h.downloads != null ? (fmtDownloads(h.downloads) + ' Downloads') : '',
].filter(Boolean).join(' · ');
const cats = (h.displayCategories || h.categories || []).slice(0, 4).join(', ');
item.innerHTML =
`<div class="mod-icon">${h.iconUrl ? `<img src="${escapeHtml(h.iconUrl)}" alt="" style="width:32px;height:32px;border-radius:4px;object-fit:cover" />` : '📦'}</div>
<div class="mod-info">
<div class="mod-name">${escapeHtml(h.title || h.slug || h.projectId)}</div>
<div class="bd-meta">${escapeHtml(meta)}${cats ? ' · ' + escapeHtml(cats) : ''}</div>
<div class="bd-meta" style="opacity:.85">${escapeHtml((h.description || '').slice(0, 120))}${(h.description || '').length > 120 ? '…' : ''}</div>
</div>
<div class="mod-action"></div>`;
const btn = document.createElement('button');
btn.className = 'btn btn-primary btn-mini';
btn.textContent = 'Installieren';
btn.addEventListener('click', () => installModpackFromSearch(h, btn));
item.querySelector('.mod-action').appendChild(btn);
box.appendChild(item);
}
} catch (err) {
st.textContent = 'Fehler: ' + (err.message || err);
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
}
}
async function installModpackFromSearch(hit, btn) {
if (!hit || !hit.projectId) return;
const gameVersion = el('mp-mc').value.trim();
const prev = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = '…'; }
status('Installiere Modpack: ' + (hit.title || hit.slug || '') + ' …');
try {
const res = hit.source === 'curseforge'
? await window.api.modpackInstallFromCurseForge({
cfId: hit.cfId, projectId: hit.projectId, title: hit.title || hit.slug, gameVersion,
})
: await window.api.modpackInstallFromModrinth({
projectId: hit.projectId,
title: hit.title || hit.slug,
gameVersion,
});
status('Bereit'); el('status-right').textContent = '';
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack installiert: ${res.instance.name} (${res.fileCount || 0} Dateien)`);
if (currentDetailId !== res.instance.id) {
try { await selectInstance(res.instance.id); } catch { /* egal */ }
}
return;
}
toast(res.message || 'Installation fehlgeschlagen.', true);
} catch (err) {
status('Bereit'); el('status-right').textContent = '';
toast('Installation fehlgeschlagen: ' + (err.message || err), true);
} finally {
if (btn) { btn.disabled = false; btn.textContent = prev || 'Installieren'; }
}
}
async function scanMc() {
const folder = el('mc-path').value.trim();
el('mc-status').textContent = 'Suche Launcher-Profile …';
const res = await window.api.mcScan(folder);
if (!res.ok) {
el('mc-status').textContent = '✖ Dort wurde kein launcher_profiles.json gefunden.';
el('mc-result').classList.add('hidden');
el('mc-do').disabled = true;
return;
}
mcFound = res.instances;
if (!folder && res.rootDir) el('mc-path').value = res.rootDir;
el('mc-status').textContent = `${res.count} Profil${res.count === 1 ? '' : 'e'} gefunden.`;
el('mc-result').classList.remove('hidden');
el('mc-all').checked = true;
renderMcList();
}
function renderMcList() {
const box = el('mc-list');
box.innerHTML = '';
if (!mcFound.length) { box.innerHTML = '<div class="mods-empty">Keine Profile gefunden.</div>'; return; }
mcFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'ohne Version', loaderTxt, inst.usesDefaultGameDir ? 'Standard-Spielordner' : 'eigener Spielordner']
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateMcButton();
}
function selectedMc() {
return [...document.querySelectorAll('#mc-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => mcFound[Number(c.dataset.idx)]);
}
function updateMcButton() {
const n = selectedMc().length;
el('mc-do').disabled = n === 0;
el('mc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runMcImport() {
const entries = selectedMc();
if (!entries.length) return;
const copy = el('mc-copy').checked;
el('mc-do').disabled = true;
el('mc-progress').textContent = 'Übernehme …';
el('mc-progress').classList.remove('hidden');
const res = await window.api.mcImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
async function scanCf() {
const folder = el('cf-path').value.trim();
if (!folder) { el('cf-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
el('cf-status').textContent = 'Suche Instanzen …';
const res = await window.api.cfScan(folder);
if (!res.ok) {
el('cf-status').textContent = '✖ Dort wurde kein CurseForge-Instances-Ordner gefunden.';
el('cf-result').classList.add('hidden');
el('cf-do').disabled = true;
return;
}
cfFound = res.instances;
el('cf-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`;
el('cf-result').classList.remove('hidden');
el('cf-all').checked = true;
renderCfList();
}
function renderCfList() {
const box = el('cf-list');
box.innerHTML = '';
if (!cfFound.length) { box.innerHTML = '<div class="mods-empty">Keine Instanzen gefunden.</div>'; return; }
cfFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'ohne Version', loaderTxt]
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateCfButton();
}
function selectedCf() {
return [...document.querySelectorAll('#cf-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => cfFound[Number(c.dataset.idx)]);
}
function updateCfButton() {
const n = selectedCf().length;
el('cf-do').disabled = n === 0;
el('cf-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runCfImport() {
const entries = selectedCf();
if (!entries.length) return;
const copy = el('cf-copy').checked;
el('cf-do').disabled = true;
el('cf-progress').textContent = 'Übernehme …';
el('cf-progress').classList.remove('hidden');
const res = await window.api.cfImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
async function scanMmc() {
const folder = el('mmc-path').value.trim();
if (!folder) { el('mmc-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
el('mmc-status').textContent = 'Suche Instanzen …';
const res = await window.api.mmcScan(folder);
if (!res.ok) {
el('mmc-status').textContent = '✖ Dort wurde kein instances-Ordner gefunden.';
el('mmc-result').classList.add('hidden');
el('mmc-do').disabled = true;
return;
}
mmcFound = res.instances;
el('mmc-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`;
el('mmc-result').classList.remove('hidden');
el('mmc-all').checked = true;
renderMmcList();
}
function renderMmcList() {
const box = el('mmc-list');
box.innerHTML = '';
if (!mmcFound.length) { box.innerHTML = '<div class="mods-empty">Keine Instanzen gefunden.</div>'; return; }
mmcFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'ohne Version', loaderTxt, inst.group ? 'Gruppe: ' + inst.group : '']
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateMmcButton();
}
function selectedMmc() {
return [...document.querySelectorAll('#mmc-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => mmcFound[Number(c.dataset.idx)]);
}
function updateMmcButton() {
const n = selectedMmc().length;
el('mmc-do').disabled = n === 0;
el('mmc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runMmcImport() {
const entries = selectedMmc();
if (!entries.length) return;
const copy = el('mmc-copy').checked;
el('mmc-do').disabled = true;
el('mmc-progress').textContent = 'Übernehme …';
el('mmc-progress').classList.remove('hidden');
const res = await window.api.mmcImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
// ---------- Technic-Plattform: Modpack-Katalog ----------
async function searchTechnicPlatform() {
const query = el('tp-query').value.trim();
const box = el('tp-results');
const st = el('tp-status');
st.textContent = 'Suche …';
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
const res = await window.api.technicPlatformSearch(query);
if (!res.ok) {
st.textContent = 'Fehler: ' + (res.message || 'Suche fehlgeschlagen.');
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
return;
}
const hits = res.hits || [];
st.textContent = hits.length ? `${hits.length} Treffer` : 'Keine Treffer.';
box.innerHTML = '';
if (!hits.length) { box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>'; return; }
for (const h of hits) {
const item = document.createElement('div');
item.className = 'mod-item';
item.innerHTML =
`<div class="mod-icon">${h.iconUrl ? `<img src="${escapeHtml(h.iconUrl)}" alt="" style="width:32px;height:32px;border-radius:4px;object-fit:cover" />` : '📦'}</div>
<div class="mod-info"><div class="mod-name">${escapeHtml(h.name)}</div></div>
<div class="mod-action"></div>`;
const btn = document.createElement('button');
btn.className = 'btn btn-primary btn-mini';
btn.textContent = 'Installieren';
btn.addEventListener('click', () => installTechnicFromSearch(h, btn));
item.querySelector('.mod-action').appendChild(btn);
box.appendChild(item);
}
}
async function installTechnicFromSearch(hit, btn) {
if (!hit || !hit.slug) return;
const prev = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const st = el('tp-status');
st.textContent = `Installiere „${hit.name}" …`;
try {
const res = await window.api.technicPlatformInstall(hit.slug);
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack installiert: ${res.instance.name}`);
await selectInstance(res.instance.id);
} else {
st.textContent = '✖ ' + (res.message || 'Installation fehlgeschlagen.');
toast(res.message || 'Installation fehlgeschlagen.', true);
}
} finally {
if (btn) { btn.disabled = false; btn.textContent = prev; }
}
}
async function scanTechnic() {
const folder = el('technic-path').value.trim();
el('technic-status').textContent = 'Suche Modpacks …';
const res = await window.api.technicScan(folder);
if (!res.ok) {
el('technic-status').textContent = '✖ Dort wurde kein modpacks-Ordner gefunden.';
el('technic-result').classList.add('hidden');
el('technic-do').disabled = true;
return;
}
technicFound = res.instances;
if (!folder && res.instancesDir) el('technic-path').value = res.instancesDir;
el('technic-status').textContent = `${res.count} Modpack${res.count === 1 ? '' : 's'} gefunden.`;
el('technic-result').classList.remove('hidden');
el('technic-all').checked = true;
renderTechnicList();
}
function renderTechnicList() {
const box = el('technic-list');
box.innerHTML = '';
if (!technicFound.length) { box.innerHTML = '<div class="mods-empty">Keine Modpacks gefunden.</div>'; return; }
technicFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'Version unbekannt', loaderTxt]
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateTechnicButton();
}
function selectedTechnic() {
return [...document.querySelectorAll('#technic-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => technicFound[Number(c.dataset.idx)]);
}
function updateTechnicButton() {
const n = selectedTechnic().length;
el('technic-do').disabled = n === 0;
el('technic-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runTechnicImport() {
const entries = selectedTechnic();
if (!entries.length) return;
const copy = el('technic-copy').checked;
el('technic-do').disabled = true;
el('technic-progress').textContent = 'Übernehme …';
el('technic-progress').classList.remove('hidden');
const res = await window.api.technicImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
// ---------- Bedrock (Windows-Store-Ausgabe) ----------
let bedrockKind = 'worlds';
// Bedrock-Knopf nur zeigen, wenn aktiviert UND tatsächlich installiert
async function applyBedrockVisibility() {
const btn = el('btn-bedrock');
let show = false;
try {
const s = await window.api.getSettings();
if (s.bedrockEnabled !== false) {
const info = await window.api.bedrockDetect();
show = !!info.installed;
const hint = el('s-bedrock-hint');
if (hint) {
hint.textContent = info.installed
? 'Bedrock erkannt (Version ' + info.version + ').'
: 'Bedrock ist auf diesem PC nicht installiert der Bereich bleibt ausgeblendet.';
}
}
} catch { /* im Zweifel ausblenden */ }
btn.classList.toggle('hidden', !show);
}
async function openBedrock() {
show('modal-bedrock');
el('bd-sub').textContent = 'Wird geprüft …';
const info = await window.api.bedrockDetect();
if (!info.installed) {
el('bd-sub').textContent = 'nicht installiert';
el('bd-missing').classList.remove('hidden');
el('bd-content').classList.add('hidden');
return;
}
el('bd-missing').classList.add('hidden');
el('bd-content').classList.remove('hidden');
el('bd-sub').textContent = 'Version ' + info.version;
switchBedrockTab('worlds');
}
function switchBedrockTab(kind) {
bedrockKind = kind;
document.querySelectorAll('.btab').forEach((t) => t.classList.toggle('on', t.dataset.kind === kind));
renderBedrockList();
}
function fmtBytes(n) {
if (!n) return '';
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(0) + ' KB';
return (n / 1048576).toFixed(1) + ' MB';
}
async function renderBedrockList() {
const box = el('bd-list');
box.innerHTML = '<div class="mods-empty">Lade …</div>';
const kind = bedrockKind;
const items = kind === 'worlds' ? await window.api.bedrockWorlds() : await window.api.bedrockList(kind);
box.innerHTML = '';
if (!items.length) {
const leer = {
worlds: 'Noch keine Welten vorhanden.',
resource_packs: 'Keine Ressourcenpakete installiert.',
behavior_packs: 'Keine Verhaltenspakete installiert.',
skin_packs: 'Keine Skin-Pakete installiert.',
screenshots: 'Noch keine Bildschirmfotos.',
};
box.innerHTML = `<div class="mods-empty">${leer[kind] || 'Nichts vorhanden.'}</div>`;
return;
}
for (const it of items) {
const name = it.name || it.id;
const item = document.createElement('div');
item.className = 'mod-item';
const meta = [fmtBytes(it.bytes), it.modified ? fmtDate(it.modified) : ''].filter(Boolean).join(' · ');
item.innerHTML =
`<div class="mod-icon">${kind === 'worlds' ? '🌍' : kind === 'screenshots' ? '🖼' : '📦'}</div>
<div class="mod-info">
<div class="mod-name">${escapeHtml(name)}</div>
<div class="bd-meta">${escapeHtml(meta)}</div>
</div>
<div class="mod-action"></div>`;
const actions = item.querySelector('.mod-action');
if (kind === 'worlds') {
const open = document.createElement('button');
open.className = 'btn btn-mini';
open.textContent = 'Ordner';
open.addEventListener('click', () => window.api.bedrockOpenWorld(it.dir));
actions.appendChild(open);
}
const del = document.createElement('button');
del.className = 'btn btn-danger btn-mini';
del.textContent = 'Löschen';
del.addEventListener('click', () => askConfirm(
'Löschen', `„${name}" wird unwiderruflich gelöscht. Fortfahren?`, 'Endgültig löschen',
async () => {
await window.api.bedrockRemove(kind, kind === 'worlds' ? it.id : it.name);
await renderBedrockList();
toast('Gelöscht: ' + name);
}));
actions.appendChild(del);
box.appendChild(item);
}
}
// ---------- 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');
}
function formatVersionLabel(version) {
return String(version || '')
.replace(/^v/i, '')
.replace(/-beta$/i, ' Beta')
.trim() || '';
}
// ---------- Automatische Updates ----------
let pendingUpdate = null;
const CHANNEL_LABEL = { portable: 'Portable', setup: 'Setup' };
function showUpdateDialog(info) {
pendingUpdate = info;
const channel = CHANNEL_LABEL[info.installKind] || null;
el('upd-current').textContent = formatVersionLabel(info.currentVersion);
el('upd-new').textContent = formatVersionLabel(info.latestVersion);
el('upd-channel').textContent = channel
? `Update-Kanal: ${channel}`
: 'Update-Kanal: unbekannt automatisches Update nicht möglich';
el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.';
el('upd-progress').classList.add('hidden');
el('upd-bar-fill').style.width = '0%';
// Ohne passendes Paket wird bewusst nichts installiert ein Portable-Launcher
// darf nie einen Installer bekommen und umgekehrt. Statt eines toten Knopfes
// führen wir dann zum Release, damit der Nutzer nicht in der Sackgasse steht.
const hatPaket = !!info.asset;
el('upd-install').classList.toggle('hidden', !hatPaket);
el('upd-install').disabled = !hatPaket;
el('upd-install').textContent = 'Installieren';
el('upd-open').classList.toggle('hidden', hatPaket || !info.url);
el('upd-hint').textContent = hatPaket
? 'Der Launcher lädt das Update herunter und startet die Installation automatisch.'
: (channel
? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.`
: 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.');
show('modal-update');
}
function openReleasePage() {
if (pendingUpdate && pendingUpdate.url) window.api.openExternal(pendingUpdate.url);
}
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 = res.kind === 'portable'
? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.'
: '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 …';
}
}
// Aktualisiert die Statuszeile im Info-Bereich genutzt sowohl vom manuellen
// "Nach Updates suchen"-Knopf als auch vom automatischen Check beim Start.
function renderUpdateStatus(info) {
const st = el('s-update-status');
if (!st) return;
st.classList.remove('status-ok', 'status-bad');
if (info.checking) {
st.textContent = 'Suche nach Updates …';
} else if (info.error) {
st.classList.add('status-bad');
st.textContent = '✖ ' + info.error;
} else if (info.noReleases) {
st.textContent = 'Noch keine Veröffentlichung vorhanden.';
} else if (info.available) {
st.textContent = `Version ${formatVersionLabel(info.latestVersion)} verfügbar.`;
} else {
st.classList.add('status-ok');
st.textContent = `✓ Du hast bereits die neueste Version (${formatVersionLabel(info.currentVersion)}).`;
}
}
// ---------- Zustand der Anmelde-Dienste in der Statusleiste ----------
function showAuthHealth(h) {
const box = el('status-auth');
if (!h || h.ok) { box.classList.add('hidden'); box.textContent = ''; return; }
const list = (h.down || []).join(', ');
box.textContent = (h.down && h.down.length === AUTH_SERVICE_COUNT)
? 'Anmelde-Server nicht erreichbar Login/Start derzeit nicht möglich'
: 'Anmelde-Dienst gestört: ' + list;
box.title = 'Zuletzt geprüft: ' + (h.checked ? new Date(h.checked).toLocaleTimeString('de-DE') : '') + ' · Klicken zum erneut prüfen';
box.classList.remove('hidden');
}
const AUTH_SERVICE_COUNT = 3;
// ---------- Skin-Hintergrund (wie MultiMCs Katze, nur mit eigenem Skin) ----------
// uuidOverride: wird vom Konten-Wechsler (siehe unten) genutzt, um gezielt
// einen bestimmten Skin zu zeigen, ohne die eigentliche Einstellung anzufassen
async function applySkinBackground(uuidOverride) {
const s = await window.api.getSettings();
const root = document.documentElement;
let uuid = '';
if (uuidOverride) {
uuid = uuidOverride;
} else if (s.bgSkin === 'active') {
const acc = await window.api.authStatus();
uuid = acc && acc.profile ? acc.profile.id : '';
} else if (s.bgSkin) {
uuid = s.bgSkin;
}
if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; }
root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100));
// Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht),
// damit die Auswahl immer sichtbar wirkt auch wenn der Pose-Dienst offline ist.
// 1) Skin lokal in 3D rendern unabhängig von Render-Diensten
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking');
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
// 2) Rückfall: externe Render-Dienste
for (const url of [
`https://nmsr.nickac.dev/fullbody/${uuid}?yaw=25&pitch=8`,
`https://visage.surgeplay.com/full/512/${uuid}`,
`https://crafthead.net/armor/body/${uuid}?size=512`,
]) {
const ok = await new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = url;
});
if (ok) { root.style.setProperty('--bg-skin', `url("${url}")`); return; }
}
root.style.setProperty('--bg-skin', 'none');
}
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
let skinCycleTimer = null;
let skinCycleIndex = 0;
function stopSkinCycle() {
if (skinCycleTimer) { clearInterval(skinCycleTimer); skinCycleTimer = null; }
}
async function skinCycleTick() {
const accs = await window.api.authAccounts();
if (accs.length < 2) { stopSkinCycle(); await applySkinBackground(); return; }
skinCycleIndex = (skinCycleIndex + 1) % accs.length; // am Ende wieder von vorn
await applySkinBackground(accs[skinCycleIndex].id);
}
/*
* Einstiegspunkt für alles, was den Skin-Hintergrund neu anzeigen soll.
* Ersetzt die frühere direkte applySkinBackground()-Aufrufe: kümmert sich
* zusätzlich darum, den Kontenwechsel-Timer je nach Einstellung zu
* starten/stoppen, statt dass jede Aufrufstelle das selbst tun müsste.
*/
async function refreshSkinBackground() {
stopSkinCycle();
const s = await window.api.getSettings();
if (s.bgSkinCycle) {
const accs = await window.api.authAccounts();
if (accs.length >= 2) {
skinCycleIndex = 0;
await applySkinBackground(accs[0].id);
const seconds = Math.max(5, Number(s.bgSkinCycleSeconds) || 30);
skinCycleTimer = setInterval(skinCycleTick, seconds * 1000);
return;
}
// weniger als 2 Konten angemeldet -> normal weiter wie ohne Wechsel
}
await applySkinBackground();
}
// Pose-Vorlagen für den lokalen 3D-Renderer (eigene Nachbildungen bekannter Motive)
const SKIN_POSES = {
walking: { label: 'Laufend', fov: 36, zoom: 0.62, apply: (p) => {
p.rotation.set(0.05, -0.62, 0);
p.skin.leftArm.rotation.set(-0.95, 0, 0.06);
p.skin.rightArm.rotation.set(0.85, 0, -0.06);
p.skin.leftLeg.rotation.set(0.8, 0, 0);
p.skin.rightLeg.rotation.set(-0.8, 0, 0);
} },
standing: { label: 'Stehend', fov: 36, zoom: 0.6, apply: (p) => {
p.rotation.set(0, -0.35, 0);
p.skin.leftArm.rotation.set(0, 0, 0.08);
p.skin.rightArm.rotation.set(0, 0, -0.08);
p.skin.leftLeg.rotation.set(0, 0, 0.02);
p.skin.rightLeg.rotation.set(0, 0, -0.02);
} },
legends: { label: 'Heldenhaft', fov: 34, zoom: 0.58, apply: (p) => {
p.rotation.set(-0.10, -0.5, 0);
p.skin.leftArm.rotation.set(-0.25, 0, 0.35);
p.skin.rightArm.rotation.set(-0.15, 0, -0.42);
p.skin.leftLeg.rotation.set(0.12, 0, 0.16);
p.skin.rightLeg.rotation.set(-0.12, 0, -0.16);
} },
bedwars: { label: 'Kampfhaltung', fov: 36, zoom: 0.6, apply: (p) => {
p.rotation.set(0.06, -0.85, 0);
p.skin.rightArm.rotation.set(-1.45, 0, -0.18); // Arm vorgestreckt
p.skin.leftArm.rotation.set(0.45, 0, 0.30);
p.skin.leftLeg.rotation.set(0.45, 0, 0.10);
p.skin.rightLeg.rotation.set(-0.35, 0, -0.10);
} },
hoverboard: { label: 'Schwebend', fov: 38, zoom: 0.6, apply: (p) => {
p.rotation.set(0.28, -0.7, 0.12); // vorgebeugt, gekippt
p.skin.leftArm.rotation.set(-0.35, 0, 1.05); // Arme zum Ausbalancieren
p.skin.rightArm.rotation.set(-0.35, 0, -1.05);
p.skin.leftLeg.rotation.set(0.5, 0, 0.18);
p.skin.rightLeg.rotation.set(0.15, 0, -0.18);
} },
sitting: { label: 'Sitzend', fov: 36, zoom: 0.62, apply: (p) => {
p.rotation.set(0.05, -0.55, 0);
p.skin.leftArm.rotation.set(-0.55, 0, 0.18);
p.skin.rightArm.rotation.set(-0.55, 0, -0.18);
p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt
p.skin.rightLeg.rotation.set(-1.5, 0, -0.12);
} },
spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => {
p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht
p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt
p.skin.leftArm.rotation.set(-1.35, 0, 0.12); // Arme nach vorn ausgestreckt
p.skin.rightArm.rotation.set(-1.25, 0, -0.16);
p.skin.leftLeg.rotation.set(0.18, 0, 0.06);
p.skin.rightLeg.rotation.set(-0.12, 0, -0.06);
} },
};
// Rendert den Skin lokal mit WebGL in der gewählten Pose
/*
* Rendert eine Skin-Textur als Spielfigur und gibt ein Bild zurück.
* quelle: Adresse ODER data:-URI so lässt sich auch eine Datei anzeigen,
* die noch gar nicht hochgeladen ist.
* modell: 'default' (breite Arme) oder 'slim' (Alex)
*/
async function renderSkin3D(quelle, poseKey, opts = {}) {
if (typeof skinview3d === 'undefined') return null;
const canvas = el('skin-canvas');
const breite = opts.breite || 420;
const hoehe = opts.hoehe || 700;
try {
const viewer = new skinview3d.SkinViewer({
canvas, width: breite, height: hoehe,
skin: quelle,
model: opts.modell === 'slim' ? 'slim' : 'default',
preserveDrawingBuffer: true,
});
// Auf das Laden der Skin-Textur warten
await new Promise((resolve, reject) => {
const t = setTimeout(reject, 8000);
const check = () => {
if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
else setTimeout(check, 60);
};
check();
});
const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking;
viewer.fov = pose.fov;
viewer.zoom = pose.zoom;
viewer.autoRotate = false;
viewer.animation = null;
pose.apply(viewer.playerObject);
viewer.render();
const data = canvas.toDataURL('image/png');
viewer.dispose();
return data && data.length > 2000 ? data : null;
} catch {
return null;
}
}
// ---------- Icon-System (SVG statt Emoji) ----------
const ICONS_SVG = {
plus: '<path d="M12 5v14M5 12h14"/>',
import: '<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 20h16"/>',
package: '<path d="M21 8l-9-5-9 5 9 5 9-5zM3 8v8l9 5 9-5V8M12 13v8"/>',
folder: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
terminal: '<path d="M4 5h16v14H4zM7 9l3 3-3 3M13 16h4"/>',
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
play: '<path d="M7 5l12 7-12 7z"/>',
blocks: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>',
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>',
image: '<path d="M4 5h16v14H4z"/><path d="M8.5 11a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM4 17l5-5 4 4 3-3 4 4"/>',
edit: '<path d="M4 20h4L18 10l-4-4L4 16v4zM14 6l4 4"/>',
copy: '<path d="M9 9h10v10H9zM5 15V5h10"/>',
export: '<path d="M12 21V9m0 0l-4 4m4-4l4 4M4 4h16"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
};
function svgIcon(name) {
const p = ICONS_SVG[name];
if (!p) return '';
const filled = name === 'play';
const attrs = filled
? 'fill="currentColor" stroke="none"'
: 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
return `<svg viewBox="0 0 24 24" ${attrs}>${p}</svg>`;
}
function applyIcons(root = document) {
root.querySelectorAll('[data-ic]').forEach((b) => {
if (b.querySelector('svg')) return;
b.insertAdjacentHTML('afterbegin', svgIcon(b.dataset.ic));
});
}
// ---------- Instanz-Kacheln ----------
const SINGLE_SELECTION_BUTTON_IDS = [
'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens', 'p-logs', 'p-servers', 'p-modpack',
'p-mcfolder', 'p-configfolder', 'p-instfolder', 'p-shortcut', 'p-export', 'p-copy',
];
function orderedSelectedInstanceIds() {
return instances.map((inst) => inst.id).filter((id) => selectedInstanceIds.has(id));
}
function syncCardSelectionUi() {
document.querySelectorAll('.card').forEach((card) => {
card.classList.toggle('sel', selectedInstanceIds.has(card.dataset.id));
});
}
function setSingleSelectionActionsDisabled(disabled) {
SINGLE_SELECTION_BUTTON_IDS.forEach((id) => { el(id).disabled = disabled; });
const groupLine = el('p-groupline');
groupLine.style.pointerEvents = disabled ? 'none' : '';
groupLine.style.color = disabled ? 'var(--dim)' : '';
}
async function refreshSelectionPanel() {
const selectedIds = orderedSelectedInstanceIds();
const count = selectedIds.length;
const panelC = el('p-content');
const panelE = el('p-empty');
syncCardSelectionUi();
if (!count) {
currentDetailId = null;
setSingleSelectionActionsDisabled(false);
el('p-delete').disabled = false;
el('p-delete').textContent = 'Löschen';
panelC.classList.add('hidden');
panelE.classList.remove('hidden');
el('status-left').textContent = 'Bereit';
el('status-play').textContent = 'Keine Instanz ausgewählt';
el('status-total').textContent = '';
return;
}
if (count > 1) {
currentDetailId = null;
setSingleSelectionActionsDisabled(true);
el('p-delete').disabled = false;
el('p-delete').textContent = `${count} Instanzen löschen`;
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
el('p-icon').innerHTML = iconOf('chest', 60);
el('p-name').textContent = `${count} Instanzen ausgewählt`;
el('p-groupline').textContent = 'Mehrfachauswahl aktiv';
el('status-left').textContent = `${count} Instanzen ausgewählt`;
el('status-play').textContent = 'Strg+Klick wählt weitere Instanzen aus oder ab';
el('status-total').textContent = 'Sammellöschen verfügbar';
return;
}
const id = selectedIds[0];
currentDetailId = id;
setSingleSelectionActionsDisabled(false);
el('p-delete').disabled = false;
el('p-delete').textContent = 'Löschen';
window.api.saveSettings({ lastSelectedId: id || '' });
const inst = id ? await window.api.getInstance(id) : null;
if (!inst) {
selectedInstanceIds.delete(id);
panelC.classList.add('hidden');
panelE.classList.remove('hidden');
el('status-left').textContent = 'Bereit';
el('status-play').textContent = 'Keine Instanz ausgewählt';
el('status-total').textContent = '';
return;
}
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
// "Modpack aktualisieren" nur bei Instanzen, die aus einem Modrinth-Paket stammen
el('p-modpack').classList.toggle('hidden', !(inst.modpack && inst.modpack.projectId));
const iconData = inst.customImage ? await window.api.instanceIcon(id) : null;
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
el('p-name').textContent = inst.name;
el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe';
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
}
async function applySelection(ids) {
const valid = new Set(instances.map((inst) => inst.id));
selectedInstanceIds = new Set((ids || []).filter((id) => valid.has(id)));
await refreshSelectionPanel();
}
async function toggleInstanceSelection(id) {
const next = new Set(selectedInstanceIds);
if (next.has(id)) next.delete(id);
else next.add(id);
await applySelection([...next]);
}
function buildCard(inst) {
const card = document.createElement('div');
card.className = 'card' + (selectedInstanceIds.has(inst.id) ? ' sel' : '');
card.dataset.id = inst.id;
const iconHtml = inst.iconData
? `<img class="card-icon-img" src="${inst.iconData}" alt="" />`
: `<span class="card-icon">${iconOf(inst.icon, 60)}</span>`;
// Tooltip: Name, Version/Loader und falls vorhanden die Notizen
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
let tip = inst.name + '\n' + (inst.minecraft.version || 'ohne Version') + loaderTxt;
if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim();
card.title = tip;
card.innerHTML = `
${iconHtml}
<div class="card-name">${escapeHtml(inst.name)}</div>`;
// einfacher Klick = auswählen (MultiMC), Doppelklick = starten
card.addEventListener('click', (e) => {
if (e.ctrlKey || e.metaKey) toggleInstanceSelection(inst.id);
else selectInstance(inst.id);
});
card.addEventListener('dblclick', () => launch(inst.id));
// Rechtsklick: Instanz auswählen und Kontextmenü öffnen
card.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (!selectedInstanceIds.has(inst.id)) await selectInstance(inst.id);
openContextMenu(e.clientX, e.clientY); // sofort anzeigen
});
// Drag & Drop in Gruppen
card.draggable = true;
card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', inst.id); card.classList.add('dragging'); });
card.addEventListener('dragend', () => card.classList.remove('dragging'));
return card;
}
function render() {
const q = el('search').value.trim().toLowerCase();
const grid = el('grid');
grid.innerHTML = '';
const filtered = instances.filter((i) =>
!q || (i.name + ' ' + i.minecraft.version + ' ' + i.minecraft.loader + ' ' + (i.group || '')).toLowerCase().includes(q));
el('empty').classList.toggle('hidden', instances.length !== 0);
grid.classList.toggle('hidden', instances.length === 0);
// nach Gruppen bündeln
const groups = new Map();
for (const inst of filtered) {
const g = (inst.group && inst.group.trim()) || '';
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(inst);
}
// benannte Gruppen alphabetisch, "ohne Gruppe" zuletzt
const names = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)));
const showHeaders = names.some((n) => n !== '');
for (const g of names) {
const list = groups.get(g);
const section = document.createElement('div');
section.className = 'group-section' + (collapsedGroups.has(g) ? ' collapsed' : '');
if (showHeaders) {
const header = document.createElement('div');
header.className = 'group-header' + (collapsedGroups.has(g) ? ' collapsed' : '');
header.innerHTML = `<span class="chev">${collapsedGroups.has(g) ? '▸' : '▾'}</span> ${escapeHtml(g || 'Ohne Gruppe')} <span class="group-count">(${list.length})</span>`;
header.addEventListener('click', () => {
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
render();
});
section.appendChild(header);
}
const gridEl = document.createElement('div');
gridEl.className = 'group-grid';
for (const inst of list) gridEl.appendChild(buildCard(inst));
section.appendChild(gridEl);
// Drop-Ziel: Instanz per Drag&Drop in diese Gruppe verschieben
section.addEventListener('dragover', (e) => { e.preventDefault(); section.classList.add('drop-hover'); });
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
section.addEventListener('drop', async (e) => {
e.preventDefault();
section.classList.remove('drop-hover');
const dropId = e.dataTransfer.getData('text/plain');
const dragged = instances.find((i) => i.id === dropId);
if (dragged && (dragged.group || '') !== g) {
await window.api.updateInstance(dropId, { group: g });
await reload();
toast('Verschoben nach: ' + (g || 'Ohne Gruppe'));
}
});
grid.appendChild(section);
}
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
}
function fillGroupDatalist() {
const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort();
el('group-list').innerHTML = groups.map((g) => `<option value="${escapeHtml(g)}"></option>`).join('');
}
async function reload() {
instances = await window.api.listInstances();
const valid = new Set(instances.map((inst) => inst.id));
selectedInstanceIds = new Set([...selectedInstanceIds].filter((id) => valid.has(id)));
render();
await refreshSelectionPanel();
}
// ---------- Minecraft-Versionen ----------
async function loadVersions() {
if (mcVersionData) return mcVersionData;
try {
mcVersionData = await window.api.mcVersions();
} catch (e) {
mcVersionData = { offline: true, versions: [], latest: {} };
}
return mcVersionData;
}
function fillVersionSelect(selectedId) {
const type = el('f-version-type').value;
const sel = el('f-version');
const hint = el('f-version-hint');
sel.innerHTML = '';
const data = mcVersionData || { versions: [], latest: {} };
let list = data.versions || [];
if (type !== 'all') list = list.filter((v) => v.type === type);
// gewählte Version muss vorhanden sein (auch wenn Filter sie ausblendet)
if (selectedId && !list.some((v) => v.id === selectedId)) {
const found = (data.versions || []).find((v) => v.id === selectedId);
list = [found || { id: selectedId, type: 'unknown' }, ...list];
}
if (!list.length) {
const o = document.createElement('option');
o.value = selectedId || '';
o.textContent = selectedId || '(keine Versionsliste offline?)';
sel.appendChild(o);
} else {
for (const v of list.slice(0, 1200)) {
const o = document.createElement('option');
o.value = v.id;
o.textContent = v.id;
if (v.id === selectedId) o.selected = true;
sel.appendChild(o);
}
}
if (data.offline) hint.textContent = '⚠ Offline Versionsliste evtl. veraltet.';
else if (data.latest) hint.textContent = `Neuestes Release: ${data.latest.release || '?'} · Snapshot: ${data.latest.snapshot || '?'}`;
}
// Loader-Versionen (Fabric/Quilt) passend zur MC-Version laden
async function updateLoaderVersions(preselect) {
const loader = el('f-loader').value;
const mc = el('f-version').value;
const sel = el('f-loader-version');
const hint = el('f-loader-hint');
sel.innerHTML = '<option value="">neueste (empfohlen)</option>';
const ofTools = el('f-optifine-tools');
if (ofTools) ofTools.classList.toggle('hidden', loader !== 'optifine');
if (loader === 'vanilla') { sel.disabled = true; hint.textContent = 'Vanilla hat keinen Loader.'; return; }
if (loader === 'optifine') {
// Kein Loader-API bei OptiFine die Liste zeigt bereits eingerichtete
// Fassungen (siehe f-optifine-tools weiter unten), keine automatisch
// ladbaren Versionen.
sel.innerHTML = '<option value=""> keine gewählt </option>';
sel.disabled = false;
if (el('f-optifine-status')) el('f-optifine-status').textContent = '';
if (el('f-optifine-confirm')) el('f-optifine-confirm').classList.add('hidden');
if (!editingId) {
hint.textContent = 'Erst speichern, dann lässt sich OptiFine im Bearbeiten-Dialog einrichten.';
if (el('f-optifine-install')) el('f-optifine-install').disabled = true;
return;
}
if (el('f-optifine-install')) el('f-optifine-install').disabled = false;
if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; }
hint.textContent = 'Suche bereits eingerichtete OptiFine-Fassungen …';
try {
const found = await window.api.optifineFind(mc);
for (const v of found) {
const o = document.createElement('option');
o.value = v; o.textContent = v;
if (v === preselect) o.selected = true;
sel.appendChild(o);
}
hint.textContent = found.length
? `${found.length} eingerichtete OptiFine-Fassung(en) für ${mc}.`
: 'Noch keine OptiFine-Installation für diese Version über den Knopf unten einrichten.';
} catch {
hint.textContent = 'Suche fehlgeschlagen.';
}
return;
}
sel.disabled = false;
if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; }
hint.textContent = 'Lade Loader-Versionen …';
try {
const versions = await window.api.loaderVersions(loader, mc);
for (const v of versions.slice(0, 80)) {
const o = document.createElement('option');
o.value = v.version;
o.textContent = v.version + (v.stable ? '' : ' (beta)');
if (v.version === preselect) o.selected = true;
sel.appendChild(o);
}
hint.textContent = versions.length ? `${versions.length} Version(en) verfügbar` : 'Keine Loader-Version für diese MC-Version.';
} catch {
hint.textContent = 'Loader-Versionen konnten nicht geladen werden.';
}
}
// ---------- 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 }));
if (real.length) return real;
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 it of availableIcons()) {
const o = document.createElement('option');
o.value = it.key;
o.textContent = it.label;
if (it.key === selected) o.selected = true;
sel.appendChild(o);
}
renderIconChoices(selected);
}
// klickbare Item-Vorschau unter der Auswahl
function renderIconChoices(selected) {
const box = el('f-icon-grid');
if (!box) return;
box.innerHTML = '';
for (const it of availableIcons()) {
const b = document.createElement('button');
b.type = 'button';
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 = it.key;
renderIconChoices(it.key);
});
box.appendChild(b);
}
}
// zeigt/versteckt die Bildvorschau im Dialog
function setIconPreview(dataUri) {
const box = el('f-icon-preview');
const clr = el('f-clear-image');
if (dataUri) {
box.innerHTML = `<img src="${dataUri}" alt="" />`;
box.classList.remove('hidden');
clr.classList.remove('hidden');
} else {
box.innerHTML = '';
box.classList.add('hidden');
clr.classList.add('hidden');
}
}
function resetIconControls() {
pendingImagePath = null;
pendingClearImage = false;
setIconPreview(null);
}
async function openCreate() {
editingId = null;
el('instance-modal-title').textContent = 'Neue Instanz';
el('f-name').value = '';
el('f-group').value = '';
fillGroupDatalist();
el('f-loader').value = 'vanilla';
el('f-loader-version').value = '';
el('f-notes').value = '';
fillIconSelect('grass');
resetIconControls();
el('f-advanced').classList.add('hidden');
show('modal-instance');
el('f-name').focus();
el('f-version-hint').textContent = 'Lade Versionsliste …';
await loadVersions();
el('f-version-type').value = 'release';
fillVersionSelect(mcVersionData.latest && mcVersionData.latest.release);
updateLoaderVersions();
}
async function openEdit(inst) {
editingId = inst.id;
el('instance-modal-title').textContent = 'Instanz bearbeiten';
el('f-name').value = inst.name;
el('f-group').value = inst.group || '';
fillGroupDatalist();
el('f-loader').value = inst.minecraft.loader || 'vanilla';
el('f-loader-version').value = inst.minecraft.loaderVersion || '';
el('f-notes').value = inst.notes || '';
fillIconSelect(inst.icon);
resetIconControls();
if (inst.customImage) {
const data = await window.api.instanceIcon(inst.id);
if (data) setIconPreview(data);
}
el('f-min-mem').value = inst.java.minMemMb || '';
el('f-max-mem').value = inst.java.maxMemMb || '';
zeigeSpeicherHinweis();
fuelleInstanzJava(inst);
el('f-extra-args').value = inst.java.extraArgs || '';
el('f-prelaunch').value = inst.preLaunchCommand || '';
el('f-postexit').value = inst.postExitCommand || '';
el('f-advanced').classList.remove('hidden');
show('modal-instance');
el('f-name').focus();
el('f-version-hint').textContent = 'Lade Versionsliste …';
await loadVersions();
el('f-version-type').value = inst.minecraft.version ? 'all' : 'release';
fillVersionSelect(inst.minecraft.version || (mcVersionData.latest && mcVersionData.latest.release));
await updateLoaderVersions(inst.minecraft.loaderVersion);
}
async function saveInstance() {
const name = el('f-name').value.trim();
if (!name) { toast('Bitte einen Namen angeben.', true); el('f-name').focus(); return; }
const payload = {
name,
group: el('f-group').value.trim(),
icon: el('f-icon').value,
notes: el('f-notes').value.trim(),
minecraft: {
version: el('f-version').value.trim(),
loader: el('f-loader').value,
loaderVersion: el('f-loader-version').value.trim(),
},
};
// Speicherangaben prüfen, bevor gespeichert wird
const minMem = numOrNull(el('f-min-mem').value);
const maxMem = numOrNull(el('f-max-mem').value);
if (minMem || maxMem) {
const pruefung = await window.api.checkMemory(minMem, maxMem);
if (!pruefung.ok) { toast(pruefung.message, true); return; }
if (pruefung.warnung) toast(pruefung.warnung, false);
}
try {
let targetId = editingId;
if (editingId) {
payload.java = {
path: el('f-java').value.trim(),
minMemMb: minMem,
maxMemMb: maxMem,
extraArgs: el('f-extra-args').value.trim(),
};
payload.preLaunchCommand = el('f-prelaunch').value.trim();
payload.postExitCommand = el('f-postexit').value.trim();
await window.api.updateInstance(editingId, payload);
} else {
const created = await window.api.createInstance(payload);
targetId = created.id;
}
// eigenes Bild anwenden bzw. entfernen
if (pendingImagePath) await window.api.setInstanceImage(targetId, pendingImagePath);
else if (pendingClearImage && editingId) await window.api.clearInstanceImage(targetId);
toast(editingId ? 'Instanz gespeichert.' : 'Instanz angelegt.');
hide('modal-instance');
await reload();
await selectInstance(targetId);
} catch (e) {
toast('Fehler: ' + e.message, true);
}
}
// ---------- Auswahl + rechte Aktionsleiste (MultiMC-Stil) ----------
async function selectInstance(id) {
await applySelection(id ? [id] : []);
}
let launching = false;
async function launch(id, accountId, launchOpts) {
if (launching) return;
launching = true;
status('Vorbereiten …');
el('status-right').textContent = '';
appendConsole('\n=== Start: ' + new Date().toLocaleTimeString('de-DE') + ' ===\n');
try {
const res = await window.api.launchInstance(id, accountId, launchOpts || null);
if (res.ok) {
status('Läuft (PID ' + res.pid + ')');
toast('Minecraft gestartet.');
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);
} else {
status('Bereit'); el('status-right').textContent = '';
toast(res.message || 'Start fehlgeschlagen.', true);
}
} catch (e) {
status('Bereit'); toast('Fehler: ' + e.message, true);
} finally {
launching = false;
}
}
// ---------- Java je Instanz ----------
async function fuelleInstanzJava(inst) {
const feld = el('f-java');
if (!feld) return;
feld.value = (inst && inst.java && inst.java.path) || '';
const sel = el('f-java-detected');
sel.innerHTML = '<option value=""> gefundene Java-Installationen </option>';
try {
for (const j of await window.api.listJava()) {
const o = document.createElement('option');
o.value = j.path;
o.textContent = `Java ${j.major} (${j.arch}) ${j.path}`;
sel.appendChild(o);
}
} catch { /* Liste bleibt leer */ }
sel.value = feld.value && [...sel.options].some((o) => o.value === feld.value) ? feld.value : '';
// Der häufigste Stolperstein: global ist eine feste Fassung eingetragen,
// die dann auch für alte Minecraft-Versionen genommen wird.
try {
const s = await window.api.getSettings();
el('f-java-hint').textContent = s.javaPath
? `Achtung: In den Einstellungen ist ${s.javaPath} fest vorgegeben und gilt für alle Instanzen. `
+ 'Trage hier etwas ein, wenn diese Instanz eine andere Fassung braucht ältere Minecraft-Versionen verlangen oft Java 8.'
: 'Leer lassen: Der Launcher nimmt die Fassung, die diese Minecraft-Version verlangt, und lädt sie bei Bedarf selbst.';
} catch { /* egal */ }
}
// ---------- Arbeitsspeicher ----------
let systemSpeicher = null;
async function zeigeSpeicherHinweis() {
const feld = el('f-mem-hint');
if (!feld) return;
try {
if (!systemSpeicher) systemSpeicher = await window.api.systemMemory();
} catch { return; }
const gesamt = systemSpeicher.totalMb;
const empfohlen = Math.floor(gesamt * 0.5 / 256) * 256;
feld.textContent = `Im Rechner stecken ${gesamt} MB. Leer lassen übernimmt die globale Vorgabe. `
+ `Für die meisten Modpacks reichen 40968192 MB; mehr als ${Math.floor(gesamt * 0.8 / 256) * 256} MB sollten es nicht sein `
+ `(Vorschlag: ${empfohlen} MB).`;
}
// ---------- Modpack aktualisieren ----------
let modpackNeueVersion = null;
async function openModpackUpdate() {
if (!currentDetailId) return;
modpackNeueVersion = null;
el('mp-status').textContent = 'Suche nach einer neueren Fassung …';
el('mp-kern').classList.add('hidden');
el('mp-do').classList.add('hidden');
show('modal-modpack');
const res = await window.api.modpackCheck(currentDetailId);
if (!res.ok) { el('mp-status').textContent = '✖ ' + (res.message || 'Suche fehlgeschlagen.'); return; }
if (!res.verfuegbar) {
el('mp-status').textContent = `${res.name || 'Das Modpack'} ist auf dem neuesten Stand (${res.aktuelleNummer}).`;
return;
}
modpackNeueVersion = res.neueVersionId;
el('mp-status').textContent = `Für ${res.name || 'dieses Modpack'} gibt es eine neuere Fassung.`;
const zeile = (t, w) => (w ? `<div class="crash-zeile"><div class="crash-titel">${t}</div><div class="crash-wert">${escapeHtml(w)}</div></div>` : '');
el('mp-kern').innerHTML = zeile('Installiert', res.aktuelleNummer) + zeile('Neu', res.neueNummer)
+ zeile('Minecraft', res.spielVersion)
+ (res.hinweise ? zeile('Änderungen', res.hinweise.slice(0, 400)) : '');
el('mp-kern').classList.remove('hidden');
el('mp-do').classList.remove('hidden');
}
async function doModpackUpdate() {
if (!modpackNeueVersion) return;
const btn = el('mp-do');
btn.disabled = true;
el('mp-status').textContent = 'Wird aktualisiert das kann einige Minuten dauern …';
const res = await window.api.modpackUpdate(currentDetailId, modpackNeueVersion);
btn.disabled = false;
if (!res.ok) { el('mp-status').textContent = '✖ ' + (res.message || 'Aktualisierung fehlgeschlagen.'); return; }
el('mp-status').textContent = `✔ Auf ${res.neueNummer} aktualisiert: ${res.eingespielt} Dateien eingespielt`
+ (res.entfernt ? `, ${res.entfernt} entfernt` : '') + '.';
el('mp-do').classList.add('hidden');
toast('Modpack aktualisiert.');
await reload();
await selectInstance(currentDetailId);
}
// ---------- Serverliste ----------
let zuUebertragenderServer = null;
async function openServers() {
if (!currentDetailId) return;
const inst = await window.api.getInstance(currentDetailId);
el('servers-title').textContent = 'Server ' + (inst ? inst.name : '');
el('srv-name').value = '';
el('srv-ip').value = '';
show('modal-servers');
await renderServers();
}
async function renderServers() {
const box = el('srv-list');
box.innerHTML = '<div class="mods-empty">Lade …</div>';
const res = await window.api.serversList(currentDetailId);
box.innerHTML = '';
if (!res.ok) { box.innerHTML = `<div class="mods-empty">${escapeHtml(res.message || 'Liste nicht lesbar.')}</div>`; return; }
if (!res.server.length) {
box.innerHTML = '<div class="mods-empty">Noch keine Server eingetragen.</div>';
return;
}
for (const s of res.server) {
const item = document.createElement('div');
item.className = 'mod-item';
item.innerHTML =
`<div class="mod-icon">🖥</div>
<div class="mod-info">
<div class="mod-name">${escapeHtml(s.name || s.ip)}</div>
<div class="mod-meta">${escapeHtml(s.ip)}</div>
<div class="mod-meta srv-status hint">Wird geprüft …</div>
</div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
pingServerRow(s.ip, item.querySelector('.srv-status'));
const starten = document.createElement('button');
starten.className = 'btn btn-primary btn-mini';
starten.textContent = 'Starten';
starten.title = 'Instanz starten und direkt diesem Server beitreten (Quick Play, ab MC 1.20)';
starten.addEventListener('click', () => {
hide('modal-servers');
launch(currentDetailId, null, { quickPlayServer: s.ip });
});
aktionen.appendChild(starten);
const favorit = document.createElement('button');
favorit.className = 'btn btn-mini';
favorit.textContent = '★ Verknüpfung';
favorit.title = 'Desktop-Verknüpfung erstellen, die Minecraft startet und direkt mit diesem Server verbindet (Quick Play, ab MC 1.20)';
favorit.addEventListener('click', async () => {
const inst = await window.api.getInstance(currentDetailId);
const vorschlag = `${inst ? inst.name : 'Instanz'} - ${s.name || s.ip}`;
const eingabe = await askPrompt({
title: 'Server-Verknüpfung erstellen',
text: 'Name der Desktop-Verknüpfung (ohne .lnk):',
label: 'Name',
value: vorschlag,
okLabel: 'Erstellen',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.serversShortcut(currentDetailId, s.ip, s.name, eingabe.trim());
if (res.ok) toast('Verknüpfung auf dem Desktop erstellt: ' + (s.name || s.ip));
else toast(res.message || 'Verknüpfung fehlgeschlagen.', true);
});
aktionen.appendChild(favorit);
const umbenennen = document.createElement('button');
umbenennen.className = 'btn btn-mini';
umbenennen.textContent = 'Umbenennen';
umbenennen.title = 'Anzeigenamen ändern';
umbenennen.addEventListener('click', async () => {
const aktuell = s.name || s.ip;
const eingabe = await askPrompt({
title: 'Server umbenennen',
text: 'Adresse: ' + s.ip,
label: 'Name',
value: aktuell,
okLabel: 'Speichern',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.serversRename(currentDetailId, s.ip, eingabe);
if (!res.ok) { toast(res.message || 'Umbenennen fehlgeschlagen.', true); return; }
if (!res.unveraendert) toast('Umbenannt: ' + eingabe.trim());
await renderServers();
});
aktionen.appendChild(umbenennen);
const kopieren = document.createElement('button');
kopieren.className = 'btn btn-mini';
kopieren.textContent = 'Übertragen…';
kopieren.title = 'Diesen Server in andere Instanzen eintragen';
kopieren.addEventListener('click', () => openServerCopy(s));
aktionen.appendChild(kopieren);
const weg = document.createElement('button');
weg.className = 'btn btn-danger btn-mini';
weg.textContent = 'Entfernen';
weg.addEventListener('click', async () => {
const r = await window.api.serversRemove(currentDetailId, s.ip);
if (r.ok) toast('Entfernt: ' + (s.name || s.ip));
else toast(r.message || 'Entfernen fehlgeschlagen.', true);
await renderServers();
});
aktionen.appendChild(weg);
box.appendChild(item);
}
}
// Fragt Live-Status (MOTD/Spieler/Version) ab und trägt ihn in die Zeile ein.
// Läuft unabhängig für jede Zeile, ohne die Liste selbst zu blockieren.
async function pingServerRow(ip, statusEl) {
if (!statusEl) return;
const res = await window.api.serversPing(ip);
// Zeile kann inzwischen aus dem DOM entfernt worden sein (Liste neu aufgebaut)
if (!statusEl.isConnected) return;
if (!res.ok) {
statusEl.textContent = '⚠ ' + (res.message || 'nicht erreichbar');
statusEl.classList.add('status-bad');
return;
}
const spieler = res.max != null ? `${res.online ?? '?'}/${res.max} Spieler` : '';
statusEl.textContent = [res.motd, spieler, res.version, res.latencyMs + ' ms']
.filter(Boolean).join(' · ');
statusEl.classList.add('status-ok');
}
async function addServer() {
const eintrag = { name: el('srv-name').value.trim(), ip: el('srv-ip').value.trim() };
const res = await window.api.serversAdd(currentDetailId, eintrag);
if (!res.ok) { toast(res.message || 'Hinzufügen fehlgeschlagen.', true); return; }
el('srv-name').value = '';
el('srv-ip').value = '';
toast('Server hinzugefügt.');
await renderServers();
}
async function openServerCopy(server) {
zuUebertragenderServer = server;
el('srv-copy-sub').textContent = `„${server.name || server.ip}" (${server.ip}) in diese Instanzen eintragen:`;
const box = el('srv-copy-list');
box.innerHTML = '';
const alle = await window.api.listInstances();
const andere = alle.filter((i) => i.id !== currentDetailId);
if (!andere.length) {
box.innerHTML = '<div class="mods-empty">Es gibt keine weitere Instanz.</div>';
}
for (const i of andere) {
const zeile = document.createElement('label');
zeile.className = 'checkbox-field';
zeile.style.padding = '6px 2px';
zeile.innerHTML = `<input type="checkbox" value="${escapeHtml(i.id)}" /> <span>${escapeHtml(i.name)}</span>`;
box.appendChild(zeile);
}
show('modal-srv-copy');
}
async function doServerCopy() {
if (!zuUebertragenderServer) return;
const ids = [...el('srv-copy-list').querySelectorAll('input:checked')].map((c) => c.value);
if (!ids.length) { toast('Keine Instanz ausgewählt.', true); return; }
const res = await window.api.serversCopyTo(ids, zuUebertragenderServer);
hide('modal-srv-copy');
if (res.erledigt.length) toast(`Eingetragen in: ${res.erledigt.join(', ')}`);
for (const f of res.fehler) toast(`${f.name || f.id}: ${f.message}`, true);
}
// ---------- Skin ändern ----------
let gewaehlterSkin = null;
/*
* Bild nur zeigen, wenn es wirklich geladen wurde ein <img> ohne Quelle
* stellt sonst das Platzhalter-Symbol des Browsers dar.
*/
function zeigeSkinBild(bildId, leerId, quelle, leerText) {
const bild = el(bildId);
const leer = el(leerId);
leer.textContent = leerText;
if (!quelle) {
bild.classList.add('hidden');
bild.removeAttribute('src');
leer.classList.remove('hidden');
return;
}
bild.onload = () => { bild.classList.remove('hidden'); leer.classList.add('hidden'); };
bild.onerror = () => {
bild.classList.add('hidden');
bild.removeAttribute('src');
leer.textContent = 'Vorschau nicht ladbar';
leer.classList.remove('hidden');
};
bild.src = quelle;
}
// Textur einer Seite merken, damit bei einem Wechsel der Armform beide
// Figuren neu gerendert werden können
let skinTexturAktuell = '';
let skinTexturNeu = '';
// Skin-Textur als Figur rendern; klappt das nicht, wird die Textur gezeigt
async function zeigeSkinFigur(bildId, leerId, textur, leerText) {
if (!textur) { zeigeSkinBild(bildId, leerId, '', leerText); return; }
el(leerId).textContent = 'wird gerendert …';
const modell = el('skin-variante').value === 'slim' ? 'slim' : 'default';
const figur = await renderSkin3D(textur, 'standing', { modell, breite: 260, hoehe: 420 });
zeigeSkinBild(bildId, leerId, figur || textur, leerText);
}
async function openSkin() {
gewaehlterSkin = null;
skinTexturAktuell = '';
skinTexturNeu = '';
el('skin-pfad').value = '';
el('skin-apply').disabled = true;
el('skin-status').textContent = '';
zeigeSkinBild('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt');
zeigeSkinBild('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …');
el('skin-konto').textContent = 'Profil wird geladen …';
show('modal-skin');
const p = await window.api.skinProfile();
if (!p.ok) {
el('skin-konto').textContent = p.message || 'Profil konnte nicht geladen werden.';
zeigeSkinBild('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen');
return;
}
el('skin-konto').textContent = `Angemeldet als ${p.name}. Die Änderung gilt für dieses Konto.`;
// 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 zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell,
'Standard-Skin (keine eigene Datei hinterlegt)');
}
// 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, '');
}
async function waehleSkinDatei() {
const res = await window.api.skinChoose();
if (res.canceled) return;
if (!res.ok) { el('skin-status').textContent = '✖ ' + res.message; return; }
gewaehlterSkin = res.pfad;
el('skin-pfad').value = res.pfad;
skinTexturNeu = res.dataUri || '';
await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar');
el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel sieht gut aus.`;
el('skin-apply').disabled = false;
}
async function setzeSkin() {
if (!gewaehlterSkin) return;
const btn = el('skin-apply');
btn.disabled = true;
el('skin-status').textContent = 'Wird hochgeladen …';
const res = await window.api.skinApply(gewaehlterSkin, el('skin-variante').value);
if (res.ok) {
el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.';
toast('Skin geändert.');
await openSkin(); // Vorschau mit dem neuen Stand auffrischen
await refreshSkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin
} else {
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
btn.disabled = false;
}
}
async function setzeSkinZurueck() {
const btn = el('skin-reset');
btn.disabled = true;
el('skin-status').textContent = 'Wird zurückgesetzt …';
const res = await window.api.skinReset();
if (res.ok) {
toast('Skin auf Standard zurückgesetzt.');
await openSkin();
await refreshSkinBackground();
} else {
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
}
btn.disabled = false;
}
// ---------- Logdateien ----------
let aktuellesLog = '';
async function openLogs() {
if (!currentDetailId) return;
const inst = await window.api.getInstance(currentDetailId);
el('logs-title').textContent = 'Logdateien ' + (inst ? inst.name : '');
const auswahl = el('logs-select');
auswahl.innerHTML = '';
el('logs-text').textContent = 'Lade …';
el('logs-info').textContent = '';
aktuellesLog = '';
show('modal-logs');
const dateien = await window.api.logsList(currentDetailId);
if (!dateien.length) {
el('logs-text').textContent = 'Für diese Instanz gibt es noch keine Logdateien. '
+ 'Sie entstehen beim ersten Spielstart.';
return;
}
for (const d of dateien) {
const o = document.createElement('option');
o.value = d.name;
const kb = Math.max(1, Math.round(d.groesse / 1024));
o.textContent = `${d.name} · ${kb} KB · ${new Date(d.geaendert).toLocaleString('de-DE')}`
+ (d.gepackt ? ' · gepackt' : '');
auswahl.appendChild(o);
}
await ladeLog(dateien[0].name);
}
async function ladeLog(name) {
el('logs-text').textContent = 'Lade …';
const res = await window.api.logsRead(currentDetailId, name);
if (!res.ok) {
el('logs-text').textContent = 'Datei konnte nicht gelesen werden.';
el('logs-info').textContent = '';
aktuellesLog = '';
return;
}
aktuellesLog = res.text;
el('logs-text').textContent = res.text || '(leer)';
el('logs-info').textContent = res.gekuerzt
? `${res.zeilenGesamt} Zeilen die letzten 5000 werden gezeigt`
: `${res.zeilenGesamt} Zeilen`;
// ans Ende springen: dort steht, was zuletzt passierte
const feld = el('logs-text');
feld.scrollTop = feld.scrollHeight;
}
// ---------- Absturzberichte ----------
let letzterStartMs = 0;
let aktuellerCrash = null;
async function zeigeAbsturzbericht(instanzId) {
const id = instanzId || currentDetailId;
if (!id) return;
// Minecraft schreibt den Bericht erst kurz nach dem Ende
await new Promise((r) => setTimeout(r, 900));
let res;
try { res = await window.api.crashLatest(id, letzterStartMs); }
catch { return; }
if (!res || !res.ok) return; // kein Bericht -> Konsole muss reichen
aktuellerCrash = res;
const inst = await window.api.getInstance(id);
el('crash-sub').textContent = `${inst ? inst.name + ' · ' : ''}${res.datei} · `
+ new Date(res.zeitpunkt).toLocaleString('de-DE');
const zeile = (titel, wert) => wert
? `<div class="crash-zeile"><div class="crash-titel">${titel}</div><div class="crash-wert">${escapeHtml(wert)}</div></div>`
: '';
let html = zeile('Diagnose', res.diagnose) + zeile('Empfohlene Lösung', res.empfehlung)
+ zeile('Beschreibung', res.beschreibung) + zeile('Fehler', res.fehler)
+ zeile('Verdächtige Mods', res.verdaechtigeMods);
if (res.verlauf && res.verlauf.length) {
html += `<div class="crash-zeile"><div class="crash-titel">Aufrufverlauf</div>`
+ `<div class="crash-wert"><div class="crash-verlauf">${escapeHtml(res.verlauf.join('\n'))}</div></div></div>`;
}
el('crash-kern').innerHTML = html || '<div class="hint">Der Bericht enthält keine der üblichen Kennzeichen.</div>';
el('crash-voll').textContent = res.text || '';
show('modal-crash');
}
// Fortschrittsmeldungen der Download-/Start-Pipeline
function onLaunchEvt(evt) {
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' };
if (evt.phase === 'log') { appendConsole(evt.line); return; }
if (evt.phase === 'exit') {
status('Minecraft beendet (Code ' + evt.code + ')');
el('status-right').textContent = '';
appendConsole('\n[Prozess beendet, Code ' + evt.code + ']\n');
// War vorher "Stopping!" im Log (regulärer Client-Shutdown), zählt ein
// nachträglicher harter Exit-Code nicht als Absturz während des Spiels
// z. B. wenn Minecrafts interner Shutdown-Watchdog wegen hängender
// Mod-Threads (z. B. JourneyMap WebMap) die JVM nach Ablauf einer
// Wartezeit zwangsweise beendet. Der Bericht bleibt trotzdem gespeichert.
const crashed = evt.code !== 0 && evt.code !== null && !evt.cleanShutdown;
if (evt.cleanShutdown && evt.code !== 0 && evt.code !== null) {
appendConsole('[Hinweis] Der Client wurde regulär beendet (Stopping!), aber danach vom '
+ 'internen Shutdown-Watchdog hart terminiert (z. B. durch einen Mod, dessen Threads '
+ 'nicht rechtzeitig beenden etwa JourneyMap WebMap). Kein Absturz während des Spiels.\n');
}
// Absturzbericht suchen, bevor über die Konsole entschieden wird
// ein Bericht sagt mehr als der Java-Auswurf
if (crashed) zeigeAbsturzbericht(evt.id);
window.api.getSettings().then((s) => {
if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; }
if (s.autoCloseConsole) hide('modal-console');
}).catch(() => {});
return;
}
// Startzeitpunkt merken: nur Berichte ab hier gehören zu diesem Lauf
if (evt.phase === 'launch') letzterStartMs = Date.now();
const label = labels[evt.phase] || evt.phase;
if (evt.total) {
status(`${label}${evt.current}/${evt.total}`);
el('status-right').textContent = Math.round((evt.current / evt.total) * 100) + '%';
} else {
status(evt.detail || label + ' …');
el('status-right').textContent = '';
appendConsole('[' + label + '] ' + (evt.detail || '') + '\n');
}
}
// ---------- Konsole ----------
function classifyLine(l) {
if (/\/(ERROR|FATAL|SEVERE)\]/.test(l) || /Exception(:| in )|^\s+at\s|Caused by:/.test(l)) return 'log-error';
if (/\/WARN(ING)?\]/.test(l) || /\bWARN\b/.test(l)) return 'log-warn';
if (/\[Pre-Launch\]|\[Post-Exit\]|=== Start:|\[Prozess beendet/.test(l)) return 'log-info';
return '';
}
function appendLineEl(out, line) {
const div = document.createElement('div');
div.className = 'log-line ' + classifyLine(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 > consoleMaxChars) consoleBuffer = consoleBuffer.slice(-Math.round(consoleMaxChars * 0.75));
consolePending += text;
const lines = consolePending.split('\n');
consolePending = lines.pop();
const out = el('console-out');
if (!el('modal-console').classList.contains('hidden')) {
for (const line of lines) appendLineEl(out, line);
out.scrollTop = out.scrollHeight;
}
}
function openConsole() {
const out = el('console-out');
out.innerHTML = '';
const lines = (consoleBuffer || '(noch keine Ausgabe)').split('\n');
for (const line of lines) appendLineEl(out, line);
consolePending = '';
show('modal-console');
out.scrollTop = out.scrollHeight;
}
// ---------- Welten & Ressourcenpakete ----------
async function openContent() {
const inst = await window.api.getInstance(currentDetailId);
el('content-title').textContent = 'Welten, Ressourcen- & Datenpakete ' + inst.name;
await renderContentLists();
show('modal-content');
}
async function renderContentLists() {
renderContentList('content-worlds', await window.api.worlds(currentDetailId), 'saves', '🌍');
renderContentList('content-rp', await window.api.resourcepacks(currentDetailId), 'resourcepacks', '🎨');
renderContentList('content-dp', await window.api.datapacks(currentDetailId), 'datapacks', '📜');
}
// ---------- Screenshots ----------
let screenSelected = new Set();
function updateScreensSelectionUi() {
const n = screenSelected.size;
const btn = el('screens-delete-sel');
if (btn) {
btn.disabled = n === 0;
btn.textContent = n ? (n + ' löschen') : 'Auswahl löschen';
}
const hint = el('screens-sel-hint');
if (hint) hint.textContent = n ? (n + ' ausgewählt Doppelklick öffnet das Bild') : 'Klick auf Vorschaubild zum Auswählen · Doppelklick öffnet';
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
const name = t.dataset.name;
const on = name && screenSelected.has(name);
t.classList.toggle('selected', !!on);
const cb = t.querySelector('.screen-check');
if (cb) cb.checked = !!on;
});
}
async function openScreens() {
const inst = await window.api.getInstance(currentDetailId);
el('screens-title').textContent = 'Screenshots ' + inst.name;
const grid = el('screens-grid');
grid.innerHTML = '<div class="screens-empty">Lade …</div>';
screenSelected = new Set();
updateScreensSelectionUi();
show('modal-screens');
const shots = await window.api.screenshots(currentDetailId);
grid.innerHTML = '';
if (!shots.length) {
grid.innerHTML = '<div class="screens-empty">Keine Screenshots vorhanden.</div>';
updateScreensSelectionUi();
return;
}
for (const s of shots) {
const thumb = document.createElement('div');
thumb.className = 'screen-thumb';
thumb.dataset.name = s.name;
thumb.innerHTML =
`<input type="checkbox" class="screen-check" title="Auswählen" />
<img alt="" /><div class="screen-name">${escapeHtml(s.name)}</div>
<button class="screen-del" title="Löschen">🗑</button>`;
const img = thumb.querySelector('img');
window.api.screenshotData(currentDetailId, s.name).then((d) => { if (d) img.src = d; });
const toggle = () => {
if (screenSelected.has(s.name)) screenSelected.delete(s.name);
else screenSelected.add(s.name);
updateScreensSelectionUi();
};
thumb.querySelector('.screen-check').addEventListener('click', (e) => {
e.stopPropagation();
toggle();
});
thumb.addEventListener('click', (e) => {
if (e.target.closest('.screen-del') || e.target.closest('.screen-check')) return;
toggle();
});
img.addEventListener('dblclick', (e) => {
e.stopPropagation();
window.api.openScreenshot(currentDetailId, s.name);
});
thumb.querySelector('.screen-del').addEventListener('click', (e) => {
e.stopPropagation();
askConfirm('Screenshot löschen', `„${s.name}" löschen?`, '🗑 Löschen', async () => {
await window.api.deleteContent(currentDetailId, 'screenshots', s.name);
openScreens();
});
});
grid.appendChild(thumb);
}
updateScreensSelectionUi();
}
async function deleteSelectedScreenshots() {
const names = [...screenSelected];
if (!names.length || !currentDetailId) return;
const label = names.length === 1
? `„${names[0]}" löschen?`
: `${names.length} Screenshots unwiderruflich löschen?`;
askConfirm('Screenshots löschen', label, '🗑 Löschen', async () => {
for (const name of names) {
await window.api.deleteContent(currentDetailId, 'screenshots', name);
}
toast(names.length === 1 ? 'Screenshot gelöscht.' : names.length + ' Screenshots gelöscht.');
openScreens();
});
}
function renderContentList(boxId, items, sub, emoji) {
const box = el(boxId);
box.innerHTML = '';
if (!items.length) { box.innerHTML = '<div class="mods-empty">Nichts vorhanden.</div>'; return; }
for (const it of items) {
const item = document.createElement('div');
item.className = 'mod-item';
item.innerHTML =
`<div class="mod-icon">${emoji}</div>
<div class="mod-info"><div class="mod-name">${escapeHtml(it.name)}</div></div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
if (sub === 'saves') {
const sichern = document.createElement('button');
sichern.className = 'btn btn-mini';
sichern.textContent = 'Sichern';
sichern.title = 'Nur diese Welt jetzt als ZIP sichern (Dokumente\\AeroMC Launcher\\Welten-Sicherungen)';
sichern.addEventListener('click', async () => {
sichern.disabled = true;
const orig = sichern.textContent;
sichern.textContent = 'Sichere …';
const res = await window.api.backupWorld(currentDetailId, it.name);
sichern.disabled = false;
sichern.textContent = orig;
if (res.ok) toast(`„${it.name}" gesichert.`);
else toast(res.message || 'Sicherung fehlgeschlagen.', true);
});
aktionen.appendChild(sichern);
}
const rm = document.createElement('button');
rm.className = 'btn btn-danger btn-mini';
rm.textContent = 'Löschen';
rm.addEventListener('click', () => askConfirm(
'Löschen', `„${it.name}" wird unwiderruflich gelöscht. Fortfahren?`, '🗑 Löschen',
async () => { await window.api.deleteContent(currentDetailId, sub, it.name); await renderContentLists(); toast('Gelöscht: ' + it.name); },
));
aktionen.appendChild(rm);
box.appendChild(item);
}
}
// ---------- Mod-Browser ----------
// Welche Inhaltsart wird gerade gezeigt: mod | resourcepack | shader
let modArt = 'mod';
const ART_LABEL = {
mod: 'Mods', resourcepack: 'Ressourcenpakete', shader: 'Shader', datapack: 'Datenpakete',
};
const ART_SUCHE = {
mod: 'Mods durchsuchen … (z. B. Sodium, JEI, Create)',
resourcepack: 'Ressourcenpakete durchsuchen … (z. B. Faithful, Bare Bones)',
shader: 'Shader durchsuchen … (z. B. Complementary, BSL)',
datapack: 'Datenpakete durchsuchen … (z. B. Terralith, Vanilla Tweaks)',
};
// Nur Mods brauchen einen Loader Ressourcenpakete, Shader und Datenpakete laufen auch in Vanilla
const ART_BRAUCHT_LOADER = {
mod: true, resourcepack: false, shader: false, datapack: false,
};
// CurseForge hat für Datenpakete keinen verlässlich zugeordneten API-Klassencode in
// diesem Launcher dort deshalb nur Modrinth als Quelle anbieten, statt falsche
// Treffer (z. B. Mods) unter "Datenpakete" anzuzeigen.
const ART_NUR_MODRINTH = { datapack: true };
async function openMods() {
const inst = await window.api.getInstance(currentDetailId);
if (!inst) return;
modArt = 'mod';
el('m-art').value = 'mod';
await zeigeModAnsicht(inst);
show('modal-mods');
}
// Wechsel der Inhaltsart: alles neu aufbauen
async function modArtGewechselt() {
modArt = el('m-art').value || 'mod';
const inst = await window.api.getInstance(currentDetailId);
if (inst) await zeigeModAnsicht(inst);
}
async function zeigeModAnsicht(inst) {
el('m-title').textContent = ART_LABEL[modArt] + ' ' + inst.name;
const loaderTxt = LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader;
el('m-sub').textContent = (inst.minecraft.version || 'keine Version') + ' · ' + loaderTxt;
el('m-query').placeholder = ART_SUCHE[modArt];
const warn = el('m-warn');
const brauchtLoader = ART_BRAUCHT_LOADER[modArt];
const blocked = !inst.minecraft.version || (brauchtLoader && inst.minecraft.loader === 'vanilla');
if (blocked) {
warn.classList.remove('hidden');
warn.textContent = !inst.minecraft.version
? '⚠ Diese Instanz hat keine Minecraft-Version. Bitte zuerst im Bearbeiten-Dialog eine Version wählen.'
: '⚠ Vanilla-Instanzen unterstützen keine Mods. Wähle beim Bearbeiten einen Mod-Loader (Fabric/Forge/NeoForge/Quilt). Ressourcenpakete und Shader gehen auch ohne.';
} else {
warn.classList.add('hidden');
}
['m-query', 'm-search-btn', 'm-sort', 'm-category'].forEach((id) => { el(id).disabled = blocked; });
// Kategorien gelten nur für Mods
el('m-category').disabled = blocked || modArt !== 'mod';
// Manche Quellen decken nicht jede Inhaltsart ab (z. B. CurseForge <-> Datenpakete)
const cfOption = el('m-source').querySelector('option[value="curseforge"]');
if (cfOption) cfOption.disabled = !!ART_NUR_MODRINTH[modArt];
if (ART_NUR_MODRINTH[modArt] && el('m-source').value === 'curseforge') el('m-source').value = 'modrinth';
el('m-query').value = '';
el('m-more').classList.add('hidden');
// Ergebnisse der vorigen Ansicht nicht mitschleppen
modUpdates = new Map();
setUpdateStatus('');
el('m-apply-updates').classList.add('hidden');
el('m-check-updates').disabled = blocked;
switchModTab('catalog');
await refreshInstalled();
if (blocked) {
const box = el('m-results');
box.innerHTML = `<div class="mods-empty">${!inst.minecraft.version
? 'Ohne Minecraft-Version geht hier nichts.'
: 'Für Mods braucht diese Instanz einen Mod-Loader.'}</div>`;
const b = document.createElement('button');
b.className = 'btn btn-primary';
b.style.margin = '0 auto';
b.style.display = 'block';
b.textContent = inst.minecraft.version ? 'Mod-Loader hinzufügen' : 'Version wählen';
b.addEventListener('click', async () => {
hide('modal-mods');
const i = await window.api.getInstance(currentDetailId);
if (i) openEdit(i);
});
box.appendChild(b);
} else {
await loadCatalog(false); // beliebte Einträge sofort anzeigen
}
}
function switchModTab(which) {
const cat = which === 'catalog';
el('m-tab-catalog').classList.toggle('on', cat);
el('m-tab-installed').classList.toggle('on', !cat);
el('m-pane-catalog').classList.toggle('hidden', !cat);
el('m-pane-installed').classList.toggle('hidden', cat);
}
// gefundene Aktualisierungen: projectId -> { title, alt, neu }
let modUpdates = new Map();
async function refreshInstalled() {
const list = await window.api.modList(currentDetailId, modArt);
currentModsInstalled = new Set(list.map((m) => m.projectId));
el('m-installed-count').textContent = String(list.length);
const box = el('m-installed');
box.innerHTML = '';
if (!list.length) { box.innerHTML = '<div class="mods-empty">Noch keine Mods.</div>'; return; }
for (const m of list) {
const up = m.projectId ? modUpdates.get(m.projectId) : null;
const aus = m.enabled === false;
const item = document.createElement('div');
item.className = 'mod-item' + (aus ? ' mod-aus' : '');
const kennzeichen = [];
if (up) kennzeichen.push('<span class="mod-new">Update</span>');
if (aus) kennzeichen.push('<span class="mod-off">aus</span>');
if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>');
// OptiFine lässt sich nicht über Modrinth/CurseForge installieren (kein API-Zugriff auf
// deren Downloads) von Hand hinzugefügt taucht es sonst wie ein beliebiger Mod auf.
const istOptiFine = modArt === 'mod' && /^optifine[_-]/i.test(m.filename || '');
if (istOptiFine) kennzeichen.push('<span class="mod-off" style="opacity:.8">OptiFine</span>');
item.innerHTML =
`<div class="mod-icon">${aus ? '🚫' : istOptiFine ? '⚡' : '🧩'}</div>
<div class="mod-info">
<div class="mod-name">${escapeHtml(m.title)}${kennzeichen.length ? ' ' + kennzeichen.join(' ') : ''}</div>
<div class="mod-meta">${up
? escapeHtml(up.alt.versionNumber) + ' → ' + escapeHtml(up.neu.versionNumber)
: escapeHtml(m.versionNumber || '') + ' · ' + escapeHtml(m.filename)}</div>
</div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
// Ab-/Anschalten: die Datei bleibt liegen, Minecraft übergeht sie nur
if (!m.fehlt) {
const schalter = document.createElement('button');
schalter.className = 'btn btn-mini';
schalter.textContent = aus ? 'Einschalten' : 'Ausschalten';
schalter.title = aus
? 'Datei wieder auf .jar umbenennen'
: 'Datei auf .jar.disabled umbenennen Minecraft lädt sie dann nicht';
schalter.addEventListener('click', async () => {
schalter.disabled = true;
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus, modArt);
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title);
else toast(res.message || 'Umschalten fehlgeschlagen.', true);
await refreshInstalled();
});
aktionen.appendChild(schalter);
}
const btn = document.createElement('button');
btn.className = 'btn btn-danger btn-mini';
btn.textContent = 'Entfernen';
btn.addEventListener('click', async () => {
const tatsaechlichEntfernen = async () => {
await window.api.modRemove(currentDetailId, m.filename, modArt);
toast('Entfernt: ' + m.title);
await refreshInstalled();
};
const abhaenger = await window.api.modDependents(currentDetailId, m.filename, modArt);
if (abhaenger.length) {
const namen = abhaenger.map((n) => `„${n}"`).join(', ');
askConfirm(
'Wird noch gebraucht',
`${namen} ${abhaenger.length === 1 ? 'benötigt' : 'benötigen'}${m.title}" als Pflicht-Abhängigkeit. `
+ 'Wird sie trotzdem entfernt, funktionieren diese Mods vermutlich nicht mehr richtig. Trotzdem entfernen?',
'Trotzdem entfernen',
tatsaechlichEntfernen,
);
} else {
await tatsaechlichEntfernen();
}
});
aktionen.appendChild(btn);
box.appendChild(item);
}
}
// ---------- Mod-Aktualisierung ----------
function setUpdateStatus(text) { el('m-update-status').textContent = text || ''; }
async function checkModUpdates() {
const btn = el('m-check-updates');
btn.disabled = true;
el('m-apply-updates').classList.add('hidden');
setUpdateStatus('Suche …');
try {
const res = await window.api.modCheckUpdates(currentDetailId, modArt);
if (!res.ok) { setUpdateStatus('✖ ' + (res.message || 'Suche fehlgeschlagen.')); return; }
modUpdates = new Map((res.treffer || []).map((t) => [t.projectId, t]));
await refreshInstalled();
const teile = [];
if (!res.treffer.length) teile.push('Alles aktuell');
else teile.push(res.treffer.length + (res.treffer.length === 1 ? ' Update verfügbar' : ' Updates verfügbar'));
teile.push(res.geprueft + ' geprüft');
// von Hand abgelegte Dateien lassen sich nicht zuordnen
if (res.uebersprungen) teile.push(res.uebersprungen + ' ohne Quelle übersprungen');
setUpdateStatus(teile.join(' · '));
el('m-apply-updates').classList.toggle('hidden', !res.treffer.length);
} finally {
btn.disabled = false;
}
}
async function applyModUpdates() {
if (!modUpdates.size) return;
const btn = el('m-apply-updates');
btn.disabled = true;
setUpdateStatus('Wird aktualisiert …');
try {
const res = await window.api.modApplyUpdates(currentDetailId, [...modUpdates.keys()], modArt);
if (!res.ok) { setUpdateStatus('✖ ' + (res.message || 'Aktualisierung fehlgeschlagen.')); return; }
modUpdates = new Map();
await refreshInstalled();
const n = res.erneuert.length;
const wort = n === 1 ? 'Eintrag' : 'Einträge';
setUpdateStatus(`${n} ${wort} aktualisiert`
+ (res.fehler.length ? ' · ' + res.fehler.length + ' fehlgeschlagen' : ''));
if (n) toast(`${n} ${wort} aktualisiert.`);
for (const f of res.fehler) toast(f.title + ': ' + f.message, true);
btn.classList.add('hidden');
} finally {
btn.disabled = false;
}
}
let modOffset = 0;
async function loadCatalog(append) {
const inst = await window.api.getInstance(currentDetailId);
if (!inst || !inst.minecraft.version) return;
// Loader nur für Mods verlangen
if (ART_BRAUCHT_LOADER[modArt] && inst.minecraft.loader === 'vanilla') return;
const box = el('m-results');
if (!append) { modOffset = 0; box.innerHTML = '<div class="mods-empty">Lade …</div>'; el('m-more').classList.add('hidden'); }
const source = (el('m-source') && el('m-source').value) || 'modrinth';
if (el('m-category')) el('m-category').disabled = source === 'curseforge' || modArt !== 'mod';
try {
const res = await window.api.modSearch({
query: el('m-query').value.trim(),
gameVersion: inst.minecraft.version,
loader: inst.minecraft.loader,
category: (source === 'modrinth' && modArt === 'mod') ? el('m-category').value : '',
sort: el('m-sort').value,
limit: 30,
offset: modOffset,
art: modArt,
source,
});
if (!append) box.innerHTML = '';
if (res && (res.error || res.needKey)) {
if (!append) {
box.innerHTML = `<div class="mods-empty">${escapeHtml(res.error || 'Suche nicht möglich.')}</div>`;
if (res.needKey || /API-Key|Weiterleitung reicht/i.test(res.error || '')) {
const wrap = document.createElement('div');
wrap.className = 'mods-empty';
const btn = document.createElement('button');
btn.className = 'btn btn-mini';
btn.textContent = 'Zu den Netzwerk-Einstellungen';
btn.addEventListener('click', () => { hide('modal-mods'); openSettings('pane-network'); });
wrap.appendChild(btn);
box.appendChild(wrap);
}
}
el('m-more').classList.add('hidden');
return;
}
const hits = (res && res.hits) || [];
if (!hits.length && !append) {
box.innerHTML = '<div class="mods-empty">Nichts gefunden.</div>';
el('m-more').classList.add('hidden');
return;
}
for (const mod of hits) box.appendChild(buildResultItem(mod));
modOffset += hits.length;
el('m-more').classList.toggle('hidden', hits.length < 30 || modOffset >= (res.total || 0));
} catch (e) {
if (!append) {
const msg = (e && e.message) ? e.message : String(e);
box.innerHTML = `<div class="mods-empty">Fehler: ${escapeHtml(msg)}</div>`;
}
}
}
function buildResultItem(mod) {
const item = document.createElement('div');
item.className = 'mod-item';
const icon = document.createElement(mod.iconUrl ? 'img' : 'div');
icon.className = 'mod-icon';
if (mod.iconUrl) {
icon.src = mod.iconUrl;
icon.addEventListener('error', () => {
const d = document.createElement('div');
d.className = 'mod-icon'; d.textContent = '🧩';
icon.replaceWith(d);
});
} else { icon.textContent = '🧩'; }
const info = document.createElement('div');
info.className = 'mod-info';
info.innerHTML =
`<div class="mod-name">${escapeHtml(mod.title)}</div>
<div class="mod-desc">${escapeHtml(mod.description || '')}</div>
<div class="mod-meta">von ${escapeHtml(mod.author || 'unbekannt')} · ${Number(mod.downloads).toLocaleString('de-DE')} Downloads${mod.source === 'curseforge' ? ' · CurseForge' : ''}</div>`;
const action = document.createElement('div');
action.className = 'mod-action';
const btn = document.createElement('button');
btn.className = 'btn btn-primary btn-mini';
const already = currentModsInstalled.has(mod.projectId);
if (already) {
btn.textContent = '✓ Installiert'; btn.disabled = true;
} else {
btn.textContent = 'Installieren';
btn.addEventListener('click', (e) => { e.stopPropagation(); installMod(mod, btn, item); });
// ganze Zeile klickbar: ein Klick installiert Mod + alle Abhängigkeiten
item.classList.add('clickable');
item.title = 'Klicken zum Installieren (inkl. Abhängigkeiten)';
item.addEventListener('click', () => { if (!btn.disabled) installMod(mod, btn, item); });
}
action.appendChild(btn);
item.appendChild(icon); item.appendChild(info); item.appendChild(action);
return item;
}
async function installMod(mod, btn, item) {
btn.disabled = true; btn.textContent = 'Lädt …';
if (item) { item.classList.add('installing'); item.classList.remove('clickable'); }
const res = await window.api.modInstall(currentDetailId, { projectId: mod.projectId, title: mod.title }, modArt);
if (item) item.classList.remove('installing');
if (res.ok) {
const dep = res.depCount ? ` (+${res.depCount} Abhängigkeit${res.depCount > 1 ? 'en' : ''})` : '';
toast('Installiert: ' + mod.title + dep);
btn.textContent = '✓ Installiert';
currentModsInstalled.add(mod.projectId);
await refreshInstalled();
} else {
toast(res.message || 'Installation fehlgeschlagen.', true);
btn.disabled = false; btn.textContent = 'Installieren';
if (item) item.classList.add('clickable');
}
}
// ---------- Einstellungen ----------
function setSettingsPane(paneId) {
document.querySelectorAll('.snav').forEach((button) => button.classList.toggle('on', button.dataset.pane === paneId));
document.querySelectorAll('.spane').forEach((pane) => pane.classList.toggle('hidden', pane.id !== paneId));
}
// pane: optionaler Bereich, der direkt geöffnet werden soll (z. B. 'pane-network')
async function openSettings(pane) {
setSettingsPane(pane || 'pane-general');
const s = await window.api.getSettings();
el('s-theme').value = s.theme || 'dark';
el('s-global-pre').value = s.globalPreLaunch || '';
el('s-global-post').value = s.globalPostExit || '';
el('s-wrapper').value = s.wrapperCommand || '';
// Skin-Hintergrund: Auswahl aus allen angemeldeten Konten
const accs = await window.api.authAccounts();
const sel = el('s-bgskin');
sel.innerHTML = '<option value="">Aus</option><option value="active">Aktives Konto</option>';
for (const a of accs) {
const o = document.createElement('option');
o.value = a.id; o.textContent = a.name;
sel.appendChild(o);
}
sel.value = s.bgSkin || '';
el('s-bgskin-pose').value = s.bgSkinPose || 'walking';
el('s-bgskin-cycle').checked = !!s.bgSkinCycle;
el('s-bgskin-cycle-seconds').value = s.bgSkinCycleSeconds || 30;
sel.disabled = !!s.bgSkinCycle;
el('s-sortby').value = s.sortBy || 'lastPlayed';
el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false;
el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled;
el('s-autobackup-every').value = s.autoBackupEvery || 'daily';
el('s-autobackup-keep').value = s.autoBackupKeep || 5;
el('s-autobackup-launch').checked = !!s.autoBackupBeforeLaunch;
el('s-autobackup-last').textContent = s.autoBackupLast
? 'Zuletzt automatisch gesichert: ' + new Date(s.autoBackupLast).toLocaleString('de-DE')
: 'Noch keine automatische Sicherung.';
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;
el('s-proxy-host').value = s.proxyHost || '';
el('s-proxy-port').value = s.proxyPort || '';
el('s-proxy-user').value = s.proxyUser || '';
el('s-proxy-pass').value = s.proxyPass || '';
el('s-cf-key').value = s.curseforgeApiKey || '';
el('s-cf-proxy').value = s.curseforgeProxyUrl || '';
el('s-cf-proxy-token').value = s.curseforgeProxyToken || '';
el('s-backup-status').className = 'hint';
el('s-backup-status').textContent = 'Noch kein Backup erstellt.';
el('s-backup-global').disabled = false;
el('s-restore-status').className = 'hint';
el('s-restore-status').textContent = 'Noch kein Backup wiederhergestellt.';
await refreshGlobalBackupOptions();
resetBackupProgressUi();
setBackupButtonsBusy(false);
el('s-instances-dir').value = await window.api.getInstancesDir();
el('s-java').value = s.javaPath || '';
el('s-min-mem').value = s.defaultMinMemMb;
el('s-max-mem').value = s.defaultMaxMemMb;
el('s-java-detected').innerHTML = '<option value=""> gefundene Java-Installationen </option>';
await refreshJavaStatus(s.javaPath);
const info = await window.api.appInfo();
el('s-appinfo').innerHTML = [
['Name', info.name || 'AeroMC'],
['Version', formatVersionLabel(info.version)],
['Autor', info.author || 'Nicht hinterlegt'],
['Webseite', info.homepage || 'Nicht hinterlegt', info.homepage || ''],
['Launcher-Webseite', info.launcherWebsite || 'Nicht hinterlegt', info.launcherWebsite || ''],
['Lizenz', 'Nur privater, nicht-kommerzieller Gebrauch Details anzeigen', null, 'license'],
].map(([label, value, url, action]) => {
const renderedValue = url
? `<a href="#" class="info-meta-link" data-url="${escapeHtml(url)}">${escapeHtml(value)}</a>`
: action
? `<a href="#" class="info-meta-link" data-action="${escapeHtml(action)}">${escapeHtml(value)}</a>`
: escapeHtml(value || '');
return `<div class="settings-kv-row"><span class="settings-kv-label">${escapeHtml(label)}</span><span class="settings-kv-value">${renderedValue}</span></div>`;
}).join('');
for (const link of el('s-appinfo').querySelectorAll('.info-meta-link')) {
link.addEventListener('click', async (evt) => {
evt.preventDefault();
const url = link.dataset.url;
if (url) { window.api.openExternal(url); return; }
if (link.dataset.action === 'license') {
show('modal-license');
el('license-text').textContent = await window.api.appLicense();
}
});
}
show('modal-settings');
await renderAccounts('s-accounts'); // Konten direkt in den Einstellungen zeigen
applyBedrockVisibility(); // aktualisiert auch den Hinweistext
}
async function refreshJavaStatus(javaPath) {
const box = el('s-java-status');
box.className = 'hint';
box.textContent = 'Prüfe Java …';
const r = await window.api.detectJava(javaPath);
if (r.ok) {
box.classList.add('status-ok'); // grün, wenn Java passt
box.textContent = '✔ ' + r.version;
} else {
box.classList.add('status-bad');
box.textContent = '✖ Java nicht gefunden (' + (r.error || 'unbekannt') + ')';
}
}
async function scanJava() {
const btn = el('s-scan-java');
btn.disabled = true; btn.textContent = 'Suche …';
try {
const jvms = await window.api.scanJava();
const sel = el('s-java-detected');
sel.innerHTML = '<option value=""> gefundene Java-Installationen </option>';
for (const j of jvms) {
const o = document.createElement('option');
o.value = j.path;
o.textContent = `Java ${j.major} (${j.arch}) ${j.path}`;
sel.appendChild(o);
}
toast(jvms.length ? `${jvms.length} Java-Installation(en) gefunden.` : 'Keine Java-Installation gefunden.', !jvms.length);
} catch (e) {
toast('Java-Suche fehlgeschlagen: ' + e.message, true);
} finally {
btn.disabled = false; btn.textContent = '🔍 Erkennen';
}
}
async function saveSettings() {
// globale Speicher-Vorgaben genauso prüfen wie die je Instanz
const gMin = numOrNull(el('s-min-mem').value) || 2048;
const gMax = numOrNull(el('s-max-mem').value) || 4096;
const pruefung = await window.api.checkMemory(gMin, gMax);
if (!pruefung.ok) { toast(pruefung.message, true); setSettingsPane('pane-minecraft'); return; }
if (pruefung.warnung) toast(pruefung.warnung, false);
const patch = {
theme: el('s-theme').value,
globalPreLaunch: el('s-global-pre').value.trim(),
globalPostExit: el('s-global-post').value.trim(),
bgSkin: el('s-bgskin').value,
bgSkinPose: el('s-bgskin-pose').value,
bgSkinCycle: el('s-bgskin-cycle').checked,
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
sortBy: el('s-sortby').value,
iso3dIcons: el('s-iso3d').checked,
bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked,
autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked,
autoBackupEvery: el('s-autobackup-every').value,
autoBackupKeep: Math.min(50, Math.max(1, numOrNull(el('s-autobackup-keep').value) || 5)),
autoBackupBeforeLaunch: el('s-autobackup-launch').checked,
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,
proxyHost: el('s-proxy-host').value.trim(),
proxyPort: el('s-proxy-port').value.trim(),
proxyUser: el('s-proxy-user').value.trim(),
proxyPass: el('s-proxy-pass').value,
curseforgeApiKey: el('s-cf-key').value.trim(),
curseforgeProxyUrl: el('s-cf-proxy').value.trim(),
curseforgeProxyToken: el('s-cf-proxy-token').value.trim(),
javaPath: el('s-java').value.trim(),
defaultMinMemMb: numOrNull(el('s-min-mem').value) || 2048,
defaultMaxMemMb: numOrNull(el('s-max-mem').value) || 4096,
instancesDir: el('s-instances-dir').value.trim() || null,
};
await window.api.saveSettings(patch);
applyTheme(patch.theme);
iso3dEnabled = patch.iso3dIcons;
await applyBedrockVisibility();
await refreshSkinBackground();
hide('modal-settings');
toast('Einstellungen gespeichert.');
await reload();
}
function resetBackupProgressUi() {
['backup', 'restore'].forEach((kind) => {
clearBackupProgressTimer(kind);
backupProgressUiState[kind].shownPercent = 0;
backupProgressUiState[kind].targetPercent = 0;
backupProgressUiState[kind].detail = '';
backupProgressUiState[kind].packing = false;
el(`s-${kind}-progress`).dataset.percent = '0';
el(`s-${kind}-progress`).dataset.complete = '0';
el(`s-${kind}-progress`).classList.add('hidden');
el(`s-${kind}-progress-fill`).style.width = '0%';
el(`s-${kind}-progress-fill`).classList.remove('backup-progress-fill-busy');
el(`s-${kind}-progress-text`).textContent = '0 %';
});
}
const backupProgressActive = { backup: false, restore: false };
const backupProgressUiState = {
backup: { timer: null, shownPercent: 0, targetPercent: 0, detail: '', packing: false },
restore: { timer: null, shownPercent: 0, targetPercent: 0, detail: '', packing: false },
};
function clearBackupProgressTimer(prefix) {
const state = backupProgressUiState[prefix];
if (!state.timer) return;
clearInterval(state.timer);
state.timer = null;
}
function renderBackupProgress(prefix) {
const state = backupProgressUiState[prefix];
const box = el(`s-${prefix}-progress`);
const fill = el(`s-${prefix}-progress-fill`);
const text = el(`s-${prefix}-progress-text`);
const displayPercent = state.targetPercent >= 100 ? 100 : Math.floor(state.shownPercent);
box.dataset.percent = String(displayPercent);
box.dataset.complete = state.targetPercent >= 100 && state.shownPercent >= 100 ? '1' : '0';
box.classList.remove('hidden');
fill.classList.toggle('backup-progress-fill-busy', state.packing && state.targetPercent < 99);
fill.style.width = `${Math.max(0, Math.min(100, state.shownPercent))}%`;
text.textContent = `${displayPercent} %${state.detail ? ' · ' + state.detail : ''}`;
}
function ensureBackupProgressTimer(prefix) {
const state = backupProgressUiState[prefix];
if (state.timer) return;
state.timer = setInterval(() => {
const gap = state.targetPercent - state.shownPercent;
if (gap > 0.05) {
const step = state.packing ? Math.max(0.18, gap * 0.18) : Math.max(0.8, gap * 0.4);
state.shownPercent = Math.min(state.targetPercent, state.shownPercent + step);
} else {
state.shownPercent = state.targetPercent;
}
renderBackupProgress(prefix);
if (state.targetPercent >= 100 && state.shownPercent >= 100) clearBackupProgressTimer(prefix);
}, 120);
}
function setBackupProgressActive(mode, active) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
backupProgressActive[prefix] = !!active;
}
function applyBackupProgressEvent(mode, percent, detail) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
if (!backupProgressActive[prefix]) return;
updateBackupProgressUi(mode, percent, detail);
}
function setBackupButtonsBusy(busy) {
const restoreSelect = el('s-restore-backup');
const hasPlaceholderOnly = restoreSelect.options.length === 1 && !restoreSelect.options[0].value;
const canRestore = restoreSelect.options.length > 0 && !hasPlaceholderOnly && !!restoreSelect.value;
el('s-backup-global').disabled = busy;
restoreSelect.disabled = busy || !canRestore;
el('s-restore-global').disabled = busy || !canRestore;
}
function formatBackupSize(bytes) {
const value = Number(bytes) || 0;
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function formatBackupModified(isoString) {
try {
return new Date(isoString).toLocaleString('de-DE');
} catch {
return isoString || '';
}
}
async function refreshGlobalBackupOptions(selectedPath) {
const select = el('s-restore-backup');
const backups = await window.api.listGlobalBackups();
select.innerHTML = '';
if (!backups.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'Keine Backups gefunden';
select.appendChild(option);
select.disabled = true;
el('s-restore-global').disabled = true;
return [];
}
backups.forEach((backup) => {
const option = document.createElement('option');
option.value = backup.path;
option.textContent = `${backup.name} · ${formatBackupModified(backup.modifiedAt)} · ${formatBackupSize(backup.size)}`;
select.appendChild(option);
});
select.value = backups.some((backup) => backup.path === selectedPath) ? selectedPath : backups[0].path;
select.disabled = false;
el('s-restore-global').disabled = !select.value;
return backups;
}
function updateBackupProgressUi(mode, percent, detail) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
const state = backupProgressUiState[prefix];
const nextPercent = Math.max(0, Math.min(100, Number(percent) || 0));
const completed = state.targetPercent >= 100;
if (nextPercent === 0) {
state.shownPercent = 0;
state.targetPercent = 0;
state.detail = detail || '';
state.packing = false;
renderBackupProgress(prefix);
return;
}
if ((completed && nextPercent < 100) || nextPercent < state.targetPercent) {
return;
}
state.targetPercent = nextPercent;
state.detail = detail || '';
state.packing = /^Packe ZIP-Archiv/.test(state.detail) && nextPercent < 99;
if (nextPercent >= 99 || (nextPercent - state.shownPercent) >= 20 || state.shownPercent === 0) {
state.shownPercent = nextPercent;
}
renderBackupProgress(prefix);
if (state.shownPercent < state.targetPercent) ensureBackupProgressTimer(prefix);
else if (state.targetPercent >= 100) clearBackupProgressTimer(prefix);
}
async function runGlobalBackup() {
const statusLine = el('s-backup-status');
setBackupButtonsBusy(true);
setBackupProgressActive('create', true);
statusLine.className = 'hint';
updateBackupProgressUi('create', 0, 'Bereite Zielordner vor');
statusLine.textContent = 'Erstelle Backup in Dokumente/AeroMC Launcher/Backups ...';
try {
const res = await window.api.createGlobalBackup();
if (res.canceled) {
statusLine.className = 'hint';
statusLine.textContent = 'Backup abgebrochen.';
resetBackupProgressUi();
setBackupProgressActive('create', false);
return;
}
if (res.ok) {
updateBackupProgressUi('create', 100, 'Backup abgeschlossen');
setBackupProgressActive('create', false);
statusLine.className = 'hint status-ok';
statusLine.textContent = 'Backup erfolgreich erstellt';
await refreshGlobalBackupOptions(res.path);
toast('Globales Backup erstellt: ' + res.path);
return;
}
setBackupProgressActive('create', false);
statusLine.className = 'hint status-bad';
statusLine.textContent = '✖ ' + (res.message || 'Backup fehlgeschlagen.');
toast(res.message || 'Backup fehlgeschlagen.', true);
} finally {
setBackupProgressActive('create', false);
setBackupButtonsBusy(false);
}
}
async function runGlobalRestore() {
const statusLine = el('s-restore-status');
const selectedBackup = el('s-restore-backup').value;
if (!selectedBackup) {
statusLine.className = 'hint status-bad';
statusLine.textContent = 'Bitte ein Backup auswählen.';
return;
}
setBackupButtonsBusy(true);
setBackupProgressActive('restore', true);
updateBackupProgressUi('restore', 0, 'Warte auf Backup-Datei');
statusLine.className = 'hint';
statusLine.textContent = 'Stelle Backup wieder her ...';
try {
const res = await window.api.restoreGlobalBackup(selectedBackup);
if (res.canceled) {
statusLine.className = 'hint';
statusLine.textContent = 'Wiederherstellen abgebrochen.';
resetBackupProgressUi();
setBackupProgressActive('restore', false);
return;
}
if (!res.ok) {
setBackupProgressActive('restore', false);
statusLine.className = 'hint status-bad';
statusLine.textContent = '✖ ' + (res.message || 'Wiederherstellen fehlgeschlagen.');
toast(res.message || 'Wiederherstellen fehlgeschlagen.', true);
return;
}
updateBackupProgressUi('restore', 100, 'Wiederherstellen abgeschlossen');
setBackupProgressActive('restore', false);
statusLine.className = 'hint status-ok';
statusLine.textContent = 'Backup erfolgreich wiederhergestellt';
const s = await window.api.getSettings();
applyTheme(s.theme || 'dark');
iso3dEnabled = s.iso3dIcons !== false;
await applyBedrockVisibility();
await refreshSkinBackground();
hide('modal-settings');
await reload();
toast('Backup wiederhergestellt: ' + res.path);
} finally {
setBackupProgressActive('restore', false);
setBackupButtonsBusy(false);
}
}
function promptGlobalRestore() {
askConfirm(
'Backup wiederherstellen',
'Das überschreibt alle aktuellen Instanzen mit dem Inhalt des Backups. Fortfahren?',
'Wiederherstellen',
runGlobalRestore,
);
}
// ---------- Microsoft-Login / Kontoverwaltung ----------
async function refreshAccountButton() {
const acc = await window.api.authStatus();
const av = el('account-av');
if (acc && acc.profile) {
av.innerHTML = '';
const img = document.createElement('img');
img.className = 'acct-head';
img.src = `https://crafthead.net/bust/${acc.profile.id}?size=64`;
img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); });
av.appendChild(img);
el('account-name').textContent = acc.profile.name;
} else {
av.innerHTML = svgIcon('user');
el('account-name').textContent = 'Konten';
}
}
// aktualisiert Kontoliste im Login-Dialog und in den Einstellungen
async function refreshAllAccountLists() {
await renderAccounts('login-accounts');
if (!el('modal-settings').classList.contains('hidden')) await renderAccounts('s-accounts');
}
async function renderAccounts(boxId = 'login-accounts') {
const accounts = await window.api.authAccounts();
const box = el(boxId);
if (!box) return;
box.innerHTML = '';
if (!accounts.length) {
box.innerHTML = '<div class="account-empty">Noch kein Konto angemeldet.</div>';
return;
}
for (const a of accounts) {
const item = document.createElement('div');
item.className = 'account-item' + (a.active ? ' active' : '');
const av = document.createElement('img');
av.className = 'account-av';
av.src = `https://crafthead.net/avatar/${a.id}?size=64`;
av.addEventListener('error', () => {
const d = document.createElement('div');
d.className = 'account-av'; d.textContent = '🎮';
av.replaceWith(d);
});
// "spielt" nur, wenn wirklich ein Spiel dieses Kontos läuft; "aktiv" heißt
// lediglich: dieses Konto wird für den nächsten Start verwendet
const merkmale = [];
if (a.active) merkmale.push('aktiv');
if (a.playing) merkmale.push('spielt gerade');
const main = document.createElement('div');
main.className = 'account-main';
main.innerHTML =
`<div class="account-name">${escapeHtml(a.name)}</div>
<div class="account-tag">${merkmale.length ? '● ' + merkmale.join(' · ') : ''}</div>`;
const actions = document.createElement('div');
actions.className = 'account-actions';
item.append(av, main, actions);
if (!a.active) {
const use = document.createElement('button');
use.className = 'btn btn-mini';
use.textContent = 'Aktivieren';
use.addEventListener('click', async () => {
await window.api.authSetActive(a.id);
await refreshAllAccountLists();
await refreshAccountButton();
await refreshSkinBackground();
toast('Aktiv: ' + a.name);
});
actions.appendChild(use);
}
const rm = document.createElement('button');
rm.className = 'btn btn-danger btn-mini';
rm.textContent = 'Entfernen';
rm.addEventListener('click', async () => {
await window.api.authRemove(a.id);
await refreshAllAccountLists();
await refreshAccountButton();
toast('Entfernt: ' + a.name);
});
actions.appendChild(rm);
box.appendChild(item);
}
}
// steht wortgleich in index.html von dort wird er beim Öffnen zurückgesetzt
const LOGIN_HINWEIS = 'Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). '
+ 'Das aktive Konto wird beim nächsten Start verwendet. Für zwei Konten gleichzeitig: '
+ 'eine Instanz starten, hier das zweite Konto aktivieren und eine weitere Instanz starten '
+ 'laufende Spiele behalten ihr Konto.';
function resetLoginHint() {
const h = el('login-hint');
h.classList.remove('login-error');
h.textContent = LOGIN_HINWEIS;
}
async function openLogin() {
el('login-code').classList.add('hidden');
resetLoginHint();
await refreshAllAccountLists();
show('modal-login');
}
async function addAccount() {
resetLoginHint();
el('login-code').classList.remove('hidden');
el('login-usercode').textContent = ' ';
el('login-url').textContent = 'Lade …';
el('login-status').textContent = 'Fordere Code an …';
el('login-add').disabled = true;
try {
const res = await window.api.authLogin(); // läuft bis zur Bestätigung
el('login-code').classList.add('hidden');
if (res.ok) {
await refreshAllAccountLists();
await refreshAccountButton();
toast('Angemeldet als ' + res.account.profile.name);
} else {
const msg = res.message || 'Login fehlgeschlagen.';
el('login-hint').textContent = '✖ ' + msg;
el('login-hint').classList.add('login-error');
toast(msg, true);
}
} finally {
el('login-add').disabled = false;
}
}
// Device-Code-Ereignis aus dem Main-Prozess
function onAuthCode(code) {
loginVerificationUri = code.verificationUri;
el('login-code').classList.remove('hidden');
el('login-url').textContent = code.verificationUri;
el('login-usercode').textContent = code.userCode;
el('login-status').textContent = 'Warte auf Bestätigung im Browser …';
}
// ---------- Bestätigung ----------
let promptResolver = null;
function askPrompt({ title, text, label, value, okLabel } = {}) {
return new Promise((resolve) => {
// falls schon ein Prompt offen ist
if (promptResolver) { promptResolver(null); promptResolver = null; }
promptResolver = resolve;
el('prompt-title').textContent = title || 'Eingabe';
el('prompt-text').textContent = text || '';
el('prompt-text').classList.toggle('hidden', !text);
el('prompt-label').textContent = label || 'Wert';
el('prompt-ok').textContent = okLabel || 'OK';
const input = el('prompt-input');
input.value = value != null ? String(value) : '';
show('modal-prompt');
setTimeout(() => { input.focus(); input.select(); }, 30);
});
}
function closePrompt(wert) {
hide('modal-prompt');
const r = promptResolver;
promptResolver = null;
if (r) r(wert);
}
function askConfirm(title, text, okLabel, action) {
el('confirm-title').textContent = title;
el('confirm-text').textContent = text;
el('confirm-ok').textContent = okLabel;
confirmAction = action;
show('modal-confirm');
}
// ---------- Events ----------
function wire() {
el('btn-new').addEventListener('click', openCreate);
el('btn-new-2').addEventListener('click', openCreate);
// ohne Wrapper käme das Klick-Ereignis als Bereichsname an
el('btn-settings').addEventListener('click', () => openSettings());
el('search').addEventListener('input', render);
el('btn-open-dir').addEventListener('click', () => {
if (currentDetailId) window.api.openInstanceFolder(currentDetailId);
else if (instances[0]) window.api.openInstanceFolder(instances[0].id);
else toast('Noch keine Instanz vorhanden.', false);
});
el('btn-import').addEventListener('click', () => { openImportDialog('zip'); });
// Skin-Hintergrund an/aus (wie MultiMCs Katzen-Knopf)
el('btn-skin').addEventListener('click', async () => {
const s = await window.api.getSettings();
if (s.bgSkin || s.bgSkinCycle) {
// auch den Kontenwechsel mit ausschalten, sonst würde er sofort wieder anspringen
await window.api.saveSettings({ bgSkin: '', bgSkinCycle: false });
toast('Skin-Hintergrund aus.');
} else {
const acc = await window.api.authStatus();
if (!acc || !acc.profile) { toast('Erst ein Microsoft-Konto anmelden.', true); return; }
await window.api.saveSettings({ bgSkin: 'active' });
toast('Skin-Hintergrund an: ' + acc.profile.name);
}
await refreshSkinBackground();
});
// 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 …';
const h = await window.api.authRecheck();
showAuthHealth(h);
if (h.ok) toast('Anmelde-Dienste wieder erreichbar.');
});
// Import
document.querySelectorAll('.import-tabs .mtab').forEach((tab) => {
tab.addEventListener('click', async () => {
setImportKind(tab.dataset.kind);
if (tab.dataset.kind === 'mc') await ensureDefaultMcScan();
if (tab.dataset.kind === 'modpack') {
const box = el('mp-results');
if (box && !box.dataset.loaded) {
box.dataset.loaded = '1';
searchModpacks();
}
}
if (tab.dataset.kind === 'technic') {
const box = el('tp-results');
if (box && !box.dataset.loaded) {
box.dataset.loaded = '1';
searchTechnicPlatform();
}
}
});
});
el('import-zip-do').addEventListener('click', runZipImport);
el('import-modpack-do').addEventListener('click', runModpackImport);
el('mp-search-btn').addEventListener('click', searchModpacks);
el('mp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchModpacks(); });
el('mp-mc').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchModpacks(); });
el('mc-pick').addEventListener('click', async () => {
const p = await window.api.mcPickFolder();
if (p) { el('mc-path').value = p; await scanMc(); }
});
el('mc-scan').addEventListener('click', scanMc);
el('mc-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanMc(); });
el('mc-all').addEventListener('change', (e) => {
document.querySelectorAll('#mc-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateMcButton();
});
el('mc-list').addEventListener('change', updateMcButton);
el('mc-do').addEventListener('click', runMcImport);
el('cf-pick').addEventListener('click', async () => {
const p = await window.api.cfPickFolder();
if (p) { el('cf-path').value = p; await scanCf(); }
});
el('cf-scan').addEventListener('click', scanCf);
el('cf-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanCf(); });
el('cf-all').addEventListener('change', (e) => {
document.querySelectorAll('#cf-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateCfButton();
});
el('cf-list').addEventListener('change', updateCfButton);
el('cf-do').addEventListener('click', runCfImport);
el('mmc-pick').addEventListener('click', async () => {
const p = await window.api.mmcPickFolder();
if (p) { el('mmc-path').value = p; await scanMmc(); }
});
el('mmc-scan').addEventListener('click', scanMmc);
el('mmc-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanMmc(); });
el('mmc-all').addEventListener('change', (e) => {
document.querySelectorAll('#mmc-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateMmcButton();
});
el('mmc-list').addEventListener('change', updateMmcButton);
el('mmc-do').addEventListener('click', runMmcImport);
el('tp-search-btn').addEventListener('click', searchTechnicPlatform);
el('tp-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchTechnicPlatform(); });
window.api.onTechnicPlatformProgress((p) => {
el('tp-status').textContent = `Lade Mods … ${p.current}/${p.total}${p.name ? ' (' + p.name + ')' : ''}`;
});
el('technic-pick').addEventListener('click', async () => {
const p = await window.api.technicPickFolder();
if (p) { el('technic-path').value = p; await scanTechnic(); }
});
el('technic-scan').addEventListener('click', scanTechnic);
el('technic-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanTechnic(); });
el('technic-all').addEventListener('change', (e) => {
document.querySelectorAll('#technic-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateTechnicButton();
});
el('technic-list').addEventListener('change', updateTechnicButton);
el('technic-do').addEventListener('click', runTechnicImport);
window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden');
});
window.api.onCfProgress((p) => {
el('cf-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('cf-progress').classList.remove('hidden');
});
window.api.onMmcProgress((p) => {
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mmc-progress').classList.remove('hidden');
});
window.api.onTechnicProgress((p) => {
el('technic-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('technic-progress').classList.remove('hidden');
});
// Bedrock
el('btn-bedrock').addEventListener('click', openBedrock);
el('bd-play').addEventListener('click', async () => {
const btn = el('bd-play');
// Ein Klick löst eine nicht abbrechbare App-Aktivierung aus, die etwas
// dauern kann Button sperren, damit ein ungeduldiger zweiter Klick
// nicht zu einem doppelt geöffneten Spiel führt.
if (btn.disabled) return;
btn.disabled = true;
toast('Bedrock wird gestartet …');
try {
const res = await window.api.bedrockLaunch();
if (res && res.ok) {
if (!res.confirmed) {
toast('Start ausgelöst, konnte aber nicht bestätigt werden. Falls sich nichts öffnet, bitte erneut versuchen.');
}
} else {
toast('Start fehlgeschlagen: ' + ((res && res.message) || 'unbekannter Fehler'), true);
}
} finally {
btn.disabled = false;
}
});
el('bd-import').addEventListener('click', async () => {
const res = await window.api.bedrockInstallAddon();
if (res.canceled) return;
if (res.ok) toast('Import gestartet Minecraft übernimmt die Installation.');
else toast(res.message || 'Import fehlgeschlagen.', true);
});
el('bd-folder').addEventListener('click', () => window.api.bedrockOpenFolder(bedrockKind));
document.querySelectorAll('.btab').forEach((t) =>
t.addEventListener('click', () => switchBedrockTab(t.dataset.kind)));
// Konsole
el('btn-console').addEventListener('click', openConsole);
el('console-clear').addEventListener('click', () => { consoleBuffer = ''; el('console-out').textContent = ''; });
el('console-copy').addEventListener('click', async () => {
try { await navigator.clipboard.writeText(consoleBuffer); toast('Log kopiert.'); }
catch { toast('Kopieren nicht möglich.', true); }
});
// Instanz-Dialog
el('btn-save-instance').addEventListener('click', saveInstance);
el('f-version-type').addEventListener('change', () => fillVersionSelect(el('f-version').value));
el('f-loader').addEventListener('change', () => updateLoaderVersions());
el('f-version').addEventListener('change', () => updateLoaderVersions());
el('f-optifine-install').addEventListener('click', async () => {
if (!editingId) return;
const btn = el('f-optifine-install');
const status = el('f-optifine-status');
btn.disabled = true;
status.textContent = 'Minecraft wird vorbereitet …';
try {
const res = await window.api.optifineSetup(editingId);
if (res.canceled) { status.textContent = ''; return; }
if (!res.ok) { status.textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); toast(res.message || 'OptiFine-Installer konnte nicht gestartet werden.', true); return; }
status.textContent = `Installer gestartet Zielordner wurde in die Zwischenablage kopiert: ${res.sharedDir} `
+ 'im OptiFine-Fenster als Minecraft-Ordner einfügen, „Install" klicken, danach hier bestätigen.';
el('f-optifine-confirm').classList.remove('hidden');
} finally {
btn.disabled = false;
}
});
el('f-optifine-confirm').addEventListener('click', async () => {
if (!editingId) return;
const btn = el('f-optifine-confirm');
const status = el('f-optifine-status');
btn.disabled = true;
try {
const res = await window.api.optifineConfirm(editingId);
if (!res.ok) { status.textContent = '✖ ' + (res.message || 'Nicht gefunden.'); toast(res.message || 'OptiFine-Version wurde nicht gefunden.', true); return; }
toast('OptiFine ' + res.versionId + ' übernommen.');
status.textContent = '✔ Übernommen: ' + res.versionId;
const sel = el('f-loader-version');
if (![...sel.options].some((o) => o.value === res.versionId)) {
const o = document.createElement('option');
o.value = res.versionId; o.textContent = res.versionId;
sel.appendChild(o);
}
sel.value = res.versionId;
btn.classList.add('hidden');
} finally {
btn.disabled = false;
}
});
el('f-pick-image').addEventListener('click', async () => {
const p = await window.api.pickImage();
if (!p) return;
pendingImagePath = p;
pendingClearImage = false;
const data = await window.api.fileDataUri(p);
setIconPreview(data);
});
el('f-clear-image').addEventListener('click', () => {
pendingImagePath = null;
pendingClearImage = true;
setIconPreview(null);
});
// Rechte Aktionsleiste (Instanz-Aktionen)
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-shortcut').addEventListener('click', async () => {
if (!currentDetailId) return;
const inst = await window.api.getInstance(currentDetailId);
const eingabe = await askPrompt({
title: 'Verknüpfung erstellen',
text: 'Name der Desktop-Verknüpfung (ohne .lnk):',
label: 'Name',
value: inst ? inst.name : '',
okLabel: 'Erstellen',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.createShortcut(currentDetailId, eingabe.trim());
if (res.ok) toast('Verknüpfung auf dem Desktop erstellt.');
else toast(res.message || 'Verknüpfung fehlgeschlagen.', true);
});
el('p-edit').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-notes').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-groupline').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-mods').addEventListener('click', openMods);
el('p-worlds').addEventListener('click', openContent);
el('p-screens').addEventListener('click', openScreens);
el('p-logs').addEventListener('click', openLogs);
el('p-servers').addEventListener('click', openServers);
el('p-modpack').addEventListener('click', openModpackUpdate);
el('mp-do').addEventListener('click', doModpackUpdate);
el('srv-add').addEventListener('click', addServer);
el('srv-ip').addEventListener('keydown', (e) => { if (e.key === 'Enter') addServer(); });
el('srv-copy-do').addEventListener('click', doServerCopy);
el('login-skin').addEventListener('click', openSkin);
el('skin-pick').addEventListener('click', waehleSkinDatei);
el('skin-variante').addEventListener('change', skinVarianteGewechselt);
el('skin-apply').addEventListener('click', setzeSkin);
el('skin-reset').addEventListener('click', setzeSkinZurueck);
el('logs-select').addEventListener('change', (e) => ladeLog(e.target.value));
el('logs-folder').addEventListener('click', async () => {
const ok = await window.api.logsOpenFolder(currentDetailId);
if (!ok) toast('Log-Ordner nicht gefunden.', true);
});
el('logs-copy').addEventListener('click', async () => {
if (!aktuellesLog) return;
try { await navigator.clipboard.writeText(aktuellesLog); toast('Log kopiert.'); }
catch { toast('Kopieren fehlgeschlagen.', true); }
});
el('p-mcfolder').addEventListener('click', () => currentDetailId && window.api.openGameDir(currentDetailId));
el('p-configfolder').addEventListener('click', () => currentDetailId && window.api.openSubdir(currentDetailId, 'config'));
el('p-instfolder').addEventListener('click', () => currentDetailId && window.api.openInstanceFolder(currentDetailId));
el('p-export').addEventListener('click', async () => {
if (!currentDetailId) return;
const res = await window.api.exportInstance(currentDetailId);
if (res.canceled) return;
if (res.ok) toast('Exportiert: ' + res.path); else toast(res.message || 'Export fehlgeschlagen.', true);
});
el('p-copy').addEventListener('click', async () => {
if (!currentDetailId) return;
const copy = await window.api.duplicateInstance(currentDetailId);
await reload(); await selectInstance(copy.id); toast('Kopiert: ' + copy.name);
});
el('p-delete').addEventListener('click', async () => {
const ids = orderedSelectedInstanceIds();
if (!ids.length) return;
if (ids.length === 1) {
const inst = await window.api.getInstance(ids[0]);
if (!inst) return;
askConfirm('Instanz löschen',
`„${inst.name}" wird mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
'Endgültig löschen',
async () => {
await window.api.deleteInstance(ids[0]);
selectedInstanceIds.clear();
await reload();
await selectInstance(instances.length ? instances[0].id : null);
toast('Gelöscht.');
});
return;
}
const names = instances.filter((inst) => selectedInstanceIds.has(inst.id)).map((inst) => inst.name);
const preview = names.slice(0, 3).map((name) => `„${name}"`).join(', ');
const more = names.length > 3 ? ` und ${names.length - 3} weitere` : '';
askConfirm('Instanzen löschen',
`${ids.length} Instanzen (${preview}${more}) werden mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
'Endgültig löschen',
async () => {
for (const id of ids) await window.api.deleteInstance(id);
selectedInstanceIds.clear();
await reload();
await selectInstance(instances.length ? instances[0].id : null);
toast(`${ids.length} Instanzen gelöscht.`);
});
});
// Ordner-Buttons in den Inhalte-/Screenshots-Dialogen
el('content-open-worlds').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'saves'));
el('content-open-rp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'resourcepacks'));
el('screens-open').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'screenshots'));
el('screens-select-all').addEventListener('click', () => {
document.querySelectorAll('#screens-grid .screen-thumb').forEach((t) => {
if (t.dataset.name) screenSelected.add(t.dataset.name);
});
updateScreensSelectionUi();
});
el('screens-select-none').addEventListener('click', () => {
screenSelected.clear();
updateScreensSelectionUi();
});
el('screens-delete-sel').addEventListener('click', () => deleteSelectedScreenshots());
el('content-open-dp').addEventListener('click', () => window.api.openSubdir(currentDetailId, 'datapacks'));
// Mod-Dialog
el('m-search-btn').addEventListener('click', () => loadCatalog(false));
el('m-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadCatalog(false); });
el('m-sort').addEventListener('change', () => loadCatalog(false));
el('m-category').addEventListener('change', () => loadCatalog(false));
if (el('m-source')) el('m-source').addEventListener('change', () => loadCatalog(false));
el('m-more').addEventListener('click', () => loadCatalog(true));
el('m-tab-catalog').addEventListener('click', () => switchModTab('catalog'));
el('m-tab-installed').addEventListener('click', () => switchModTab('installed'));
el('m-art').addEventListener('change', modArtGewechselt);
el('m-open-folder').addEventListener('click', () => window.api.openGameDir(currentDetailId));
// Automatische Sicherung: unaufdringlich melden, Fehler deutlich
window.api.onAutoBackup((d) => {
if (d.ok) toast(d.anlass === 'vor-dem-start' ? 'Sicherung vor dem Start erstellt.' : 'Automatische Sicherung erstellt.');
else toast('Automatische Sicherung fehlgeschlagen: ' + (d.message || 'unbekannter Grund'), true);
});
// Absturzbericht
el('crash-copy').addEventListener('click', async () => {
if (!aktuellerCrash) return;
try { await navigator.clipboard.writeText(aktuellerCrash.text || ''); toast('Bericht kopiert.'); }
catch { toast('Kopieren fehlgeschlagen.', true); }
});
el('crash-folder').addEventListener('click', async () => {
const ok = await window.api.crashOpenFolder(currentDetailId);
if (!ok) toast('Ordner mit den Berichten nicht gefunden.', true);
});
el('crash-console').addEventListener('click', () => { hide('modal-crash'); openConsole(); });
el('m-add-file').addEventListener('click', async () => {
const res = await window.api.modAddFile(currentDetailId, modArt);
if (res.canceled) return;
if (!res.ok) { toast(res.message || 'Datei konnte nicht hinzugefügt werden.', true); return; }
const n = res.added.length;
const f = res.failed.length;
if (n) toast(`${n} Datei${n === 1 ? '' : 'en'} hinzugefügt${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0 && !n);
else if (f) toast(res.failed[0].message || 'Hinzufügen fehlgeschlagen.', true);
await refreshInstalled();
});
el('m-check-updates').addEventListener('click', checkModUpdates);
el('m-apply-updates').addEventListener('click', applyModUpdates);
window.api.onModUpdateProgress((p) => setUpdateStatus(`Suche … ${p.geprueft}/${p.gesamt} (${p.titel})`));
// Einstellungen
el('s-pick-dir').addEventListener('click', async () => {
const p = await window.api.pickFolder('Instanz-Verzeichnis wählen');
if (p) el('s-instances-dir').value = p;
});
el('s-pick-java').addEventListener('click', async () => {
const p = await window.api.pickJava();
if (p) { el('s-java').value = p; await refreshJavaStatus(p); }
});
el('s-theme').addEventListener('change', (e) => applyTheme(e.target.value));
el('s-bgskin-opacity').addEventListener('input', (e) => {
el('s-bgskin-val').textContent = e.target.value;
document.documentElement.style.setProperty('--bg-skin-opacity', String(e.target.value / 100));
});
// Konto-/Pose-Wechsel sofort anwenden (ohne erst zu speichern)
const previewSkin = async () => {
await window.api.saveSettings({
bgSkin: el('s-bgskin').value,
bgSkinPose: el('s-bgskin-pose').value,
});
await refreshSkinBackground();
};
el('s-bgskin').addEventListener('change', previewSkin);
el('s-bgskin-pose').addEventListener('change', previewSkin);
const previewSkinCycle = async () => {
el('s-bgskin').disabled = el('s-bgskin-cycle').checked;
await window.api.saveSettings({
bgSkinCycle: el('s-bgskin-cycle').checked,
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
});
await refreshSkinBackground();
};
el('s-bgskin-cycle').addEventListener('change', previewSkinCycle);
el('s-bgskin-cycle-seconds').addEventListener('change', previewSkinCycle);
el('s-scan-java').addEventListener('click', scanJava);
el('f-java-detected').addEventListener('change', (e) => {
if (e.target.value) el('f-java').value = e.target.value;
});
el('f-java-pick').addEventListener('click', async () => {
const p = await window.api.pickJava();
if (p) { el('f-java').value = p; el('f-java-detected').value = ''; }
});
el('f-extra-args-aikar').addEventListener('click', () => {
const feld = el('f-extra-args');
const vorhanden = feld.value.trim();
if (vorhanden.includes('G1NewSizePercent')) { toast('Die GC-Flags stehen schon drin.'); return; }
feld.value = vorhanden ? vorhanden + ' ' + AIKAR_FLAGS : AIKAR_FLAGS;
toast('GC-Flags eingefügt.');
});
el('s-java-detected').addEventListener('change', async (e) => {
if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); }
});
el('s-java').addEventListener('change', (e) => refreshJavaStatus(e.target.value.trim()));
el('s-backup-global').addEventListener('click', runGlobalBackup);
el('s-restore-global').addEventListener('click', promptGlobalRestore);
el('btn-save-settings').addEventListener('click', saveSettings);
if (el('s-cf-test')) {
el('s-cf-test').addEventListener('click', async () => {
const st = el('s-cf-test-status');
const key = el('s-cf-key').value.trim();
st.textContent = 'Prüfe bei CurseForge …';
try {
const res = await window.api.curseforgeTestKey(key);
st.textContent = (res.ok ? '✓ ' : '✖ ') + (res.message || '');
if (res.proben) {
st.textContent += ' [' + res.proben.map((p) => p.name + ':' + p.status).join(', ') + ']';
}
if (res.siehtAusWieCurseForge === false && key) {
st.textContent += ' Hinweis: Erwartet wird ein Studios-Key (cfc_pat_…) oder ein klassischer Key ($2a$10$…).';
}
} catch (err) {
st.textContent = '✖ ' + (err.message || err);
}
});
}
document.querySelectorAll('.snav').forEach((b) => b.addEventListener('click', () => setSettingsPane(b.dataset.pane)));
el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); });
el('s-add-account').addEventListener('click', () => { hide('modal-settings'); openLogin().then(addAccount); });
// Updates
el('upd-install').addEventListener('click', startUpdate);
el('upd-open').addEventListener('click', openReleasePage);
if (el('s-check-update')) {
el('s-check-update').addEventListener('click', async () => {
renderUpdateStatus({ checking: true });
const info = await window.api.updateCheck();
renderUpdateStatus(info);
if (info.available) { hide('modal-settings'); showUpdateDialog(info); }
});
}
// Microsoft-Login / Kontoverwaltung
el('btn-account').addEventListener('click', openLogin);
el('login-add').addEventListener('click', addAccount);
el('login-url').addEventListener('click', (e) => {
e.preventDefault();
if (loginVerificationUri) window.api.openExternal(loginVerificationUri);
});
el('login-copy').addEventListener('click', async () => {
try { await navigator.clipboard.writeText(el('login-usercode').textContent.trim()); toast('Code kopiert.'); }
catch { toast('Kopieren nicht möglich.', true); }
});
// Bestätigung
el('prompt-ok').addEventListener('click', () => {
const v = el('prompt-input').value;
closePrompt(v);
});
el('prompt-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
closePrompt(el('prompt-input').value);
}
});
// Abbrechen / Schließen des Prompt-Dialogs liefert null
el('modal-prompt').querySelectorAll('[data-close]').forEach((b) => {
b.addEventListener('click', (e) => {
e.stopPropagation();
closePrompt(null);
});
});
el('modal-prompt').addEventListener('mousedown', (e) => {
if (e.target === el('modal-prompt')) closePrompt(null);
});
el('confirm-ok').addEventListener('click', async () => {
hide('modal-confirm');
if (confirmAction) { const a = confirmAction; confirmAction = null; await a(); }
});
// generisches Schließen
document.querySelectorAll('[data-close]').forEach((b) =>
b.addEventListener('click', () => {
const m = b.closest('.modal');
if (!m) return;
if (m.id === 'modal-prompt') { closePrompt(null); return; }
m.classList.add('hidden');
}));
document.querySelectorAll('.modal').forEach((m) =>
m.addEventListener('mousedown', (e) => {
if (e.target !== m) return;
if (m.id === 'modal-prompt') { closePrompt(null); return; }
m.classList.add('hidden');
}));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open) open.classList.add('hidden');
return;
}
const strg = e.ctrlKey || e.metaKey;
const modalOffen = document.querySelector('.modal:not(.hidden)');
const inEingabe = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)
|| document.activeElement.isContentEditable;
// Strg+F: Suche fokussieren nicht wenn gerade ein Dialog offen ist,
// der eine eigene Suche hat (Mods, Modpack-Katalog, Import …)
if (strg && e.key.toLowerCase() === 'f' && !modalOffen) {
e.preventDefault();
el('search').focus();
el('search').select();
return;
}
// Strg+N: neue Instanz anlegen
if (strg && e.key.toLowerCase() === 'n' && !modalOffen) {
e.preventDefault();
el('btn-new').click();
return;
}
// Entf: markierte Instanz(en) löschen nicht während man irgendwo tippt
if (e.key === 'Delete' && !modalOffen && !inEingabe) {
e.preventDefault();
el('p-delete').click();
}
});
wireFileDrop();
}
// ---------- Datei-Drag&Drop aufs Fenster (.jar/.mrpack/.zip) ----------
function wireFileDrop() {
let dragDepth = 0; // dragenter/-leave feuern auch für Kindelemente
const istDatei = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
window.addEventListener('dragenter', (e) => {
if (!istDatei(e)) return;
e.preventDefault();
dragDepth++;
document.body.classList.add('file-drop-active');
});
window.addEventListener('dragover', (e) => {
if (!istDatei(e)) return;
e.preventDefault();
});
window.addEventListener('dragleave', (e) => {
if (!istDatei(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) document.body.classList.remove('file-drop-active');
});
window.addEventListener('drop', async (e) => {
if (!istDatei(e)) return;
e.preventDefault();
dragDepth = 0;
document.body.classList.remove('file-drop-active');
const files = [...(e.dataTransfer.files || [])].filter((f) => f.path);
for (const f of files) await handleDroppedFile(f.path);
});
}
async function handleDroppedFile(filePath) {
const ext = ('.' + filePath.split('.').pop()).toLowerCase();
if (!['.jar', '.mrpack', '.zip'].includes(ext)) {
toast('Nicht unterstützter Dateityp: ' + filePath.split(/[\\/]/).pop(), true);
return;
}
// Für .jar: läuft der Mods-Dialog gerade, zählt die dort gewählte
// Inhaltsart (Mod/Ressourcenpaket/Shader) sonst landet es als Mod
// direkt in der aktuell ausgewählten Instanz.
const modsOpen = !el('modal-mods').classList.contains('hidden');
const targetArt = modsOpen ? modArt : 'mod';
const res = await window.api.filesDrop(filePath, currentDetailId, targetArt);
if (!res.ok) { toast(res.message || 'Import fehlgeschlagen.', true); return; }
if (res.kind === 'mod') {
toast(`Datei hinzugefügt: ${res.added[0]}`);
if (modsOpen) await refreshInstalled();
return;
}
await reload();
if (res.instance) {
toast(`Instanz übernommen: ${res.instance.name}`);
await selectInstance(res.instance.id);
} else {
toast('Import abgeschlossen.');
}
}
window.addEventListener('DOMContentLoaded', async () => {
wire();
applyIcons();
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 refreshSkinBackground();
window.api.onLaunchProgress(onLaunchEvt);
window.api.onBackupProgress((p) => applyBackupProgressEvent(p.mode, p.percent || 0, p.detail || ''));
window.api.onAuthCode(onAuthCode);
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await refreshSkinBackground(); });
window.api.onAuthHealth(showAuthHealth);
// Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen
window.api.onAccountsChanged(() => { refreshAllAccountLists(); });
// über eine Desktop-Verknüpfung gestartet -> Instanz auswählen und anwerfen
// (bei einer Server-Verknüpfung zusätzlich direkt mit dem Server verbinden)
window.api.onLaunchRequest(async (payload) => {
const id = payload && typeof payload === 'object' ? payload.id : payload;
const serverIp = payload && typeof payload === 'object' ? payload.serverIp : null;
await selectInstance(id);
launch(id, null, serverIp ? { quickPlayServer: serverIp } : null);
});
// Empfänger steht jetzt dem Hauptprozess Bescheid geben, damit ein über
// eine Verknüpfung mitgegebener Startwunsch sicher zugestellt werden kann
window.api.notifyReady();
window.api.onUpdateAvailable(showUpdateDialog);
window.api.onUpdateProgress(onUpdateProgress);
window.api.onUpdateChecked(renderUpdateStatus);
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }
await loadMcIcons(); // echte Minecraft-Icons bereitstellen
await applyBedrockVisibility();
await refreshAccountButton();
await reload();
// zuletzt gewählte Instanz wieder markieren (sonst die zuletzt gespielte)
if (instances.length) {
let start = null;
try {
const s = await window.api.getSettings();
if (s.lastSelectedId && instances.some((i) => i.id === s.lastSelectedId)) start = s.lastSelectedId;
} catch { /* Einstellungen nicht lesbar */ }
await selectInstance(start || instances[0].id); // instances ist nach lastPlayed sortiert
}
});