diff --git a/launcher/launcher/src/resources/ui/launcher.js b/launcher/launcher/src/resources/ui/launcher.js
index 3ba1fbe..4f56434 100644
--- a/launcher/launcher/src/resources/ui/launcher.js
+++ b/launcher/launcher/src/resources/ui/launcher.js
@@ -66,6 +66,10 @@ const LOCALES = {
'install.serverPack.label': 'Server Pack',
'install.localName.label': 'Local Name',
'install.downloadBtn': 'Download & Install',
+ 'install.custom.name': 'Pack Name',
+ 'install.custom.mcVersion': 'Minecraft Version',
+ 'install.custom.loader': 'Loader',
+ 'install.custom.loaderVersion': 'Loader Version',
'install.custom.unavailable': 'Not available yet',
'install.custom.desc': 'Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.',
'install.progress.installing': 'Installing...',
@@ -164,6 +168,18 @@ const LOCALES = {
'whitelist.done': 'Mods installed!',
'whitelist.selectNone': 'Select mods first',
'whitelist.installError': 'Failed to install mod',
+ 'mods.title': 'Mods',
+ 'mods.loading': 'Loading mods...',
+ 'mods.empty': 'No mods installed',
+ 'mods.search': 'Search mods...',
+ 'mods.enabled': 'Enabled',
+ 'mods.disabled': 'Disabled',
+ 'mods.delete': 'Delete',
+ 'mods.enable': 'Enable',
+ 'mods.disable': 'Disable',
+ 'mods.noResults': 'No mods found',
+ 'mods.deleteConfirm': 'Delete {name}?',
+ 'mods.count': '{count} mods',
'nav.admin': 'Admin',
'admin.title': 'Admin Panel',
'admin.clients': 'Clients',
@@ -211,6 +227,42 @@ const LOCALES = {
'install.preset.label': 'Preset (optional)',
'install.preset.default': 'Default',
'pack.disabled': 'Disabled',
+ 'ctx.openFolder': 'Open in files',
+ 'ctx.settings': 'Settings',
+ 'ctx.delete': 'Delete',
+ 'ctx.deleteConfirm': 'Delete pack {name}? This will delete all files.',
+ 'ctx.openLogs': 'Open logs',
+ 'ctx.name': 'Name',
+ 'ctx.loader': 'Loader',
+ 'ctx.loaderVersion': 'Loader version',
+ 'ctx.save': 'Save',
+ 'ctx.cancel': 'Cancel',
+ 'ctx.filemanager': 'File manager',
+ 'onboard.title.install': 'Where to install packs',
+ 'onboard.desc.install': 'Click + in sidebar or Install pack — choose server or custom.',
+ 'onboard.title.pass': 'Where to activate pass',
+ 'onboard.desc.pass': 'Pass unlocks server packs. Activate in Settings → Activate pass.',
+ 'onboard.title.settings': 'Where settings are',
+ 'onboard.desc.settings': 'Gear at bottom — RAM, resolution, JVM, Java, language, network.',
+ 'onboard.title.manage': 'How to manage a pack',
+ 'onboard.desc.manage': 'Click to open card. Right-click for Open in files / Settings / Delete. Card has Play, Update, delete.',
+ 'onboard.title.mods': 'Mods in pack',
+ 'onboard.desc.mods': 'Server packs: toggle only additional mods. Custom: toggle/delete any mods, search on top.',
+ 'onboard.title.logs': 'Where game logs are',
+ 'onboard.desc.logs': 'Settings → Game Log — live output. Or right-click → Open in files → logs folder.',
+ 'onboard.next': 'Next',
+ 'onboard.back': 'Back',
+ 'onboard.skip': 'Skip',
+ 'onboard.done': 'Got it',
+ 'onboard.progress': 'Step {current} of {total}',
+ 'onboard.showAgain': 'Show tutorial again',
+ 'onboard.neverShow': "Don't show again",
+ 'onboard.confirmTitle': 'Skip tutorial?',
+ 'onboard.confirmDesc': 'Are you sure you want to skip the tutorial?',
+ 'onboard.yes': 'Yes',
+ 'onboard.no': 'No',
+ 'onboard.infoTitle': 'Tutorial',
+ 'onboard.infoDesc': 'You can replay the tutorial in Settings.',
},
ru: {
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
@@ -277,6 +329,10 @@ const LOCALES = {
'install.serverPack.label': 'Серверная сборка',
'install.localName.label': 'Локальное имя',
'install.downloadBtn': 'Скачать и установить',
+ 'install.custom.name': 'Название сборки',
+ 'install.custom.mcVersion': 'Версия Minecraft',
+ 'install.custom.loader': 'Загрузчик',
+ 'install.custom.loaderVersion': 'Версия загрузчика',
'install.custom.unavailable': 'Пока недоступно',
'install.custom.desc': 'Установка своей сборки отключена в этой версии. Используйте вкладку "Серверная сборка".',
'install.progress.installing': 'Установка...',
@@ -375,6 +431,18 @@ const LOCALES = {
'whitelist.done': 'Моды установлены!',
'whitelist.selectNone': 'Выберите моды',
'whitelist.installError': 'Ошибка установки мода',
+ 'mods.title': 'Моды',
+ 'mods.loading': 'Загрузка модов...',
+ 'mods.empty': 'Модов не установлено',
+ 'mods.search': 'Поиск модов...',
+ 'mods.enabled': 'Включен',
+ 'mods.disabled': 'Отключен',
+ 'mods.delete': 'Удалить',
+ 'mods.enable': 'Включить',
+ 'mods.disable': 'Выключить',
+ 'mods.noResults': 'Ничего не найдено',
+ 'mods.deleteConfirm': 'Удалить {name}?',
+ 'mods.count': '{count} модов',
'nav.admin': 'Админка',
'admin.title': 'Панель администратора',
'admin.clients': 'Клиенты',
@@ -422,6 +490,42 @@ const LOCALES = {
'install.preset.label': 'Пресет (опционально)',
'install.preset.default': 'Стандартный',
'pack.disabled': 'Отключена',
+ 'ctx.openFolder': 'Открыть в файлах',
+ 'ctx.settings': 'Настройки',
+ 'ctx.delete': 'Удалить',
+ 'ctx.deleteConfirm': 'Удалить сборку {name}? Это удалит все файлы.',
+ 'ctx.openLogs': 'Открыть логи',
+ 'ctx.name': 'Название',
+ 'ctx.loader': 'Загрузчик',
+ 'ctx.loaderVersion': 'Версия загрузчика',
+ 'ctx.save': 'Сохранить',
+ 'ctx.cancel': 'Отмена',
+ 'ctx.filemanager': 'Файловый менеджер',
+ 'onboard.title.install': 'Где устанавливать сборки',
+ 'onboard.desc.install': 'Нажми + в боковой панели или кнопку «Установить сборку» — выбери серверную или свою кастомную.',
+ 'onboard.title.pass': 'Где активировать проходку',
+ 'onboard.desc.pass': 'Проходка даёт доступ к серверным сборкам. Активируй её в Настройках → Активировать проходку или через серверные сборки.',
+ 'onboard.title.settings': 'Где настройки',
+ 'onboard.desc.settings': 'Шестерёнка внизу — память, разрешение, JVM, Java, язык, сеть. Настрой под своё железо.',
+ 'onboard.title.manage': 'Как управлять сборкой',
+ 'onboard.desc.manage': 'Клик — открыть карточку. ПКМ — открыть в файлах / настройки / удалить. В карточке — Играть, Обновить, удалить, описание.',
+ 'onboard.title.mods': 'Моды в сборке',
+ 'onboard.desc.mods': 'В серверных — управляй только дополнительными модами. В своих — включай/выключай и удаляй любые моды, поиск сверху.',
+ 'onboard.title.logs': 'Где логи игры',
+ 'onboard.desc.logs': 'Настройки → Лог игры — смотри вывод в реальном времени. Или ПКМ → Открыть в файлах → папка logs.',
+ 'onboard.next': 'Далее',
+ 'onboard.back': 'Назад',
+ 'onboard.skip': 'Пропустить',
+ 'onboard.done': 'Понятно',
+ 'onboard.progress': 'Шаг {current} из {total}',
+ 'onboard.showAgain': 'Показать обучение снова',
+ 'onboard.neverShow': 'Не показывать снова',
+ 'onboard.confirmTitle': 'Точно пропустить туториал?',
+ 'onboard.confirmDesc': 'Вы уверены, что хотите пропустить обучение?',
+ 'onboard.yes': 'Да',
+ 'onboard.no': 'Нет',
+ 'onboard.infoTitle': 'Туториал',
+ 'onboard.infoDesc': 'Туториал можно будет пройти в настройках.',
}
};
@@ -485,49 +589,157 @@ class ZernMCLauncher {
}
}
- // ==================== BACKGROUND ====================
+ // ==================== BACKGROUND — hollow diamonds ====================
initBg() {
const c = document.getElementById('bg-canvas');
const ctx = c.getContext('2d');
- let t = 0, mode = 'login', modePulse = 0;
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
+ let mode = 'login'; // login=blue, register=orange
+ let modeLerp = 0; // 0=login, 1=register
+ let exitProgress = 0; // 0..1 fly-out
+ let exitActive = false;
+ let rafId = 0;
+ const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- this.setWaveMode = m => { mode = m; modePulse = 1; };
-
- const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; };
- window.addEventListener('resize', resize);
-
- const draw = () => {
- ctx.clearRect(0, 0, c.width, c.height);
- const w = c.width, h = c.height;
- modePulse += (0 - modePulse) * 0.03;
-
- const baseAlpha = 0.04 + modePulse * 0.03;
- const amp = 20 + modePulse * 10;
- const freq = 0.008 + modePulse * 0.003;
- const speed = 0.008;
- const lines = 5;
-
- for (let i = 0; i < lines; i++) {
- const yOff = (h / (lines + 1)) * (i + 1);
- const alpha = baseAlpha * (1 - i * 0.12);
- ctx.beginPath();
- for (let x = 0; x <= w; x += 2) {
- const wave = Math.sin(x * freq + t * speed + i * 1.8) * amp;
- const wave2 = Math.sin(x * freq * 0.5 + t * speed * 0.7 + i * 2.5) * amp * 0.5;
- const y = yOff + wave + wave2;
- x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
- }
- ctx.strokeStyle = `rgba(233, 69, 96, ${alpha})`;
- ctx.lineWidth = 1.2;
- ctx.stroke();
+ const PALETTES = {
+ login: {
+ far: { r: 59, g: 130, b: 246, a: 0.18 },
+ mid: { r: 59, g: 130, b: 246, a: 0.32 },
+ near: { r: 96, g: 165, b: 255, a: 0.42 }
+ },
+ register: {
+ far: { r: 249, g: 115, b: 22, a: 0.18 },
+ mid: { r: 249, g: 115, b: 22, a: 0.34 },
+ near: { r: 255, g: 162, b: 77, a: 0.44 }
}
-
- t++;
};
- const anim = () => { draw(); requestAnimationFrame(anim); };
+ const diamonds = [];
+ const COUNT = 44;
+ const seed = () => Math.random();
+ const lerp = (a,b,t) => a + (b-a)*t;
+ const lerpColor = (ca, cb, t) => ({
+ r: lerp(ca.r, cb.r, t),
+ g: lerp(ca.g, cb.g, t),
+ b: lerp(ca.b, cb.b, t),
+ a: lerp(ca.a, cb.a, t)
+ });
+
+ const rebuild = () => {
+ diamonds.length = 0;
+ const w = c.width / dpr, h = c.height / dpr;
+ // distribute with jitter, keep margins so diamonds not clipped
+ for (let i = 0; i < COUNT; i++) {
+ const layerRoll = seed();
+ let layer, size;
+ if (layerRoll < 0.35) { layer = 'far'; size = 12 + seed()*6; }
+ else if (layerRoll < 0.70) { layer = 'mid'; size = 22 + seed()*10; }
+ else { layer = 'near'; size = 36 + seed()*16; }
+ // avoid center where login form sits (roughly 360x420 centered)
+ let x, y, attempts = 0;
+ do {
+ x = seed() * (w - size*1.4) + size*0.7;
+ y = seed() * (h - size*1.4) + size*0.7;
+ attempts++;
+ } while (attempts < 5 && Math.abs(x - w/2) < 190 && Math.abs(y - h/2) < 220);
+ diamonds.push({
+ x, y, ox: x, oy: y,
+ size,
+ layer,
+ rot: 45 * Math.PI/180,
+ driftX: (seed()-0.5)*0.6,
+ driftY: 0.15 + seed()*0.45,
+ phase: seed()*Math.PI*2,
+ depthMul: layer==='far'?0.15 : layer==='mid'?0.45 : 1.0
+ });
+ }
+ };
+
+ const resize = () => {
+ const w = window.innerWidth, h = window.innerHeight;
+ c.width = w * dpr; c.height = h * dpr;
+ c.style.width = w + 'px'; c.style.height = h + 'px';
+ ctx.setTransform(dpr,0,0,dpr,0,0);
+ rebuild();
+ };
+ window.addEventListener('resize', resize);
+
+ // public API
+ this.setDiamondMode = (m) => { mode = m==='register'?'register':'login'; };
+ // keep compat
+ this.setWaveMode = this.setDiamondMode;
+
+ this.animateDiamondExit = () => {
+ if (reduceMotion || exitActive) return Promise.resolve();
+ exitActive = true;
+ exitProgress = 0;
+ return new Promise(res => {
+ const start = performance.now();
+ const dur = 900;
+ const ease = t => t<0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; // easeInOutCubic
+ const step = (now) => {
+ const p = Math.min(1, (now - start)/dur);
+ exitProgress = ease(p);
+ if (p < 1) requestAnimationFrame(step);
+ else { exitActive = false; res(); }
+ };
+ requestAnimationFrame(step);
+ });
+ };
+
+ let tick = 0;
+ const draw = () => {
+ const w = c.width / dpr, h = c.height / dpr;
+ ctx.clearRect(0, 0, w, h);
+ // lerp palette between modes
+ const target = mode === 'register' ? 1 : 0;
+ modeLerp += (target - modeLerp) * 0.06;
+ const palFar = lerpColor(PALETTES.login.far, PALETTES.register.far, modeLerp);
+ const palMid = lerpColor(PALETTES.login.mid, PALETTES.register.mid, modeLerp);
+ const palNear = lerpColor(PALETTES.login.near, PALETTES.register.near, modeLerp);
+
+ // exit parallax offset (vh in px)
+ const farExit = reduceMotion ? 0 : exitProgress * (h*0.40);
+ const midExit = reduceMotion ? 0 : exitProgress * (h*0.70);
+ const nearExit = reduceMotion ? 0 : exitProgress * (h*1.10);
+
+ for (const d of diamonds) {
+ const pal = d.layer==='far'? palFar : d.layer==='mid'? palMid : palNear;
+ // idle drift
+ const t = tick * 0.001 + d.phase;
+ const dx = Math.sin(t * 0.6 + d.ox*0.01) * d.driftX;
+ const dyBase = reduceMotion ? 0 : (tick * 0.015 * d.depthMul * d.driftY);
+ // exit offset + breathing scale
+ const exitOff = d.layer==='far'? farExit : d.layer==='mid'? midExit : nearExit;
+ const breath = reduceMotion ? 1 : 1 + Math.sin(t*0.8)*0.03;
+ const x = d.ox + dx;
+ let y = (d.oy + dyBase - exitOff) % (h + d.size*2);
+ if (y < -d.size) y += h + d.size*2;
+ const s = d.size * breath * (exitActive && d.layer==='near' ? (1 + exitProgress*0.06) : 1);
+ const alpha = pal.a * (exitActive ? (1 - exitProgress*0.7) : 1);
+ if (alpha < 0.02) continue;
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.rotate(d.rot);
+ ctx.strokeStyle = `rgba(${Math.round(pal.r)},${Math.round(pal.g)},${Math.round(pal.b)},${alpha})`;
+ ctx.lineWidth = d.layer==='far'? 1 : d.layer==='mid'? 1.1 : 1.4;
+ // hollow diamond: centered square
+ ctx.strokeRect(-s/2, -s/2, s, s);
+ ctx.restore();
+ }
+
+ tick += 16;
+ rafId = requestAnimationFrame(draw);
+ };
+
+ // pause when hidden
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) cancelAnimationFrame(rafId);
+ else draw();
+ });
+
resize();
- anim();
+ draw();
}
// ==================== API ====================
@@ -631,6 +843,22 @@ class ZernMCLauncher {
if (r.success) {
this.state.account = r.data;
+ // hollow diamond exit — parallax fly-out
+ if (this.animateDiamondExit) await this.animateDiamondExit();
+ const loginScreen = document.getElementById('login-screen');
+ const loginContainer = document.getElementById('login-container');
+ if (loginContainer && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ loginContainer.style.transition = 'transform 0.9s cubic-bezier(0.16,1,0.3,1), opacity 0.9s ease, filter 0.9s ease';
+ loginContainer.style.transform = 'translateY(-110vh) scale(0.92)';
+ loginContainer.style.opacity = '0';
+ loginContainer.style.filter = 'blur(6px)';
+ await new Promise(res => setTimeout(res, 900));
+ loginContainer.style.transition = '';
+ loginContainer.style.transform = '';
+ loginContainer.style.opacity = '';
+ loginContainer.style.filter = '';
+ }
+ if (loginScreen) loginScreen.classList.add('hidden');
this.enterMain();
const key = this._registerMode ? 'toast.accountCreated' : 'toast.welcome';
this.toast(tr(key, null, {username: r.data.username}), 'success');
@@ -639,6 +867,21 @@ class ZernMCLauncher {
var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
if (reg.success) {
this.state.account = reg.data;
+ if (this.animateDiamondExit) await this.animateDiamondExit();
+ const loginScreen2 = document.getElementById('login-screen');
+ const loginContainer2 = document.getElementById('login-container');
+ if (loginContainer2 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ loginContainer2.style.transition = 'transform 0.9s cubic-bezier(0.16,1,0.3,1), opacity 0.9s ease, filter 0.9s ease';
+ loginContainer2.style.transform = 'translateY(-110vh) scale(0.92)';
+ loginContainer2.style.opacity = '0';
+ loginContainer2.style.filter = 'blur(6px)';
+ await new Promise(res => setTimeout(res, 900));
+ loginContainer2.style.transition = '';
+ loginContainer2.style.transform = '';
+ loginContainer2.style.opacity = '';
+ loginContainer2.style.filter = '';
+ }
+ if (loginScreen2) loginScreen2.classList.add('hidden');
this.enterMain();
this.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success');
return;
@@ -728,7 +971,8 @@ class ZernMCLauncher {
container.style.transform = '';
container.style.opacity = '';
- if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
+ if (this.setDiamondMode) this.setDiamondMode(isReg ? 'register' : 'login');
+ else if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
}, 150);
}
@@ -739,6 +983,8 @@ class ZernMCLauncher {
}
showLogin() {
+ const c = document.getElementById('login-container');
+ if (c) { c.style.transition=''; c.style.transform=''; c.style.opacity=''; c.style.filter=''; }
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
@@ -771,6 +1017,7 @@ class ZernMCLauncher {
this.startFriendStatusHeartbeat();
this.enhanceSelects();
this.updateAdminNav();
+ setTimeout(()=> this.checkOnboarding(), 600);
}
async loadServerPacksList() {
@@ -1107,6 +1354,7 @@ class ZernMCLauncher {
`;
if (!isDisabled) {
el.addEventListener('click', () => this.selectPack(inst));
+ el.addEventListener('contextmenu', (e) => { e.preventDefault(); this.showCtxMenu(inst, e.clientX, e.clientY); });
}
targetList.appendChild(el);
});
@@ -1262,6 +1510,9 @@ class ZernMCLauncher {
} else {
whitelistSection.classList.add('hidden');
}
+
+ // Local mods for any pack (server = readOnly except whitelist)
+ this.loadLocalMods(inst.name, inst.isServerPack);
}
async loadPackInfo(name) {
@@ -1330,6 +1581,83 @@ class ZernMCLauncher {
btn.disabled = false;
btn.innerHTML = '
Install Selected';
this.toast(t('whitelist.done'), 'success');
+ // refresh local mods
+ this.loadLocalMods(instanceName, true);
+ }
+
+ async loadLocalMods(instanceName, isServerPack) {
+ const section = document.getElementById('local-mods-section');
+ const listEl = document.getElementById('local-mods-list');
+ const countEl = document.getElementById('mods-count');
+ if (!section || !listEl) return;
+ section.classList.remove('hidden');
+ listEl.innerHTML = '
' + t('mods.loading') + '
';
+ const r = await this.req('/pack-info?name=' + encodeURIComponent(instanceName));
+ if (!r.success || !r.data) {
+ listEl.innerHTML = '
' + t('mods.empty') + '
';
+ return;
+ }
+ const mods = r.data.mods || [];
+ // cache for filtering
+ this._localModsCache = mods;
+ this._localModsIsServer = !!isServerPack;
+ this._localModsInstance = instanceName;
+ if (countEl) countEl.textContent = tr('mods.count', null, {count: String(mods.length)});
+ this.renderLocalMods(mods, isServerPack);
+ }
+
+ renderLocalMods(mods, isServerPack) {
+ const listEl = document.getElementById('local-mods-list');
+ if (!listEl) return;
+ if (!mods || mods.length === 0) {
+ listEl.innerHTML = '
' + t('mods.empty') + '
';
+ return;
+ }
+ const q = (document.getElementById('mods-search')?.value || '').toLowerCase();
+ const filtered = q ? mods.filter(m => (m.displayName||m.fileName||'').toLowerCase().includes(q) || (m.modId||'').toLowerCase().includes(q)) : mods;
+ if (filtered.length === 0) {
+ listEl.innerHTML = '
' + t('mods.noResults') + '
';
+ return;
+ }
+ let html = '';
+ filtered.forEach(m => {
+ const sizeStr = m.size > 1048576 ? (m.size/1048576).toFixed(1)+' MB' : Math.ceil(m.size/1024)+' KB';
+ const enabled = m.enabled !== false;
+ const badge = enabled ? t('mods.enabled') : t('mods.disabled');
+ const badgeCls = enabled ? 'mod-badge-enabled' : 'mod-badge-disabled';
+ const canToggle = !isServerPack; // server: read-only for local mods
+ const canDelete = !isServerPack; // server: only whitelist mods can be managed
+ html += '
'
+ + '
'
+ + '
' + this.esc(m.displayName || m.fileName) + '
' + this.esc(m.modId || m.fileName) + ' · ' + this.esc(m.version || '?') + ' · ' + sizeStr + '
'
+ + '
' + badge + ''
+ + (canToggle ? '
' : '')
+ + (canDelete ? '
' : '')
+ + '
';
+ });
+ listEl.innerHTML = html;
+ }
+
+ filterLocalMods() {
+ if (!this._localModsCache) return;
+ this.renderLocalMods(this._localModsCache, this._localModsIsServer);
+ }
+
+ async toggleLocalMod(fileName, enable) {
+ const inst = this._localModsInstance;
+ if (!inst) return;
+ const r = await this.req('/mods/toggle', { method:'POST', body: JSON.stringify({instance: inst, file: fileName, enable: enable}) });
+ if (r.success) { this.toast(enable? t('mods.enabled'):t('mods.disabled'), 'success'); this.loadLocalMods(inst, this._localModsIsServer); }
+ else this.toast(r.error||'Error', 'error');
+ }
+
+ async deleteLocalMod(fileName) {
+ const inst = this._localModsInstance;
+ if (!inst) return;
+ if (!confirm(tr('mods.deleteConfirm', null, {name: fileName}))) return;
+ const r = await this.req('/mods/delete', { method:'POST', body: JSON.stringify({instance: inst, file: fileName}) });
+ if (r.success) { this.toast(t('mods.delete')+': '+fileName, 'success'); this.loadLocalMods(inst, this._localModsIsServer); }
+ else this.toast(r.error||'Error', 'error');
}
// ==================== ADMIN ====================
@@ -2595,12 +2923,313 @@ class ZernMCLauncher {
}
}
+ replayOnboarding() {
+ localStorage.removeItem('zern_onboardBlocks');
+ localStorage.removeItem('zern_onboardVersion');
+ // force show all blocks
+ const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
+ const BLOCKS = [
+ {id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
+ {id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
+ {id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
+ {id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
+ {id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
+ {id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
+ ];
+ this.showOnboarding(BLOCKS, curVer, []);
+ }
+
esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
+
+ // ==================== CTX MENU (PKM) ====================
+ async showCtxMenu(inst, x, y) {
+ this.hideCtxMenu();
+ const isServer = !!inst.isServerPack;
+ // fetch fm name once
+ let fmName = this._fmName;
+ if (!fmName) {
+ try { const r = await this.req('/filemanager'); if (r.success) fmName = r.data?.name || 'File Manager'; } catch(e) {}
+ this._fmName = fmName || 'File Manager';
+ fmName = this._fmName;
+ }
+ const menu = document.createElement('div');
+ menu.id = 'ctx-menu';
+ menu.className = 'ctx-menu';
+ menu.innerHTML = `
+
+
+
+
+ `;
+ const backdrop = document.createElement('div');
+ backdrop.id = 'ctx-backdrop';
+ backdrop.className = 'ctx-backdrop';
+ backdrop.addEventListener('click', () => this.hideCtxMenu());
+ backdrop.addEventListener('contextmenu', (e)=>{ e.preventDefault(); this.hideCtxMenu(); });
+ document.body.appendChild(backdrop);
+ document.body.appendChild(menu);
+ // position with flip
+ const pad = 8;
+ let mx = x, my = y;
+ const rect = menu.getBoundingClientRect();
+ if (mx + rect.width + pad > window.innerWidth) mx = window.innerWidth - rect.width - pad;
+ if (my + rect.height + pad > window.innerHeight) my = window.innerHeight - rect.height - pad;
+ menu.style.left = mx + 'px';
+ menu.style.top = my + 'px';
+ menu.querySelector('[data-act="open"]').addEventListener('click', () => { this.hideCtxMenu(); this.openPackFolder(inst.name); });
+ menu.querySelector('[data-act="settings"]').addEventListener('click', () => { this.hideCtxMenu(); this.openPackSettings(inst); });
+ menu.querySelector('[data-act="delete"]').addEventListener('click', () => { this.hideCtxMenu(); this.deletePack(inst.name); });
+ const onKey = (e)=>{ if(e.key==='Escape') this.hideCtxMenu(); };
+ document.addEventListener('keydown', onKey, {once:true});
+ this._ctxKeyHandler = onKey;
+ }
+ hideCtxMenu() {
+ document.getElementById('ctx-menu')?.remove();
+ document.getElementById('ctx-backdrop')?.remove();
+ if (this._ctxKeyHandler) { document.removeEventListener('keydown', this._ctxKeyHandler); this._ctxKeyHandler=null; }
+ }
+ async openPackFolder(name) {
+ const r = await this.req('/pack/open-folder', { method:'POST', body: JSON.stringify({name}) });
+ if (!r.success) this.toast(r.error||'Failed to open folder','error');
+ }
+ openPackSettings(inst) {
+ const isServer = !!inst.isServerPack;
+ let modal = document.getElementById('pack-settings-modal');
+ if (modal) modal.remove();
+ modal = document.createElement('div');
+ modal.id = 'pack-settings-modal';
+ modal.className = 'modal-backdrop';
+ modal.innerHTML = `
+
+
${t('ctx.settings')} — ${this.esc(inst.name)}
+
+
+
+
+
+
+ ${isServer?'
'+t('mods.disabled')+' — server pack is read-only
':''}
+
+
+
+
+
+
`;
+ document.body.appendChild(modal);
+ modal.addEventListener('click', e=>{ if(e.target===modal) modal.remove(); });
+ document.getElementById('ps-close').addEventListener('click', ()=>modal.remove());
+ document.getElementById('ps-cancel').addEventListener('click', ()=>modal.remove());
+ const loaderSel = document.getElementById('ps-loader');
+ loaderSel?.addEventListener('change', e=>{
+ const f=document.getElementById('ps-loader-ver-field');
+ if(e.target.value==='vanilla') f.classList.add('hidden'); else f.classList.remove('hidden');
+ });
+ document.getElementById('ps-save').addEventListener('click', async ()=>{
+ const newName = document.getElementById('ps-name').value.trim();
+ const loader = document.getElementById('ps-loader').value;
+ const loaderVer = document.getElementById('ps-loader-ver').value.trim();
+ const body = {name: inst.name};
+ if (newName && newName!==inst.name) body.newName=newName;
+ if (loader) body.loader=loader;
+ if (loaderVer) body.loaderVersion=loaderVer;
+ const r = await this.req('/pack/settings', {method:'POST', body: JSON.stringify(body)});
+ if (r.success) { modal.remove(); this.toast(t('ctx.save')+' ✓','success'); await this.loadInstances(); if(newName) this.state.selectedPack=null; }
+ else this.toast(r.error||'Error','error');
+ });
+ }
+ async deletePack(name) {
+ if (!confirm(tr('ctx.deleteConfirm', null, {name}))) return;
+ const r = await this.req('/pack/delete', {method:'POST', body: JSON.stringify({name})});
+ if (r.success) { this.toast(t('ctx.delete')+': '+name,'success'); if(this.state.selectedPack?.name===name) this.showEmptyState(); await this.loadInstances(); }
+ else this.toast(r.error||'Error','error');
+ }
+
+ // ==================== ONBOARDING ====================
+ async checkOnboarding() {
+ // show only on first login or version diff, highlight-only — persist via Config + localStorage
+ const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
+ let storedVer = localStorage.getItem('zern_onboardVersion') || '';
+ let completed = (localStorage.getItem('zern_onboardBlocks') || '').split(',').filter(Boolean);
+ try {
+ const r = await this.req('/settings');
+ if (r.success && r.data) {
+ const srvVer = r.data.onboardedVersion || '';
+ const srvBlocks = (r.data.completedOnboardBlocks || '').split(',').filter(Boolean);
+ if (!storedVer && srvVer) { storedVer = srvVer; localStorage.setItem('zern_onboardVersion', srvVer); }
+ if (completed.length===0 && srvBlocks.length) { completed = srvBlocks; localStorage.setItem('zern_onboardBlocks', srvBlocks.join(',')); }
+ // if local newer, sync to server
+ if (storedVer && !srvVer) { this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: storedVer, completedOnboardBlocks: completed.join(',')})}); }
+ }
+ } catch(e){}
+ const firstLogin = !storedVer; // no onboard yet
+ const BLOCKS = [
+ {id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
+ {id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
+ {id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
+ {id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
+ {id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
+ {id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
+ ];
+ let toShow;
+ if (firstLogin) toShow = BLOCKS;
+ else {
+ // version diff: show only blocks added after storedVer
+ const isNewer = (a,b)=> a.localeCompare(b, undefined, {numeric:true})>0;
+ toShow = BLOCKS.filter(b=> !completed.includes(b.id) && isNewer(b.addedIn, storedVer));
+ if (toShow.length===0 && storedVer !== curVer) {
+ // version bump but all blocks already completed -> no show
+ localStorage.setItem('zern_onboardVersion', curVer);
+ return;
+ }
+ }
+ if (toShow.length===0) return;
+ this.showOnboarding(toShow, curVer, completed);
+ }
+
+ showOnboarding(blocks, curVer, completed) {
+ let idx = 0;
+ const overlay = document.createElement('div');
+ overlay.className = 'onboard-overlay';
+ overlay.id = 'onboard-overlay';
+ document.body.appendChild(overlay);
+ const card = document.createElement('div');
+ card.className = 'onboard-card';
+ card.id = 'onboard-card';
+ document.body.appendChild(card);
+ const spot = document.createElement('div');
+ spot.className = 'onboard-spot';
+ spot.id = 'onboard-spot';
+ const label = document.createElement('div');
+ label.className = 'onboard-spot-label';
+ label.id = 'onboard-spot-label';
+ document.body.appendChild(spot);
+ document.body.appendChild(label);
+
+ const resolveTarget = (b) => {
+ const sels = Array.isArray(b.sel) ? b.sel : [b.sel];
+ for (const s of sels) {
+ for (const el of document.querySelectorAll(s)) {
+ const r = el.getBoundingClientRect();
+ const style = getComputedStyle(el);
+ const visible = r.width>0 && r.height>0 && style.display!=='none' && style.visibility!=='hidden' && el.offsetParent!==null;
+ if (!visible) continue;
+ if (el.closest('.hidden')) continue;
+ // check collapsed accordion
+ if (el.closest('.pack-list-collapse') && el.closest('.section-collapsible.collapsed')) continue;
+ return el;
+ }
+ }
+ return null;
+ };
+ const ensureView = async (b) => {
+ if (b.requiresView==='settings' || b.id==='pass' || b.id==='logs') {
+ this.switchView('settings');
+ await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
+ } else if (b.id==='install' || b.id==='manage' || b.id==='mods') {
+ this.switchView('packs');
+ await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
+ }
+ };
+ const persist = (vers, blocksArr) => {
+ const v = vers || curVer;
+ const b = blocksArr.join(',');
+ localStorage.setItem('zern_onboardBlocks', b);
+ localStorage.setItem('zern_onboardVersion', v);
+ // persist to backend Config so WebView localStorage volatility doesn't resurrect tutorial
+ this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: v, completedOnboardBlocks: b})}).catch(()=>{});
+ };
+ const goNext = async () => {
+ if (document.getElementById('onboard-never')?.checked) {
+ const all = [...new Set(blocks.map(x=>x.id).concat(completed))];
+ persist(curVer, all);
+ closeAll(); return;
+ }
+ if (idx===blocks.length-1) {
+ const newCompleted = [...new Set([...completed, ...blocks.map(x=>x.id)])];
+ persist(curVer, newCompleted);
+ closeAll();
+ } else { idx++; await render(); }
+ };
+ const goBack = async () => { if(idx>0){ idx--; await render(); } };
+ const showConfirm = (onYes) => {
+ let m = document.createElement('div');
+ m.className='modal-backdrop'; m.style.zIndex='410';
+ m.innerHTML=`
${t('onboard.confirmTitle')||'Точно пропустить туториал?'}
${t('onboard.confirmDesc')||'Вы уверены?'}
`;
+ document.body.appendChild(m);
+ const close=()=>m.remove();
+ m.querySelector('#c-close').onclick=close;
+ m.querySelector('#c-no').onclick=close;
+ m.addEventListener('click', e=>{ if(e.target===m) close(); });
+ m.querySelector('#c-yes').onclick=()=>{ close(); onYes(); };
+ };
+ const showInfo = () => {
+ let m=document.createElement('div'); m.className='modal-backdrop'; m.style.zIndex='411';
+ m.innerHTML=`
${t('onboard.infoTitle')||'Туториал'}
${t('onboard.infoDesc')||'Туториал можно будет пройти в настройках.'}
`;
+ document.body.appendChild(m);
+ const close=()=>m.remove();
+ m.querySelector('#i-close').onclick=close;
+ m.querySelector('#i-ok').onclick=close;
+ m.addEventListener('click', e=>{ if(e.target===m) close(); });
+ };
+ const requestClose = () => {
+ showConfirm(()=>{ closeAll(); showInfo(); });
+ };
+ const render = async () => {
+ const b = blocks[idx];
+ await ensureView(b);
+ card.innerHTML = `
+
${tr('onboard.progress', null, {current:String(idx+1), total:String(blocks.length)})}
+
${t(b.titleKey)}
+
${t(b.descKey)}
+
+
+ ${idx>0?'':''}
+
+
+
+ `;
+ const el = resolveTarget(b);
+ if (el) {
+ const r = el.getBoundingClientRect();
+ spot.style.left = (r.left-4)+'px';
+ spot.style.top = (r.top-4)+'px';
+ spot.style.width = (r.width+8)+'px';
+ spot.style.height = (r.height+8)+'px';
+ spot.style.display='block';
+ label.textContent = t(b.titleKey);
+ label.style.left = (r.left + r.width/2)+'px';
+ label.style.top = (r.top)+'px';
+ label.style.display='block';
+ el.scrollIntoView({block:'center', behavior:'smooth'});
+ } else {
+ spot.style.display='none'; label.style.display='none';
+ }
+ document.getElementById('onboard-skip').addEventListener('click', requestClose);
+ document.getElementById('onboard-next').addEventListener('click', goNext);
+ const backBtn=document.getElementById('onboard-back');
+ if(backBtn) backBtn.addEventListener('click', goBack);
+ };
+ const closeAll = ()=>{
+ overlay.remove(); card.remove(); spot.remove(); label.remove();
+ document.removeEventListener('keydown', escHandler);
+ };
+ spot.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
+ label.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
+ const escHandler = (e)=>{ if(e.key==='Escape') requestClose(); };
+ document.addEventListener('keydown', escHandler);
+ overlay.addEventListener('click', (e)=>{ if(e.target===overlay) requestClose(); });
+ render();
+ }
}
function renderFriendItem(f) {
diff --git a/launcher/launcher/src/resources/ui/style.css b/launcher/launcher/src/resources/ui/style.css
index c749faa..269ec07 100644
--- a/launcher/launcher/src/resources/ui/style.css
+++ b/launcher/launcher/src/resources/ui/style.css
@@ -29,8 +29,10 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-html, body, * { -webkit-user-select: none; user-select: none; }
-input, textarea { -webkit-user-select: text; user-select: text; }
+html, body { -webkit-user-select: none; user-select: none; }
+.pack-entry, .pack-entry *, .ctx-menu, .ctx-menu * { -webkit-user-select: none; user-select: none; }
+input, textarea, [contenteditable], .log-viewer-content, .news-modal-body, .pack-description-text { -webkit-user-select: text; user-select: text; }
+.pack-entry { -webkit-touch-callout: default; }
html { font-size: 14px; }
@@ -45,8 +47,11 @@ body {
#bg-canvas {
position: fixed; inset: 0; width: 100%; height: 100%;
- z-index: 0; opacity: 0.08; pointer-events: none;
+ z-index: 0; opacity: 0.14; pointer-events: none;
}
+#login-screen.exiting { pointer-events: none; }
+.login-container.exit { will-change: transform, opacity, filter; }
+#bg-canvas.bg-exit { will-change: transform; }
#app { position: relative; z-index: 1; height: 100vh; display: flex; }
@@ -331,6 +336,9 @@ body {
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
.view.active { display: flex; }
+#view-packs { overflow: hidden; }
+#view-packs .pack-detail { flex: 1; overflow-y: auto; min-height: 0; }
+#view-packs .play-bar { position: sticky; bottom: 0; z-index: 5; flex-shrink: 0; margin-top: 12px; }
.view-header {
display: flex; align-items: flex-start; justify-content: space-between;
@@ -983,3 +991,45 @@ body {
transition: border-color 150ms ease, color 150ms ease;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
+
+/* ========== LOCAL MODS ========== */
+.mods-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
+.mods-section .section-header { display:flex; align-items:center; gap:10px; flex-wrap:wrap; font-size:11px; font-weight:600; text-transform:uppercase; color:var(--text-muted); padding:4px; margin-bottom:8px; letter-spacing:0.5px; }
+.mods-count { font-size:11px; color:var(--text-secondary); margin-left:auto; }
+.mods-search { margin-left:auto; min-width:160px; padding:6px 10px; border-radius:var(--radius-sm); background:var(--bg-surface); border:1px solid var(--border-light); color:var(--text); font-size:12px; outline:none; }
+.mods-search:focus { border-color:var(--accent); }
+.mods-list { display:flex; flex-direction:column; gap:4px; }
+.mods-loading { text-align:center; padding:20px; color:var(--text-muted); font-size:12px; }
+.mod-item { display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:var(--radius-sm); background:var(--bg-card); border:1px solid var(--border); transition:var(--transition); }
+.mod-item:hover { background:var(--bg-card-hover); }
+.mod-item.mod-disabled { opacity:0.6; }
+.mod-icon { width:32px; height:32px; border-radius:6px; display:flex; align-items:center; justify-content:center; background:var(--bg-inset); color:var(--text-muted); flex-shrink:0; }
+.mod-info { flex:1; min-width:0; }
+.mod-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.mod-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.mod-badge { font-size:10px; font-weight:700; padding:2px 6px; border-radius:4px; flex-shrink:0; }
+.mod-badge-enabled { background:rgba(74,222,128,0.15); color:var(--success); }
+.mod-badge-disabled { background:rgba(248,113,113,0.12); color:var(--error); }
+
+/* ========== CONTEXT MENU (PKM) ========== */
+.ctx-menu { position:fixed; z-index:300; min-width:200px; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-sm); box-shadow:0 12px 32px rgba(0,0,0,0.5); padding:4px; display:flex; flex-direction:column; gap:2px; animation:ctxIn 0.12s ease; }
+@keyframes ctxIn { from { opacity:0; transform:translateY(4px) scale(0.98); } to { opacity:1; transform:none; } }
+.ctx-item { display:flex; align-items:center; gap:10px; padding:8px 12px; border-radius:4px; font-size:13px; color:var(--text-secondary); cursor:pointer; transition:var(--transition); border:none; background:transparent; font-family:var(--font); text-align:left; width:100%; }
+.ctx-item:hover { background:var(--bg-card); color:var(--text); }
+.ctx-item.danger { color:var(--error); }
+.ctx-item.danger:hover { background:rgba(248,113,113,0.08); }
+.ctx-sep { height:1px; background:var(--border); margin:2px 0; }
+.ctx-backdrop { position:fixed; inset:0; z-index:299; }
+
+/* ========== ONBOARDING SPOTLIGHT ========== */
+.onboard-overlay { position:fixed; inset:0; z-index:400; background:rgba(7,7,10,0.72); backdrop-filter:blur(2px); animation:fadeIn 0.2s ease; }
+.onboard-card { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); z-index:405; max-width:420px; width:90%; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-lg); padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.6); animation:floatIn 0.25s ease; }
+.onboard-card h3 { font-size:18px; font-weight:700; margin-bottom:8px; }
+.onboard-card p { font-size:13px; color:var(--text-secondary); line-height:1.6; margin-bottom:16px; }
+.onboard-progress { font-size:11px; color:var(--text-muted); letter-spacing:0.5px; text-transform:uppercase; margin-bottom:12px; }
+.onboard-actions { display:flex; gap:8px; justify-content:flex-end; }
+.onboard-spot { position:fixed; z-index:401; border:2px solid var(--accent); border-radius:var(--radius-sm); box-shadow:0 0 0 9999px rgba(7,7,10,0.55), 0 0 24px var(--accent-glow); pointer-events:auto; cursor:pointer; transition:all 0.35s ease; }
+.onboard-spot-label { position:fixed; z-index:402; background:var(--accent); color:#fff; font-size:12px; font-weight:600; padding:6px 10px; border-radius:999px; pointer-events:auto; cursor:pointer; transform:translate(-50%, -100%); margin-top:-8px; white-space:nowrap; }
+
+/* ========== PACK SETTINGS MODAL ========== */
+.pack-settings-form { display:flex; flex-direction:column; gap:16px; }
diff --git a/launcher/pom.xml b/launcher/pom.xml
index 26429df..f03fba2 100644
--- a/launcher/pom.xml
+++ b/launcher/pom.xml
@@ -19,8 +19,8 @@
- 1.1.0
- 0
+ 1.1.1
+ 2
21
21
UTF-8
diff --git a/server/main.py b/server/main.py
index 5d5c895..e198e9d 100644
--- a/server/main.py
+++ b/server/main.py
@@ -1798,6 +1798,21 @@ async def download_launcher_exe(request: Request = None):
)
+@app.get("/launcher/download/jre")
+async def download_jre(request: Request):
+ """Download JRE zip for online installer (47M, Range supported)"""
+ candidates = [
+ BUILDS_DIR / ".." / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip", # /root/launcher/OpenJDK...
+ Path(__file__).parent.parent / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip",
+ Path("/root/launcher/OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"),
+ BUILDS_DIR / "jre.zip",
+ ]
+ for p in candidates:
+ pp = p.resolve()
+ if pp.exists() and pp.is_file():
+ return await send_file_async(pp, request, content_type="application/zip", cache=True)
+ raise HTTPException(404, "JRE zip not found on server")
+
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str, request: Request = None):
"""Download specific launcher ZIP archive"""
diff --git a/server/site/index.html b/server/site/index.html
index 78d5abf..66fc60a 100644
--- a/server/site/index.html
+++ b/server/site/index.html
@@ -382,8 +382,19 @@
Windows · —